@phosphorco/bb-identity 0.1.0
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/LICENSE +21 -0
- package/README.md +465 -0
- package/bb.d.ts +138 -0
- package/client.d.ts +175 -0
- package/dist/bb-entry-runtime.js +2574 -0
- package/dist/client-entry-runtime.js +2705 -0
- package/dist/host-runtime.js +932 -0
- package/dist/index-runtime.js +201 -0
- package/dist/model-runtime.js +207 -0
- package/dist/react-entry-runtime.js +3324 -0
- package/dist/server-runtime.js +263 -0
- package/dist/state-controller-runtime.js +950 -0
- package/dist/state-runtime.js +1200 -0
- package/dist/state-service-runtime.js +380 -0
- package/dist/state-transport-runtime.js +349 -0
- package/dist/testing-runtime.js +4084 -0
- package/host.d.ts +277 -0
- package/index.d.ts +3 -0
- package/model.d.ts +231 -0
- package/package.json +110 -0
- package/react.d.ts +129 -0
- package/server.d.ts +80 -0
- package/state.d.ts +170 -0
- package/testing.d.ts +202 -0
|
@@ -0,0 +1,3324 @@
|
|
|
1
|
+
// react-runtime.ts
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { useRealtime, useRealtimeConnectionState, useRpc } from "@get-bb/plugin-sdk/app";
|
|
4
|
+
|
|
5
|
+
// model-runtime.ts
|
|
6
|
+
var ID_MAX_LENGTH = 256;
|
|
7
|
+
var ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:@/-]*$/;
|
|
8
|
+
function failure(code, message, retry = "never") {
|
|
9
|
+
return { code, message, retry };
|
|
10
|
+
}
|
|
11
|
+
function ok(value) {
|
|
12
|
+
return { ok: true, value };
|
|
13
|
+
}
|
|
14
|
+
function err(code, message, retry = "never") {
|
|
15
|
+
return { ok: false, error: failure(code, message, retry) };
|
|
16
|
+
}
|
|
17
|
+
function isRecord(value) {
|
|
18
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
19
|
+
}
|
|
20
|
+
function isBoundedString(value, maximum = ID_MAX_LENGTH) {
|
|
21
|
+
return typeof value === "string" && value.length > 0 && value.length <= maximum;
|
|
22
|
+
}
|
|
23
|
+
function idCodec(kind) {
|
|
24
|
+
return {
|
|
25
|
+
decode(input) {
|
|
26
|
+
const valid = kind === "server-session" ? isBoundedString(input, 4096) && !/[\u0000-\u0020\u007f]/.test(input) : isBoundedString(input) && ID_PATTERN.test(input);
|
|
27
|
+
if (!valid) {
|
|
28
|
+
return err("invalid-input", `Invalid ${kind} identifier.`);
|
|
29
|
+
}
|
|
30
|
+
return ok(input);
|
|
31
|
+
},
|
|
32
|
+
encode(value) {
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
var identityKeyCodec = {
|
|
38
|
+
decode(input) {
|
|
39
|
+
if (!isBoundedString(input, 512) || /[\u0000-\u001f\u007f]/.test(input)) {
|
|
40
|
+
return err("invalid-input", "Invalid identity key.");
|
|
41
|
+
}
|
|
42
|
+
return ok(input);
|
|
43
|
+
},
|
|
44
|
+
encode(value) {
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
function decodePresentation(value) {
|
|
49
|
+
return isRecord(value) && isBoundedString(value.displayName, 512) && (value.handle === null || isBoundedString(value.handle, 512)) && (value.avatarUrl === null || isBoundedString(value.avatarUrl, 2048));
|
|
50
|
+
}
|
|
51
|
+
function decodeIdentity(value) {
|
|
52
|
+
if (!isRecord(value) || !identityKeyCodec.decode(value.key).ok)
|
|
53
|
+
return false;
|
|
54
|
+
if (value.kind === "person")
|
|
55
|
+
return isBoundedString(value.issuer, 512) && isBoundedString(value.subject, 512);
|
|
56
|
+
if (value.kind === "default-user")
|
|
57
|
+
return isBoundedString(value.instanceId);
|
|
58
|
+
if (value.kind === "machine") {
|
|
59
|
+
return isBoundedString(value.instanceId) && (value.hostId === null || isBoundedString(value.hostId, 512));
|
|
60
|
+
}
|
|
61
|
+
return value.kind === "external" && isBoundedString(value.pluginId) && isBoundedString(value.subject, 512);
|
|
62
|
+
}
|
|
63
|
+
var profileCodec = {
|
|
64
|
+
decode(input) {
|
|
65
|
+
if (!isRecord(input) || !decodeIdentity(input.identity) || !decodePresentation(input.presentation) || !isBoundedString(input.revision) || input.status !== "current" && input.status !== "historical") {
|
|
66
|
+
return err("invalid-input", "Invalid identity profile.");
|
|
67
|
+
}
|
|
68
|
+
return ok(input);
|
|
69
|
+
},
|
|
70
|
+
encode(value) {
|
|
71
|
+
return value;
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
function newOperationId() {
|
|
75
|
+
const random = globalThis.crypto?.randomUUID?.() ?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
76
|
+
return `op-${random}`;
|
|
77
|
+
}
|
|
78
|
+
function newConflictToken() {
|
|
79
|
+
return `conflict-${globalThis.crypto?.randomUUID?.() ?? Date.now().toString(36)}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// state-service-runtime.ts
|
|
83
|
+
function isRecord2(value) {
|
|
84
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
85
|
+
}
|
|
86
|
+
function nonEmpty(value) {
|
|
87
|
+
return typeof value === "string" && value.length > 0;
|
|
88
|
+
}
|
|
89
|
+
function decodeAddress(value) {
|
|
90
|
+
if (!isRecord2(value) || !nonEmpty(value.collection) || !nonEmpty(value.recordId))
|
|
91
|
+
return err("invalid-input", "Invalid state address.");
|
|
92
|
+
const instanceId = idCodec("instance").decode(value.instanceId);
|
|
93
|
+
const pluginId = idCodec("plugin").decode(value.pluginId);
|
|
94
|
+
const owner = identityKeyCodec.decode(value.owner);
|
|
95
|
+
return instanceId.ok && pluginId.ok && owner.ok ? ok({ instanceId: instanceId.value, pluginId: pluginId.value, collection: value.collection, recordId: value.recordId, owner: owner.value }) : err("invalid-input", "Invalid state address identifiers.");
|
|
96
|
+
}
|
|
97
|
+
function decodeVersion(value) {
|
|
98
|
+
return isRecord2(value) && nonEmpty(value.epoch) && typeof value.sequence === "number" && Number.isInteger(value.sequence) && value.sequence >= 0 ? ok({ epoch: value.epoch, sequence: value.sequence }) : err("invalid-input", "Invalid state version.");
|
|
99
|
+
}
|
|
100
|
+
function decodeActor(value) {
|
|
101
|
+
if (value === null)
|
|
102
|
+
return ok(null);
|
|
103
|
+
if (!isRecord2(value) || !isRecord2(value.identity) || !isRecord2(value.presentation) || !nonEmpty(value.presentation.displayName) || value.presentation.handle !== null && typeof value.presentation.handle !== "string" || value.presentation.avatarUrl !== null && typeof value.presentation.avatarUrl !== "string" || !["provider-verified", "local-user", "upstream-default", "integration-asserted", "legacy", "machine"].includes(value.evidence)) {
|
|
104
|
+
return err("invalid-input", "Invalid state editor snapshot.");
|
|
105
|
+
}
|
|
106
|
+
const key = identityKeyCodec.decode(value.identity.key);
|
|
107
|
+
if (!key.ok)
|
|
108
|
+
return key;
|
|
109
|
+
const identity = value.identity;
|
|
110
|
+
const presentation = { displayName: value.presentation.displayName, handle: value.presentation.handle, avatarUrl: value.presentation.avatarUrl };
|
|
111
|
+
if (identity.kind === "machine") {
|
|
112
|
+
const instanceId = idCodec("instance").decode(identity.instanceId);
|
|
113
|
+
if (!instanceId.ok || value.evidence !== "machine" || identity.hostId !== null && (typeof identity.hostId !== "string" || identity.hostId.length === 0 || identity.hostId.length > 512)) {
|
|
114
|
+
return err("invalid-input", "Invalid machine editor identity.");
|
|
115
|
+
}
|
|
116
|
+
return ok({ identity: { kind: "machine", key: key.value, instanceId: instanceId.value, hostId: identity.hostId }, presentation, evidence: "machine" });
|
|
117
|
+
}
|
|
118
|
+
if (value.evidence === "machine")
|
|
119
|
+
return err("invalid-input", "Machine evidence requires a machine identity.");
|
|
120
|
+
if (identity.kind === "person" && nonEmpty(identity.issuer) && nonEmpty(identity.subject)) {
|
|
121
|
+
return ok({ identity: { kind: "person", key: key.value, issuer: identity.issuer, subject: identity.subject }, presentation, evidence: value.evidence });
|
|
122
|
+
}
|
|
123
|
+
if (identity.kind === "default-user") {
|
|
124
|
+
const instanceId = idCodec("instance").decode(identity.instanceId);
|
|
125
|
+
return instanceId.ok ? ok({ identity: { kind: "default-user", key: key.value, instanceId: instanceId.value }, presentation, evidence: value.evidence }) : instanceId;
|
|
126
|
+
}
|
|
127
|
+
if (identity.kind === "external") {
|
|
128
|
+
const pluginId = idCodec("plugin").decode(identity.pluginId);
|
|
129
|
+
return pluginId.ok && nonEmpty(identity.subject) ? ok({ identity: { kind: "external", key: key.value, pluginId: pluginId.value, subject: identity.subject }, presentation, evidence: value.evidence }) : err("invalid-input", "Invalid external editor identity.");
|
|
130
|
+
}
|
|
131
|
+
return err("invalid-input", "Invalid editor identity.");
|
|
132
|
+
}
|
|
133
|
+
function decodeEnvelope(value, codec) {
|
|
134
|
+
if (!isRecord2(value) || typeof value.schemaVersion !== "number" || !Number.isInteger(value.schemaVersion) || value.schemaVersion < 0) {
|
|
135
|
+
return err("invalid-input", "Invalid state envelope.");
|
|
136
|
+
}
|
|
137
|
+
const address = decodeAddress(value.address);
|
|
138
|
+
const version = decodeVersion(value.version);
|
|
139
|
+
const decoded = codec.decode(value.value);
|
|
140
|
+
const editor = decodeActor(value.lastEditedBy);
|
|
141
|
+
return address.ok && version.ok && decoded.ok && editor.ok ? ok({ address: address.value, version: version.value, schemaVersion: value.schemaVersion, value: decoded.value, lastEditedBy: editor.value }) : err("invalid-input", "Invalid state envelope fields.");
|
|
142
|
+
}
|
|
143
|
+
function decodeRead(value, codec) {
|
|
144
|
+
if (!isRecord2(value) || typeof value.status !== "string")
|
|
145
|
+
return err("invalid-input", "Invalid state read.");
|
|
146
|
+
if (value.status === "present") {
|
|
147
|
+
const envelope = decodeEnvelope(value.envelope, codec);
|
|
148
|
+
return envelope.ok ? ok({ status: "present", envelope: envelope.value }) : envelope;
|
|
149
|
+
}
|
|
150
|
+
if (value.status === "empty") {
|
|
151
|
+
const address = decodeAddress(value.address);
|
|
152
|
+
const version = decodeVersion(value.version);
|
|
153
|
+
return address.ok && version.ok ? ok({ status: "empty", address: address.value, version: version.value }) : err("invalid-input", "Invalid empty state read.");
|
|
154
|
+
}
|
|
155
|
+
if (value.status === "migration-required") {
|
|
156
|
+
const address = decodeAddress(value.address);
|
|
157
|
+
return address.ok && typeof value.storedSchemaVersion === "number" && Number.isInteger(value.storedSchemaVersion) && value.storedSchemaVersion >= 0 ? ok({ status: "migration-required", address: address.value, storedSchemaVersion: value.storedSchemaVersion }) : err("invalid-input", "Invalid migration-required read.");
|
|
158
|
+
}
|
|
159
|
+
return err("invalid-input", "Unknown state read status.");
|
|
160
|
+
}
|
|
161
|
+
function decodeMutation(value, codec) {
|
|
162
|
+
if (!isRecord2(value) || value.kind !== "initialize" && value.kind !== "replace" || !isRecord2(value.expected) || typeof value.schemaVersion !== "number" || !Number.isInteger(value.schemaVersion) || value.schemaVersion < 0 || typeof value.localGeneration !== "number" || !Number.isInteger(value.localGeneration) || value.localGeneration < 0) {
|
|
163
|
+
return err("invalid-input", "Invalid state mutation.");
|
|
164
|
+
}
|
|
165
|
+
const address = decodeAddress(value.address);
|
|
166
|
+
const expectedVersion = decodeVersion(value.expectedVersion);
|
|
167
|
+
const actor = identityKeyCodec.decode(value.expected.actor);
|
|
168
|
+
const session = idCodec("server-session").decode(value.expected.session);
|
|
169
|
+
const ownerSession = idCodec("owner-session").decode(value.ownerSession);
|
|
170
|
+
const operationId = idCodec("operation").decode(value.operationId);
|
|
171
|
+
const decoded = codec.decode(value.value);
|
|
172
|
+
return address.ok && expectedVersion.ok && actor.ok && session.ok && ownerSession.ok && operationId.ok && decoded.ok ? ok({
|
|
173
|
+
kind: value.kind,
|
|
174
|
+
address: address.value,
|
|
175
|
+
expectedVersion: expectedVersion.value,
|
|
176
|
+
expected: { actor: actor.value, session: session.value },
|
|
177
|
+
ownerSession: ownerSession.value,
|
|
178
|
+
localGeneration: value.localGeneration,
|
|
179
|
+
operationId: operationId.value,
|
|
180
|
+
schemaVersion: value.schemaVersion,
|
|
181
|
+
value: decoded.value
|
|
182
|
+
}) : err("invalid-input", "Invalid state mutation fields.");
|
|
183
|
+
}
|
|
184
|
+
function decodeOutcome(value, codec) {
|
|
185
|
+
if (!isRecord2(value) || typeof value.status !== "string")
|
|
186
|
+
return err("invalid-input", "Invalid state outcome.");
|
|
187
|
+
const operationId = idCodec("operation").decode(value.operationId);
|
|
188
|
+
if (!operationId.ok)
|
|
189
|
+
return operationId;
|
|
190
|
+
if (value.status === "saved" || value.status === "unchanged") {
|
|
191
|
+
const envelope = decodeEnvelope(value.envelope, codec);
|
|
192
|
+
return envelope.ok ? ok({ status: value.status, envelope: envelope.value, operationId: operationId.value }) : envelope;
|
|
193
|
+
}
|
|
194
|
+
if (value.status === "already-initialized" || value.status === "conflict") {
|
|
195
|
+
const current = decodeRead(value.current, codec);
|
|
196
|
+
return current.ok ? ok({ status: value.status, current: current.value, operationId: operationId.value }) : current;
|
|
197
|
+
}
|
|
198
|
+
return err("invalid-input", "Unknown state outcome status.");
|
|
199
|
+
}
|
|
200
|
+
function decodeSave(value, codec) {
|
|
201
|
+
if (isRecord2(value) && value.status === "indeterminate") {
|
|
202
|
+
const operationId = idCodec("operation").decode(value.operationId);
|
|
203
|
+
return operationId.ok ? ok({ status: "indeterminate", operationId: operationId.value }) : operationId;
|
|
204
|
+
}
|
|
205
|
+
return decodeOutcome(value, codec);
|
|
206
|
+
}
|
|
207
|
+
function stateAddressKey(address) {
|
|
208
|
+
return JSON.stringify([address.instanceId, address.pluginId, address.collection, address.recordId, address.owner]);
|
|
209
|
+
}
|
|
210
|
+
function decodeLookup(value, codec) {
|
|
211
|
+
if (!isRecord2(value) || typeof value.status !== "string")
|
|
212
|
+
return err("invalid-input", "Invalid state lookup.");
|
|
213
|
+
if (value.status === "final") {
|
|
214
|
+
const outcome = decodeOutcome(value.outcome, codec);
|
|
215
|
+
return outcome.ok ? ok({ status: "final", outcome: outcome.value }) : outcome;
|
|
216
|
+
}
|
|
217
|
+
if (value.status === "pending")
|
|
218
|
+
return ok({ status: "pending" });
|
|
219
|
+
if (value.status === "absent-final" && value.retry === "same-operation-only")
|
|
220
|
+
return ok({ status: "absent-final", retry: "same-operation-only" });
|
|
221
|
+
if (value.status === "unknown" && (value.reason === "unsupported" || value.reason === "expired" || value.reason === "unavailable"))
|
|
222
|
+
return ok({ status: "unknown", reason: value.reason });
|
|
223
|
+
return err("invalid-input", "Invalid state lookup metadata.");
|
|
224
|
+
}
|
|
225
|
+
function encodeAddress(address) {
|
|
226
|
+
return { instanceId: address.instanceId, pluginId: address.pluginId, collection: address.collection, recordId: address.recordId, owner: address.owner };
|
|
227
|
+
}
|
|
228
|
+
function encodeVersion(version) {
|
|
229
|
+
return { epoch: version.epoch, sequence: version.sequence };
|
|
230
|
+
}
|
|
231
|
+
function encodeActor(actor) {
|
|
232
|
+
return actor === null ? null : { identity: actor.identity, presentation: actor.presentation, evidence: actor.evidence };
|
|
233
|
+
}
|
|
234
|
+
function encodeEnvelope(envelope, codec) {
|
|
235
|
+
return { address: encodeAddress(envelope.address), version: encodeVersion(envelope.version), schemaVersion: envelope.schemaVersion, value: codec.encode(envelope.value), lastEditedBy: encodeActor(envelope.lastEditedBy) };
|
|
236
|
+
}
|
|
237
|
+
function encodeRead(read, codec) {
|
|
238
|
+
return read.status === "present" ? { status: "present", envelope: encodeEnvelope(read.envelope, codec) } : read.status === "empty" ? { status: "empty", address: encodeAddress(read.address), version: encodeVersion(read.version) } : read.status === "migration-required" ? { status: "migration-required", address: encodeAddress(read.address), storedSchemaVersion: read.storedSchemaVersion } : (() => {
|
|
239
|
+
throw new TypeError("Invalid state read status.");
|
|
240
|
+
})();
|
|
241
|
+
}
|
|
242
|
+
function encodeMutation(mutation, codec) {
|
|
243
|
+
return { ...mutation, address: encodeAddress(mutation.address), expectedVersion: encodeVersion(mutation.expectedVersion), value: codec.encode(mutation.value) };
|
|
244
|
+
}
|
|
245
|
+
function encodeOutcome(outcome, codec) {
|
|
246
|
+
if (outcome.status === "saved" || outcome.status === "unchanged") {
|
|
247
|
+
return { status: outcome.status, envelope: encodeEnvelope(outcome.envelope, codec), operationId: outcome.operationId };
|
|
248
|
+
}
|
|
249
|
+
if ("current" in outcome)
|
|
250
|
+
return { status: outcome.status, current: encodeRead(outcome.current, codec), operationId: outcome.operationId };
|
|
251
|
+
throw new TypeError("Invalid state outcome.");
|
|
252
|
+
}
|
|
253
|
+
function encodeSave(save, codec) {
|
|
254
|
+
return save.status === "indeterminate" ? { status: "indeterminate", operationId: save.operationId } : encodeOutcome(save, codec);
|
|
255
|
+
}
|
|
256
|
+
function stateCodecs(value) {
|
|
257
|
+
return {
|
|
258
|
+
read: { decode: (input) => decodeRead(input, value), encode: (input) => encodeRead(input, value) },
|
|
259
|
+
mutation: { decode: (input) => decodeMutation(input, value), encode: (input) => encodeMutation(input, value) },
|
|
260
|
+
save: { decode: (input) => decodeSave(input, value), encode: (input) => encodeSave(input, value) },
|
|
261
|
+
invalidation: {
|
|
262
|
+
decode(input) {
|
|
263
|
+
if (!isRecord2(input))
|
|
264
|
+
return err("invalid-input", "Invalid state invalidation.");
|
|
265
|
+
const address = decodeAddress(input.address);
|
|
266
|
+
const version = decodeVersion(input.version);
|
|
267
|
+
const operationId = input.operationId === null ? ok(null) : idCodec("operation").decode(input.operationId);
|
|
268
|
+
return address.ok && version.ok && operationId.ok ? ok({ address: address.value, version: version.value, operationId: operationId.value }) : err("invalid-input", "Invalid state invalidation fields.");
|
|
269
|
+
},
|
|
270
|
+
encode: (input) => ({ address: encodeAddress(input.address), version: encodeVersion(input.version), operationId: input.operationId })
|
|
271
|
+
},
|
|
272
|
+
lookup: {
|
|
273
|
+
decode: (input) => decodeLookup(input, value),
|
|
274
|
+
encode: (input) => input.status === "final" ? { status: "final", outcome: encodeOutcome(input.outcome, value) } : input
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// state-controller-runtime.ts
|
|
280
|
+
var defaultScheduler = { now: Date.now, schedule(delay, callback) {
|
|
281
|
+
const timer = setTimeout(callback, delay);
|
|
282
|
+
return () => clearTimeout(timer);
|
|
283
|
+
} };
|
|
284
|
+
function frozen(value) {
|
|
285
|
+
if (value !== null && typeof value === "object") {
|
|
286
|
+
for (const child of Object.values(value))
|
|
287
|
+
frozen(child);
|
|
288
|
+
Object.freeze(value);
|
|
289
|
+
}
|
|
290
|
+
return value;
|
|
291
|
+
}
|
|
292
|
+
function valueClone(codec, value) {
|
|
293
|
+
const result = codec.decode(structuredClone(codec.encode(value)));
|
|
294
|
+
if (!result.ok)
|
|
295
|
+
throw new TypeError(result.error.message);
|
|
296
|
+
return frozen(result.value);
|
|
297
|
+
}
|
|
298
|
+
function version(read) {
|
|
299
|
+
return read.status === "present" ? read.envelope.version : read.version;
|
|
300
|
+
}
|
|
301
|
+
function sameVersion(a, b) {
|
|
302
|
+
return a.status === b.status && version(a).epoch === version(b).epoch && version(a).sequence === version(b).sequence;
|
|
303
|
+
}
|
|
304
|
+
function createIdentityState(options) {
|
|
305
|
+
const address = frozen({ ...options.address });
|
|
306
|
+
const expected = frozen({ ...options.expected });
|
|
307
|
+
const definition = options.definition;
|
|
308
|
+
const codec = definition.codec;
|
|
309
|
+
const codecs = stateCodecs(codec);
|
|
310
|
+
const drafts = options.drafts;
|
|
311
|
+
const initial = valueClone(codec, definition.initialValue());
|
|
312
|
+
const scheduler = options.scheduler ?? defaultScheduler;
|
|
313
|
+
const retryLimit = options.retryLimit ?? 3;
|
|
314
|
+
const debounce = options.debounceMs ?? 300;
|
|
315
|
+
if (!Number.isSafeInteger(retryLimit) || retryLimit < 0 || !Number.isFinite(debounce) || debounce < 0)
|
|
316
|
+
throw new TypeError("Invalid state scheduling limits");
|
|
317
|
+
let lifecycle = "new";
|
|
318
|
+
let epoch = 0;
|
|
319
|
+
let loadId = 0;
|
|
320
|
+
let localGeneration = 0;
|
|
321
|
+
let base = null;
|
|
322
|
+
let desired = null;
|
|
323
|
+
let blocked = null;
|
|
324
|
+
let pending = null;
|
|
325
|
+
let networkActive = false;
|
|
326
|
+
let acknowledgedGeneration = -1;
|
|
327
|
+
let activeSave = null;
|
|
328
|
+
let startWork = null;
|
|
329
|
+
let activeLoad = null;
|
|
330
|
+
let timer = null;
|
|
331
|
+
let subscription = null;
|
|
332
|
+
let remotePending = false;
|
|
333
|
+
let remoteSerial = 0;
|
|
334
|
+
let remoteHint = null;
|
|
335
|
+
let closing = false;
|
|
336
|
+
let checkpoint = null;
|
|
337
|
+
let recoverySource = null;
|
|
338
|
+
let automaticSuspensions = 0;
|
|
339
|
+
let automaticEpoch = 0;
|
|
340
|
+
let persistQueue = Promise.resolve();
|
|
341
|
+
let persistSerial = 0;
|
|
342
|
+
const abort = new AbortController;
|
|
343
|
+
const listeners = new Set;
|
|
344
|
+
let snapshot = { status: "loading", address };
|
|
345
|
+
const currentValue = (read) => read.status === "present" ? read.envelope.value : initial;
|
|
346
|
+
const dirty = () => base !== null && desired !== null && !definition.equal(currentValue(base), desired.value);
|
|
347
|
+
const needsSave = () => dirty() || base?.status === "empty" && options.initializeEmpty;
|
|
348
|
+
const active = (captured = epoch) => lifecycle === "active" && captured === epoch;
|
|
349
|
+
const clearTimer = () => {
|
|
350
|
+
timer?.();
|
|
351
|
+
timer = null;
|
|
352
|
+
};
|
|
353
|
+
const draft = () => base === null || desired === null ? null : frozen({ formatVersion: 1, key: { address, actor: expected.actor, ownerSession: options.ownerSession }, schemaVersion: definition.schemaVersion, acknowledged: base, desired: desired.value, localGeneration, inFlight: pending?.mutation ?? null });
|
|
354
|
+
function emit() {
|
|
355
|
+
if (lifecycle === "detached" || lifecycle === "disposed")
|
|
356
|
+
snapshot = { status: "detached", address, draft: draft() };
|
|
357
|
+
else if (blocked)
|
|
358
|
+
snapshot = { status: "blocked", address, ...blocked, draft: draft() };
|
|
359
|
+
else if (base && desired)
|
|
360
|
+
snapshot = { status: "ready", address, ownerSession: options.ownerSession, acknowledged: base, desired: desired.value, dirty: dirty(), saving: networkActive, localGeneration, remotePending };
|
|
361
|
+
else
|
|
362
|
+
snapshot = { status: "loading", address };
|
|
363
|
+
for (const listener of listeners) {
|
|
364
|
+
try {
|
|
365
|
+
listener();
|
|
366
|
+
} catch {}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
function block(reason, message, conflict2 = null) {
|
|
370
|
+
clearTimer();
|
|
371
|
+
blocked = { reason, message, conflict: conflict2 };
|
|
372
|
+
emit();
|
|
373
|
+
}
|
|
374
|
+
function sessionCheck() {
|
|
375
|
+
if (!active())
|
|
376
|
+
return err("disposed", "This state controller is no longer active.");
|
|
377
|
+
const result = options.currentSession();
|
|
378
|
+
if (!result.ok) {
|
|
379
|
+
block("identity-unavailable", result.error.message);
|
|
380
|
+
return result;
|
|
381
|
+
}
|
|
382
|
+
const session = result.value;
|
|
383
|
+
if (session.status !== "ready") {
|
|
384
|
+
block("identity-unavailable", session.error.message);
|
|
385
|
+
return { ok: false, error: session.error };
|
|
386
|
+
}
|
|
387
|
+
if (session.instanceId !== address.instanceId || session.actor.identity.key !== expected.actor || session.stamp !== expected.session) {
|
|
388
|
+
block("owner-changed", "The identity session changed.");
|
|
389
|
+
return err("stale-context", "The identity session changed.", "after-refresh");
|
|
390
|
+
}
|
|
391
|
+
return ok(undefined);
|
|
392
|
+
}
|
|
393
|
+
async function safe(run) {
|
|
394
|
+
try {
|
|
395
|
+
return await run();
|
|
396
|
+
} catch (error) {
|
|
397
|
+
return err("unavailable", error instanceof Error ? error.message : "State operation failed.", "after-reconnect");
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
function persist(captured = draft()) {
|
|
401
|
+
if (!drafts)
|
|
402
|
+
return Promise.resolve(ok(null));
|
|
403
|
+
if (!captured)
|
|
404
|
+
return Promise.resolve(ok(null));
|
|
405
|
+
const capturedEpoch = epoch;
|
|
406
|
+
const serial = ++persistSerial;
|
|
407
|
+
const work = persistQueue.then(async () => {
|
|
408
|
+
const result = await safe(() => drafts.write(captured, checkpoint?.revision ?? null));
|
|
409
|
+
if (result.ok)
|
|
410
|
+
checkpoint = result.value;
|
|
411
|
+
else if (active(capturedEpoch) && serial === persistSerial)
|
|
412
|
+
block("storage-error", result.error.message);
|
|
413
|
+
return result;
|
|
414
|
+
});
|
|
415
|
+
persistQueue = work.then(() => {
|
|
416
|
+
return;
|
|
417
|
+
});
|
|
418
|
+
return work;
|
|
419
|
+
}
|
|
420
|
+
function schedule() {
|
|
421
|
+
clearTimer();
|
|
422
|
+
if (!active() || automaticSuspensions || closing || blocked || pending || !needsSave())
|
|
423
|
+
return;
|
|
424
|
+
timer = scheduler.schedule(debounce, () => {
|
|
425
|
+
timer = null;
|
|
426
|
+
sendNew(true);
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
function readClone(read) {
|
|
430
|
+
const decoded = codecs.read.decode(structuredClone(codecs.read.encode(read)));
|
|
431
|
+
if (!decoded.ok)
|
|
432
|
+
return decoded;
|
|
433
|
+
const value = decoded.value;
|
|
434
|
+
const returnedAddress = value.status === "present" ? value.envelope.address : value.address;
|
|
435
|
+
if (stateAddressKey(returnedAddress) !== stateAddressKey(address) || value.status === "present" && value.envelope.schemaVersion !== definition.schemaVersion)
|
|
436
|
+
return err("incompatible", "State response does not match this resource.");
|
|
437
|
+
return ok(frozen(value));
|
|
438
|
+
}
|
|
439
|
+
async function applyDecision(context, decision) {
|
|
440
|
+
if (!active() || blocked?.conflict?.token !== context.token)
|
|
441
|
+
return err("stale-context", "Conflict decision was superseded.");
|
|
442
|
+
if (decision.kind === "needs-review") {
|
|
443
|
+
block("conflict", decision.reason, context);
|
|
444
|
+
return ok(undefined);
|
|
445
|
+
}
|
|
446
|
+
if (context.remote.status === "migration-required") {
|
|
447
|
+
block("migration-required", "State migration is required.");
|
|
448
|
+
return err("conflict", "State migration is required.");
|
|
449
|
+
}
|
|
450
|
+
base = context.remote;
|
|
451
|
+
desired = { value: valueClone(codec, decision.kind === "rebase" ? decision.value : currentValue(base)) };
|
|
452
|
+
localGeneration++;
|
|
453
|
+
blocked = null;
|
|
454
|
+
emit();
|
|
455
|
+
const capturedEpoch = epoch;
|
|
456
|
+
const saved = await persist();
|
|
457
|
+
if (!saved.ok)
|
|
458
|
+
return saved;
|
|
459
|
+
if (active(capturedEpoch))
|
|
460
|
+
schedule();
|
|
461
|
+
return ok(undefined);
|
|
462
|
+
}
|
|
463
|
+
async function conflict(cause, remote, original) {
|
|
464
|
+
if (!desired)
|
|
465
|
+
return err("unavailable", "State has no local value.");
|
|
466
|
+
const context = frozen({ cause, token: newConflictToken(), ownerSession: options.ownerSession, base: original, local: desired.value, remote });
|
|
467
|
+
block("conflict", "Remote state conflicts with local intent.", context);
|
|
468
|
+
try {
|
|
469
|
+
return await applyDecision(context, options.onConflict(context));
|
|
470
|
+
} catch (error) {
|
|
471
|
+
if (blocked?.conflict?.token !== context.token)
|
|
472
|
+
return err("stale-context", "Conflict decision was superseded.");
|
|
473
|
+
block("conflict", error instanceof Error ? error.message : "Conflict policy failed.", context);
|
|
474
|
+
return err("conflict", "Conflict policy failed.");
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
async function applyRemote(read, cause) {
|
|
478
|
+
if (read.status === "migration-required") {
|
|
479
|
+
block("migration-required", "State migration is required.");
|
|
480
|
+
return err("conflict", "State migration is required.");
|
|
481
|
+
}
|
|
482
|
+
if (pending) {
|
|
483
|
+
remotePending = true;
|
|
484
|
+
emit();
|
|
485
|
+
return ok(undefined);
|
|
486
|
+
}
|
|
487
|
+
if (base && version(base).epoch === version(read).epoch && version(read).sequence < version(base).sequence)
|
|
488
|
+
return ok(undefined);
|
|
489
|
+
if (base && dirty()) {
|
|
490
|
+
if (sameVersion(base, read)) {
|
|
491
|
+
if (!blocked?.conflict)
|
|
492
|
+
blocked = null;
|
|
493
|
+
emit();
|
|
494
|
+
schedule();
|
|
495
|
+
return ok(undefined);
|
|
496
|
+
}
|
|
497
|
+
return conflict(cause, read, base);
|
|
498
|
+
}
|
|
499
|
+
base = read;
|
|
500
|
+
desired = { value: valueClone(codec, currentValue(read)) };
|
|
501
|
+
blocked = null;
|
|
502
|
+
emit();
|
|
503
|
+
schedule();
|
|
504
|
+
return ok(undefined);
|
|
505
|
+
}
|
|
506
|
+
function queueRemote() {
|
|
507
|
+
if (!active() || activeLoad || pending)
|
|
508
|
+
return;
|
|
509
|
+
reload("reconnect");
|
|
510
|
+
}
|
|
511
|
+
function reload(cause) {
|
|
512
|
+
const valid = sessionCheck();
|
|
513
|
+
if (!valid.ok)
|
|
514
|
+
return Promise.resolve(valid);
|
|
515
|
+
const capturedEpoch = epoch;
|
|
516
|
+
const id = ++loadId;
|
|
517
|
+
const serial = remoteSerial;
|
|
518
|
+
remotePending = false;
|
|
519
|
+
const work = (async () => {
|
|
520
|
+
const loaded = await safe(() => options.transport.load({ address, expected }, { signal: abort.signal }));
|
|
521
|
+
if (!active(capturedEpoch) || id !== loadId || serial !== remoteSerial)
|
|
522
|
+
return err("cancelled", "State load was superseded.");
|
|
523
|
+
const live = sessionCheck();
|
|
524
|
+
if (!live.ok)
|
|
525
|
+
return live;
|
|
526
|
+
if (!loaded.ok) {
|
|
527
|
+
block("storage-error", loaded.error.message);
|
|
528
|
+
return loaded;
|
|
529
|
+
}
|
|
530
|
+
const parsed = readClone(loaded.value);
|
|
531
|
+
if (!parsed.ok) {
|
|
532
|
+
block("storage-error", parsed.error.message);
|
|
533
|
+
return parsed;
|
|
534
|
+
}
|
|
535
|
+
return applyRemote(parsed.value, cause);
|
|
536
|
+
})();
|
|
537
|
+
activeLoad = work;
|
|
538
|
+
work.then(() => {
|
|
539
|
+
if (activeLoad === work) {
|
|
540
|
+
activeLoad = null;
|
|
541
|
+
if (active(capturedEpoch) && remoteSerial !== serial)
|
|
542
|
+
queueRemote();
|
|
543
|
+
}
|
|
544
|
+
});
|
|
545
|
+
return work;
|
|
546
|
+
}
|
|
547
|
+
function outcomeCheck(outcome, operationId) {
|
|
548
|
+
const parsed = codecs.save.decode(structuredClone(codecs.save.encode(outcome)));
|
|
549
|
+
if (!parsed.ok)
|
|
550
|
+
return parsed;
|
|
551
|
+
if (parsed.value.operationId !== operationId || parsed.value.status === "indeterminate")
|
|
552
|
+
return err("incompatible", "State receipt has a different operation.");
|
|
553
|
+
const read = "envelope" in parsed.value ? { status: "present", envelope: parsed.value.envelope } : parsed.value.current;
|
|
554
|
+
const checked = readClone(read);
|
|
555
|
+
return checked.ok ? ok(frozen(parsed.value)) : checked;
|
|
556
|
+
}
|
|
557
|
+
async function applyOutcome(captured, raw) {
|
|
558
|
+
const checked = outcomeCheck(raw, captured.mutation.operationId);
|
|
559
|
+
if (!checked.ok) {
|
|
560
|
+
block("indeterminate", checked.error.message);
|
|
561
|
+
return checked;
|
|
562
|
+
}
|
|
563
|
+
const outcome = checked.value;
|
|
564
|
+
pending = null;
|
|
565
|
+
loadId++;
|
|
566
|
+
blocked = null;
|
|
567
|
+
if (outcome.status === "saved" || outcome.status === "unchanged") {
|
|
568
|
+
base = { status: "present", envelope: outcome.envelope };
|
|
569
|
+
acknowledgedGeneration = Math.max(acknowledgedGeneration, captured.generation);
|
|
570
|
+
if (localGeneration === captured.generation)
|
|
571
|
+
desired = { value: valueClone(codec, outcome.envelope.value) };
|
|
572
|
+
emit();
|
|
573
|
+
} else if ("current" in outcome) {
|
|
574
|
+
if (outcome.current.status === "migration-required") {
|
|
575
|
+
block("migration-required", "State migration is required.");
|
|
576
|
+
return err("conflict", "State migration is required.");
|
|
577
|
+
}
|
|
578
|
+
if (outcome.status === "already-initialized" && localGeneration === captured.generation) {
|
|
579
|
+
base = outcome.current;
|
|
580
|
+
desired = { value: valueClone(codec, currentValue(base)) };
|
|
581
|
+
acknowledgedGeneration = captured.generation;
|
|
582
|
+
emit();
|
|
583
|
+
} else {
|
|
584
|
+
const result = await conflict("save-conflict", outcome.current, captured.base);
|
|
585
|
+
if (!result.ok)
|
|
586
|
+
return result;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
const capturedEpoch = epoch;
|
|
590
|
+
const saved = await persist();
|
|
591
|
+
if (!saved.ok)
|
|
592
|
+
return saved;
|
|
593
|
+
if (active(capturedEpoch)) {
|
|
594
|
+
if (remotePending)
|
|
595
|
+
return reload("reconnect");
|
|
596
|
+
schedule();
|
|
597
|
+
}
|
|
598
|
+
return ok(undefined);
|
|
599
|
+
}
|
|
600
|
+
function transmit(captured, automatic = false) {
|
|
601
|
+
const capturedEpoch = epoch;
|
|
602
|
+
const dispatchEpoch = automaticEpoch;
|
|
603
|
+
let suppressed = false;
|
|
604
|
+
const work = (async () => {
|
|
605
|
+
networkActive = true;
|
|
606
|
+
emit();
|
|
607
|
+
const persisted = await persist();
|
|
608
|
+
if (!active(capturedEpoch))
|
|
609
|
+
return err("cancelled", "State save was detached.");
|
|
610
|
+
if (!persisted.ok) {
|
|
611
|
+
pending = null;
|
|
612
|
+
networkActive = false;
|
|
613
|
+
emit();
|
|
614
|
+
return persisted;
|
|
615
|
+
}
|
|
616
|
+
if (automatic && (automaticSuspensions || automaticEpoch !== dispatchEpoch)) {
|
|
617
|
+
suppressed = true;
|
|
618
|
+
if (pending === captured)
|
|
619
|
+
pending = null;
|
|
620
|
+
networkActive = false;
|
|
621
|
+
emit();
|
|
622
|
+
const saved = await persist();
|
|
623
|
+
return saved.ok ? ok(undefined) : saved;
|
|
624
|
+
}
|
|
625
|
+
const valid = sessionCheck();
|
|
626
|
+
if (!valid.ok) {
|
|
627
|
+
networkActive = false;
|
|
628
|
+
return valid;
|
|
629
|
+
}
|
|
630
|
+
const response = await safe(() => options.transport.save(captured.mutation, { signal: abort.signal }));
|
|
631
|
+
if (!active(capturedEpoch) || pending !== captured)
|
|
632
|
+
return err("cancelled", "State save was detached.");
|
|
633
|
+
networkActive = false;
|
|
634
|
+
const live = sessionCheck();
|
|
635
|
+
if (!live.ok)
|
|
636
|
+
return live;
|
|
637
|
+
if (!response.ok) {
|
|
638
|
+
block("indeterminate", response.error.message);
|
|
639
|
+
return response;
|
|
640
|
+
}
|
|
641
|
+
if (response.value.operationId !== captured.mutation.operationId) {
|
|
642
|
+
block("indeterminate", "State operation identity changed.");
|
|
643
|
+
return err("incompatible", "State operation identity changed.");
|
|
644
|
+
}
|
|
645
|
+
if (response.value.status === "indeterminate") {
|
|
646
|
+
block("indeterminate", "State save outcome is unknown.");
|
|
647
|
+
return err("unavailable", "State save outcome is unknown.", "same-operation");
|
|
648
|
+
}
|
|
649
|
+
return applyOutcome(captured, response.value);
|
|
650
|
+
})();
|
|
651
|
+
activeSave = work;
|
|
652
|
+
work.then(() => {
|
|
653
|
+
if (activeSave === work)
|
|
654
|
+
activeSave = null;
|
|
655
|
+
if (suppressed)
|
|
656
|
+
schedule();
|
|
657
|
+
});
|
|
658
|
+
return work;
|
|
659
|
+
}
|
|
660
|
+
function sendNew(automatic = false) {
|
|
661
|
+
if (automatic && automaticSuspensions)
|
|
662
|
+
return Promise.resolve(ok(undefined));
|
|
663
|
+
const valid = sessionCheck();
|
|
664
|
+
if (!valid.ok)
|
|
665
|
+
return Promise.resolve(valid);
|
|
666
|
+
if (blocked)
|
|
667
|
+
return Promise.resolve(err("conflict", blocked.message));
|
|
668
|
+
if (activeSave)
|
|
669
|
+
return activeSave;
|
|
670
|
+
if (pending)
|
|
671
|
+
return Promise.resolve(err("unavailable", "An earlier save needs reconciliation.", "same-operation"));
|
|
672
|
+
if (!base || !desired || !needsSave())
|
|
673
|
+
return Promise.resolve(ok(undefined));
|
|
674
|
+
const mutation = frozen({ kind: base.status === "empty" ? "initialize" : "replace", address, expectedVersion: { ...version(base) }, expected, ownerSession: options.ownerSession, localGeneration, operationId: newOperationId(), schemaVersion: definition.schemaVersion, value: valueClone(codec, desired.value) });
|
|
675
|
+
pending = { mutation, base, generation: localGeneration };
|
|
676
|
+
return transmit(pending, automatic);
|
|
677
|
+
}
|
|
678
|
+
let activeReconcile = null;
|
|
679
|
+
function reconcile(allowReplay) {
|
|
680
|
+
if (activeReconcile)
|
|
681
|
+
return activeReconcile;
|
|
682
|
+
const work = performReconcile(allowReplay);
|
|
683
|
+
activeReconcile = work;
|
|
684
|
+
const clear = () => {
|
|
685
|
+
if (activeReconcile === work)
|
|
686
|
+
activeReconcile = null;
|
|
687
|
+
};
|
|
688
|
+
work.then(clear, clear);
|
|
689
|
+
return work;
|
|
690
|
+
}
|
|
691
|
+
async function performReconcile(allowReplay) {
|
|
692
|
+
const captured = pending;
|
|
693
|
+
if (!captured)
|
|
694
|
+
return ok(undefined);
|
|
695
|
+
const capturedEpoch = epoch;
|
|
696
|
+
const valid = sessionCheck();
|
|
697
|
+
if (!valid.ok)
|
|
698
|
+
return valid;
|
|
699
|
+
const result = await safe(() => options.transport.reconcile({ address, expected, operationId: captured.mutation.operationId }, { signal: abort.signal }));
|
|
700
|
+
if (!active(capturedEpoch) || pending !== captured)
|
|
701
|
+
return err("cancelled", "State reconciliation was superseded.");
|
|
702
|
+
const live = sessionCheck();
|
|
703
|
+
if (!live.ok)
|
|
704
|
+
return live;
|
|
705
|
+
if (!result.ok) {
|
|
706
|
+
block("indeterminate", result.error.message);
|
|
707
|
+
return result;
|
|
708
|
+
}
|
|
709
|
+
if (result.value.status === "final")
|
|
710
|
+
return applyOutcome(captured, result.value.outcome);
|
|
711
|
+
if (result.value.status === "absent-final" && allowReplay) {
|
|
712
|
+
if (captured.mutation.expected.actor !== expected.actor || captured.mutation.expected.session !== expected.session) {
|
|
713
|
+
block("draft-recovery", "The original operation cannot be re-authored under a new session.");
|
|
714
|
+
return err("stale-context", "The original operation requires its original session.");
|
|
715
|
+
}
|
|
716
|
+
blocked = null;
|
|
717
|
+
return transmit(captured);
|
|
718
|
+
}
|
|
719
|
+
block("indeterminate", "The previous save remains unresolved; no replacement was sent.");
|
|
720
|
+
return err("unavailable", "The previous save remains unresolved.", "same-operation");
|
|
721
|
+
}
|
|
722
|
+
async function performFlush() {
|
|
723
|
+
clearTimer();
|
|
724
|
+
const valid = sessionCheck();
|
|
725
|
+
if (!valid.ok)
|
|
726
|
+
return valid;
|
|
727
|
+
const goal = localGeneration;
|
|
728
|
+
let retries = 0;
|
|
729
|
+
if (activeSave) {
|
|
730
|
+
const result = await activeSave;
|
|
731
|
+
if (!result.ok)
|
|
732
|
+
return result;
|
|
733
|
+
}
|
|
734
|
+
while (active()) {
|
|
735
|
+
if (pending) {
|
|
736
|
+
const result2 = await reconcile(retries++ < retryLimit);
|
|
737
|
+
if (!result2.ok)
|
|
738
|
+
return result2;
|
|
739
|
+
}
|
|
740
|
+
if (blocked)
|
|
741
|
+
return err("conflict", blocked.message);
|
|
742
|
+
if (!needsSave() || acknowledgedGeneration >= goal)
|
|
743
|
+
return ok(undefined);
|
|
744
|
+
const result = await sendNew();
|
|
745
|
+
if (!result.ok)
|
|
746
|
+
return result;
|
|
747
|
+
}
|
|
748
|
+
return err("disposed", "The state controller is detached.");
|
|
749
|
+
}
|
|
750
|
+
async function flush() {
|
|
751
|
+
let cancel = () => {};
|
|
752
|
+
const timeout = new Promise((resolve) => {
|
|
753
|
+
cancel = scheduler.schedule(30000, () => {
|
|
754
|
+
if (active())
|
|
755
|
+
block("indeterminate", "Flush timed out; pending intent is retained.");
|
|
756
|
+
resolve(err("unavailable", "Flush timed out; pending intent is retained.", "same-operation"));
|
|
757
|
+
});
|
|
758
|
+
});
|
|
759
|
+
try {
|
|
760
|
+
return await Promise.race([performFlush(), timeout]);
|
|
761
|
+
} finally {
|
|
762
|
+
cancel();
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
function detach() {
|
|
766
|
+
if (lifecycle === "detached" || lifecycle === "disposed")
|
|
767
|
+
return draft();
|
|
768
|
+
lifecycle = "detached";
|
|
769
|
+
epoch++;
|
|
770
|
+
loadId++;
|
|
771
|
+
clearTimer();
|
|
772
|
+
abort.abort();
|
|
773
|
+
subscription?.();
|
|
774
|
+
subscription = null;
|
|
775
|
+
emit();
|
|
776
|
+
return draft();
|
|
777
|
+
}
|
|
778
|
+
return {
|
|
779
|
+
getSnapshot: () => snapshot,
|
|
780
|
+
subscribe(listener) {
|
|
781
|
+
if (lifecycle === "disposed")
|
|
782
|
+
return () => {};
|
|
783
|
+
listeners.add(listener);
|
|
784
|
+
return () => listeners.delete(listener);
|
|
785
|
+
},
|
|
786
|
+
start() {
|
|
787
|
+
if (lifecycle === "detached" || lifecycle === "disposed")
|
|
788
|
+
return Promise.resolve(err("disposed", "Create a new state controller after detach."));
|
|
789
|
+
if (startWork)
|
|
790
|
+
return startWork;
|
|
791
|
+
lifecycle = "active";
|
|
792
|
+
subscription = options.transport.subscribe(address, (event) => {
|
|
793
|
+
if (!active() || stateAddressKey(event.address) !== stateAddressKey(address))
|
|
794
|
+
return;
|
|
795
|
+
if (base && event.version.epoch === version(base).epoch && event.version.sequence <= version(base).sequence)
|
|
796
|
+
return;
|
|
797
|
+
if (remoteHint && event.version.epoch === remoteHint.epoch && event.version.sequence <= remoteHint.sequence)
|
|
798
|
+
return;
|
|
799
|
+
remoteHint = { ...event.version };
|
|
800
|
+
remotePending = true;
|
|
801
|
+
remoteSerial++;
|
|
802
|
+
if (blocked?.conflict)
|
|
803
|
+
block("conflict", "Refreshing the remote state before reviewing this conflict.");
|
|
804
|
+
else
|
|
805
|
+
emit();
|
|
806
|
+
queueRemote();
|
|
807
|
+
});
|
|
808
|
+
startWork = reload("dirty-load");
|
|
809
|
+
return startWork;
|
|
810
|
+
},
|
|
811
|
+
edit(value) {
|
|
812
|
+
const valid = sessionCheck();
|
|
813
|
+
if (!valid.ok)
|
|
814
|
+
return valid;
|
|
815
|
+
if (closing || blocked || snapshot.status !== "ready")
|
|
816
|
+
return err("unavailable", "State is not editable.");
|
|
817
|
+
try {
|
|
818
|
+
desired = { value: valueClone(codec, value) };
|
|
819
|
+
} catch (error) {
|
|
820
|
+
return err("invalid-input", error instanceof Error ? error.message : "Invalid state.");
|
|
821
|
+
}
|
|
822
|
+
localGeneration++;
|
|
823
|
+
emit();
|
|
824
|
+
schedule();
|
|
825
|
+
return ok(undefined);
|
|
826
|
+
},
|
|
827
|
+
suspendAutomaticDispatch() {
|
|
828
|
+
automaticSuspensions++;
|
|
829
|
+
automaticEpoch++;
|
|
830
|
+
clearTimer();
|
|
831
|
+
let released = false;
|
|
832
|
+
return () => {
|
|
833
|
+
if (released)
|
|
834
|
+
return;
|
|
835
|
+
released = true;
|
|
836
|
+
automaticSuspensions--;
|
|
837
|
+
if (!automaticSuspensions)
|
|
838
|
+
schedule();
|
|
839
|
+
};
|
|
840
|
+
},
|
|
841
|
+
flush,
|
|
842
|
+
async reconnect(input) {
|
|
843
|
+
const valid = sessionCheck();
|
|
844
|
+
if (!valid.ok)
|
|
845
|
+
return valid;
|
|
846
|
+
const existing = activeSave ?? activeReconcile ?? activeLoad;
|
|
847
|
+
if (input?.reuseInFlight && existing)
|
|
848
|
+
return existing;
|
|
849
|
+
if (activeSave) {
|
|
850
|
+
const settled = await activeSave;
|
|
851
|
+
if (!settled.ok && !pending)
|
|
852
|
+
return settled;
|
|
853
|
+
}
|
|
854
|
+
if (pending) {
|
|
855
|
+
const result = await reconcile(false);
|
|
856
|
+
if (!result.ok)
|
|
857
|
+
return result;
|
|
858
|
+
}
|
|
859
|
+
return reload("reconnect");
|
|
860
|
+
},
|
|
861
|
+
async resolveConflict(token, decision) {
|
|
862
|
+
const valid = sessionCheck();
|
|
863
|
+
if (!valid.ok)
|
|
864
|
+
return valid;
|
|
865
|
+
const context = blocked?.conflict;
|
|
866
|
+
if (!context || context.token !== token)
|
|
867
|
+
return err("stale-context", "Conflict decision was superseded.");
|
|
868
|
+
return applyDecision(context, decision);
|
|
869
|
+
},
|
|
870
|
+
async recover(candidate) {
|
|
871
|
+
if (!drafts)
|
|
872
|
+
return err("unsupported", "Draft recovery is disabled for this state.");
|
|
873
|
+
const valid = sessionCheck();
|
|
874
|
+
if (!valid.ok)
|
|
875
|
+
return valid;
|
|
876
|
+
if (activeSave || pending || dirty())
|
|
877
|
+
return err("conflict", "Resolve current edits before recovering another draft.");
|
|
878
|
+
const source = candidate.draft;
|
|
879
|
+
if (source.formatVersion !== 1 || source.key.actor !== expected.actor || source.schemaVersion !== definition.schemaVersion || stateAddressKey(source.key.address) !== stateAddressKey(address))
|
|
880
|
+
return err("invalid-input", "Draft does not match this resource and actor.");
|
|
881
|
+
const parsed = readClone(source.acknowledged);
|
|
882
|
+
if (!parsed.ok || parsed.value.status === "migration-required")
|
|
883
|
+
return err("invalid-input", "Draft base is invalid.");
|
|
884
|
+
let mutation = null;
|
|
885
|
+
if (source.inFlight) {
|
|
886
|
+
const result = codecs.mutation.decode(structuredClone(codecs.mutation.encode(source.inFlight)));
|
|
887
|
+
if (!result.ok || stateAddressKey(result.value.address) !== stateAddressKey(address) || result.value.schemaVersion !== definition.schemaVersion || result.value.expected.actor !== expected.actor || result.value.localGeneration > source.localGeneration)
|
|
888
|
+
return err("invalid-input", "Draft operation does not match its owner or generation.");
|
|
889
|
+
mutation = frozen(result.value);
|
|
890
|
+
}
|
|
891
|
+
clearTimer();
|
|
892
|
+
base = parsed.value;
|
|
893
|
+
desired = { value: valueClone(codec, source.desired) };
|
|
894
|
+
localGeneration = source.localGeneration;
|
|
895
|
+
recoverySource = frozen(structuredClone(candidate));
|
|
896
|
+
checkpoint = source.key.ownerSession === options.ownerSession ? recoverySource : null;
|
|
897
|
+
if (mutation) {
|
|
898
|
+
pending = { mutation, base, generation: mutation.localGeneration };
|
|
899
|
+
block("draft-recovery", "Reconciling the original operation.");
|
|
900
|
+
const result = await reconcile(false);
|
|
901
|
+
if (!result.ok)
|
|
902
|
+
return result;
|
|
903
|
+
}
|
|
904
|
+
const capturedEpoch = epoch;
|
|
905
|
+
const loaded = await reload("draft-recovery");
|
|
906
|
+
if (!loaded.ok || !active(capturedEpoch))
|
|
907
|
+
return loaded;
|
|
908
|
+
return ok(undefined);
|
|
909
|
+
},
|
|
910
|
+
async discardRecovery(candidate) {
|
|
911
|
+
if (!drafts)
|
|
912
|
+
return err("unsupported", "Draft recovery is disabled for this state.");
|
|
913
|
+
if (!active())
|
|
914
|
+
return err("disposed", "This state controller is no longer active.");
|
|
915
|
+
const source = candidate.draft;
|
|
916
|
+
if (source.formatVersion !== 1 || source.key.actor !== expected.actor || source.schemaVersion !== definition.schemaVersion || stateAddressKey(source.key.address) !== stateAddressKey(address)) {
|
|
917
|
+
return err("invalid-input", "Draft does not match this resource and actor.");
|
|
918
|
+
}
|
|
919
|
+
const removed = await safe(() => drafts.remove(source.key, candidate.revision));
|
|
920
|
+
if (!removed.ok || !active())
|
|
921
|
+
return removed.ok ? err("disposed", "This state controller is no longer active.") : removed;
|
|
922
|
+
const sameCheckpoint = (entry) => entry !== null && entry.revision === candidate.revision && entry.draft.key.actor === source.key.actor && entry.draft.key.ownerSession === source.key.ownerSession && stateAddressKey(entry.draft.key.address) === stateAddressKey(source.key.address);
|
|
923
|
+
if (sameCheckpoint(checkpoint))
|
|
924
|
+
checkpoint = null;
|
|
925
|
+
if (sameCheckpoint(recoverySource))
|
|
926
|
+
recoverySource = null;
|
|
927
|
+
return ok(undefined);
|
|
928
|
+
},
|
|
929
|
+
async recoveryCandidates(readOptions) {
|
|
930
|
+
if (readOptions?.signal?.aborted)
|
|
931
|
+
return err("cancelled", "Recovery lookup was cancelled.");
|
|
932
|
+
if (!drafts)
|
|
933
|
+
return ok([]);
|
|
934
|
+
const result = await safe(() => drafts.find(address, expected.actor, 20));
|
|
935
|
+
if (readOptions?.signal?.aborted)
|
|
936
|
+
return err("cancelled", "Recovery lookup was cancelled.");
|
|
937
|
+
return result;
|
|
938
|
+
},
|
|
939
|
+
checkpoint: () => persist(),
|
|
940
|
+
detach,
|
|
941
|
+
async close(input) {
|
|
942
|
+
if (!drafts) {
|
|
943
|
+
detach();
|
|
944
|
+
return ok(undefined);
|
|
945
|
+
}
|
|
946
|
+
if (input.pending === "flush") {
|
|
947
|
+
closing = true;
|
|
948
|
+
const result = await flush();
|
|
949
|
+
const retained2 = detach();
|
|
950
|
+
if (!result.ok) {
|
|
951
|
+
await persist(retained2);
|
|
952
|
+
return result;
|
|
953
|
+
}
|
|
954
|
+
return ok(undefined);
|
|
955
|
+
}
|
|
956
|
+
const retained = detach();
|
|
957
|
+
if (input.pending === "preserve") {
|
|
958
|
+
const result = await persist(retained);
|
|
959
|
+
return result.ok ? ok(undefined) : result;
|
|
960
|
+
}
|
|
961
|
+
await persistQueue;
|
|
962
|
+
const candidates = new Map;
|
|
963
|
+
for (const entry of [checkpoint, recoverySource])
|
|
964
|
+
if (entry)
|
|
965
|
+
candidates.set(JSON.stringify(entry.draft.key), entry);
|
|
966
|
+
for (const entry of candidates.values()) {
|
|
967
|
+
const removed = await safe(() => drafts.remove(entry.draft.key, entry.revision));
|
|
968
|
+
if (!removed.ok)
|
|
969
|
+
return removed;
|
|
970
|
+
}
|
|
971
|
+
return ok(undefined);
|
|
972
|
+
},
|
|
973
|
+
dispose() {
|
|
974
|
+
detach();
|
|
975
|
+
lifecycle = "disposed";
|
|
976
|
+
listeners.clear();
|
|
977
|
+
}
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// rpc-routes-runtime.ts
|
|
982
|
+
var identityRoutes = Object.freeze({
|
|
983
|
+
bootstrap: "bb-identity/v1/bootstrap",
|
|
984
|
+
selfProfile: "bb-identity/v1/selfProfile",
|
|
985
|
+
search: "bb-identity/v1/search",
|
|
986
|
+
profiles: "bb-identity/v1/profiles",
|
|
987
|
+
participants: "bb-identity/v1/participants",
|
|
988
|
+
participantPreviews: "bb-identity/v1/participantPreviews"
|
|
989
|
+
});
|
|
990
|
+
var identityStateRoutes = Object.freeze({
|
|
991
|
+
load: "bb-identity/v1/state/load",
|
|
992
|
+
save: "bb-identity/v1/state/save",
|
|
993
|
+
reconcile: "bb-identity/v1/state/reconcile"
|
|
994
|
+
});
|
|
995
|
+
var identityStateRpcMethods = Object.freeze({
|
|
996
|
+
[identityStateRoutes.load]: "bb-identity.v1.state.load",
|
|
997
|
+
[identityStateRoutes.save]: "bb-identity.v1.state.save",
|
|
998
|
+
[identityStateRoutes.reconcile]: "bb-identity.v1.state.reconcile"
|
|
999
|
+
});
|
|
1000
|
+
var identityRpcMethods = Object.freeze({
|
|
1001
|
+
[identityRoutes.bootstrap]: "bb-identity.v1.bootstrap",
|
|
1002
|
+
[identityRoutes.selfProfile]: "bb-identity.v1.selfProfile",
|
|
1003
|
+
[identityRoutes.search]: "bb-identity.v1.search",
|
|
1004
|
+
[identityRoutes.profiles]: "bb-identity.v1.profiles",
|
|
1005
|
+
[identityRoutes.participants]: "bb-identity.v1.participants",
|
|
1006
|
+
[identityRoutes.participantPreviews]: "bb-identity.v1.participantPreviews",
|
|
1007
|
+
...identityStateRpcMethods
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
// state-transport-runtime.ts
|
|
1011
|
+
function writeUnavailable(connection) {
|
|
1012
|
+
const health = connection.getHealth();
|
|
1013
|
+
if (health.identity.status === "healthy" && health.state.status === "healthy")
|
|
1014
|
+
return null;
|
|
1015
|
+
const failed = health.identity.status === "unavailable" ? health.identity : health.state.status === "unavailable" ? health.state : null;
|
|
1016
|
+
return failed ? err(failed.error.code, failed.error.message, failed.error.retry) : err("unavailable", "Identity and state links must be healthy before writing.", "after-reconnect");
|
|
1017
|
+
}
|
|
1018
|
+
function decodeError(value) {
|
|
1019
|
+
const codes = new Set(["unavailable", "unauthenticated", "unsupported", "incompatible", "invalid-input", "not-found", "ambiguous", "stale-owner", "stale-context", "conflict", "cancelled", "disposed", "limit-exceeded", "invalid-operation", "expired"]);
|
|
1020
|
+
const retries = new Set(["never", "after-refresh", "after-reconnect", "same-operation"]);
|
|
1021
|
+
return typeof value === "object" && value !== null && typeof value.code === "string" && codes.has(value.code) && typeof value.message === "string" && typeof value.retry === "string" && retries.has(value.retry) ? ok(value) : err("incompatible", "Malformed state route error.");
|
|
1022
|
+
}
|
|
1023
|
+
function decodeResult(value, decode) {
|
|
1024
|
+
if (!value || typeof value !== "object" || typeof value.ok !== "boolean")
|
|
1025
|
+
return err("incompatible", "Malformed state route Result.");
|
|
1026
|
+
if (!value.ok) {
|
|
1027
|
+
const failure2 = decodeError(value.error);
|
|
1028
|
+
return failure2.ok ? { ok: false, error: failure2.value } : failure2;
|
|
1029
|
+
}
|
|
1030
|
+
return decode(value.value);
|
|
1031
|
+
}
|
|
1032
|
+
function createStateTransport(options) {
|
|
1033
|
+
const codecs = stateCodecs(options.resource.definition.codec);
|
|
1034
|
+
const request = async (method, payload, decode, readOptions) => {
|
|
1035
|
+
if (readOptions?.signal?.aborted)
|
|
1036
|
+
return err("cancelled", "State request was cancelled.");
|
|
1037
|
+
try {
|
|
1038
|
+
return decodeResult(await options.connection.request(method, payload, readOptions), decode);
|
|
1039
|
+
} catch {
|
|
1040
|
+
return err("unavailable", "State route request failed.", "after-reconnect");
|
|
1041
|
+
}
|
|
1042
|
+
};
|
|
1043
|
+
return {
|
|
1044
|
+
load(input, readOptions) {
|
|
1045
|
+
return request(identityStateRoutes.load, { address: input.address, expected: input.expected }, codecs.read.decode, readOptions);
|
|
1046
|
+
},
|
|
1047
|
+
async save(input, readOptions) {
|
|
1048
|
+
const normalized = codecs.mutation.decode(structuredClone(codecs.mutation.encode(input)));
|
|
1049
|
+
if (!normalized.ok)
|
|
1050
|
+
return normalized;
|
|
1051
|
+
if (normalized.value.address.pluginId !== options.resource.pluginId || normalized.value.address.collection !== options.resource.definition.collection || normalized.value.schemaVersion !== options.resource.definition.schemaVersion) {
|
|
1052
|
+
return err("invalid-input", "State mutation does not match this resource.");
|
|
1053
|
+
}
|
|
1054
|
+
const unavailable = writeUnavailable(options.connection);
|
|
1055
|
+
if (unavailable)
|
|
1056
|
+
return unavailable;
|
|
1057
|
+
const refreshed = await options.connection.revalidate(readOptions);
|
|
1058
|
+
if (!refreshed.ok)
|
|
1059
|
+
return refreshed;
|
|
1060
|
+
const after = writeUnavailable(options.connection);
|
|
1061
|
+
if (after)
|
|
1062
|
+
return after;
|
|
1063
|
+
const response = await request(identityStateRoutes.save, codecs.mutation.encode(normalized.value), codecs.save.decode, readOptions);
|
|
1064
|
+
if (!response.ok)
|
|
1065
|
+
return response;
|
|
1066
|
+
const outcome = response.value;
|
|
1067
|
+
if (outcome.operationId !== normalized.value.operationId)
|
|
1068
|
+
return err("incompatible", "State route returned a different operation.");
|
|
1069
|
+
if (outcome.status === "indeterminate")
|
|
1070
|
+
return response;
|
|
1071
|
+
const read = "envelope" in outcome ? { status: "present", envelope: outcome.envelope } : outcome.current;
|
|
1072
|
+
const actual = read.status === "present" ? read.envelope.address : read.address;
|
|
1073
|
+
if (actual.instanceId !== normalized.value.address.instanceId || actual.pluginId !== normalized.value.address.pluginId || actual.collection !== normalized.value.address.collection || actual.recordId !== normalized.value.address.recordId || actual.owner !== normalized.value.address.owner || read.status === "present" && read.envelope.schemaVersion !== normalized.value.schemaVersion)
|
|
1074
|
+
return err("incompatible", "State route returned a mismatched resource record.");
|
|
1075
|
+
return response;
|
|
1076
|
+
},
|
|
1077
|
+
reconcile(input, readOptions) {
|
|
1078
|
+
return request(identityStateRoutes.reconcile, { address: input.address, expected: input.expected, operationId: input.operationId }, codecs.lookup.decode, readOptions);
|
|
1079
|
+
},
|
|
1080
|
+
subscribe(address, listener) {
|
|
1081
|
+
return options.connection.subscribe((event) => {
|
|
1082
|
+
if (event.kind !== "state")
|
|
1083
|
+
return;
|
|
1084
|
+
const value = codecs.invalidation.decode(event.event);
|
|
1085
|
+
if (value.ok && value.value.address.instanceId === address.instanceId && value.value.address.pluginId === address.pluginId && value.value.address.collection === address.collection && value.value.address.recordId === address.recordId && value.value.address.owner === address.owner)
|
|
1086
|
+
listener(value.value);
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
// client-state-binding-runtime.ts
|
|
1093
|
+
var waiting = Object.freeze({ status: "waiting-for-identity" });
|
|
1094
|
+
function bindIdentityState(options, retirementOwner) {
|
|
1095
|
+
let disposed = false;
|
|
1096
|
+
let viewDisposed = false;
|
|
1097
|
+
let current = null;
|
|
1098
|
+
let pause = null;
|
|
1099
|
+
let snapshot = waiting;
|
|
1100
|
+
let stateHealthy = options.client.connection.getHealth().state.status === "healthy";
|
|
1101
|
+
const listeners = new Set;
|
|
1102
|
+
const retirements = new Map;
|
|
1103
|
+
const publish = () => {
|
|
1104
|
+
for (const listener of [...listeners]) {
|
|
1105
|
+
try {
|
|
1106
|
+
listener();
|
|
1107
|
+
} catch {}
|
|
1108
|
+
}
|
|
1109
|
+
};
|
|
1110
|
+
const hasPending = (state) => state.status === "ready" ? state.dirty || state.saving : (state.status === "blocked" || state.status === "detached") && state.draft !== null;
|
|
1111
|
+
const live = () => {
|
|
1112
|
+
if (disposed || viewDisposed)
|
|
1113
|
+
return null;
|
|
1114
|
+
const result = options.client.currentSession();
|
|
1115
|
+
if (!result.ok || result.value.status !== "ready")
|
|
1116
|
+
return null;
|
|
1117
|
+
const session = result.value;
|
|
1118
|
+
const view = options.view.getSnapshot();
|
|
1119
|
+
if (options.target === "viewed-subject" && (view.status !== "ready" || view.session.instanceId !== session.instanceId || view.session.stamp !== session.stamp || view.session.actor.identity.key !== session.actor.identity.key))
|
|
1120
|
+
return null;
|
|
1121
|
+
const subject = options.target === "viewed-subject" && view.status === "ready" ? view.subject : session.actor.identity;
|
|
1122
|
+
return {
|
|
1123
|
+
session,
|
|
1124
|
+
subject,
|
|
1125
|
+
key: JSON.stringify([session.instanceId, session.actor.identity.key, session.stamp, subject.key]),
|
|
1126
|
+
writable: options.editPolicy === "collaborators" || subject.key === session.actor.identity.key
|
|
1127
|
+
};
|
|
1128
|
+
};
|
|
1129
|
+
const valid = (entry) => !disposed && current === entry && live()?.key === entry.key;
|
|
1130
|
+
const failedPreservation = (draft, error) => {
|
|
1131
|
+
if (draft) {
|
|
1132
|
+
try {
|
|
1133
|
+
options.onUnpersistedDraft?.(draft, error);
|
|
1134
|
+
} catch {}
|
|
1135
|
+
}
|
|
1136
|
+
};
|
|
1137
|
+
const retire = (reason, pending = "preserve") => {
|
|
1138
|
+
const entry = current;
|
|
1139
|
+
if (!entry)
|
|
1140
|
+
return;
|
|
1141
|
+
current = null;
|
|
1142
|
+
pause = null;
|
|
1143
|
+
entry.stop();
|
|
1144
|
+
const preserve = hasPending(entry.controller.getSnapshot());
|
|
1145
|
+
const draft = entry.controller.detach(reason);
|
|
1146
|
+
snapshot = entry.controller.getSnapshot();
|
|
1147
|
+
if (!preserve && pending !== "discard") {
|
|
1148
|
+
entry.controller.dispose();
|
|
1149
|
+
publish();
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1152
|
+
const group = retirements.get(entry.recoveryKey) ?? new Set;
|
|
1153
|
+
retirements.set(entry.recoveryKey, group);
|
|
1154
|
+
const task = Promise.resolve().then(() => entry.controller.close({ pending })).then((result) => {
|
|
1155
|
+
if (!result.ok)
|
|
1156
|
+
failedPreservation(draft, result.error);
|
|
1157
|
+
return result;
|
|
1158
|
+
}, () => {
|
|
1159
|
+
const failure2 = err("unavailable", "Draft preservation failed.", "after-refresh");
|
|
1160
|
+
if (!failure2.ok)
|
|
1161
|
+
failedPreservation(draft, failure2.error);
|
|
1162
|
+
return failure2;
|
|
1163
|
+
}).finally(() => {
|
|
1164
|
+
entry.controller.dispose();
|
|
1165
|
+
group.delete(task);
|
|
1166
|
+
if (!group.size)
|
|
1167
|
+
retirements.delete(entry.recoveryKey);
|
|
1168
|
+
if (!disposed)
|
|
1169
|
+
publish();
|
|
1170
|
+
});
|
|
1171
|
+
group.add(task);
|
|
1172
|
+
if (entry.preservation)
|
|
1173
|
+
retirementOwner?.begin(entry.preservation.storage, entry.preservation.scope, task);
|
|
1174
|
+
publish();
|
|
1175
|
+
};
|
|
1176
|
+
let observing = false;
|
|
1177
|
+
let observeAgain = false;
|
|
1178
|
+
const observe = () => {
|
|
1179
|
+
if (disposed || viewDisposed)
|
|
1180
|
+
return;
|
|
1181
|
+
if (observing) {
|
|
1182
|
+
observeAgain = true;
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
observing = true;
|
|
1186
|
+
try {
|
|
1187
|
+
do {
|
|
1188
|
+
observeAgain = false;
|
|
1189
|
+
const next = live();
|
|
1190
|
+
if (!next) {
|
|
1191
|
+
retire("identity-invalidated");
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1194
|
+
if (current?.key === next.key)
|
|
1195
|
+
return;
|
|
1196
|
+
retire("identity-invalidated");
|
|
1197
|
+
if (live()?.key !== next.key) {
|
|
1198
|
+
observeAgain = true;
|
|
1199
|
+
continue;
|
|
1200
|
+
}
|
|
1201
|
+
const owner = idCodec("owner-session").decode(`owner-${newOperationId()}`);
|
|
1202
|
+
if (!owner.ok)
|
|
1203
|
+
throw new Error(owner.error.message);
|
|
1204
|
+
const controller = options.create ? options.create({ session: next.session, subject: next.subject, ownerSession: owner.value }) : createIdentityState({
|
|
1205
|
+
address: {
|
|
1206
|
+
instanceId: next.session.instanceId,
|
|
1207
|
+
pluginId: options.resource.pluginId,
|
|
1208
|
+
collection: options.resource.definition.collection,
|
|
1209
|
+
recordId: options.recordId,
|
|
1210
|
+
owner: next.subject.key
|
|
1211
|
+
},
|
|
1212
|
+
expected: { actor: next.session.actor.identity.key, session: next.session.stamp },
|
|
1213
|
+
ownerSession: owner.value,
|
|
1214
|
+
definition: options.resource.definition,
|
|
1215
|
+
transport: createStateTransport({ connection: options.client.connection, resource: options.resource }),
|
|
1216
|
+
...options.drafts ? { drafts: options.drafts } : {},
|
|
1217
|
+
onConflict: options.onConflict,
|
|
1218
|
+
initializeEmpty: next.writable && options.initializeEmpty,
|
|
1219
|
+
...options.scheduler ? { scheduler: options.scheduler } : {},
|
|
1220
|
+
currentSession: () => options.client.currentSession()
|
|
1221
|
+
});
|
|
1222
|
+
if (live()?.key !== next.key) {
|
|
1223
|
+
controller.dispose();
|
|
1224
|
+
observeAgain = true;
|
|
1225
|
+
continue;
|
|
1226
|
+
}
|
|
1227
|
+
const preservation = options.resource && options.drafts ? { storage: options.drafts, scope: {
|
|
1228
|
+
address: controller.getSnapshot().address,
|
|
1229
|
+
actor: next.session.actor.identity.key,
|
|
1230
|
+
schemaVersion: options.resource.definition.schemaVersion
|
|
1231
|
+
} } : null;
|
|
1232
|
+
const entry = { key: next.key, recoveryKey: JSON.stringify([next.session.instanceId, next.session.actor.identity.key, next.subject.key]), owner: owner.value, controller, stop: () => {}, writable: next.writable, reconnect: null, preservation };
|
|
1233
|
+
current = entry;
|
|
1234
|
+
const update = () => {
|
|
1235
|
+
if (valid(entry)) {
|
|
1236
|
+
snapshot = controller.getSnapshot();
|
|
1237
|
+
publish();
|
|
1238
|
+
}
|
|
1239
|
+
};
|
|
1240
|
+
entry.stop = controller.subscribe(update);
|
|
1241
|
+
update();
|
|
1242
|
+
controller.start().then(update, update);
|
|
1243
|
+
} while (observeAgain && !disposed && !viewDisposed);
|
|
1244
|
+
} finally {
|
|
1245
|
+
observing = false;
|
|
1246
|
+
}
|
|
1247
|
+
};
|
|
1248
|
+
const command = async (owner, run, write = false) => {
|
|
1249
|
+
const entry = current;
|
|
1250
|
+
if (!entry || owner !== entry.owner || !valid(entry))
|
|
1251
|
+
return err("stale-context", "State controller ownership changed.");
|
|
1252
|
+
if (write && (!entry.writable || pause))
|
|
1253
|
+
return err("unsupported", "The selected state is not editable during this view or transition.");
|
|
1254
|
+
let result;
|
|
1255
|
+
try {
|
|
1256
|
+
result = await run(entry.controller);
|
|
1257
|
+
} catch {
|
|
1258
|
+
result = err("unavailable", "State operation failed.", "after-refresh");
|
|
1259
|
+
}
|
|
1260
|
+
return valid(entry) ? result : err("stale-context", "State controller ownership changed.");
|
|
1261
|
+
};
|
|
1262
|
+
const reconnect = (owner) => {
|
|
1263
|
+
const entry = current;
|
|
1264
|
+
if (entry === null) {
|
|
1265
|
+
return Promise.resolve(err("stale-context", "State controller ownership changed or is not writable."));
|
|
1266
|
+
}
|
|
1267
|
+
const eligible = () => current === entry && entry.owner === owner && valid(entry) && entry.writable && pause === null;
|
|
1268
|
+
if (!eligible()) {
|
|
1269
|
+
return Promise.resolve(err("stale-context", "State controller ownership changed or is not writable."));
|
|
1270
|
+
}
|
|
1271
|
+
if (entry.reconnect) {
|
|
1272
|
+
return entry.reconnect.then((result) => eligible() ? result : err("stale-context", "State controller ownership changed."));
|
|
1273
|
+
}
|
|
1274
|
+
const work = (async () => {
|
|
1275
|
+
if (!eligible())
|
|
1276
|
+
return err("stale-context", "State controller ownership changed or is not writable.");
|
|
1277
|
+
let verified;
|
|
1278
|
+
try {
|
|
1279
|
+
verified = await options.client.connection.revalidate();
|
|
1280
|
+
} catch {
|
|
1281
|
+
verified = err("unavailable", "State connection revalidation failed.", "after-refresh");
|
|
1282
|
+
}
|
|
1283
|
+
if (!eligible())
|
|
1284
|
+
return err("stale-context", "State controller ownership changed.");
|
|
1285
|
+
if (!verified.ok)
|
|
1286
|
+
return verified;
|
|
1287
|
+
let recovered;
|
|
1288
|
+
try {
|
|
1289
|
+
recovered = await entry.controller.reconnect();
|
|
1290
|
+
} catch {
|
|
1291
|
+
recovered = err("unavailable", "State reconciliation failed.", "after-refresh");
|
|
1292
|
+
}
|
|
1293
|
+
return eligible() ? recovered : err("stale-context", "State controller ownership changed.");
|
|
1294
|
+
})();
|
|
1295
|
+
entry.reconnect = work;
|
|
1296
|
+
work.finally(() => {
|
|
1297
|
+
if (entry.reconnect === work)
|
|
1298
|
+
entry.reconnect = null;
|
|
1299
|
+
});
|
|
1300
|
+
return work;
|
|
1301
|
+
};
|
|
1302
|
+
const guard = {
|
|
1303
|
+
invalidate(reason) {
|
|
1304
|
+
if (reason === "disposed")
|
|
1305
|
+
viewDisposed = true;
|
|
1306
|
+
retire("identity-invalidated");
|
|
1307
|
+
},
|
|
1308
|
+
inspect(transition) {
|
|
1309
|
+
const entry = current;
|
|
1310
|
+
const unaffected = options.target === "actor" || !entry;
|
|
1311
|
+
if (!unaffected && transition.pending === "block" && hasPending(entry.controller.getSnapshot()))
|
|
1312
|
+
return err("conflict", "Resolve or preserve pending edits before changing view.");
|
|
1313
|
+
const token = {};
|
|
1314
|
+
let committed = false;
|
|
1315
|
+
let resume = null;
|
|
1316
|
+
return ok({
|
|
1317
|
+
async prepare() {
|
|
1318
|
+
if (unaffected)
|
|
1319
|
+
return ok(undefined);
|
|
1320
|
+
if (!valid(entry) || transition.signal.aborted)
|
|
1321
|
+
return err("stale-context", "View transition expired.");
|
|
1322
|
+
pause = token;
|
|
1323
|
+
resume = entry.controller.suspendAutomaticDispatch();
|
|
1324
|
+
if (transition.pending === "block" && hasPending(entry.controller.getSnapshot()))
|
|
1325
|
+
return err("conflict", "New edits arrived before the view transition.");
|
|
1326
|
+
const result = transition.pending === "flush" ? await entry.controller.flush() : transition.pending === "preserve" ? await entry.controller.checkpoint() : ok(undefined);
|
|
1327
|
+
if (!result.ok)
|
|
1328
|
+
return result;
|
|
1329
|
+
return valid(entry) && !transition.signal.aborted ? ok(undefined) : err("stale-context", "View transition expired.");
|
|
1330
|
+
},
|
|
1331
|
+
commit() {
|
|
1332
|
+
committed = true;
|
|
1333
|
+
if (!unaffected && current === entry)
|
|
1334
|
+
retire("view-change", transition.pending === "discard" ? "discard" : "preserve");
|
|
1335
|
+
resume?.();
|
|
1336
|
+
resume = null;
|
|
1337
|
+
if (pause === token)
|
|
1338
|
+
pause = null;
|
|
1339
|
+
},
|
|
1340
|
+
cancel() {
|
|
1341
|
+
if (!committed && pause === token)
|
|
1342
|
+
pause = null;
|
|
1343
|
+
resume?.();
|
|
1344
|
+
resume = null;
|
|
1345
|
+
}
|
|
1346
|
+
});
|
|
1347
|
+
}
|
|
1348
|
+
};
|
|
1349
|
+
const stopGuard = options.view.registerGuard(guard);
|
|
1350
|
+
const stopView = options.view.subscribe(observe);
|
|
1351
|
+
const stopClient = options.client.subscribe(observe);
|
|
1352
|
+
const stopHealth = options.client.connection.subscribeHealth(() => {
|
|
1353
|
+
const healthy = options.client.connection.getHealth().state.status === "healthy";
|
|
1354
|
+
const reconnect2 = healthy && !stateHealthy;
|
|
1355
|
+
stateHealthy = healthy;
|
|
1356
|
+
const entry = current;
|
|
1357
|
+
if (reconnect2 && entry && valid(entry))
|
|
1358
|
+
entry.controller.reconnect({ reuseInFlight: true });
|
|
1359
|
+
});
|
|
1360
|
+
observe();
|
|
1361
|
+
return {
|
|
1362
|
+
getSnapshot: () => snapshot,
|
|
1363
|
+
subscribe(listener) {
|
|
1364
|
+
if (disposed)
|
|
1365
|
+
return () => {};
|
|
1366
|
+
listeners.add(listener);
|
|
1367
|
+
return () => listeners.delete(listener);
|
|
1368
|
+
},
|
|
1369
|
+
currentOwnerSession: () => current?.owner ?? null,
|
|
1370
|
+
edit(value) {
|
|
1371
|
+
const entry = current;
|
|
1372
|
+
if (!entry || !valid(entry))
|
|
1373
|
+
return err("stale-context", "State identity is not ready.");
|
|
1374
|
+
return entry.writable && !pause ? entry.controller.edit(value) : err("unsupported", "The selected state is read-only or changing view.");
|
|
1375
|
+
},
|
|
1376
|
+
flush() {
|
|
1377
|
+
return current ? command(current.owner, (controller) => controller.flush(), true) : Promise.resolve(err("stale-context", "State identity is not ready."));
|
|
1378
|
+
},
|
|
1379
|
+
reconnect(owner) {
|
|
1380
|
+
return reconnect(owner);
|
|
1381
|
+
},
|
|
1382
|
+
recoveryCandidates(owner, readOptions) {
|
|
1383
|
+
const entry = current;
|
|
1384
|
+
return command(owner, async (controller) => {
|
|
1385
|
+
if (!entry)
|
|
1386
|
+
return err("stale-context", "State controller ownership changed.");
|
|
1387
|
+
if (entry.preservation && retirementOwner) {
|
|
1388
|
+
const settled = await retirementOwner.wait(entry.preservation.storage, entry.preservation.scope, readOptions?.signal);
|
|
1389
|
+
if (!settled.ok)
|
|
1390
|
+
return settled;
|
|
1391
|
+
}
|
|
1392
|
+
while (retirements.get(entry.recoveryKey)?.size) {
|
|
1393
|
+
if (!valid(entry))
|
|
1394
|
+
return err("stale-context", "State controller ownership changed.");
|
|
1395
|
+
if (readOptions?.signal?.aborted)
|
|
1396
|
+
return err("cancelled", "Recovery lookup was cancelled.");
|
|
1397
|
+
const pending = Promise.all([...retirements.get(entry.recoveryKey)]);
|
|
1398
|
+
if (readOptions?.signal) {
|
|
1399
|
+
const signal = readOptions.signal;
|
|
1400
|
+
await new Promise((resolve) => {
|
|
1401
|
+
const done = () => {
|
|
1402
|
+
signal.removeEventListener("abort", done);
|
|
1403
|
+
resolve();
|
|
1404
|
+
};
|
|
1405
|
+
signal.addEventListener("abort", done, { once: true });
|
|
1406
|
+
pending.then(done);
|
|
1407
|
+
if (signal.aborted)
|
|
1408
|
+
done();
|
|
1409
|
+
});
|
|
1410
|
+
} else
|
|
1411
|
+
await pending;
|
|
1412
|
+
}
|
|
1413
|
+
if (!valid(entry))
|
|
1414
|
+
return err("stale-context", "State controller ownership changed.");
|
|
1415
|
+
return controller.recoveryCandidates(readOptions);
|
|
1416
|
+
});
|
|
1417
|
+
},
|
|
1418
|
+
checkpoint(owner) {
|
|
1419
|
+
return command(owner, (controller) => controller.checkpoint());
|
|
1420
|
+
},
|
|
1421
|
+
recover(owner, checkpoint) {
|
|
1422
|
+
return command(owner, (controller) => controller.recover(checkpoint), true);
|
|
1423
|
+
},
|
|
1424
|
+
discardRecovery(owner, checkpoint) {
|
|
1425
|
+
return command(owner, (controller) => controller.discardRecovery(checkpoint), true);
|
|
1426
|
+
},
|
|
1427
|
+
resolveConflict(owner, token, decision) {
|
|
1428
|
+
return command(owner, (controller) => controller.resolveConflict(token, decision), true);
|
|
1429
|
+
},
|
|
1430
|
+
dispose() {
|
|
1431
|
+
if (disposed)
|
|
1432
|
+
return;
|
|
1433
|
+
disposed = true;
|
|
1434
|
+
stopGuard();
|
|
1435
|
+
stopView();
|
|
1436
|
+
stopClient();
|
|
1437
|
+
stopHealth();
|
|
1438
|
+
retire("unmount");
|
|
1439
|
+
listeners.clear();
|
|
1440
|
+
}
|
|
1441
|
+
};
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
// client-connection-runtime.ts
|
|
1445
|
+
var errorCodes = new Set([
|
|
1446
|
+
"unavailable",
|
|
1447
|
+
"unauthenticated",
|
|
1448
|
+
"unsupported",
|
|
1449
|
+
"incompatible",
|
|
1450
|
+
"invalid-input",
|
|
1451
|
+
"not-found",
|
|
1452
|
+
"ambiguous",
|
|
1453
|
+
"stale-owner",
|
|
1454
|
+
"stale-context",
|
|
1455
|
+
"conflict",
|
|
1456
|
+
"cancelled",
|
|
1457
|
+
"disposed",
|
|
1458
|
+
"limit-exceeded",
|
|
1459
|
+
"invalid-operation",
|
|
1460
|
+
"expired"
|
|
1461
|
+
]);
|
|
1462
|
+
var retryKinds = new Set(["never", "after-refresh", "after-reconnect", "same-operation"]);
|
|
1463
|
+
function isRecord3(value) {
|
|
1464
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1465
|
+
}
|
|
1466
|
+
function decodeError2(value) {
|
|
1467
|
+
if (!isRecord3(value) || typeof value.code !== "string" || !errorCodes.has(value.code) || typeof value.message !== "string" || typeof value.retry !== "string" || !retryKinds.has(value.retry)) {
|
|
1468
|
+
return err("incompatible", "Malformed identity transport error.");
|
|
1469
|
+
}
|
|
1470
|
+
return ok({ code: value.code, message: value.message, retry: value.retry });
|
|
1471
|
+
}
|
|
1472
|
+
function isJson(value) {
|
|
1473
|
+
if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean")
|
|
1474
|
+
return true;
|
|
1475
|
+
if (Array.isArray(value))
|
|
1476
|
+
return value.every(isJson);
|
|
1477
|
+
return isRecord3(value) && Object.values(value).every(isJson);
|
|
1478
|
+
}
|
|
1479
|
+
var jsonCodec = {
|
|
1480
|
+
decode(value) {
|
|
1481
|
+
return isJson(value) ? ok(value) : err("invalid-input", "Expected JSON payload.");
|
|
1482
|
+
},
|
|
1483
|
+
encode(value) {
|
|
1484
|
+
return value;
|
|
1485
|
+
}
|
|
1486
|
+
};
|
|
1487
|
+
var stateInvalidationCodec = stateCodecs(jsonCodec).invalidation;
|
|
1488
|
+
var clientInvalidationCodec = {
|
|
1489
|
+
decode(value) {
|
|
1490
|
+
if (!isRecord3(value) || typeof value.kind !== "string")
|
|
1491
|
+
return err("invalid-input", "Invalid identity invalidation.");
|
|
1492
|
+
if (value.kind === "session") {
|
|
1493
|
+
const reason = value.reason;
|
|
1494
|
+
return reason === undefined ? ok({ kind: "session" }) : reason === "actor" || reason === "provider" || reason === "capabilities" ? ok({ kind: "session", reason }) : err("invalid-input", "Invalid session invalidation reason.");
|
|
1495
|
+
}
|
|
1496
|
+
if (value.kind === "disconnected" || value.kind === "reconnected")
|
|
1497
|
+
return ok({ kind: value.kind });
|
|
1498
|
+
if (value.kind === "directory" && Array.isArray(value.keys)) {
|
|
1499
|
+
const keys = value.keys.map((key) => identityKeyCodec.decode(key));
|
|
1500
|
+
const revision = value.revision === null ? ok(null) : idCodec("revision").decode(value.revision);
|
|
1501
|
+
return keys.every((key) => key.ok) && revision.ok ? ok({ kind: "directory", keys: keys.map((key) => key.value), revision: revision.value }) : err("invalid-input", "Invalid directory invalidation keys.");
|
|
1502
|
+
}
|
|
1503
|
+
if (value.kind === "participants" && Array.isArray(value.threadIds)) {
|
|
1504
|
+
const threadIds = value.threadIds.map((id) => idCodec("thread").decode(id));
|
|
1505
|
+
return threadIds.every((id) => id.ok) ? ok({ kind: "participants", threadIds: threadIds.map((id) => id.value) }) : err("invalid-input", "Invalid participant invalidation thread IDs.");
|
|
1506
|
+
}
|
|
1507
|
+
return err("invalid-input", "Unknown identity invalidation.");
|
|
1508
|
+
},
|
|
1509
|
+
encode(value) {
|
|
1510
|
+
return value;
|
|
1511
|
+
}
|
|
1512
|
+
};
|
|
1513
|
+
function decodeEvent(value) {
|
|
1514
|
+
const client = clientInvalidationCodec.decode(value);
|
|
1515
|
+
if (client.ok)
|
|
1516
|
+
return client.value;
|
|
1517
|
+
if (isRecord3(value) && value.kind === "state") {
|
|
1518
|
+
const state = stateInvalidationCodec.decode(value.event);
|
|
1519
|
+
if (state.ok)
|
|
1520
|
+
return { kind: "state", event: state.value };
|
|
1521
|
+
}
|
|
1522
|
+
return null;
|
|
1523
|
+
}
|
|
1524
|
+
function cloneHealth(value) {
|
|
1525
|
+
const clone = (link) => link.status === "unavailable" ? { status: "unavailable", error: { ...link.error } } : { status: link.status };
|
|
1526
|
+
return { generation: value.generation, identity: clone(value.identity), state: clone(value.state) };
|
|
1527
|
+
}
|
|
1528
|
+
function cancelled(options) {
|
|
1529
|
+
return options?.signal?.aborted ? err("cancelled", "Identity request was cancelled.") : null;
|
|
1530
|
+
}
|
|
1531
|
+
function createIdentityConnection(inputs) {
|
|
1532
|
+
let disposed = false;
|
|
1533
|
+
const channel = (subscribeSource) => {
|
|
1534
|
+
const listeners = new Set;
|
|
1535
|
+
let source = null;
|
|
1536
|
+
const close = () => {
|
|
1537
|
+
const previous = source;
|
|
1538
|
+
source = null;
|
|
1539
|
+
if (previous) {
|
|
1540
|
+
previous.closed = true;
|
|
1541
|
+
try {
|
|
1542
|
+
previous.stop?.();
|
|
1543
|
+
} catch {}
|
|
1544
|
+
previous.stop = null;
|
|
1545
|
+
}
|
|
1546
|
+
};
|
|
1547
|
+
return {
|
|
1548
|
+
subscribe(listener) {
|
|
1549
|
+
if (disposed)
|
|
1550
|
+
return () => {};
|
|
1551
|
+
const entry = { observer: listener };
|
|
1552
|
+
listeners.add(entry);
|
|
1553
|
+
if (!source) {
|
|
1554
|
+
const current = { closed: false, stop: null };
|
|
1555
|
+
source = current;
|
|
1556
|
+
try {
|
|
1557
|
+
const stop = subscribeSource((value) => {
|
|
1558
|
+
if (disposed || current.closed)
|
|
1559
|
+
return;
|
|
1560
|
+
for (const entry2 of [...listeners]) {
|
|
1561
|
+
if (disposed || current.closed)
|
|
1562
|
+
break;
|
|
1563
|
+
if (listeners.has(entry2)) {
|
|
1564
|
+
try {
|
|
1565
|
+
entry2.observer(value);
|
|
1566
|
+
} catch {}
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
});
|
|
1570
|
+
if (current.closed) {
|
|
1571
|
+
try {
|
|
1572
|
+
stop();
|
|
1573
|
+
} catch {}
|
|
1574
|
+
} else
|
|
1575
|
+
current.stop = stop;
|
|
1576
|
+
} catch (error) {
|
|
1577
|
+
if (source === current)
|
|
1578
|
+
source = null;
|
|
1579
|
+
current.closed = true;
|
|
1580
|
+
listeners.delete(entry);
|
|
1581
|
+
throw error;
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
let removed = false;
|
|
1585
|
+
return () => {
|
|
1586
|
+
if (removed)
|
|
1587
|
+
return;
|
|
1588
|
+
removed = true;
|
|
1589
|
+
listeners.delete(entry);
|
|
1590
|
+
if (!listeners.size)
|
|
1591
|
+
close();
|
|
1592
|
+
};
|
|
1593
|
+
},
|
|
1594
|
+
dispose() {
|
|
1595
|
+
close();
|
|
1596
|
+
listeners.clear();
|
|
1597
|
+
}
|
|
1598
|
+
};
|
|
1599
|
+
};
|
|
1600
|
+
const events = channel((listener) => inputs.subscribe((payload) => {
|
|
1601
|
+
const event = decodeEvent(payload);
|
|
1602
|
+
if (event)
|
|
1603
|
+
listener(event);
|
|
1604
|
+
}));
|
|
1605
|
+
const healthEvents = channel((listener) => inputs.subscribeHealth(() => listener()));
|
|
1606
|
+
return {
|
|
1607
|
+
getHealth() {
|
|
1608
|
+
return disposed ? { generation: -1, identity: { status: "unavailable", error: { code: "disposed", message: "Identity connection is disposed.", retry: "never" } }, state: { status: "unavailable", error: { code: "disposed", message: "Identity connection is disposed.", retry: "never" } } } : cloneHealth(inputs.getHealth());
|
|
1609
|
+
},
|
|
1610
|
+
subscribeHealth(listener) {
|
|
1611
|
+
return healthEvents.subscribe(listener);
|
|
1612
|
+
},
|
|
1613
|
+
async request(method, input, options) {
|
|
1614
|
+
const aborted = cancelled(options);
|
|
1615
|
+
if (aborted && !aborted.ok)
|
|
1616
|
+
throw new Error(aborted.error.message);
|
|
1617
|
+
if (disposed)
|
|
1618
|
+
throw new Error("Identity connection is disposed.");
|
|
1619
|
+
return inputs.request(method, input, options);
|
|
1620
|
+
},
|
|
1621
|
+
subscribe(listener) {
|
|
1622
|
+
return events.subscribe(listener);
|
|
1623
|
+
},
|
|
1624
|
+
async revalidate(options) {
|
|
1625
|
+
const aborted = cancelled(options);
|
|
1626
|
+
if (aborted)
|
|
1627
|
+
return aborted;
|
|
1628
|
+
if (disposed)
|
|
1629
|
+
return err("disposed", "Identity connection is disposed.");
|
|
1630
|
+
try {
|
|
1631
|
+
const result = await inputs.revalidate(options);
|
|
1632
|
+
if (disposed)
|
|
1633
|
+
return err("disposed", "Identity connection is disposed.");
|
|
1634
|
+
const aborted2 = cancelled(options);
|
|
1635
|
+
if (aborted2)
|
|
1636
|
+
return aborted2;
|
|
1637
|
+
if (!isRecord3(result) || typeof result.ok !== "boolean")
|
|
1638
|
+
return err("incompatible", "Malformed connection revalidation result.");
|
|
1639
|
+
if (result.ok)
|
|
1640
|
+
return ok(undefined);
|
|
1641
|
+
const failure2 = decodeError2(result.error);
|
|
1642
|
+
return failure2.ok ? { ok: false, error: failure2.value } : failure2;
|
|
1643
|
+
} catch {
|
|
1644
|
+
if (disposed)
|
|
1645
|
+
return err("disposed", "Identity connection is disposed.");
|
|
1646
|
+
const aborted2 = cancelled(options);
|
|
1647
|
+
if (aborted2)
|
|
1648
|
+
return aborted2;
|
|
1649
|
+
return err("unavailable", "Identity connection revalidation failed.", "after-reconnect");
|
|
1650
|
+
}
|
|
1651
|
+
},
|
|
1652
|
+
dispose() {
|
|
1653
|
+
if (disposed)
|
|
1654
|
+
return;
|
|
1655
|
+
disposed = true;
|
|
1656
|
+
events.dispose();
|
|
1657
|
+
healthEvents.dispose();
|
|
1658
|
+
inputs.dispose?.();
|
|
1659
|
+
}
|
|
1660
|
+
};
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
// host-runtime.ts
|
|
1664
|
+
function isRecord4(value) {
|
|
1665
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1666
|
+
}
|
|
1667
|
+
function isPresentation(value) {
|
|
1668
|
+
return isRecord4(value) && typeof value.displayName === "string" && (value.handle === null || typeof value.handle === "string") && (value.avatarUrl === null || typeof value.avatarUrl === "string");
|
|
1669
|
+
}
|
|
1670
|
+
function isPersonIdentity(value) {
|
|
1671
|
+
if (!isRecord4(value) || !identityKeyCodec.decode(value.key).ok)
|
|
1672
|
+
return false;
|
|
1673
|
+
return value.kind === "person" ? typeof value.issuer === "string" && typeof value.subject === "string" : value.kind === "default-user" && idCodec("instance").decode(value.instanceId).ok;
|
|
1674
|
+
}
|
|
1675
|
+
function isMachineIdentity(value) {
|
|
1676
|
+
return isRecord4(value) && value.kind === "machine" && identityKeyCodec.decode(value.key).ok && idCodec("instance").decode(value.instanceId).ok && (value.hostId === null || typeof value.hostId === "string" && value.hostId.length > 0 && value.hostId.length <= 512);
|
|
1677
|
+
}
|
|
1678
|
+
function isActorIdentity(value) {
|
|
1679
|
+
return isPersonIdentity(value) || isMachineIdentity(value);
|
|
1680
|
+
}
|
|
1681
|
+
function isCapabilities(value) {
|
|
1682
|
+
return isRecord4(value) && (value.requestIdentity === "singleton" || value.requestIdentity === "host-resolved") && (value.acceptance === "pre-dispatch-check" || value.acceptance === "transactional-check") && (value.forwarding === "singleton-convention" || value.forwarding === "host-bound") && isRecord4(value.directory) && typeof value.directory.search === "boolean" && typeof value.directory.lookup === "boolean" && typeof value.participants === "boolean" && (value.externalSend === "source-labelled" || value.externalSend === "structured") && (value.toolProvenance === "unknown" || value.toolProvenance === "partial" || value.toolProvenance === "causal") && typeof value.operationLookup === "boolean";
|
|
1683
|
+
}
|
|
1684
|
+
var serverSessionCodec = {
|
|
1685
|
+
decode(input) {
|
|
1686
|
+
if (!isRecord4(input) || typeof input.status !== "string" || !identityKeyCodec.decode(input.instanceId).ok) {
|
|
1687
|
+
return err("invalid-input", "Invalid server session.");
|
|
1688
|
+
}
|
|
1689
|
+
if (input.status !== "ready") {
|
|
1690
|
+
if ((input.status === "unavailable" || input.status === "unauthenticated" || input.status === "incompatible") && isRecord4(input.error)) {
|
|
1691
|
+
return ok(input);
|
|
1692
|
+
}
|
|
1693
|
+
return err("invalid-input", "Invalid unavailable server session.");
|
|
1694
|
+
}
|
|
1695
|
+
if (!isRecord4(input.actor) || !isActorIdentity(input.actor.identity) || !isPresentation(input.actor.presentation) || input.actor.evidence !== "provider-verified" && input.actor.evidence !== "local-user" && input.actor.evidence !== "upstream-default" && input.actor.evidence !== "integration-asserted" && input.actor.evidence !== "legacy" && input.actor.evidence !== "machine" || isMachineIdentity(input.actor.identity) !== (input.actor.evidence === "machine") || !isCapabilities(input.capabilities) || typeof input.stamp !== "string" || input.mode !== "single-user" && input.mode !== "multi-user") {
|
|
1696
|
+
return err("invalid-input", "Invalid ready server session.");
|
|
1697
|
+
}
|
|
1698
|
+
return ok(input);
|
|
1699
|
+
},
|
|
1700
|
+
encode(value) {
|
|
1701
|
+
return value;
|
|
1702
|
+
}
|
|
1703
|
+
};
|
|
1704
|
+
|
|
1705
|
+
// bb-client-connection-runtime.ts
|
|
1706
|
+
var nativeIdentityRealtimeChannel = "bb-identity/v1";
|
|
1707
|
+
function unavailable(message) {
|
|
1708
|
+
return { status: "unavailable", error: { code: "unavailable", message, retry: "after-reconnect" } };
|
|
1709
|
+
}
|
|
1710
|
+
function link(state) {
|
|
1711
|
+
return state === "connected" ? { status: "healthy" } : { status: state };
|
|
1712
|
+
}
|
|
1713
|
+
function sameLink(left, right) {
|
|
1714
|
+
return left.status === right.status && (left.status !== "unavailable" || right.status === "unavailable" && left.error.code === right.error.code && left.error.message === right.error.message && left.error.retry === right.error.retry);
|
|
1715
|
+
}
|
|
1716
|
+
function cloneLink(value) {
|
|
1717
|
+
return value.status === "unavailable" ? { status: "unavailable", error: { ...value.error } } : { status: value.status };
|
|
1718
|
+
}
|
|
1719
|
+
function decodeError3(value) {
|
|
1720
|
+
if (!value || typeof value !== "object")
|
|
1721
|
+
return err("incompatible", "Malformed native identity error.");
|
|
1722
|
+
const error = value;
|
|
1723
|
+
const codes = new Set(["unavailable", "unauthenticated", "unsupported", "incompatible", "invalid-input", "not-found", "ambiguous", "stale-owner", "stale-context", "conflict", "cancelled", "disposed", "limit-exceeded", "invalid-operation", "expired"]);
|
|
1724
|
+
const retries = new Set(["never", "after-refresh", "after-reconnect", "same-operation"]);
|
|
1725
|
+
return typeof error.code === "string" && codes.has(error.code) && typeof error.message === "string" && typeof error.retry === "string" && retries.has(error.retry) ? ok({ code: error.code, message: error.message, retry: error.retry }) : err("incompatible", "Malformed native identity error.");
|
|
1726
|
+
}
|
|
1727
|
+
function decodeBootstrap(value) {
|
|
1728
|
+
if (!value || typeof value !== "object" || typeof value.ok !== "boolean")
|
|
1729
|
+
return err("incompatible", "Malformed bootstrap Result.");
|
|
1730
|
+
if (!value.ok) {
|
|
1731
|
+
const failure2 = decodeError3(value.error);
|
|
1732
|
+
return failure2.ok ? { ok: false, error: failure2.value } : failure2;
|
|
1733
|
+
}
|
|
1734
|
+
return serverSessionCodec.decode(value.value);
|
|
1735
|
+
}
|
|
1736
|
+
function isStateRoute(route) {
|
|
1737
|
+
return route.includes("/state/");
|
|
1738
|
+
}
|
|
1739
|
+
function isSessionInvalidation(value) {
|
|
1740
|
+
return !!value && typeof value === "object" && value.kind === "session";
|
|
1741
|
+
}
|
|
1742
|
+
function waitFor(flight, signal) {
|
|
1743
|
+
if (!signal)
|
|
1744
|
+
return flight;
|
|
1745
|
+
if (signal.aborted)
|
|
1746
|
+
return Promise.resolve(err("cancelled", "Native identity revalidation was cancelled."));
|
|
1747
|
+
return new Promise((resolve) => {
|
|
1748
|
+
const abort = () => {
|
|
1749
|
+
signal.removeEventListener("abort", abort);
|
|
1750
|
+
resolve(err("cancelled", "Native identity revalidation was cancelled."));
|
|
1751
|
+
};
|
|
1752
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
1753
|
+
flight.then((result) => {
|
|
1754
|
+
signal.removeEventListener("abort", abort);
|
|
1755
|
+
resolve(result);
|
|
1756
|
+
});
|
|
1757
|
+
});
|
|
1758
|
+
}
|
|
1759
|
+
function createNativeIdentityConnection(options) {
|
|
1760
|
+
let disposed = false;
|
|
1761
|
+
let nativeState = options.realtimeState;
|
|
1762
|
+
let health = { generation: 0, identity: link(nativeState), state: link(nativeState) };
|
|
1763
|
+
let verified = null;
|
|
1764
|
+
let authorityEpoch = 0;
|
|
1765
|
+
let bootstrapAttempt = 0;
|
|
1766
|
+
let revalidateFlight = null;
|
|
1767
|
+
const events = new Set;
|
|
1768
|
+
const healthListeners = new Set;
|
|
1769
|
+
const emitHealth = () => {
|
|
1770
|
+
for (const listener of [...healthListeners])
|
|
1771
|
+
try {
|
|
1772
|
+
listener();
|
|
1773
|
+
} catch {}
|
|
1774
|
+
};
|
|
1775
|
+
const emit = (payload) => {
|
|
1776
|
+
for (const listener of [...events])
|
|
1777
|
+
try {
|
|
1778
|
+
listener(payload);
|
|
1779
|
+
} catch {}
|
|
1780
|
+
};
|
|
1781
|
+
const changeHealth = (next) => {
|
|
1782
|
+
if (sameLink(health.identity, next.identity) && sameLink(health.state, next.state))
|
|
1783
|
+
return;
|
|
1784
|
+
health = { generation: health.generation + 1, identity: cloneLink(next.identity), state: cloneLink(next.state) };
|
|
1785
|
+
emitHealth();
|
|
1786
|
+
};
|
|
1787
|
+
const fail = (target, message) => {
|
|
1788
|
+
const next = { identity: health.identity, state: health.state };
|
|
1789
|
+
next[target] = unavailable(message);
|
|
1790
|
+
const lostAuthority = target === "identity" && health.identity.status === "healthy";
|
|
1791
|
+
changeHealth(next);
|
|
1792
|
+
if (lostAuthority)
|
|
1793
|
+
emit({ kind: "disconnected" });
|
|
1794
|
+
};
|
|
1795
|
+
const inputs = {
|
|
1796
|
+
async request(route, input, readOptions) {
|
|
1797
|
+
if (disposed || readOptions?.signal?.aborted)
|
|
1798
|
+
throw new Error("Native identity request was cancelled.");
|
|
1799
|
+
const method = Object.hasOwn(identityRpcMethods, route) ? identityRpcMethods[route] : undefined;
|
|
1800
|
+
if (!method)
|
|
1801
|
+
throw new Error(`Unsupported identity route ${route}.`);
|
|
1802
|
+
const current = authorityEpoch;
|
|
1803
|
+
try {
|
|
1804
|
+
const value = await options.rpc.call(method, input);
|
|
1805
|
+
if (disposed || current !== authorityEpoch || readOptions?.signal?.aborted)
|
|
1806
|
+
throw new Error("Native identity request became stale.");
|
|
1807
|
+
if (route === identityRoutes.bootstrap) {
|
|
1808
|
+
const checked = decodeBootstrap(value);
|
|
1809
|
+
if (checked.ok && checked.value.status === "ready") {
|
|
1810
|
+
const next = { instanceId: checked.value.instanceId, actor: checked.value.actor.identity.key, stamp: checked.value.stamp };
|
|
1811
|
+
const changed = verified !== null && (verified.instanceId !== next.instanceId || verified.actor !== next.actor || verified.stamp !== next.stamp);
|
|
1812
|
+
if (changed) {
|
|
1813
|
+
verified = next;
|
|
1814
|
+
authorityEpoch++;
|
|
1815
|
+
if (disposed || nativeState !== "connected")
|
|
1816
|
+
throw new Error("Native bootstrap became stale.");
|
|
1817
|
+
} else
|
|
1818
|
+
verified = next;
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
if (isStateRoute(route) && health.state.status !== "healthy")
|
|
1822
|
+
changeHealth({ identity: health.identity, state: { status: "healthy" } });
|
|
1823
|
+
return value;
|
|
1824
|
+
} catch (cause) {
|
|
1825
|
+
if (disposed || current !== authorityEpoch || readOptions?.signal?.aborted)
|
|
1826
|
+
throw cause;
|
|
1827
|
+
fail(isStateRoute(route) ? "state" : "identity", cause instanceof Error ? cause.message : "Native identity request failed.");
|
|
1828
|
+
throw cause;
|
|
1829
|
+
}
|
|
1830
|
+
},
|
|
1831
|
+
subscribe(listener) {
|
|
1832
|
+
events.add(listener);
|
|
1833
|
+
return () => events.delete(listener);
|
|
1834
|
+
},
|
|
1835
|
+
getHealth: () => health,
|
|
1836
|
+
subscribeHealth(listener) {
|
|
1837
|
+
healthListeners.add(listener);
|
|
1838
|
+
return () => healthListeners.delete(listener);
|
|
1839
|
+
},
|
|
1840
|
+
async revalidate(readOptions) {
|
|
1841
|
+
if (disposed)
|
|
1842
|
+
return err("disposed", "Native identity connection is disposed.");
|
|
1843
|
+
if (readOptions?.signal?.aborted)
|
|
1844
|
+
return err("cancelled", "Native identity revalidation was cancelled.");
|
|
1845
|
+
if (nativeState !== "connected")
|
|
1846
|
+
return err("unavailable", "Native realtime is not connected.", "after-reconnect");
|
|
1847
|
+
if (revalidateFlight)
|
|
1848
|
+
return waitFor(revalidateFlight, readOptions?.signal);
|
|
1849
|
+
const currentAuthority = authorityEpoch;
|
|
1850
|
+
const currentAttempt = ++bootstrapAttempt;
|
|
1851
|
+
const flight = (async () => {
|
|
1852
|
+
try {
|
|
1853
|
+
const response = await options.rpc.call(identityRpcMethods[identityRoutes.bootstrap], {});
|
|
1854
|
+
const checked = decodeBootstrap(response);
|
|
1855
|
+
if (disposed)
|
|
1856
|
+
return err("disposed", "Native identity connection is disposed.");
|
|
1857
|
+
if (currentAuthority !== authorityEpoch || currentAttempt !== bootstrapAttempt || nativeState !== "connected")
|
|
1858
|
+
return err("stale-context", "Native identity authority changed during bootstrap.");
|
|
1859
|
+
if (!checked.ok) {
|
|
1860
|
+
fail("identity", checked.error.message);
|
|
1861
|
+
return checked;
|
|
1862
|
+
}
|
|
1863
|
+
if (checked.value.status !== "ready") {
|
|
1864
|
+
fail("identity", checked.value.error.message);
|
|
1865
|
+
return err(checked.value.error.code, checked.value.error.message, checked.value.error.retry);
|
|
1866
|
+
}
|
|
1867
|
+
const next = { instanceId: checked.value.instanceId, actor: checked.value.actor.identity.key, stamp: checked.value.stamp };
|
|
1868
|
+
const changed = verified !== null && (verified.instanceId !== next.instanceId || verified.actor !== next.actor || verified.stamp !== next.stamp);
|
|
1869
|
+
if (changed) {
|
|
1870
|
+
verified = next;
|
|
1871
|
+
const publishedEpoch = ++authorityEpoch;
|
|
1872
|
+
changeHealth({ identity: { status: "connecting" }, state: health.state });
|
|
1873
|
+
emit({ kind: "session" });
|
|
1874
|
+
if (disposed || publishedEpoch !== authorityEpoch || nativeState !== "connected")
|
|
1875
|
+
return err("stale-context", "Native identity authority changed during session notification.");
|
|
1876
|
+
} else
|
|
1877
|
+
verified = next;
|
|
1878
|
+
changeHealth({ identity: { status: "healthy" }, state: health.state });
|
|
1879
|
+
return ok(undefined);
|
|
1880
|
+
} catch (cause) {
|
|
1881
|
+
if (disposed)
|
|
1882
|
+
return err("disposed", "Native identity connection is disposed.");
|
|
1883
|
+
if (currentAuthority !== authorityEpoch || currentAttempt !== bootstrapAttempt || nativeState !== "connected")
|
|
1884
|
+
return err("stale-context", "Native identity authority changed during bootstrap.");
|
|
1885
|
+
fail("identity", cause instanceof Error ? cause.message : "Native bootstrap failed.");
|
|
1886
|
+
return err("unavailable", "Native bootstrap failed.", "after-reconnect");
|
|
1887
|
+
}
|
|
1888
|
+
})();
|
|
1889
|
+
revalidateFlight = flight;
|
|
1890
|
+
flight.finally(() => {
|
|
1891
|
+
if (revalidateFlight === flight)
|
|
1892
|
+
revalidateFlight = null;
|
|
1893
|
+
});
|
|
1894
|
+
return waitFor(flight, readOptions?.signal);
|
|
1895
|
+
},
|
|
1896
|
+
dispose() {
|
|
1897
|
+
disposed = true;
|
|
1898
|
+
authorityEpoch++;
|
|
1899
|
+
bootstrapAttempt++;
|
|
1900
|
+
revalidateFlight = null;
|
|
1901
|
+
events.clear();
|
|
1902
|
+
healthListeners.clear();
|
|
1903
|
+
}
|
|
1904
|
+
};
|
|
1905
|
+
const connection = createIdentityConnection(inputs);
|
|
1906
|
+
return {
|
|
1907
|
+
connection,
|
|
1908
|
+
acceptRealtime(payload) {
|
|
1909
|
+
if (disposed)
|
|
1910
|
+
return;
|
|
1911
|
+
if (isSessionInvalidation(payload)) {
|
|
1912
|
+
authorityEpoch++;
|
|
1913
|
+
bootstrapAttempt++;
|
|
1914
|
+
revalidateFlight = null;
|
|
1915
|
+
verified = null;
|
|
1916
|
+
changeHealth({ identity: { status: "connecting" }, state: health.state });
|
|
1917
|
+
}
|
|
1918
|
+
emit(payload);
|
|
1919
|
+
},
|
|
1920
|
+
setRealtimeState(state) {
|
|
1921
|
+
if (disposed || state === nativeState)
|
|
1922
|
+
return;
|
|
1923
|
+
const wasConnected = nativeState === "connected";
|
|
1924
|
+
nativeState = state;
|
|
1925
|
+
authorityEpoch++;
|
|
1926
|
+
bootstrapAttempt++;
|
|
1927
|
+
revalidateFlight = null;
|
|
1928
|
+
if (state === "connected") {
|
|
1929
|
+
changeHealth({ identity: { status: "connecting" }, state: health.state.status === "healthy" ? health.state : { status: "reconnecting" } });
|
|
1930
|
+
if (!wasConnected)
|
|
1931
|
+
emit({ kind: "reconnected" });
|
|
1932
|
+
} else {
|
|
1933
|
+
const next = link(state);
|
|
1934
|
+
const lostAuthority = health.identity.status === "healthy";
|
|
1935
|
+
changeHealth({ identity: next, state: next });
|
|
1936
|
+
if (lostAuthority)
|
|
1937
|
+
emit({ kind: "disconnected" });
|
|
1938
|
+
}
|
|
1939
|
+
},
|
|
1940
|
+
dispose() {
|
|
1941
|
+
connection.dispose();
|
|
1942
|
+
}
|
|
1943
|
+
};
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
// client-runtime.ts
|
|
1947
|
+
var errorCodes2 = new Set([
|
|
1948
|
+
"unavailable",
|
|
1949
|
+
"unauthenticated",
|
|
1950
|
+
"unsupported",
|
|
1951
|
+
"incompatible",
|
|
1952
|
+
"invalid-input",
|
|
1953
|
+
"not-found",
|
|
1954
|
+
"ambiguous",
|
|
1955
|
+
"stale-owner",
|
|
1956
|
+
"stale-context",
|
|
1957
|
+
"conflict",
|
|
1958
|
+
"cancelled",
|
|
1959
|
+
"disposed",
|
|
1960
|
+
"limit-exceeded",
|
|
1961
|
+
"invalid-operation",
|
|
1962
|
+
"expired"
|
|
1963
|
+
]);
|
|
1964
|
+
var retryKinds2 = new Set(["never", "after-refresh", "after-reconnect", "same-operation"]);
|
|
1965
|
+
function isRecord5(value) {
|
|
1966
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1967
|
+
}
|
|
1968
|
+
function decodeError4(value) {
|
|
1969
|
+
return isRecord5(value) && typeof value.code === "string" && errorCodes2.has(value.code) && typeof value.message === "string" && typeof value.retry === "string" && retryKinds2.has(value.retry) ? ok({ code: value.code, message: value.message, retry: value.retry }) : err("incompatible", "Malformed identity route error.");
|
|
1970
|
+
}
|
|
1971
|
+
function decodeResult2(value, decode) {
|
|
1972
|
+
if (!isRecord5(value) || typeof value.ok !== "boolean")
|
|
1973
|
+
return err("incompatible", "Malformed identity route Result.");
|
|
1974
|
+
if (!value.ok) {
|
|
1975
|
+
const failure2 = decodeError4(value.error);
|
|
1976
|
+
return failure2.ok ? { ok: false, error: failure2.value } : failure2;
|
|
1977
|
+
}
|
|
1978
|
+
return decode(value.value);
|
|
1979
|
+
}
|
|
1980
|
+
function unavailable2(connection) {
|
|
1981
|
+
const link2 = connection.getHealth().identity;
|
|
1982
|
+
if (link2.status === "unavailable")
|
|
1983
|
+
return err(link2.error.code, link2.error.message, link2.error.retry);
|
|
1984
|
+
return link2.status === "healthy" ? null : err("unavailable", "Identity transport is reconnecting.", "after-reconnect");
|
|
1985
|
+
}
|
|
1986
|
+
function cancelled2(options) {
|
|
1987
|
+
return options?.signal?.aborted ? err("cancelled", "Identity request was cancelled.") : null;
|
|
1988
|
+
}
|
|
1989
|
+
function decodePage(value) {
|
|
1990
|
+
if (!isRecord5(value) || !Array.isArray(value.items) || value.nextCursor !== null && typeof value.nextCursor !== "string" || typeof value.revision !== "string") {
|
|
1991
|
+
return err("incompatible", "Malformed directory page.");
|
|
1992
|
+
}
|
|
1993
|
+
const profiles = value.items.map((item) => profileCodec.decode(item));
|
|
1994
|
+
return profiles.every((profile) => profile.ok) ? ok({ items: profiles.map((profile) => profile.value), nextCursor: value.nextCursor, revision: value.revision }) : err("incompatible", "Malformed directory profile.");
|
|
1995
|
+
}
|
|
1996
|
+
function decodeLookups(value) {
|
|
1997
|
+
if (!Array.isArray(value))
|
|
1998
|
+
return err("incompatible", "Malformed profile lookup list.");
|
|
1999
|
+
const values = [];
|
|
2000
|
+
for (const entry of value) {
|
|
2001
|
+
if (!isRecord5(entry))
|
|
2002
|
+
return err("incompatible", "Malformed profile lookup.");
|
|
2003
|
+
const key = identityKeyCodec.decode(entry.key);
|
|
2004
|
+
if (!key.ok)
|
|
2005
|
+
return err("incompatible", "Malformed profile lookup key.");
|
|
2006
|
+
if (entry.status === "missing")
|
|
2007
|
+
values.push({ key: key.value, status: "missing" });
|
|
2008
|
+
else if (entry.status === "found") {
|
|
2009
|
+
const profile = profileCodec.decode(entry.profile);
|
|
2010
|
+
if (!profile.ok || profile.value.identity.key !== key.value)
|
|
2011
|
+
return err("incompatible", "Malformed profile lookup profile.");
|
|
2012
|
+
values.push({ key: key.value, status: "found", profile: profile.value });
|
|
2013
|
+
} else
|
|
2014
|
+
return err("incompatible", "Malformed profile lookup status.");
|
|
2015
|
+
}
|
|
2016
|
+
return ok(values);
|
|
2017
|
+
}
|
|
2018
|
+
function decodeParticipant(value) {
|
|
2019
|
+
if (!isRecord5(value) || !Array.isArray(value.roles) || !value.roles.every((role) => role === "author" || role === "editor" || role === "mentioned" || role === "interaction-resolver"))
|
|
2020
|
+
return err("incompatible", "Malformed participant.");
|
|
2021
|
+
const profile = profileCodec.decode({ identity: value.identity, presentation: value.presentation, revision: "participant", status: "current" });
|
|
2022
|
+
return profile.ok ? ok({ identity: profile.value.identity, presentation: profile.value.presentation, roles: [...value.roles] }) : err("incompatible", "Malformed participant identity.");
|
|
2023
|
+
}
|
|
2024
|
+
function decodeParticipantPage(value) {
|
|
2025
|
+
if (!isRecord5(value) || !Array.isArray(value.items) || value.nextCursor !== null && typeof value.nextCursor !== "string" || typeof value.revision !== "string" || value.coverage !== "complete-history" && value.coverage !== "partial-history")
|
|
2026
|
+
return err("incompatible", "Malformed participant page.");
|
|
2027
|
+
const items = value.items.map(decodeParticipant);
|
|
2028
|
+
return items.every((item) => item.ok) ? ok({ items: items.map((item) => item.value), nextCursor: value.nextCursor, revision: value.revision, coverage: value.coverage }) : err("incompatible", "Malformed participant item.");
|
|
2029
|
+
}
|
|
2030
|
+
function decodePreviews(value) {
|
|
2031
|
+
if (!Array.isArray(value))
|
|
2032
|
+
return err("incompatible", "Malformed participant previews.");
|
|
2033
|
+
const result = [];
|
|
2034
|
+
for (const preview of value) {
|
|
2035
|
+
if (!isRecord5(preview) || !Array.isArray(preview.participants) || typeof preview.hasMore !== "boolean" || typeof preview.revision !== "string" || preview.coverage !== "complete-history" && preview.coverage !== "partial-history")
|
|
2036
|
+
return err("incompatible", "Malformed participant preview.");
|
|
2037
|
+
const threadId = idCodec("thread").decode(preview.threadId);
|
|
2038
|
+
const revision = idCodec("revision").decode(preview.revision);
|
|
2039
|
+
if (!threadId.ok || !revision.ok)
|
|
2040
|
+
return err("incompatible", "Malformed participant preview identifiers.");
|
|
2041
|
+
const participants = preview.participants.map(decodeParticipant);
|
|
2042
|
+
if (!participants.every((participant) => participant.ok))
|
|
2043
|
+
return err("incompatible", "Malformed participant preview item.");
|
|
2044
|
+
result.push({ threadId: threadId.value, participants: participants.map((participant) => participant.value), hasMore: preview.hasMore, coverage: preview.coverage, revision: revision.value });
|
|
2045
|
+
}
|
|
2046
|
+
return ok(result);
|
|
2047
|
+
}
|
|
2048
|
+
function createIdentityClientTransport(connection) {
|
|
2049
|
+
const call = async (method, input, decode, options) => {
|
|
2050
|
+
const stopped = cancelled2(options);
|
|
2051
|
+
if (stopped)
|
|
2052
|
+
return stopped;
|
|
2053
|
+
const failed = unavailable2(connection);
|
|
2054
|
+
if (failed)
|
|
2055
|
+
return failed;
|
|
2056
|
+
try {
|
|
2057
|
+
return decodeResult2(await connection.request(method, input, options), decode);
|
|
2058
|
+
} catch {
|
|
2059
|
+
return err("unavailable", "Identity route request failed.", "after-reconnect");
|
|
2060
|
+
}
|
|
2061
|
+
};
|
|
2062
|
+
const directory = {
|
|
2063
|
+
search: (input, options) => call(identityRoutes.search, input, decodePage, options),
|
|
2064
|
+
getMany: (input, options) => call(identityRoutes.profiles, input, decodeLookups, options),
|
|
2065
|
+
subscribe(keys, listener) {
|
|
2066
|
+
return connection.subscribe((event) => {
|
|
2067
|
+
if (event.kind !== "directory")
|
|
2068
|
+
return;
|
|
2069
|
+
if (!keys.length || !event.keys.length || event.keys.some((key) => keys.includes(key)))
|
|
2070
|
+
listener({ keys: event.keys, revision: event.revision });
|
|
2071
|
+
});
|
|
2072
|
+
}
|
|
2073
|
+
};
|
|
2074
|
+
const participants = {
|
|
2075
|
+
list: (input, options) => call(identityRoutes.participants, input, decodeParticipantPage, options),
|
|
2076
|
+
previews: (input, options) => call(identityRoutes.participantPreviews, input, decodePreviews, options)
|
|
2077
|
+
};
|
|
2078
|
+
return {
|
|
2079
|
+
bootstrap: (options) => call(identityRoutes.bootstrap, {}, serverSessionCodec.decode, options),
|
|
2080
|
+
selfProfile: (options) => call(identityRoutes.selfProfile, {}, profileCodec.decode, options),
|
|
2081
|
+
directory,
|
|
2082
|
+
participants,
|
|
2083
|
+
subscribe(listener) {
|
|
2084
|
+
return connection.subscribe((event) => {
|
|
2085
|
+
if (event.kind !== "state")
|
|
2086
|
+
listener(event);
|
|
2087
|
+
});
|
|
2088
|
+
}
|
|
2089
|
+
};
|
|
2090
|
+
}
|
|
2091
|
+
function waitFor2(promise, signal) {
|
|
2092
|
+
if (!signal)
|
|
2093
|
+
return promise;
|
|
2094
|
+
if (signal.aborted)
|
|
2095
|
+
return Promise.resolve(err("cancelled", "Identity read was cancelled."));
|
|
2096
|
+
return new Promise((resolve) => {
|
|
2097
|
+
const abort = () => {
|
|
2098
|
+
signal.removeEventListener("abort", abort);
|
|
2099
|
+
resolve(err("cancelled", "Identity read was cancelled."));
|
|
2100
|
+
};
|
|
2101
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
2102
|
+
promise.then((result) => {
|
|
2103
|
+
signal.removeEventListener("abort", abort);
|
|
2104
|
+
resolve(result);
|
|
2105
|
+
});
|
|
2106
|
+
});
|
|
2107
|
+
}
|
|
2108
|
+
function profileKey(query) {
|
|
2109
|
+
return JSON.stringify(query);
|
|
2110
|
+
}
|
|
2111
|
+
function createIdentityClient(options) {
|
|
2112
|
+
const transport = createIdentityClientTransport(options.connection);
|
|
2113
|
+
const now = () => options.scheduler?.now() ?? Date.now();
|
|
2114
|
+
const cacheMax = options.profileCache?.maxEntries ?? 512;
|
|
2115
|
+
const staleAfter = options.profileCache?.staleAfterMs ?? 60000;
|
|
2116
|
+
if (!Number.isSafeInteger(cacheMax) || cacheMax <= 0 || !Number.isFinite(staleAfter) || staleAfter < 0) {
|
|
2117
|
+
throw new RangeError("profileCache requires a positive finite maxEntries and non-negative finite staleAfterMs.");
|
|
2118
|
+
}
|
|
2119
|
+
let disposed = false;
|
|
2120
|
+
let started = null;
|
|
2121
|
+
let snapshot = { status: "loading" };
|
|
2122
|
+
let generation = 0;
|
|
2123
|
+
let refreshOrder = 0;
|
|
2124
|
+
let cacheEpoch = 0;
|
|
2125
|
+
let requiresBootstrap = true;
|
|
2126
|
+
const listeners = new Set;
|
|
2127
|
+
const profiles = new Map;
|
|
2128
|
+
const searches = new Map;
|
|
2129
|
+
const pending = new Map;
|
|
2130
|
+
const batches = [];
|
|
2131
|
+
const batchControllers = new Set;
|
|
2132
|
+
const batchEntries = new Map;
|
|
2133
|
+
const pendingSearches = new Map;
|
|
2134
|
+
const searchQueue = [];
|
|
2135
|
+
let activeSearches = 0;
|
|
2136
|
+
const scheduledProfileKeys = new Set;
|
|
2137
|
+
let batchScheduled = false;
|
|
2138
|
+
let activeBatches = 0;
|
|
2139
|
+
const notify = () => {
|
|
2140
|
+
for (const listener of [...listeners])
|
|
2141
|
+
listener();
|
|
2142
|
+
};
|
|
2143
|
+
const clearDirectory = (keys) => {
|
|
2144
|
+
cacheEpoch++;
|
|
2145
|
+
for (const entry of pending.values())
|
|
2146
|
+
entry.resolve(err("stale-context", "Directory read was invalidated."));
|
|
2147
|
+
pending.clear();
|
|
2148
|
+
batches.length = 0;
|
|
2149
|
+
scheduledProfileKeys.clear();
|
|
2150
|
+
for (const controller of batchControllers)
|
|
2151
|
+
controller.abort();
|
|
2152
|
+
batchControllers.clear();
|
|
2153
|
+
for (const entry of pendingSearches.values()) {
|
|
2154
|
+
entry.settled = true;
|
|
2155
|
+
entry.controller.abort();
|
|
2156
|
+
entry.resolve(err("stale-context", "Directory search was invalidated."));
|
|
2157
|
+
}
|
|
2158
|
+
pendingSearches.clear();
|
|
2159
|
+
searchQueue.length = 0;
|
|
2160
|
+
if (!keys || !keys.length) {
|
|
2161
|
+
profiles.clear();
|
|
2162
|
+
searches.clear();
|
|
2163
|
+
return;
|
|
2164
|
+
}
|
|
2165
|
+
for (const key of keys)
|
|
2166
|
+
profiles.delete(key);
|
|
2167
|
+
searches.clear();
|
|
2168
|
+
};
|
|
2169
|
+
const queueProfiles = () => {
|
|
2170
|
+
if (batchScheduled)
|
|
2171
|
+
return;
|
|
2172
|
+
batchScheduled = true;
|
|
2173
|
+
queueMicrotask(() => {
|
|
2174
|
+
batchScheduled = false;
|
|
2175
|
+
if (disposed)
|
|
2176
|
+
return;
|
|
2177
|
+
const keys = [...pending.keys()].filter((key) => !scheduledProfileKeys.has(key));
|
|
2178
|
+
for (let index = 0;index < keys.length; index += 64) {
|
|
2179
|
+
const batch = keys.slice(index, index + 64);
|
|
2180
|
+
batch.forEach((key) => scheduledProfileKeys.add(key));
|
|
2181
|
+
batches.push(batch);
|
|
2182
|
+
}
|
|
2183
|
+
runBatches();
|
|
2184
|
+
});
|
|
2185
|
+
};
|
|
2186
|
+
const runBatches = () => {
|
|
2187
|
+
while (activeBatches < 4 && batches.length) {
|
|
2188
|
+
const keys = batches.shift().filter((key) => (pending.get(key)?.waiters ?? 0) > 0);
|
|
2189
|
+
if (!keys.length)
|
|
2190
|
+
continue;
|
|
2191
|
+
activeBatches++;
|
|
2192
|
+
const entries = new Map(keys.map((key) => [key, pending.get(key)]));
|
|
2193
|
+
const controller = new AbortController;
|
|
2194
|
+
const epoch = cacheEpoch;
|
|
2195
|
+
batchControllers.add(controller);
|
|
2196
|
+
batchEntries.set(controller, entries);
|
|
2197
|
+
transport.directory.getMany({ keys }, { signal: controller.signal }).then((result) => {
|
|
2198
|
+
if (disposed || epoch !== cacheEpoch)
|
|
2199
|
+
return;
|
|
2200
|
+
if (result.ok) {
|
|
2201
|
+
const seen = new Set;
|
|
2202
|
+
if (result.value.length !== keys.length || result.value.some((value) => !keys.includes(value.key) || seen.has(value.key) || (seen.add(value.key), false)))
|
|
2203
|
+
result = err("incompatible", "Profile batch did not return exactly the requested keys.");
|
|
2204
|
+
}
|
|
2205
|
+
const byKey = new Map(result.ok ? result.value.map((value) => [value.key, value]) : []);
|
|
2206
|
+
for (const key of keys) {
|
|
2207
|
+
const observer = entries.get(key);
|
|
2208
|
+
if (!observer || pending.get(key) !== observer)
|
|
2209
|
+
continue;
|
|
2210
|
+
const value = result.ok ? byKey.get(key) ?? { key, status: "missing" } : result;
|
|
2211
|
+
const normalized = "ok" in value ? value : ok(value);
|
|
2212
|
+
if (normalized.ok) {
|
|
2213
|
+
profiles.set(key, { value: normalized.value, at: now() });
|
|
2214
|
+
while (profiles.size > cacheMax)
|
|
2215
|
+
profiles.delete(profiles.keys().next().value);
|
|
2216
|
+
}
|
|
2217
|
+
pending.delete(key);
|
|
2218
|
+
observer.resolve(normalized);
|
|
2219
|
+
}
|
|
2220
|
+
}, () => {
|
|
2221
|
+
for (const key of keys) {
|
|
2222
|
+
const observer = entries.get(key);
|
|
2223
|
+
if (observer && pending.get(key) === observer) {
|
|
2224
|
+
pending.delete(key);
|
|
2225
|
+
observer.resolve(err("unavailable", "Profile batch failed.", "after-reconnect"));
|
|
2226
|
+
}
|
|
2227
|
+
}
|
|
2228
|
+
}).finally(() => {
|
|
2229
|
+
batchControllers.delete(controller);
|
|
2230
|
+
batchEntries.delete(controller);
|
|
2231
|
+
keys.forEach((key) => scheduledProfileKeys.delete(key));
|
|
2232
|
+
activeBatches--;
|
|
2233
|
+
queueProfiles();
|
|
2234
|
+
runBatches();
|
|
2235
|
+
});
|
|
2236
|
+
}
|
|
2237
|
+
};
|
|
2238
|
+
const profileRead = (key) => {
|
|
2239
|
+
if (disposed)
|
|
2240
|
+
return Promise.resolve(err("disposed", "Identity client is disposed."));
|
|
2241
|
+
const fresh = profiles.get(key);
|
|
2242
|
+
if (fresh && now() - fresh.at <= staleAfter)
|
|
2243
|
+
return Promise.resolve(ok(fresh.value));
|
|
2244
|
+
const existing = pending.get(key);
|
|
2245
|
+
if (existing)
|
|
2246
|
+
return existing.promise;
|
|
2247
|
+
let resolve;
|
|
2248
|
+
const promise = new Promise((done) => {
|
|
2249
|
+
resolve = done;
|
|
2250
|
+
});
|
|
2251
|
+
pending.set(key, { promise, resolve, waiters: 0 });
|
|
2252
|
+
queueProfiles();
|
|
2253
|
+
return promise;
|
|
2254
|
+
};
|
|
2255
|
+
const observeProfile = (key, signal) => {
|
|
2256
|
+
if (signal?.aborted)
|
|
2257
|
+
return Promise.resolve(err("cancelled", "Identity read was cancelled."));
|
|
2258
|
+
const promise = profileRead(key);
|
|
2259
|
+
const entry = pending.get(key);
|
|
2260
|
+
if (!entry)
|
|
2261
|
+
return waitFor2(promise, signal);
|
|
2262
|
+
entry.waiters++;
|
|
2263
|
+
return new Promise((resolve) => {
|
|
2264
|
+
let done = false;
|
|
2265
|
+
const release = (value) => {
|
|
2266
|
+
if (done)
|
|
2267
|
+
return;
|
|
2268
|
+
done = true;
|
|
2269
|
+
signal?.removeEventListener("abort", abort);
|
|
2270
|
+
const left = Math.max(0, entry.waiters - 1);
|
|
2271
|
+
entry.waiters = left;
|
|
2272
|
+
if (!left) {
|
|
2273
|
+
if (pending.get(key) === entry) {
|
|
2274
|
+
pending.delete(key);
|
|
2275
|
+
entry.resolve(err("cancelled", "All profile readers cancelled."));
|
|
2276
|
+
}
|
|
2277
|
+
for (const [controller, entries] of batchEntries)
|
|
2278
|
+
if ([...entries.values()].every((item) => item.waiters === 0))
|
|
2279
|
+
controller.abort();
|
|
2280
|
+
}
|
|
2281
|
+
resolve(value);
|
|
2282
|
+
};
|
|
2283
|
+
const abort = () => release(err("cancelled", "Identity read was cancelled."));
|
|
2284
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
2285
|
+
promise.then(release);
|
|
2286
|
+
});
|
|
2287
|
+
};
|
|
2288
|
+
const runSearches = () => {
|
|
2289
|
+
while (!disposed && activeSearches < 4 && searchQueue.length) {
|
|
2290
|
+
const entry = searchQueue.shift();
|
|
2291
|
+
if (entry.settled || entry.waiters === 0 || entry.epoch !== cacheEpoch)
|
|
2292
|
+
continue;
|
|
2293
|
+
activeSearches++;
|
|
2294
|
+
transport.directory.search(entry.input, { signal: entry.controller.signal }).then((result) => {
|
|
2295
|
+
if (!entry.settled) {
|
|
2296
|
+
entry.settled = true;
|
|
2297
|
+
pendingSearches.delete(profileKey(entry.input));
|
|
2298
|
+
if (disposed)
|
|
2299
|
+
entry.resolve(err("disposed", "Identity client is disposed."));
|
|
2300
|
+
else if (entry.epoch !== cacheEpoch)
|
|
2301
|
+
entry.resolve(err("stale-context", "Directory search was invalidated."));
|
|
2302
|
+
else {
|
|
2303
|
+
if (result.ok) {
|
|
2304
|
+
searches.set(profileKey(entry.input), { value: result.value, at: now() });
|
|
2305
|
+
while (searches.size > 64)
|
|
2306
|
+
searches.delete(searches.keys().next().value);
|
|
2307
|
+
}
|
|
2308
|
+
entry.resolve(result);
|
|
2309
|
+
}
|
|
2310
|
+
}
|
|
2311
|
+
}).finally(() => {
|
|
2312
|
+
activeSearches--;
|
|
2313
|
+
runSearches();
|
|
2314
|
+
});
|
|
2315
|
+
}
|
|
2316
|
+
};
|
|
2317
|
+
const observeSearch = (input, signal) => {
|
|
2318
|
+
if (signal?.aborted)
|
|
2319
|
+
return Promise.resolve(err("cancelled", "Identity read was cancelled."));
|
|
2320
|
+
const key = profileKey(input);
|
|
2321
|
+
let entry = pendingSearches.get(key);
|
|
2322
|
+
if (!entry) {
|
|
2323
|
+
let resolve;
|
|
2324
|
+
const promise = new Promise((done) => {
|
|
2325
|
+
resolve = done;
|
|
2326
|
+
});
|
|
2327
|
+
entry = { promise, resolve, controller: new AbortController, epoch: cacheEpoch, input: structuredClone(input), waiters: 1, settled: false };
|
|
2328
|
+
pendingSearches.set(key, entry);
|
|
2329
|
+
searchQueue.push(entry);
|
|
2330
|
+
runSearches();
|
|
2331
|
+
} else
|
|
2332
|
+
entry.waiters++;
|
|
2333
|
+
return new Promise((resolve) => {
|
|
2334
|
+
let done = false;
|
|
2335
|
+
const release = (value) => {
|
|
2336
|
+
if (done)
|
|
2337
|
+
return;
|
|
2338
|
+
done = true;
|
|
2339
|
+
signal?.removeEventListener("abort", abort);
|
|
2340
|
+
entry.waiters--;
|
|
2341
|
+
if (entry.waiters === 0 && !entry.settled) {
|
|
2342
|
+
entry.settled = true;
|
|
2343
|
+
pendingSearches.delete(key);
|
|
2344
|
+
entry.controller.abort();
|
|
2345
|
+
entry.resolve(err("cancelled", "All directory search readers cancelled."));
|
|
2346
|
+
}
|
|
2347
|
+
resolve(value);
|
|
2348
|
+
};
|
|
2349
|
+
const abort = () => release(err("cancelled", "Identity read was cancelled."));
|
|
2350
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
2351
|
+
entry.promise.then(release);
|
|
2352
|
+
});
|
|
2353
|
+
};
|
|
2354
|
+
const directory = {
|
|
2355
|
+
async search(input, readOptions) {
|
|
2356
|
+
if (disposed)
|
|
2357
|
+
return err("disposed", "Identity client is disposed.");
|
|
2358
|
+
const key = profileKey(input);
|
|
2359
|
+
const cached = searches.get(key);
|
|
2360
|
+
if (cached && now() - cached.at <= staleAfter)
|
|
2361
|
+
return ok(cached.value);
|
|
2362
|
+
return observeSearch(input, readOptions?.signal);
|
|
2363
|
+
},
|
|
2364
|
+
async getMany(input, readOptions) {
|
|
2365
|
+
if (disposed)
|
|
2366
|
+
return err("disposed", "Identity client is disposed.");
|
|
2367
|
+
const unique = [...new Set(input.keys)];
|
|
2368
|
+
const results = await Promise.all(unique.map((key) => observeProfile(key, readOptions?.signal)));
|
|
2369
|
+
const failed = results.find((result) => !result.ok);
|
|
2370
|
+
return failed && !failed.ok ? failed : ok(results.map((result) => result.value));
|
|
2371
|
+
},
|
|
2372
|
+
subscribe(keys, listener) {
|
|
2373
|
+
return transport.directory.subscribe(keys, listener);
|
|
2374
|
+
}
|
|
2375
|
+
};
|
|
2376
|
+
const subscription = transport.subscribe((event) => {
|
|
2377
|
+
if (disposed)
|
|
2378
|
+
return;
|
|
2379
|
+
if (event.kind === "directory")
|
|
2380
|
+
clearDirectory(event.revision === null ? undefined : event.keys);
|
|
2381
|
+
if (event.kind === "session") {
|
|
2382
|
+
clearDirectory();
|
|
2383
|
+
refreshInternal(undefined, event.reason !== "provider");
|
|
2384
|
+
}
|
|
2385
|
+
if (event.kind === "reconnected") {
|
|
2386
|
+
refresh();
|
|
2387
|
+
}
|
|
2388
|
+
if (event.kind === "disconnected")
|
|
2389
|
+
suspendAuthority();
|
|
2390
|
+
});
|
|
2391
|
+
const suspendAuthority = () => {
|
|
2392
|
+
if (disposed)
|
|
2393
|
+
return;
|
|
2394
|
+
generation++;
|
|
2395
|
+
requiresBootstrap = true;
|
|
2396
|
+
snapshot = { status: "loading" };
|
|
2397
|
+
notify();
|
|
2398
|
+
};
|
|
2399
|
+
let previousIdentity = options.connection.getHealth().identity;
|
|
2400
|
+
let revalidating = false;
|
|
2401
|
+
const stopHealth = options.connection.subscribeHealth(() => {
|
|
2402
|
+
if (disposed)
|
|
2403
|
+
return;
|
|
2404
|
+
const next = options.connection.getHealth();
|
|
2405
|
+
const identity = next.identity;
|
|
2406
|
+
const changed = identity.status !== previousIdentity.status || identity.status === "unavailable" && previousIdentity.status === "unavailable" && (identity.error.code !== previousIdentity.error.code || identity.error.message !== previousIdentity.error.message || identity.error.retry !== previousIdentity.error.retry);
|
|
2407
|
+
const wasUnhealthy = previousIdentity.status !== "healthy";
|
|
2408
|
+
previousIdentity = identity;
|
|
2409
|
+
if (!changed || revalidating)
|
|
2410
|
+
return;
|
|
2411
|
+
suspendAuthority();
|
|
2412
|
+
if (identity.status === "healthy" && wasUnhealthy)
|
|
2413
|
+
refresh();
|
|
2414
|
+
});
|
|
2415
|
+
const refreshInternal = async (readOptions, suspend = true) => {
|
|
2416
|
+
if (disposed)
|
|
2417
|
+
return err("disposed", "Identity client is disposed.");
|
|
2418
|
+
const requestOrder = ++refreshOrder;
|
|
2419
|
+
if (suspend)
|
|
2420
|
+
suspendAuthority();
|
|
2421
|
+
const requestGeneration = generation;
|
|
2422
|
+
revalidating = true;
|
|
2423
|
+
const revalidated = await options.connection.revalidate(readOptions);
|
|
2424
|
+
revalidating = false;
|
|
2425
|
+
if (!revalidated.ok) {
|
|
2426
|
+
if (requestOrder === refreshOrder && requestGeneration === generation && suspend) {
|
|
2427
|
+
snapshot = { status: "loading" };
|
|
2428
|
+
notify();
|
|
2429
|
+
}
|
|
2430
|
+
return revalidated;
|
|
2431
|
+
}
|
|
2432
|
+
const session = await transport.bootstrap(readOptions);
|
|
2433
|
+
if (requestOrder !== refreshOrder || requestGeneration !== generation || disposed)
|
|
2434
|
+
return session.ok ? err("stale-context", "Superseded identity refresh.") : session;
|
|
2435
|
+
if (!session.ok) {
|
|
2436
|
+
if (requestOrder === refreshOrder && suspend) {
|
|
2437
|
+
snapshot = { status: "loading" };
|
|
2438
|
+
notify();
|
|
2439
|
+
}
|
|
2440
|
+
return session;
|
|
2441
|
+
}
|
|
2442
|
+
snapshot = session.value;
|
|
2443
|
+
requiresBootstrap = false;
|
|
2444
|
+
notify();
|
|
2445
|
+
return ok(undefined);
|
|
2446
|
+
};
|
|
2447
|
+
const refresh = (readOptions) => refreshInternal(readOptions, true);
|
|
2448
|
+
return {
|
|
2449
|
+
connection: options.connection,
|
|
2450
|
+
getSnapshot: () => snapshot,
|
|
2451
|
+
currentSession() {
|
|
2452
|
+
if (disposed)
|
|
2453
|
+
return err("disposed", "Identity client is disposed.");
|
|
2454
|
+
const health = options.connection.getHealth().identity;
|
|
2455
|
+
if (health.status === "unavailable")
|
|
2456
|
+
return err(health.error.code, health.error.message, health.error.retry);
|
|
2457
|
+
if (snapshot.status === "ready" && health.status === "healthy" && !requiresBootstrap)
|
|
2458
|
+
return ok(snapshot);
|
|
2459
|
+
if (snapshot.status === "unauthenticated" || snapshot.status === "unavailable" || snapshot.status === "incompatible")
|
|
2460
|
+
return err(snapshot.error.code, snapshot.error.message, snapshot.error.retry);
|
|
2461
|
+
return err("unavailable", "Identity session is not live.", "after-reconnect");
|
|
2462
|
+
},
|
|
2463
|
+
subscribe(listener) {
|
|
2464
|
+
if (disposed)
|
|
2465
|
+
return () => {};
|
|
2466
|
+
listeners.add(listener);
|
|
2467
|
+
return () => listeners.delete(listener);
|
|
2468
|
+
},
|
|
2469
|
+
start() {
|
|
2470
|
+
if (disposed)
|
|
2471
|
+
return Promise.resolve(err("disposed", "Identity client is disposed."));
|
|
2472
|
+
return started ??= refresh();
|
|
2473
|
+
},
|
|
2474
|
+
refresh,
|
|
2475
|
+
selfProfile: (readOptions) => transport.selfProfile(readOptions),
|
|
2476
|
+
directory,
|
|
2477
|
+
participants: transport.participants,
|
|
2478
|
+
dispose() {
|
|
2479
|
+
if (disposed)
|
|
2480
|
+
return;
|
|
2481
|
+
disposed = true;
|
|
2482
|
+
generation++;
|
|
2483
|
+
requiresBootstrap = true;
|
|
2484
|
+
subscription();
|
|
2485
|
+
stopHealth();
|
|
2486
|
+
listeners.clear();
|
|
2487
|
+
profiles.clear();
|
|
2488
|
+
searches.clear();
|
|
2489
|
+
batches.length = 0;
|
|
2490
|
+
for (const controller of batchControllers)
|
|
2491
|
+
controller.abort();
|
|
2492
|
+
batchControllers.clear();
|
|
2493
|
+
for (const entry of pendingSearches.values()) {
|
|
2494
|
+
entry.settled = true;
|
|
2495
|
+
entry.controller.abort();
|
|
2496
|
+
entry.resolve(err("disposed", "Identity client is disposed."));
|
|
2497
|
+
}
|
|
2498
|
+
pendingSearches.clear();
|
|
2499
|
+
searchQueue.length = 0;
|
|
2500
|
+
for (const entry of pending.values())
|
|
2501
|
+
entry.resolve(err("disposed", "Identity client is disposed."));
|
|
2502
|
+
pending.clear();
|
|
2503
|
+
snapshot = { status: "disposed" };
|
|
2504
|
+
}
|
|
2505
|
+
};
|
|
2506
|
+
}
|
|
2507
|
+
|
|
2508
|
+
// client-view-runtime.ts
|
|
2509
|
+
function ready(client, selection, subject, generation) {
|
|
2510
|
+
const session = client.currentSession();
|
|
2511
|
+
if (!session.ok)
|
|
2512
|
+
return session;
|
|
2513
|
+
if (session.value.status !== "ready")
|
|
2514
|
+
return err(session.value.error.code, session.value.error.message, session.value.error.retry);
|
|
2515
|
+
if (selection.kind === "person" && session.value.capabilities.requestIdentity === "singleton")
|
|
2516
|
+
return err("unsupported", "View-as is unavailable on the singleton host.");
|
|
2517
|
+
const resolved = selection.kind === "self" ? session.value.actor.identity : subject;
|
|
2518
|
+
return resolved ? ok({ status: "ready", session: session.value, subject: resolved, overriding: selection.kind === "person", viewGeneration: generation }) : err("unavailable", "Selected person is not resolved.", "after-refresh");
|
|
2519
|
+
}
|
|
2520
|
+
function createIdentityView(options) {
|
|
2521
|
+
let disposed = false;
|
|
2522
|
+
let generation = 0;
|
|
2523
|
+
let serial = 0;
|
|
2524
|
+
let selection = { kind: "self" };
|
|
2525
|
+
let selectedSubject = null;
|
|
2526
|
+
let snapshot = { status: "loading" };
|
|
2527
|
+
let lastReady = null;
|
|
2528
|
+
let initial = options.initialSelection;
|
|
2529
|
+
const guards = new Set;
|
|
2530
|
+
const listeners = new Set;
|
|
2531
|
+
let active = null;
|
|
2532
|
+
let finalizing = false;
|
|
2533
|
+
const safe = (callback) => {
|
|
2534
|
+
try {
|
|
2535
|
+
callback();
|
|
2536
|
+
} catch {}
|
|
2537
|
+
};
|
|
2538
|
+
const publish = () => {
|
|
2539
|
+
for (const listener of [...listeners])
|
|
2540
|
+
safe(listener);
|
|
2541
|
+
};
|
|
2542
|
+
const cancel = (work) => {
|
|
2543
|
+
if (work.cancelled)
|
|
2544
|
+
return;
|
|
2545
|
+
work.cancelled = true;
|
|
2546
|
+
work.controller.abort();
|
|
2547
|
+
for (const item of work.prepared)
|
|
2548
|
+
safe(() => item.cancel());
|
|
2549
|
+
if (active === work)
|
|
2550
|
+
active = null;
|
|
2551
|
+
};
|
|
2552
|
+
const refresh = () => {
|
|
2553
|
+
const value = ready(options.client, selection, selectedSubject, generation);
|
|
2554
|
+
if (value.ok) {
|
|
2555
|
+
snapshot = value.value;
|
|
2556
|
+
lastReady = snapshot;
|
|
2557
|
+
} else
|
|
2558
|
+
snapshot = { status: "blocked", session: options.client.getSnapshot(), requestedSubject: selection.kind === "person" ? selection.key : null, lastReady, error: value.error };
|
|
2559
|
+
publish();
|
|
2560
|
+
};
|
|
2561
|
+
const invalidate = (reason) => {
|
|
2562
|
+
if (active)
|
|
2563
|
+
cancel(active);
|
|
2564
|
+
generation++;
|
|
2565
|
+
for (const guard of [...guards])
|
|
2566
|
+
safe(() => guard.invalidate(reason));
|
|
2567
|
+
};
|
|
2568
|
+
const select = async (input, pending = options.pendingEdits ?? "block") => {
|
|
2569
|
+
if (disposed)
|
|
2570
|
+
return err("disposed", "Identity view is disposed.");
|
|
2571
|
+
if (finalizing)
|
|
2572
|
+
return Promise.resolve().then(() => select(input, pending));
|
|
2573
|
+
if (active)
|
|
2574
|
+
cancel(active);
|
|
2575
|
+
const live = options.client.currentSession();
|
|
2576
|
+
if (!live.ok || live.value.status !== "ready")
|
|
2577
|
+
return live.ok ? err("unavailable", "Identity is unavailable.") : live;
|
|
2578
|
+
const next = input.kind === "self" || input.key === live.value.actor.identity.key ? { kind: "self" } : { kind: "person", key: input.key };
|
|
2579
|
+
if (next.kind === selection.kind && (next.kind === "self" || selection.kind === "person" && next.key === selection.key))
|
|
2580
|
+
return ok(undefined);
|
|
2581
|
+
if (next.kind === "person" && live.value.capabilities.requestIdentity === "singleton")
|
|
2582
|
+
return err("unsupported", "View-as is unavailable on the singleton host.");
|
|
2583
|
+
const expected = JSON.stringify([live.value.instanceId, live.value.actor.identity.key, live.value.stamp]);
|
|
2584
|
+
const from = snapshot;
|
|
2585
|
+
const work = { controller: new AbortController, prepared: [], cancelled: false };
|
|
2586
|
+
active = work;
|
|
2587
|
+
const valid = () => {
|
|
2588
|
+
const current = options.client.currentSession();
|
|
2589
|
+
return !disposed && active === work && !work.controller.signal.aborted && current.ok && current.value.status === "ready" && JSON.stringify([current.value.instanceId, current.value.actor.identity.key, current.value.stamp]) === expected;
|
|
2590
|
+
};
|
|
2591
|
+
const awaited = (promise) => new Promise((resolve) => {
|
|
2592
|
+
const abort = () => {
|
|
2593
|
+
work.controller.signal.removeEventListener("abort", abort);
|
|
2594
|
+
resolve(err("stale-context", "View transition was superseded."));
|
|
2595
|
+
};
|
|
2596
|
+
if (work.controller.signal.aborted) {
|
|
2597
|
+
abort();
|
|
2598
|
+
return;
|
|
2599
|
+
}
|
|
2600
|
+
work.controller.signal.addEventListener("abort", abort, { once: true });
|
|
2601
|
+
promise.then((value) => {
|
|
2602
|
+
work.controller.signal.removeEventListener("abort", abort);
|
|
2603
|
+
resolve(value);
|
|
2604
|
+
}, () => {
|
|
2605
|
+
work.controller.signal.removeEventListener("abort", abort);
|
|
2606
|
+
resolve(err("unavailable", "View preparation failed.", "after-refresh"));
|
|
2607
|
+
});
|
|
2608
|
+
});
|
|
2609
|
+
try {
|
|
2610
|
+
let resolved = null;
|
|
2611
|
+
if (next.kind === "person") {
|
|
2612
|
+
const lookup = await awaited(options.client.directory.getMany({ keys: [next.key] }, { signal: work.controller.signal }));
|
|
2613
|
+
if (!valid())
|
|
2614
|
+
return err("stale-context", "View transition was superseded.");
|
|
2615
|
+
if (!lookup.ok)
|
|
2616
|
+
return lookup;
|
|
2617
|
+
const found = lookup.value[0];
|
|
2618
|
+
if (lookup.value.length !== 1 || !found || found.key !== next.key || found.status !== "found" || found.profile.identity.kind !== "person" || found.profile.identity.key !== next.key)
|
|
2619
|
+
return err(found?.status === "missing" ? "not-found" : "incompatible", "Selected identity is not a resolvable person.");
|
|
2620
|
+
resolved = Object.freeze({ ...found.profile.identity });
|
|
2621
|
+
}
|
|
2622
|
+
const transition = { id: String(++serial), from, selection: next, pending, signal: work.controller.signal };
|
|
2623
|
+
for (const guard of [...guards]) {
|
|
2624
|
+
if (!valid())
|
|
2625
|
+
return err("stale-context", "View transition was superseded.");
|
|
2626
|
+
const result = guard.inspect(transition);
|
|
2627
|
+
if (!result.ok)
|
|
2628
|
+
return result;
|
|
2629
|
+
work.prepared.push(result.value);
|
|
2630
|
+
}
|
|
2631
|
+
for (const item of work.prepared) {
|
|
2632
|
+
if (!valid())
|
|
2633
|
+
return err("stale-context", "View transition was superseded.");
|
|
2634
|
+
const value = await awaited(item.prepare());
|
|
2635
|
+
if (!valid())
|
|
2636
|
+
return err("stale-context", "View transition was superseded.");
|
|
2637
|
+
if (!value.ok)
|
|
2638
|
+
return value;
|
|
2639
|
+
}
|
|
2640
|
+
const candidate = ready(options.client, next, resolved, generation + 1);
|
|
2641
|
+
if (!candidate.ok)
|
|
2642
|
+
return candidate;
|
|
2643
|
+
finalizing = true;
|
|
2644
|
+
for (const item of work.prepared) {
|
|
2645
|
+
if (!valid())
|
|
2646
|
+
return err("stale-context", "View transition was superseded.");
|
|
2647
|
+
item.commit();
|
|
2648
|
+
}
|
|
2649
|
+
if (!valid())
|
|
2650
|
+
return err("stale-context", "View transition was superseded.");
|
|
2651
|
+
selection = next;
|
|
2652
|
+
selectedSubject = resolved;
|
|
2653
|
+
generation++;
|
|
2654
|
+
snapshot = candidate.value;
|
|
2655
|
+
lastReady = snapshot;
|
|
2656
|
+
active = null;
|
|
2657
|
+
publish();
|
|
2658
|
+
return ok(undefined);
|
|
2659
|
+
} catch {
|
|
2660
|
+
return err("unavailable", "View guard or identity lookup failed.", "after-refresh");
|
|
2661
|
+
} finally {
|
|
2662
|
+
finalizing = false;
|
|
2663
|
+
if (active === work || work.cancelled)
|
|
2664
|
+
cancel(work);
|
|
2665
|
+
}
|
|
2666
|
+
};
|
|
2667
|
+
let lastStamp = null;
|
|
2668
|
+
let lastActor = null;
|
|
2669
|
+
const observeClient = () => {
|
|
2670
|
+
if (disposed)
|
|
2671
|
+
return;
|
|
2672
|
+
const live = options.client.currentSession();
|
|
2673
|
+
if (!live.ok || live.value.status !== "ready") {
|
|
2674
|
+
invalidate("disconnected");
|
|
2675
|
+
lastStamp = null;
|
|
2676
|
+
lastActor = null;
|
|
2677
|
+
refresh();
|
|
2678
|
+
return;
|
|
2679
|
+
}
|
|
2680
|
+
const actor = live.value.actor.identity.key;
|
|
2681
|
+
if (lastActor !== null && lastActor !== actor)
|
|
2682
|
+
invalidate("actor");
|
|
2683
|
+
else if (lastStamp !== null && lastStamp !== live.value.stamp)
|
|
2684
|
+
invalidate("session");
|
|
2685
|
+
lastStamp = live.value.stamp;
|
|
2686
|
+
lastActor = actor;
|
|
2687
|
+
refresh();
|
|
2688
|
+
if (initial) {
|
|
2689
|
+
const next = initial;
|
|
2690
|
+
initial = undefined;
|
|
2691
|
+
select(next);
|
|
2692
|
+
}
|
|
2693
|
+
};
|
|
2694
|
+
const stop = options.client.subscribe(observeClient);
|
|
2695
|
+
observeClient();
|
|
2696
|
+
return {
|
|
2697
|
+
getSnapshot: () => snapshot,
|
|
2698
|
+
subscribe(listener) {
|
|
2699
|
+
if (disposed)
|
|
2700
|
+
return () => {};
|
|
2701
|
+
listeners.add(listener);
|
|
2702
|
+
return () => listeners.delete(listener);
|
|
2703
|
+
},
|
|
2704
|
+
select(next, input) {
|
|
2705
|
+
return select(next, input?.pendingEdits);
|
|
2706
|
+
},
|
|
2707
|
+
reset(input) {
|
|
2708
|
+
return select({ kind: "self" }, input?.pendingEdits);
|
|
2709
|
+
},
|
|
2710
|
+
registerGuard(guard) {
|
|
2711
|
+
if (disposed)
|
|
2712
|
+
return () => {};
|
|
2713
|
+
guards.add(guard);
|
|
2714
|
+
return () => {
|
|
2715
|
+
guards.delete(guard);
|
|
2716
|
+
if (active)
|
|
2717
|
+
cancel(active);
|
|
2718
|
+
};
|
|
2719
|
+
},
|
|
2720
|
+
dispose() {
|
|
2721
|
+
if (disposed)
|
|
2722
|
+
return;
|
|
2723
|
+
disposed = true;
|
|
2724
|
+
invalidate("disposed");
|
|
2725
|
+
stop();
|
|
2726
|
+
guards.clear();
|
|
2727
|
+
listeners.clear();
|
|
2728
|
+
}
|
|
2729
|
+
};
|
|
2730
|
+
}
|
|
2731
|
+
|
|
2732
|
+
// draft-retirement-runtime.ts
|
|
2733
|
+
var scopeKey = ({ address: a, actor, schemaVersion }) => JSON.stringify([a.instanceId, a.pluginId, a.collection, a.recordId, a.owner, actor, schemaVersion]);
|
|
2734
|
+
function createDraftRetirementOwner() {
|
|
2735
|
+
let partitions = new WeakMap;
|
|
2736
|
+
const lifetime = new AbortController;
|
|
2737
|
+
const terminal = (signal) => lifetime.signal.aborted ? err("disposed", "Draft preservation owner is disposed.") : signal?.aborted ? err("cancelled", "Draft preservation lookup was cancelled.") : null;
|
|
2738
|
+
const awaitPending = (tasks, signal) => new Promise((resolve) => {
|
|
2739
|
+
const finish = (result) => {
|
|
2740
|
+
lifetime.signal.removeEventListener("abort", abort);
|
|
2741
|
+
signal?.removeEventListener("abort", abort);
|
|
2742
|
+
resolve(result);
|
|
2743
|
+
};
|
|
2744
|
+
const abort = () => finish(terminal(signal) ?? err("cancelled", "Draft preservation lookup was cancelled."));
|
|
2745
|
+
lifetime.signal.addEventListener("abort", abort, { once: true });
|
|
2746
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
2747
|
+
const stopped = terminal(signal);
|
|
2748
|
+
if (stopped) {
|
|
2749
|
+
finish(stopped);
|
|
2750
|
+
return;
|
|
2751
|
+
}
|
|
2752
|
+
Promise.all(tasks).then(() => finish(terminal(signal) ?? ok(undefined)));
|
|
2753
|
+
});
|
|
2754
|
+
return {
|
|
2755
|
+
begin(storage, scope, task) {
|
|
2756
|
+
if (lifetime.signal.aborted)
|
|
2757
|
+
return;
|
|
2758
|
+
const key = scopeKey(scope);
|
|
2759
|
+
const scopes = partitions.get(storage) ?? new Map;
|
|
2760
|
+
partitions.set(storage, scopes);
|
|
2761
|
+
const pending = scopes.get(key) ?? new Set;
|
|
2762
|
+
scopes.set(key, pending);
|
|
2763
|
+
const work = task.catch(() => err("unavailable", "Draft preservation failed.", "after-refresh"));
|
|
2764
|
+
pending.add(work);
|
|
2765
|
+
work.then(() => {
|
|
2766
|
+
pending.delete(work);
|
|
2767
|
+
if (!pending.size)
|
|
2768
|
+
scopes.delete(key);
|
|
2769
|
+
if (!scopes.size)
|
|
2770
|
+
partitions.delete(storage);
|
|
2771
|
+
});
|
|
2772
|
+
},
|
|
2773
|
+
async wait(storage, scope, signal) {
|
|
2774
|
+
const key = scopeKey(scope);
|
|
2775
|
+
while (true) {
|
|
2776
|
+
const stopped = terminal(signal);
|
|
2777
|
+
if (stopped)
|
|
2778
|
+
return stopped;
|
|
2779
|
+
const pending = partitions.get(storage)?.get(key);
|
|
2780
|
+
if (!pending?.size)
|
|
2781
|
+
return ok(undefined);
|
|
2782
|
+
const result = await awaitPending([...pending], signal);
|
|
2783
|
+
if (!result.ok)
|
|
2784
|
+
return result;
|
|
2785
|
+
}
|
|
2786
|
+
},
|
|
2787
|
+
dispose() {
|
|
2788
|
+
if (lifetime.signal.aborted)
|
|
2789
|
+
return;
|
|
2790
|
+
lifetime.abort();
|
|
2791
|
+
partitions = new WeakMap;
|
|
2792
|
+
}
|
|
2793
|
+
};
|
|
2794
|
+
}
|
|
2795
|
+
|
|
2796
|
+
// react-runtime.ts
|
|
2797
|
+
var waitingForBinding = Object.freeze({ status: "waiting-for-identity" });
|
|
2798
|
+
function safeAvatarUrl(value) {
|
|
2799
|
+
if (value === null)
|
|
2800
|
+
return null;
|
|
2801
|
+
try {
|
|
2802
|
+
const url = new URL(value);
|
|
2803
|
+
return url.protocol === "https:" ? url.href : null;
|
|
2804
|
+
} catch {
|
|
2805
|
+
return null;
|
|
2806
|
+
}
|
|
2807
|
+
}
|
|
2808
|
+
function avatarInitials(label) {
|
|
2809
|
+
const words = label.trim().split(/\s+/u).filter(Boolean);
|
|
2810
|
+
return `${words[0]?.[0] ?? "?"}${words.length > 1 ? words.at(-1)?.[0] ?? "" : ""}`.toLocaleUpperCase();
|
|
2811
|
+
}
|
|
2812
|
+
function selectableProfile(profile) {
|
|
2813
|
+
return profile.identity.kind === "person" || profile.identity.kind === "default-user";
|
|
2814
|
+
}
|
|
2815
|
+
function createFailureSink() {
|
|
2816
|
+
let disposed = false;
|
|
2817
|
+
const listeners = new Set;
|
|
2818
|
+
const entries = new Map;
|
|
2819
|
+
let current = Object.freeze([]);
|
|
2820
|
+
let nextGeneration = 0;
|
|
2821
|
+
const keyFor = (draft) => JSON.stringify([
|
|
2822
|
+
draft.key.address.instanceId,
|
|
2823
|
+
draft.key.address.pluginId,
|
|
2824
|
+
draft.key.address.collection,
|
|
2825
|
+
draft.key.address.recordId,
|
|
2826
|
+
draft.key.address.owner,
|
|
2827
|
+
draft.key.actor,
|
|
2828
|
+
draft.key.ownerSession,
|
|
2829
|
+
draft.schemaVersion
|
|
2830
|
+
]);
|
|
2831
|
+
const emit = () => {
|
|
2832
|
+
for (const listener of [...listeners])
|
|
2833
|
+
try {
|
|
2834
|
+
listener();
|
|
2835
|
+
} catch {}
|
|
2836
|
+
};
|
|
2837
|
+
return {
|
|
2838
|
+
report(draft, error) {
|
|
2839
|
+
if (disposed)
|
|
2840
|
+
return;
|
|
2841
|
+
const key = keyFor(draft);
|
|
2842
|
+
entries.delete(key);
|
|
2843
|
+
entries.set(key, Object.freeze({ generation: ++nextGeneration, draft, error }));
|
|
2844
|
+
while (entries.size > 16)
|
|
2845
|
+
entries.delete(entries.keys().next().value);
|
|
2846
|
+
current = Object.freeze([...entries.values()]);
|
|
2847
|
+
emit();
|
|
2848
|
+
},
|
|
2849
|
+
acknowledge(generation) {
|
|
2850
|
+
if (disposed)
|
|
2851
|
+
return false;
|
|
2852
|
+
const entry = [...entries.entries()].find(([, failure2]) => failure2.generation === generation);
|
|
2853
|
+
if (!entry)
|
|
2854
|
+
return false;
|
|
2855
|
+
entries.delete(entry[0]);
|
|
2856
|
+
current = Object.freeze([...entries.values()]);
|
|
2857
|
+
emit();
|
|
2858
|
+
return true;
|
|
2859
|
+
},
|
|
2860
|
+
snapshot: () => current,
|
|
2861
|
+
subscribe(listener) {
|
|
2862
|
+
if (disposed)
|
|
2863
|
+
return () => {};
|
|
2864
|
+
listeners.add(listener);
|
|
2865
|
+
return () => listeners.delete(listener);
|
|
2866
|
+
},
|
|
2867
|
+
dispose() {
|
|
2868
|
+
if (disposed)
|
|
2869
|
+
return;
|
|
2870
|
+
disposed = true;
|
|
2871
|
+
entries.clear();
|
|
2872
|
+
current = Object.freeze([]);
|
|
2873
|
+
listeners.clear();
|
|
2874
|
+
}
|
|
2875
|
+
};
|
|
2876
|
+
}
|
|
2877
|
+
function createProviderRoot() {
|
|
2878
|
+
const retirements = createDraftRetirementOwner();
|
|
2879
|
+
const failures = createFailureSink();
|
|
2880
|
+
let disposed = false;
|
|
2881
|
+
return {
|
|
2882
|
+
retirements,
|
|
2883
|
+
failures,
|
|
2884
|
+
dispose() {
|
|
2885
|
+
if (disposed)
|
|
2886
|
+
return;
|
|
2887
|
+
disposed = true;
|
|
2888
|
+
retirements.dispose();
|
|
2889
|
+
failures.dispose();
|
|
2890
|
+
}
|
|
2891
|
+
};
|
|
2892
|
+
}
|
|
2893
|
+
function useCommittedResource(key, create, dispose) {
|
|
2894
|
+
const [entry, setEntry] = React.useState(null);
|
|
2895
|
+
const epoch = React.useRef(0);
|
|
2896
|
+
React.useLayoutEffect(() => {
|
|
2897
|
+
const current = ++epoch.current;
|
|
2898
|
+
const value = create();
|
|
2899
|
+
setEntry({ key, epoch: current, value });
|
|
2900
|
+
return () => {
|
|
2901
|
+
try {
|
|
2902
|
+
dispose(value);
|
|
2903
|
+
} catch {}
|
|
2904
|
+
};
|
|
2905
|
+
}, [key]);
|
|
2906
|
+
return entry !== null && entry.epoch === epoch.current && Object.is(entry.key, key) ? entry.value : null;
|
|
2907
|
+
}
|
|
2908
|
+
function requireContext(value, name) {
|
|
2909
|
+
if (value === null)
|
|
2910
|
+
throw new Error(`${name} requires an enclosing BbIdentity Provider.`);
|
|
2911
|
+
return value;
|
|
2912
|
+
}
|
|
2913
|
+
function createIdentityReactRuntime(native) {
|
|
2914
|
+
const ProviderContext = React.createContext(null);
|
|
2915
|
+
const ViewContext = React.createContext(null);
|
|
2916
|
+
const Provider = ({ client, children, fallback }) => {
|
|
2917
|
+
const root = useCommittedResource("provider-root", createProviderRoot, (value) => value.dispose());
|
|
2918
|
+
const context = React.useMemo(() => root === null || client === null ? null : { root, client }, [root, client]);
|
|
2919
|
+
if (context === null)
|
|
2920
|
+
return fallback ?? null;
|
|
2921
|
+
return React.createElement(ProviderContext.Provider, { value: context }, children);
|
|
2922
|
+
};
|
|
2923
|
+
const Context = (props) => {
|
|
2924
|
+
const provider = requireContext(React.useContext(ProviderContext), "BbIdentity.Context");
|
|
2925
|
+
const viewKey = React.useMemo(() => ({ client: provider.client, supplied: props.view ?? null, pending: props.pendingEdits ?? null }), [provider.client, props.view, props.pendingEdits]);
|
|
2926
|
+
const view = useCommittedResource(viewKey, () => props.view ?? createIdentityView({
|
|
2927
|
+
client: provider.client,
|
|
2928
|
+
...props.pendingEdits ? { pendingEdits: props.pendingEdits } : {}
|
|
2929
|
+
}), (value) => {
|
|
2930
|
+
if (!props.view)
|
|
2931
|
+
value.dispose();
|
|
2932
|
+
});
|
|
2933
|
+
const root = React.useMemo(() => view === null ? null : { ...provider, view }, [provider, view]);
|
|
2934
|
+
if (root === null)
|
|
2935
|
+
return null;
|
|
2936
|
+
return React.createElement(ViewContext.Provider, { value: root }, props.children);
|
|
2937
|
+
};
|
|
2938
|
+
const useBorrowedIdentityClient = () => requireContext(React.useContext(ProviderContext), "useBorrowedIdentityClient").client;
|
|
2939
|
+
const useIdentitySession = () => {
|
|
2940
|
+
const client = useBorrowedIdentityClient();
|
|
2941
|
+
return React.useSyncExternalStore(React.useCallback((listener) => client.subscribe(listener), [client]), React.useCallback(() => client.getSnapshot(), [client]), React.useCallback(() => client.getSnapshot(), [client]));
|
|
2942
|
+
};
|
|
2943
|
+
const useNativeIdentityConnection = () => {
|
|
2944
|
+
const rpc = native.useRpc();
|
|
2945
|
+
const realtimeState = native.useRealtimeConnectionState();
|
|
2946
|
+
const adapter = useCommittedResource(rpc, () => createNativeIdentityConnection({ rpc, realtimeState }), (value) => value.dispose());
|
|
2947
|
+
const receive = React.useCallback((payload) => {
|
|
2948
|
+
adapter?.acceptRealtime(payload);
|
|
2949
|
+
}, [adapter]);
|
|
2950
|
+
native.useRealtime(nativeIdentityRealtimeChannel, receive);
|
|
2951
|
+
React.useEffect(() => {
|
|
2952
|
+
adapter?.setRealtimeState(realtimeState);
|
|
2953
|
+
}, [adapter, realtimeState]);
|
|
2954
|
+
return adapter?.connection ?? null;
|
|
2955
|
+
};
|
|
2956
|
+
const useNativeIdentityClient = () => {
|
|
2957
|
+
const connection = useNativeIdentityConnection();
|
|
2958
|
+
const client = useCommittedResource(connection, () => connection === null ? null : createIdentityClient({ connection }), (value) => value?.dispose());
|
|
2959
|
+
React.useEffect(() => {
|
|
2960
|
+
if (client !== null)
|
|
2961
|
+
client.start();
|
|
2962
|
+
}, [client]);
|
|
2963
|
+
return client;
|
|
2964
|
+
};
|
|
2965
|
+
const useIdentityStateSnapshot = (binding) => React.useSyncExternalStore(React.useCallback((listener) => binding?.subscribe(listener) ?? (() => {}), [binding]), React.useCallback(() => binding?.getSnapshot() ?? waitingForBinding, [binding]), React.useCallback(() => binding?.getSnapshot() ?? waitingForBinding, [binding]));
|
|
2966
|
+
const useBoundIdentityState = (options) => {
|
|
2967
|
+
const root = requireContext(React.useContext(ViewContext), "useBoundIdentityState");
|
|
2968
|
+
const suppliedConflict = "resource" in options && options.resource !== undefined ? options.onConflict : null;
|
|
2969
|
+
const callbacks = React.useRef({ failure: options.onUnpersistedDraft, conflict: suppliedConflict });
|
|
2970
|
+
React.useLayoutEffect(() => {
|
|
2971
|
+
callbacks.current = { failure: options.onUnpersistedDraft, conflict: suppliedConflict };
|
|
2972
|
+
}, [options.onUnpersistedDraft, suppliedConflict]);
|
|
2973
|
+
const sourceKey = React.useMemo(() => {
|
|
2974
|
+
if ("resource" in options && options.resource !== undefined)
|
|
2975
|
+
return {
|
|
2976
|
+
kind: "resource",
|
|
2977
|
+
resource: options.resource,
|
|
2978
|
+
recordId: options.recordId,
|
|
2979
|
+
drafts: options.drafts,
|
|
2980
|
+
initializeEmpty: options.initializeEmpty,
|
|
2981
|
+
scheduler: options.scheduler ?? null
|
|
2982
|
+
};
|
|
2983
|
+
return { kind: "custom", create: options.create };
|
|
2984
|
+
}, [
|
|
2985
|
+
"create" in options ? options.create : null,
|
|
2986
|
+
"resource" in options ? options.resource : null,
|
|
2987
|
+
"recordId" in options ? options.recordId : null,
|
|
2988
|
+
"drafts" in options ? options.drafts : null,
|
|
2989
|
+
"initializeEmpty" in options ? options.initializeEmpty : null,
|
|
2990
|
+
"scheduler" in options ? options.scheduler : null
|
|
2991
|
+
]);
|
|
2992
|
+
const key = React.useMemo(() => ({ root, source: sourceKey, target: options.target, editPolicy: options.editPolicy }), [root, sourceKey, options.target, options.editPolicy]);
|
|
2993
|
+
const binding = useCommittedResource(key, () => {
|
|
2994
|
+
const onUnpersistedDraft = (draft, error) => {
|
|
2995
|
+
if ("resource" in options && options.resource !== undefined && options.drafts)
|
|
2996
|
+
root.root.failures.report(draft, error);
|
|
2997
|
+
try {
|
|
2998
|
+
callbacks.current.failure?.(draft, error);
|
|
2999
|
+
} catch {}
|
|
3000
|
+
};
|
|
3001
|
+
if ("resource" in options && options.resource !== undefined) {
|
|
3002
|
+
return bindIdentityState({
|
|
3003
|
+
...options,
|
|
3004
|
+
client: root.client,
|
|
3005
|
+
view: root.view,
|
|
3006
|
+
onUnpersistedDraft,
|
|
3007
|
+
onConflict(context) {
|
|
3008
|
+
return callbacks.current.conflict?.(context) ?? { kind: "needs-review", reason: "Committed conflict callback is unavailable." };
|
|
3009
|
+
}
|
|
3010
|
+
}, root.root.retirements);
|
|
3011
|
+
}
|
|
3012
|
+
return bindIdentityState({ ...options, client: root.client, view: root.view, onUnpersistedDraft }, root.root.retirements);
|
|
3013
|
+
}, (value) => value.dispose());
|
|
3014
|
+
useIdentityStateSnapshot(binding);
|
|
3015
|
+
return binding;
|
|
3016
|
+
};
|
|
3017
|
+
const useIdentityViewSnapshot = () => {
|
|
3018
|
+
const root = requireContext(React.useContext(ViewContext), "useIdentityViewSnapshot");
|
|
3019
|
+
return React.useSyncExternalStore(React.useCallback((listener) => root.view.subscribe(listener), [root.view]), React.useCallback(() => root.view.getSnapshot(), [root.view]), React.useCallback(() => root.view.getSnapshot(), [root.view]));
|
|
3020
|
+
};
|
|
3021
|
+
const useIdentityViewActions = () => {
|
|
3022
|
+
const root = requireContext(React.useContext(ViewContext), "useIdentityViewActions");
|
|
3023
|
+
return React.useMemo(() => ({ select: root.view.select.bind(root.view), reset: root.view.reset.bind(root.view) }), [root.view]);
|
|
3024
|
+
};
|
|
3025
|
+
const IdentityAvatar = ({ presentation, label, className }) => {
|
|
3026
|
+
const imageUrl = React.useMemo(() => safeAvatarUrl(presentation?.avatarUrl ?? null), [presentation?.avatarUrl]);
|
|
3027
|
+
const [failedImageUrl, setFailedImageUrl] = React.useState(null);
|
|
3028
|
+
React.useEffect(() => {
|
|
3029
|
+
setFailedImageUrl(null);
|
|
3030
|
+
}, [imageUrl]);
|
|
3031
|
+
const accessibleLabel = presentation?.displayName || label;
|
|
3032
|
+
if (imageUrl !== null && failedImageUrl !== imageUrl) {
|
|
3033
|
+
return React.createElement("img", {
|
|
3034
|
+
className,
|
|
3035
|
+
src: imageUrl,
|
|
3036
|
+
alt: accessibleLabel,
|
|
3037
|
+
crossOrigin: "anonymous",
|
|
3038
|
+
referrerPolicy: "no-referrer",
|
|
3039
|
+
onError: () => setFailedImageUrl(imageUrl)
|
|
3040
|
+
});
|
|
3041
|
+
}
|
|
3042
|
+
return React.createElement("span", { className, role: "img", "aria-label": accessibleLabel }, avatarInitials(accessibleLabel));
|
|
3043
|
+
};
|
|
3044
|
+
const IdentityLabel = ({ profile, fallback, className, primaryClassName, secondaryClassName }) => {
|
|
3045
|
+
const displayName = profile?.presentation.displayName || fallback;
|
|
3046
|
+
const handle = profile?.presentation.handle ?? null;
|
|
3047
|
+
return React.createElement("span", { className }, React.createElement("strong", { className: primaryClassName }, displayName), handle === null ? null : React.createElement("small", { className: secondaryClassName }, handle));
|
|
3048
|
+
};
|
|
3049
|
+
const IdentityViewPicker = (props) => {
|
|
3050
|
+
const client = useBorrowedIdentityClient();
|
|
3051
|
+
const view = useIdentityViewSnapshot();
|
|
3052
|
+
const actions = useIdentityViewActions();
|
|
3053
|
+
const [open, setOpen] = React.useState(false);
|
|
3054
|
+
const [query, setQuery] = React.useState("");
|
|
3055
|
+
const [results, setResults] = React.useState([]);
|
|
3056
|
+
const [loading, setLoading] = React.useState(false);
|
|
3057
|
+
const [error, setError] = React.useState(null);
|
|
3058
|
+
const searchGeneration = React.useRef(0);
|
|
3059
|
+
const actionGeneration = React.useRef(0);
|
|
3060
|
+
const shellRef = React.useRef(null);
|
|
3061
|
+
const triggerRef = React.useRef(null);
|
|
3062
|
+
const popupRef = React.useRef(null);
|
|
3063
|
+
const inputRef = React.useRef(null);
|
|
3064
|
+
const active = view.status === "ready" ? view : view.status === "blocked" ? view.lastReady : null;
|
|
3065
|
+
const canSearch = active !== null && active.session.mode === "multi-user" && active.session.capabilities.directory.search;
|
|
3066
|
+
const scope = active === null ? null : `${active.session.instanceId}:${active.session.stamp}:${active.subject.key}:${active.viewGeneration}`;
|
|
3067
|
+
const selectedKey = active?.subject.key ?? null;
|
|
3068
|
+
const close = React.useCallback(() => {
|
|
3069
|
+
setOpen(false);
|
|
3070
|
+
setQuery("");
|
|
3071
|
+
queueMicrotask(() => triggerRef.current?.focus());
|
|
3072
|
+
}, []);
|
|
3073
|
+
React.useEffect(() => {
|
|
3074
|
+
const current = ++searchGeneration.current;
|
|
3075
|
+
setResults([]);
|
|
3076
|
+
setError(null);
|
|
3077
|
+
if (!open || !canSearch || props.selectionDisabled || scope === null)
|
|
3078
|
+
return;
|
|
3079
|
+
const timer = window.setTimeout(() => {
|
|
3080
|
+
setLoading(true);
|
|
3081
|
+
client.directory.search({
|
|
3082
|
+
query,
|
|
3083
|
+
kinds: ["person", "default-user"],
|
|
3084
|
+
history: "current",
|
|
3085
|
+
limit: 20
|
|
3086
|
+
}).then((result) => {
|
|
3087
|
+
if (searchGeneration.current !== current)
|
|
3088
|
+
return;
|
|
3089
|
+
if (result.ok)
|
|
3090
|
+
setResults(result.value.items.filter(selectableProfile));
|
|
3091
|
+
else
|
|
3092
|
+
setError(result.error.message);
|
|
3093
|
+
}, () => {
|
|
3094
|
+
if (searchGeneration.current === current)
|
|
3095
|
+
setError(props.labels.searchUnavailable);
|
|
3096
|
+
}).finally(() => {
|
|
3097
|
+
if (searchGeneration.current === current)
|
|
3098
|
+
setLoading(false);
|
|
3099
|
+
});
|
|
3100
|
+
}, query ? 150 : 0);
|
|
3101
|
+
return () => window.clearTimeout(timer);
|
|
3102
|
+
}, [canSearch, client, open, props.labels.searchUnavailable, props.selectionDisabled, query, scope]);
|
|
3103
|
+
React.useLayoutEffect(() => {
|
|
3104
|
+
if (!open || !canSearch)
|
|
3105
|
+
return;
|
|
3106
|
+
const popup = popupRef.current;
|
|
3107
|
+
const trigger = triggerRef.current;
|
|
3108
|
+
if (popup === null || trigger === null)
|
|
3109
|
+
return;
|
|
3110
|
+
const position = () => {
|
|
3111
|
+
const viewport = window.visualViewport;
|
|
3112
|
+
const left = viewport?.offsetLeft ?? 0;
|
|
3113
|
+
const top = viewport?.offsetTop ?? 0;
|
|
3114
|
+
const width = viewport?.width ?? window.innerWidth;
|
|
3115
|
+
const height = viewport?.height ?? window.innerHeight;
|
|
3116
|
+
const anchor = trigger.getBoundingClientRect();
|
|
3117
|
+
popup.style.maxWidth = `${Math.max(0, width - 24)}px`;
|
|
3118
|
+
popup.style.maxHeight = `${Math.max(0, height - 24)}px`;
|
|
3119
|
+
const bounds = popup.getBoundingClientRect();
|
|
3120
|
+
const x = Math.max(left + 12, Math.min(anchor.left, left + width - bounds.width - 12));
|
|
3121
|
+
const below = anchor.bottom + 8;
|
|
3122
|
+
const y = below + bounds.height <= top + height - 12 ? below : Math.max(top + 12, anchor.top - bounds.height - 8);
|
|
3123
|
+
popup.style.left = `${x}px`;
|
|
3124
|
+
popup.style.top = `${y}px`;
|
|
3125
|
+
};
|
|
3126
|
+
popup.showPopover?.();
|
|
3127
|
+
position();
|
|
3128
|
+
inputRef.current?.focus({ preventScroll: true });
|
|
3129
|
+
const onPointerDown = (event) => {
|
|
3130
|
+
if (event.target instanceof Node && !shellRef.current?.contains(event.target))
|
|
3131
|
+
close();
|
|
3132
|
+
};
|
|
3133
|
+
const observer = new ResizeObserver(position);
|
|
3134
|
+
observer.observe(popup);
|
|
3135
|
+
window.addEventListener("resize", position);
|
|
3136
|
+
window.addEventListener("scroll", position, true);
|
|
3137
|
+
window.visualViewport?.addEventListener("resize", position);
|
|
3138
|
+
window.visualViewport?.addEventListener("scroll", position);
|
|
3139
|
+
document.addEventListener("pointerdown", onPointerDown);
|
|
3140
|
+
return () => {
|
|
3141
|
+
observer.disconnect();
|
|
3142
|
+
window.removeEventListener("resize", position);
|
|
3143
|
+
window.removeEventListener("scroll", position, true);
|
|
3144
|
+
window.visualViewport?.removeEventListener("resize", position);
|
|
3145
|
+
window.visualViewport?.removeEventListener("scroll", position);
|
|
3146
|
+
document.removeEventListener("pointerdown", onPointerDown);
|
|
3147
|
+
};
|
|
3148
|
+
}, [canSearch, close, open]);
|
|
3149
|
+
const profiles = React.useMemo(() => {
|
|
3150
|
+
const byKey = new Map;
|
|
3151
|
+
if (props.selectedProfile !== undefined && props.selectedProfile !== null && selectableProfile(props.selectedProfile)) {
|
|
3152
|
+
byKey.set(props.selectedProfile.identity.key, props.selectedProfile);
|
|
3153
|
+
}
|
|
3154
|
+
for (const profile of results)
|
|
3155
|
+
byKey.set(profile.identity.key, profile);
|
|
3156
|
+
return [...byKey.values()];
|
|
3157
|
+
}, [props.selectedProfile, results]);
|
|
3158
|
+
const select = React.useCallback(async (profile) => {
|
|
3159
|
+
const current = ++actionGeneration.current;
|
|
3160
|
+
const result = profile === null || profile.identity.kind === "default-user" ? await actions.reset() : profile.identity.kind === "person" ? await actions.select({ kind: "person", key: profile.identity.key }) : { ok: false, error: { code: "unsupported", message: "This identity cannot be selected as a view.", retry: "never" } };
|
|
3161
|
+
if (actionGeneration.current !== current)
|
|
3162
|
+
return;
|
|
3163
|
+
props.onResult?.(result);
|
|
3164
|
+
if (result.ok)
|
|
3165
|
+
close();
|
|
3166
|
+
else
|
|
3167
|
+
setError(result.error.message);
|
|
3168
|
+
}, [actions, close, props]);
|
|
3169
|
+
const onKeyDown = React.useCallback((event) => {
|
|
3170
|
+
if (event.key !== "Escape")
|
|
3171
|
+
return;
|
|
3172
|
+
event.preventDefault();
|
|
3173
|
+
close();
|
|
3174
|
+
}, [close]);
|
|
3175
|
+
if (!canSearch)
|
|
3176
|
+
return null;
|
|
3177
|
+
const triggerLabel = active?.overriding ? props.selectedProfile?.presentation.displayName ?? props.labels.viewingFallback : props.labels.trigger;
|
|
3178
|
+
return React.createElement("div", { ref: shellRef, style: { position: "relative" } }, React.createElement("button", {
|
|
3179
|
+
ref: triggerRef,
|
|
3180
|
+
type: "button",
|
|
3181
|
+
className: props.className,
|
|
3182
|
+
disabled: props.selectionDisabled === true,
|
|
3183
|
+
"aria-label": props.labels.trigger,
|
|
3184
|
+
title: props.labels.trigger,
|
|
3185
|
+
"aria-haspopup": "dialog",
|
|
3186
|
+
"aria-expanded": open,
|
|
3187
|
+
"data-bb-identity-view-picker": "",
|
|
3188
|
+
onClick: () => {
|
|
3189
|
+
setError(null);
|
|
3190
|
+
setOpen(true);
|
|
3191
|
+
}
|
|
3192
|
+
}, props.selectedProfile === undefined || props.selectedProfile === null ? React.createElement("svg", { "aria-hidden": true, width: 16, height: 16, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.75 }, React.createElement("circle", { cx: 12, cy: 8, r: 4 }), React.createElement("path", { d: "M4 21v-2a8 8 0 0 1 16 0v2" })) : React.createElement(IdentityAvatar, {
|
|
3193
|
+
presentation: props.selectedProfile.presentation,
|
|
3194
|
+
label: triggerLabel,
|
|
3195
|
+
...props.avatarClassName ? { className: props.avatarClassName } : {}
|
|
3196
|
+
}), React.createElement("span", null, triggerLabel)), !open ? null : React.createElement("div", {
|
|
3197
|
+
ref: popupRef,
|
|
3198
|
+
popover: "manual",
|
|
3199
|
+
role: "dialog",
|
|
3200
|
+
"aria-label": props.labels.dialog,
|
|
3201
|
+
className: props.contentClassName,
|
|
3202
|
+
style: { position: "fixed", margin: 0, padding: 0, inset: "auto", overflowY: "auto" },
|
|
3203
|
+
onKeyDown
|
|
3204
|
+
}, React.createElement("label", { className: props.searchClassName }, React.createElement("span", null, props.labels.search), React.createElement("input", {
|
|
3205
|
+
ref: inputRef,
|
|
3206
|
+
value: query,
|
|
3207
|
+
onChange: (event) => setQuery(event.currentTarget.value),
|
|
3208
|
+
placeholder: props.labels.search,
|
|
3209
|
+
"aria-label": props.labels.search,
|
|
3210
|
+
"data-bb-identity-view-search": ""
|
|
3211
|
+
})), React.createElement("div", { className: props.resultsClassName, role: "listbox" }, active?.overriding ? React.createElement("button", {
|
|
3212
|
+
type: "button",
|
|
3213
|
+
role: "option",
|
|
3214
|
+
"aria-selected": false,
|
|
3215
|
+
onClick: () => {
|
|
3216
|
+
select(null);
|
|
3217
|
+
}
|
|
3218
|
+
}, props.labels.self) : null, profiles.map((profile) => React.createElement("button", {
|
|
3219
|
+
type: "button",
|
|
3220
|
+
role: "option",
|
|
3221
|
+
key: profile.identity.key,
|
|
3222
|
+
"aria-selected": profile.identity.key === selectedKey,
|
|
3223
|
+
"data-bb-identity-view-option": "",
|
|
3224
|
+
disabled: props.selectionDisabled === true,
|
|
3225
|
+
onClick: () => {
|
|
3226
|
+
select(profile);
|
|
3227
|
+
}
|
|
3228
|
+
}, React.createElement(IdentityAvatar, {
|
|
3229
|
+
presentation: profile.presentation,
|
|
3230
|
+
label: props.labels.viewingFallback,
|
|
3231
|
+
...props.avatarClassName ? { className: props.avatarClassName } : {}
|
|
3232
|
+
}), React.createElement(IdentityLabel, { profile, fallback: props.labels.viewingFallback }), profile.identity.key === selectedKey ? React.createElement("span", { "aria-hidden": true }, "✓") : null)), !loading && error !== null ? React.createElement("p", { role: "alert" }, error) : null, !loading && error === null && profiles.length === 0 ? React.createElement("p", null, props.labels.noResults) : null, loading ? React.createElement("p", { role: "status" }, "Searching…") : null)));
|
|
3233
|
+
};
|
|
3234
|
+
const IdentityViewStatus = (props) => {
|
|
3235
|
+
const view = useIdentityViewSnapshot();
|
|
3236
|
+
const actions = useIdentityViewActions();
|
|
3237
|
+
const [error, setError] = React.useState(null);
|
|
3238
|
+
const active = view.status === "ready" ? view : view.status === "blocked" ? view.lastReady : null;
|
|
3239
|
+
if (active?.overriding !== true)
|
|
3240
|
+
return null;
|
|
3241
|
+
const reset = async () => {
|
|
3242
|
+
const result = await actions.reset();
|
|
3243
|
+
props.onResult?.(result);
|
|
3244
|
+
if (!result.ok)
|
|
3245
|
+
setError(result.error.message);
|
|
3246
|
+
};
|
|
3247
|
+
return React.createElement("span", { className: props.className }, React.createElement("span", null, props.labels.viewing), React.createElement("button", {
|
|
3248
|
+
type: "button",
|
|
3249
|
+
...props.returnSelfAttributes,
|
|
3250
|
+
"data-bb-identity-return-self": "",
|
|
3251
|
+
onClick: () => {
|
|
3252
|
+
reset();
|
|
3253
|
+
}
|
|
3254
|
+
}, props.labels.returnSelf), error === null ? null : React.createElement("span", { role: "alert" }, error));
|
|
3255
|
+
};
|
|
3256
|
+
const usePreservationFailures = () => {
|
|
3257
|
+
const root = requireContext(React.useContext(ProviderContext), "usePreservationFailures");
|
|
3258
|
+
return React.useSyncExternalStore(React.useCallback((listener) => root.root.failures.subscribe(listener), [root.root]), React.useCallback(() => root.root.failures.snapshot(), [root.root]), React.useCallback(() => root.root.failures.snapshot(), [root.root]));
|
|
3259
|
+
};
|
|
3260
|
+
const useAcknowledgePreservationFailure = () => {
|
|
3261
|
+
const root = requireContext(React.useContext(ProviderContext), "useAcknowledgePreservationFailure");
|
|
3262
|
+
return React.useCallback((generation) => root.root.failures.acknowledge(generation), [root.root]);
|
|
3263
|
+
};
|
|
3264
|
+
return {
|
|
3265
|
+
Provider,
|
|
3266
|
+
Context,
|
|
3267
|
+
IdentityAvatar,
|
|
3268
|
+
IdentityLabel,
|
|
3269
|
+
IdentityViewPicker,
|
|
3270
|
+
IdentityViewStatus,
|
|
3271
|
+
useBorrowedIdentityClient,
|
|
3272
|
+
useIdentitySession,
|
|
3273
|
+
useNativeIdentityConnection,
|
|
3274
|
+
useNativeIdentityClient,
|
|
3275
|
+
useBoundIdentityState,
|
|
3276
|
+
useIdentityStateSnapshot,
|
|
3277
|
+
useIdentityViewSnapshot,
|
|
3278
|
+
useIdentityViewActions,
|
|
3279
|
+
usePreservationFailures,
|
|
3280
|
+
useAcknowledgePreservationFailure
|
|
3281
|
+
};
|
|
3282
|
+
}
|
|
3283
|
+
var BbIdentityReact = createIdentityReactRuntime({
|
|
3284
|
+
useRpc: () => useRpc(),
|
|
3285
|
+
useRealtime,
|
|
3286
|
+
useRealtimeConnectionState
|
|
3287
|
+
});
|
|
3288
|
+
|
|
3289
|
+
// react-entry-runtime.ts
|
|
3290
|
+
var BbIdentity = {
|
|
3291
|
+
Provider: BbIdentityReact.Provider,
|
|
3292
|
+
Context: BbIdentityReact.Context
|
|
3293
|
+
};
|
|
3294
|
+
var IdentityAvatar = BbIdentityReact.IdentityAvatar;
|
|
3295
|
+
var IdentityLabel = BbIdentityReact.IdentityLabel;
|
|
3296
|
+
var IdentityViewPicker = BbIdentityReact.IdentityViewPicker;
|
|
3297
|
+
var IdentityViewStatus = BbIdentityReact.IdentityViewStatus;
|
|
3298
|
+
var useBbIdentityConnection = () => BbIdentityReact.useNativeIdentityConnection();
|
|
3299
|
+
var useBbIdentityClient = () => BbIdentityReact.useNativeIdentityClient();
|
|
3300
|
+
var useBorrowedIdentityClient = () => BbIdentityReact.useBorrowedIdentityClient();
|
|
3301
|
+
var useIdentitySession = () => BbIdentityReact.useIdentitySession();
|
|
3302
|
+
var useIdentityStateBinding = BbIdentityReact.useBoundIdentityState;
|
|
3303
|
+
var useIdentityStateSnapshot = BbIdentityReact.useIdentityStateSnapshot;
|
|
3304
|
+
var useIdentityViewSnapshot = () => BbIdentityReact.useIdentityViewSnapshot();
|
|
3305
|
+
var useIdentityViewActions = () => BbIdentityReact.useIdentityViewActions();
|
|
3306
|
+
var useIdentityPreservationFailures = () => BbIdentityReact.usePreservationFailures();
|
|
3307
|
+
var useAcknowledgeIdentityPreservationFailure = () => BbIdentityReact.useAcknowledgePreservationFailure();
|
|
3308
|
+
export {
|
|
3309
|
+
useIdentityViewSnapshot,
|
|
3310
|
+
useIdentityViewActions,
|
|
3311
|
+
useIdentityStateSnapshot,
|
|
3312
|
+
useIdentityStateBinding,
|
|
3313
|
+
useIdentitySession,
|
|
3314
|
+
useIdentityPreservationFailures,
|
|
3315
|
+
useBorrowedIdentityClient,
|
|
3316
|
+
useBbIdentityConnection,
|
|
3317
|
+
useBbIdentityClient,
|
|
3318
|
+
useAcknowledgeIdentityPreservationFailure,
|
|
3319
|
+
IdentityViewStatus,
|
|
3320
|
+
IdentityViewPicker,
|
|
3321
|
+
IdentityLabel,
|
|
3322
|
+
IdentityAvatar,
|
|
3323
|
+
BbIdentity
|
|
3324
|
+
};
|