@abraca/dabra 2.0.9 → 2.3.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/dist/abracadabra-provider.cjs +540 -9
- package/dist/abracadabra-provider.cjs.map +1 -1
- package/dist/abracadabra-provider.esm.js +534 -10
- package/dist/abracadabra-provider.esm.js.map +1 -1
- package/dist/index.d.ts +434 -2
- package/package.json +8 -2
- package/src/AbracadabraBaseProvider.ts +28 -0
- package/src/AbracadabraClient.ts +32 -0
- package/src/BackgroundSyncManager.ts +34 -1
- package/src/DocumentManager.ts +70 -0
- package/src/MetaManager.ts +98 -1
- package/src/QueryClient.ts +209 -0
- package/src/SchemaTypes.ts +179 -0
- package/src/TokenManager.ts +329 -0
- package/src/TreeManager.ts +27 -0
- package/src/index.ts +32 -2
|
@@ -2215,6 +2215,119 @@ function serializeTarget(t) {
|
|
|
2215
2215
|
}
|
|
2216
2216
|
}
|
|
2217
2217
|
|
|
2218
|
+
//#endregion
|
|
2219
|
+
//#region packages/provider/src/QueryClient.ts
|
|
2220
|
+
/**
|
|
2221
|
+
* V2 query layer — live subscription client.
|
|
2222
|
+
*
|
|
2223
|
+
* Mirrors the server in `crates/abracadabra/src/query_stream.rs`. Frames
|
|
2224
|
+
* travel as `MSG_STATELESS` payloads with the literal `query:v1:` prefix
|
|
2225
|
+
* followed by a JSON envelope.
|
|
2226
|
+
*
|
|
2227
|
+
* Each call to `subscribeQuery(...)` opens a long-lived subscription:
|
|
2228
|
+
* 1. The client sends `req` with a fresh correlation id.
|
|
2229
|
+
* 2. The server replies `ack` with an initial snapshot
|
|
2230
|
+
* (`DocumentMeta[]`).
|
|
2231
|
+
* 3. The server emits `delta` frames as the doc-tree projection
|
|
2232
|
+
* drifts under the predicate.
|
|
2233
|
+
* 4. The client sends `cancel` to tear it down (or relies on the
|
|
2234
|
+
* WebSocket close path to free server state).
|
|
2235
|
+
*
|
|
2236
|
+
* The shape of the `query` field mirrors the REST `POST /docs/query`
|
|
2237
|
+
* body — `type`, `parentId`, `labelContains`, `where`, `limit`.
|
|
2238
|
+
*/
|
|
2239
|
+
const QUERY_PREFIX = "query:v1:";
|
|
2240
|
+
var QueryError = class extends Error {
|
|
2241
|
+
constructor(code, message) {
|
|
2242
|
+
super(message);
|
|
2243
|
+
this.name = "QueryError";
|
|
2244
|
+
this.code = code;
|
|
2245
|
+
}
|
|
2246
|
+
};
|
|
2247
|
+
var QueryClient = class extends EventEmitter {
|
|
2248
|
+
constructor(transport) {
|
|
2249
|
+
super();
|
|
2250
|
+
this.subs = /* @__PURE__ */ new Map();
|
|
2251
|
+
this.idCounter = 0;
|
|
2252
|
+
this.transport = transport;
|
|
2253
|
+
this.onStatelessBound = (data) => this.receive(data.payload);
|
|
2254
|
+
this.transport.on("stateless", this.onStatelessBound);
|
|
2255
|
+
}
|
|
2256
|
+
destroy() {
|
|
2257
|
+
for (const sub of this.subs.values()) if (!sub.cancelled) {
|
|
2258
|
+
this.send({
|
|
2259
|
+
kind: "cancel",
|
|
2260
|
+
id: sub.id
|
|
2261
|
+
});
|
|
2262
|
+
sub.cancelled = true;
|
|
2263
|
+
}
|
|
2264
|
+
this.subs.clear();
|
|
2265
|
+
this.transport.off("stateless", this.onStatelessBound);
|
|
2266
|
+
}
|
|
2267
|
+
subscribeQuery(spec, handlers = {}) {
|
|
2268
|
+
const id = this.nextId();
|
|
2269
|
+
const sub = {
|
|
2270
|
+
id,
|
|
2271
|
+
handlers,
|
|
2272
|
+
cancelled: false
|
|
2273
|
+
};
|
|
2274
|
+
this.subs.set(id, sub);
|
|
2275
|
+
this.send({
|
|
2276
|
+
kind: "req",
|
|
2277
|
+
id,
|
|
2278
|
+
query: spec
|
|
2279
|
+
});
|
|
2280
|
+
const self = this;
|
|
2281
|
+
return {
|
|
2282
|
+
id,
|
|
2283
|
+
cancel() {
|
|
2284
|
+
if (sub.cancelled) return;
|
|
2285
|
+
sub.cancelled = true;
|
|
2286
|
+
self.subs.delete(id);
|
|
2287
|
+
self.send({
|
|
2288
|
+
kind: "cancel",
|
|
2289
|
+
id
|
|
2290
|
+
});
|
|
2291
|
+
}
|
|
2292
|
+
};
|
|
2293
|
+
}
|
|
2294
|
+
nextId() {
|
|
2295
|
+
this.idCounter += 1;
|
|
2296
|
+
return `q-${Date.now()}-${this.idCounter}`;
|
|
2297
|
+
}
|
|
2298
|
+
send(frame) {
|
|
2299
|
+
this.transport.sendStateless(`${QUERY_PREFIX}${JSON.stringify(frame)}`);
|
|
2300
|
+
}
|
|
2301
|
+
receive(payload) {
|
|
2302
|
+
if (!payload.startsWith(QUERY_PREFIX)) return;
|
|
2303
|
+
const rest = payload.slice(9);
|
|
2304
|
+
let frame;
|
|
2305
|
+
try {
|
|
2306
|
+
frame = JSON.parse(rest);
|
|
2307
|
+
} catch {
|
|
2308
|
+
return;
|
|
2309
|
+
}
|
|
2310
|
+
const sub = this.subs.get(frame.id);
|
|
2311
|
+
if (!sub || sub.cancelled) return;
|
|
2312
|
+
switch (frame.kind) {
|
|
2313
|
+
case "ack":
|
|
2314
|
+
sub.handlers.onSnapshot?.(frame.initial ?? []);
|
|
2315
|
+
break;
|
|
2316
|
+
case "delta":
|
|
2317
|
+
sub.handlers.onDelta?.({
|
|
2318
|
+
added: frame.added ?? [],
|
|
2319
|
+
removed: frame.removed ?? []
|
|
2320
|
+
});
|
|
2321
|
+
break;
|
|
2322
|
+
case "err":
|
|
2323
|
+
if (frame.error) sub.handlers.onError?.(new QueryError(frame.error.code, frame.error.message));
|
|
2324
|
+
this.subs.delete(frame.id);
|
|
2325
|
+
break;
|
|
2326
|
+
default: break;
|
|
2327
|
+
}
|
|
2328
|
+
}
|
|
2329
|
+
};
|
|
2330
|
+
|
|
2218
2331
|
//#endregion
|
|
2219
2332
|
//#region packages/provider/src/OutgoingMessages/SyncStepOneMessage.ts
|
|
2220
2333
|
var SyncStepOneMessage = class extends OutgoingMessage {
|
|
@@ -2265,6 +2378,18 @@ var AbracadabraBaseProvider = class extends EventEmitter {
|
|
|
2265
2378
|
if (!this._rpc) this._rpc = new RpcClient(this);
|
|
2266
2379
|
return this._rpc;
|
|
2267
2380
|
}
|
|
2381
|
+
getQueryClient() {
|
|
2382
|
+
if (!this._queryClient) this._queryClient = new QueryClient(this);
|
|
2383
|
+
return this._queryClient;
|
|
2384
|
+
}
|
|
2385
|
+
/**
|
|
2386
|
+
* Open a live query subscription. Mirrors the v1 REST shape
|
|
2387
|
+
* (`POST /docs/query`) but pushes deltas as the server-side
|
|
2388
|
+
* projection drifts under the predicate.
|
|
2389
|
+
*/
|
|
2390
|
+
subscribeQuery(spec, handlers = {}) {
|
|
2391
|
+
return this.getQueryClient().subscribeQuery(spec, handlers);
|
|
2392
|
+
}
|
|
2268
2393
|
constructor(configuration) {
|
|
2269
2394
|
super();
|
|
2270
2395
|
this.configuration = {
|
|
@@ -2780,7 +2905,7 @@ var SubdocMessage = class extends OutgoingMessage {
|
|
|
2780
2905
|
//#endregion
|
|
2781
2906
|
//#region packages/provider/src/AbracadabraProvider.ts
|
|
2782
2907
|
/** Validate that a string is a UUID acceptable by the server's DocId parser. */
|
|
2783
|
-
function isValidDocId(id) {
|
|
2908
|
+
function isValidDocId$1(id) {
|
|
2784
2909
|
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id);
|
|
2785
2910
|
}
|
|
2786
2911
|
/**
|
|
@@ -2989,7 +3114,7 @@ var AbracadabraProvider = class AbracadabraProvider extends AbracadabraBaseProvi
|
|
|
2989
3114
|
*/
|
|
2990
3115
|
handleYSubdocsChange({ added, removed }) {
|
|
2991
3116
|
for (const subdoc of added) {
|
|
2992
|
-
if (!isValidDocId(subdoc.guid)) continue;
|
|
3117
|
+
if (!isValidDocId$1(subdoc.guid)) continue;
|
|
2993
3118
|
this.registerSubdoc(subdoc);
|
|
2994
3119
|
}
|
|
2995
3120
|
for (const subdoc of removed) this.unloadChild(subdoc.guid);
|
|
@@ -3033,7 +3158,7 @@ var AbracadabraProvider = class AbracadabraProvider extends AbracadabraBaseProvi
|
|
|
3033
3158
|
* explicit `unloadChild(id)` once they're done.
|
|
3034
3159
|
*/
|
|
3035
3160
|
loadChild(childId, options = {}) {
|
|
3036
|
-
if (!isValidDocId(childId)) return Promise.reject(/* @__PURE__ */ new Error(`loadChild: "${childId}" is not a valid document ID (must be a UUID). If this node was created with an older version of the app, delete it and recreate it.`));
|
|
3161
|
+
if (!isValidDocId$1(childId)) return Promise.reject(/* @__PURE__ */ new Error(`loadChild: "${childId}" is not a valid document ID (must be a UUID). If this node was created with an older version of the app, delete it and recreate it.`));
|
|
3037
3162
|
if (childId === this.configuration.name) return Promise.resolve(this);
|
|
3038
3163
|
const evictable = options.evictable !== false;
|
|
3039
3164
|
if (this.childProviders.has(childId)) {
|
|
@@ -10295,7 +10420,8 @@ var AbracadabraClient = class {
|
|
|
10295
10420
|
deviceName: opts.deviceName,
|
|
10296
10421
|
displayName: opts.displayName,
|
|
10297
10422
|
email: opts.email,
|
|
10298
|
-
inviteCode: opts.inviteCode
|
|
10423
|
+
inviteCode: opts.inviteCode,
|
|
10424
|
+
x25519Key: opts.x25519Key
|
|
10299
10425
|
},
|
|
10300
10426
|
auth: false
|
|
10301
10427
|
});
|
|
@@ -10614,6 +10740,27 @@ var AbracadabraClient = class {
|
|
|
10614
10740
|
return (await this.request("GET", `/docs/search?${params.toString()}`)).results;
|
|
10615
10741
|
}
|
|
10616
10742
|
/**
|
|
10743
|
+
* Query for documents matching a structural predicate. v1 of the
|
|
10744
|
+
* indexed-query layer described in
|
|
10745
|
+
* `ARCHITECTURE/20-kickoff-phase3-queries.md`.
|
|
10746
|
+
*
|
|
10747
|
+
* The wire shape accepts a `where` field so v2 (meta-predicate
|
|
10748
|
+
* filtering on top of the `doc_meta_index` projection) can light up
|
|
10749
|
+
* without a compatibility break. Sending a non-empty `where` today
|
|
10750
|
+
* returns a 400 — callers should leave it absent.
|
|
10751
|
+
*
|
|
10752
|
+
* Returns docs the requester can read at viewer-or-above, filtered
|
|
10753
|
+
* through the cascading permission resolver server-side.
|
|
10754
|
+
*/
|
|
10755
|
+
async queryDocs(opts) {
|
|
10756
|
+
const body = {};
|
|
10757
|
+
if (opts.type != null) body.type = opts.type;
|
|
10758
|
+
if (opts.parentId != null) body.parent_id = opts.parentId;
|
|
10759
|
+
if (opts.labelContains != null) body.label_contains = opts.labelContains;
|
|
10760
|
+
if (opts.limit != null) body.limit = opts.limit;
|
|
10761
|
+
return (await this.request("POST", "/docs/query", { body })).documents;
|
|
10762
|
+
}
|
|
10763
|
+
/**
|
|
10617
10764
|
* List the direct children of a document, returning full metadata. Pass
|
|
10618
10765
|
* no argument to list the children of the server root — what the
|
|
10619
10766
|
* dashboard renders as the Spaces sidebar.
|
|
@@ -11149,6 +11296,234 @@ var AbracadabraClient = class {
|
|
|
11149
11296
|
}
|
|
11150
11297
|
};
|
|
11151
11298
|
|
|
11299
|
+
//#endregion
|
|
11300
|
+
//#region packages/provider/src/TokenManager.ts
|
|
11301
|
+
const DEFAULT_STORAGE_KEY = "abracadabra:device-session";
|
|
11302
|
+
/** localStorage-backed storage; safe in SSR/Node (no-ops without localStorage). */
|
|
11303
|
+
var LocalStorageDeviceSessionStorage = class {
|
|
11304
|
+
constructor(key = DEFAULT_STORAGE_KEY) {
|
|
11305
|
+
this.key = key;
|
|
11306
|
+
}
|
|
11307
|
+
load() {
|
|
11308
|
+
try {
|
|
11309
|
+
const raw = localStorage.getItem(this.key);
|
|
11310
|
+
if (!raw) return null;
|
|
11311
|
+
const parsed = JSON.parse(raw);
|
|
11312
|
+
if (typeof parsed?.sessionId === "string" && typeof parsed?.sessionToken === "string" && typeof parsed?.expiresAt === "number") return parsed;
|
|
11313
|
+
return null;
|
|
11314
|
+
} catch {
|
|
11315
|
+
return null;
|
|
11316
|
+
}
|
|
11317
|
+
}
|
|
11318
|
+
save(record) {
|
|
11319
|
+
try {
|
|
11320
|
+
localStorage.setItem(this.key, JSON.stringify(record));
|
|
11321
|
+
} catch {}
|
|
11322
|
+
}
|
|
11323
|
+
clear() {
|
|
11324
|
+
try {
|
|
11325
|
+
localStorage.removeItem(this.key);
|
|
11326
|
+
} catch {}
|
|
11327
|
+
}
|
|
11328
|
+
};
|
|
11329
|
+
/**
|
|
11330
|
+
* Manages the lifetime of the short-lived JWT against a long-lived device
|
|
11331
|
+
* session token.
|
|
11332
|
+
*
|
|
11333
|
+
* Flow:
|
|
11334
|
+
* - Bootstrap: if a stored device session exists, exchange it for a fresh
|
|
11335
|
+
* JWT immediately (`bootstrap()`). Otherwise the consumer must run a
|
|
11336
|
+
* full crypto-auth handshake and then call `attachSession(record)` once
|
|
11337
|
+
* the server returns a device-session token.
|
|
11338
|
+
* - Steady state: `getValidToken()` returns the JWT, refreshing if the
|
|
11339
|
+
* JWT is within `refreshLeadMs` of expiry. Concurrent callers share one
|
|
11340
|
+
* in-flight refresh.
|
|
11341
|
+
* - Background: a proactive timer fires `refreshLeadMs` before `exp`.
|
|
11342
|
+
* `visibilitychange` and `online` events trigger an opportunistic
|
|
11343
|
+
* refresh in case the timer was suppressed (hidden tab, sleeping
|
|
11344
|
+
* laptop). The combination is what restores the WS without a reload.
|
|
11345
|
+
* - Failure: refresh errors with status 401/403 mean the device session
|
|
11346
|
+
* itself is dead (revoked or 30 d expired). The record is cleared and
|
|
11347
|
+
* `session-expired` fires so the consumer can prompt re-auth. Other
|
|
11348
|
+
* errors (network) are transparent — the existing JWT is returned and
|
|
11349
|
+
* the caller's normal retry loop covers it.
|
|
11350
|
+
*/
|
|
11351
|
+
var TokenManager = class extends EventEmitter {
|
|
11352
|
+
constructor(options) {
|
|
11353
|
+
super();
|
|
11354
|
+
this.refreshing = null;
|
|
11355
|
+
this.proactiveTimer = null;
|
|
11356
|
+
this.resumeHandlersInstalled = false;
|
|
11357
|
+
this.boundOnResume = null;
|
|
11358
|
+
this.disposed = false;
|
|
11359
|
+
this.client = options.client;
|
|
11360
|
+
this.storage = options.storage ?? new LocalStorageDeviceSessionStorage();
|
|
11361
|
+
this.refreshLeadMs = options.refreshLeadMs ?? 300 * 1e3;
|
|
11362
|
+
this.session = this.storage.load();
|
|
11363
|
+
if (options.installResumeHandlers !== false) this.installResumeHandlers();
|
|
11364
|
+
this.scheduleProactiveRefresh();
|
|
11365
|
+
}
|
|
11366
|
+
get hasSession() {
|
|
11367
|
+
return this.session !== null;
|
|
11368
|
+
}
|
|
11369
|
+
get currentSession() {
|
|
11370
|
+
return this.session;
|
|
11371
|
+
}
|
|
11372
|
+
/**
|
|
11373
|
+
* Persist a freshly-issued device session and exchange it immediately
|
|
11374
|
+
* for a JWT. Call this once after a successful crypto-auth login when
|
|
11375
|
+
* the server returns a device-session token.
|
|
11376
|
+
*/
|
|
11377
|
+
async attachSession(record) {
|
|
11378
|
+
this.session = record;
|
|
11379
|
+
this.storage.save(record);
|
|
11380
|
+
return await this.runRefresh();
|
|
11381
|
+
}
|
|
11382
|
+
/**
|
|
11383
|
+
* Drop the local device session (no server call). Use after a logout
|
|
11384
|
+
* or when `session-expired` fires. To revoke server-side as well, call
|
|
11385
|
+
* `client.revokeDeviceSession(sessionId)` first.
|
|
11386
|
+
*/
|
|
11387
|
+
clearSession() {
|
|
11388
|
+
this.session = null;
|
|
11389
|
+
this.storage.clear();
|
|
11390
|
+
if (this.proactiveTimer) {
|
|
11391
|
+
clearTimeout(this.proactiveTimer);
|
|
11392
|
+
this.proactiveTimer = null;
|
|
11393
|
+
}
|
|
11394
|
+
}
|
|
11395
|
+
/**
|
|
11396
|
+
* Return a valid JWT, refreshing if it is missing or within
|
|
11397
|
+
* `refreshLeadMs` of expiry. Safe to call concurrently — one
|
|
11398
|
+
* refresh is in flight at a time.
|
|
11399
|
+
*
|
|
11400
|
+
* Transient refresh errors (network) fall back to the cached JWT so
|
|
11401
|
+
* the WS auth attempt can still proceed; the server will reject and
|
|
11402
|
+
* the next reconnect tries again. Only 401/403 from the refresh
|
|
11403
|
+
* endpoint surfaces as a thrown error here, since those mean the
|
|
11404
|
+
* device session itself is dead.
|
|
11405
|
+
*/
|
|
11406
|
+
async getValidToken() {
|
|
11407
|
+
if (this.refreshing) return this.refreshing;
|
|
11408
|
+
const current = this.client.token;
|
|
11409
|
+
if (current && this.tokenIsFresh(current)) return current;
|
|
11410
|
+
if (!this.session) return current ?? "";
|
|
11411
|
+
try {
|
|
11412
|
+
return await this.runRefresh();
|
|
11413
|
+
} catch (err) {
|
|
11414
|
+
const status = err?.status;
|
|
11415
|
+
if (status === 401 || status === 403) throw err;
|
|
11416
|
+
return current ?? "";
|
|
11417
|
+
}
|
|
11418
|
+
}
|
|
11419
|
+
/**
|
|
11420
|
+
* If a stored device session exists, exchange it for a fresh JWT now.
|
|
11421
|
+
* Returns the JWT (or null if there is no session to bootstrap from).
|
|
11422
|
+
* Throws on 401/403 — the stored session is invalid and the caller
|
|
11423
|
+
* should fall through to a full crypto-auth flow.
|
|
11424
|
+
*/
|
|
11425
|
+
async bootstrap() {
|
|
11426
|
+
if (!this.session) return null;
|
|
11427
|
+
try {
|
|
11428
|
+
return await this.runRefresh();
|
|
11429
|
+
} catch (err) {
|
|
11430
|
+
const status = err?.status;
|
|
11431
|
+
if (status === 401 || status === 403) throw err;
|
|
11432
|
+
return null;
|
|
11433
|
+
}
|
|
11434
|
+
}
|
|
11435
|
+
dispose() {
|
|
11436
|
+
if (this.disposed) return;
|
|
11437
|
+
this.disposed = true;
|
|
11438
|
+
if (this.proactiveTimer) {
|
|
11439
|
+
clearTimeout(this.proactiveTimer);
|
|
11440
|
+
this.proactiveTimer = null;
|
|
11441
|
+
}
|
|
11442
|
+
if (this.boundOnResume) {
|
|
11443
|
+
try {
|
|
11444
|
+
if (typeof document !== "undefined") document.removeEventListener("visibilitychange", this.boundOnResume);
|
|
11445
|
+
if (typeof window !== "undefined") window.removeEventListener("online", this.boundOnResume);
|
|
11446
|
+
} catch {}
|
|
11447
|
+
this.boundOnResume = null;
|
|
11448
|
+
}
|
|
11449
|
+
this.removeAllListeners();
|
|
11450
|
+
}
|
|
11451
|
+
runRefresh() {
|
|
11452
|
+
if (this.refreshing) return this.refreshing;
|
|
11453
|
+
if (!this.session) return Promise.reject(/* @__PURE__ */ new Error("No device session"));
|
|
11454
|
+
const sessionToken = this.session.sessionToken;
|
|
11455
|
+
this.refreshing = (async () => {
|
|
11456
|
+
try {
|
|
11457
|
+
const jwt = await this.client.refreshWithDeviceSession(sessionToken);
|
|
11458
|
+
this.scheduleProactiveRefresh();
|
|
11459
|
+
this.emit("refresh", jwt);
|
|
11460
|
+
return jwt;
|
|
11461
|
+
} catch (err) {
|
|
11462
|
+
const status = err?.status;
|
|
11463
|
+
if (status === 401 || status === 403) {
|
|
11464
|
+
const message = err?.message ?? "device session rejected";
|
|
11465
|
+
this.clearSession();
|
|
11466
|
+
this.emit("session-expired", {
|
|
11467
|
+
status,
|
|
11468
|
+
message
|
|
11469
|
+
});
|
|
11470
|
+
}
|
|
11471
|
+
throw err;
|
|
11472
|
+
} finally {
|
|
11473
|
+
this.refreshing = null;
|
|
11474
|
+
}
|
|
11475
|
+
})();
|
|
11476
|
+
return this.refreshing;
|
|
11477
|
+
}
|
|
11478
|
+
tokenIsFresh(jwt) {
|
|
11479
|
+
const exp = this.parseExp(jwt);
|
|
11480
|
+
if (exp == null) return false;
|
|
11481
|
+
return exp * 1e3 - Date.now() > this.refreshLeadMs;
|
|
11482
|
+
}
|
|
11483
|
+
parseExp(jwt) {
|
|
11484
|
+
try {
|
|
11485
|
+
const [, payload] = jwt.split(".");
|
|
11486
|
+
if (!payload) return null;
|
|
11487
|
+
const { exp } = JSON.parse(atob(payload));
|
|
11488
|
+
return typeof exp === "number" ? exp : null;
|
|
11489
|
+
} catch {
|
|
11490
|
+
return null;
|
|
11491
|
+
}
|
|
11492
|
+
}
|
|
11493
|
+
scheduleProactiveRefresh() {
|
|
11494
|
+
if (this.proactiveTimer) {
|
|
11495
|
+
clearTimeout(this.proactiveTimer);
|
|
11496
|
+
this.proactiveTimer = null;
|
|
11497
|
+
}
|
|
11498
|
+
if (!this.session || this.disposed) return;
|
|
11499
|
+
const jwt = this.client.token;
|
|
11500
|
+
const exp = jwt ? this.parseExp(jwt) : null;
|
|
11501
|
+
if (exp == null) return;
|
|
11502
|
+
const delay = Math.max(3e4, exp * 1e3 - Date.now() - this.refreshLeadMs);
|
|
11503
|
+
this.proactiveTimer = setTimeout(() => {
|
|
11504
|
+
this.runRefresh().catch(() => {});
|
|
11505
|
+
}, delay);
|
|
11506
|
+
}
|
|
11507
|
+
installResumeHandlers() {
|
|
11508
|
+
if (this.resumeHandlersInstalled) return;
|
|
11509
|
+
if (typeof document === "undefined" && typeof window === "undefined") return;
|
|
11510
|
+
const onResume = () => {
|
|
11511
|
+
if (this.disposed) return;
|
|
11512
|
+
if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
|
|
11513
|
+
if (!this.session) return;
|
|
11514
|
+
const jwt = this.client.token;
|
|
11515
|
+
if (jwt && this.tokenIsFresh(jwt)) return;
|
|
11516
|
+
this.runRefresh().catch(() => {});
|
|
11517
|
+
};
|
|
11518
|
+
this.boundOnResume = onResume;
|
|
11519
|
+
try {
|
|
11520
|
+
if (typeof document !== "undefined") document.addEventListener("visibilitychange", onResume);
|
|
11521
|
+
if (typeof window !== "undefined") window.addEventListener("online", onResume);
|
|
11522
|
+
this.resumeHandlersInstalled = true;
|
|
11523
|
+
} catch {}
|
|
11524
|
+
}
|
|
11525
|
+
};
|
|
11526
|
+
|
|
11152
11527
|
//#endregion
|
|
11153
11528
|
//#region packages/provider/src/CloseEvents.ts
|
|
11154
11529
|
/**
|
|
@@ -15283,10 +15658,22 @@ var Semaphore = class {
|
|
|
15283
15658
|
else this.slots++;
|
|
15284
15659
|
}
|
|
15285
15660
|
};
|
|
15661
|
+
/**
|
|
15662
|
+
* Server-accepted doc-id format. The Rust server's `DocId::from_string`
|
|
15663
|
+
* is a strict UUID parse; any other string returns 422. Stale entries in
|
|
15664
|
+
* the doc-tree map (legacy slug ids from earlier demos, manual experiments)
|
|
15665
|
+
* would otherwise emit one 422 per `syncAll()` forever — see the warn
|
|
15666
|
+
* below in `_buildQueue`.
|
|
15667
|
+
*/
|
|
15668
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
15669
|
+
function isValidDocId(id) {
|
|
15670
|
+
return UUID_RE.test(id);
|
|
15671
|
+
}
|
|
15286
15672
|
var BackgroundSyncManager = class extends EventEmitter {
|
|
15287
15673
|
constructor(rootProvider, client, fileBlobStore, opts) {
|
|
15288
15674
|
super();
|
|
15289
15675
|
this.syncStates = /* @__PURE__ */ new Map();
|
|
15676
|
+
this._warnedInvalidIds = /* @__PURE__ */ new Set();
|
|
15290
15677
|
this._destroyed = false;
|
|
15291
15678
|
this._initPromise = null;
|
|
15292
15679
|
this.rootProvider = rootProvider;
|
|
@@ -15428,7 +15815,19 @@ var BackgroundSyncManager = class extends EventEmitter {
|
|
|
15428
15815
|
* 3. Errored docs last
|
|
15429
15816
|
*/
|
|
15430
15817
|
_buildQueue(entries) {
|
|
15431
|
-
const
|
|
15818
|
+
const filtered = [];
|
|
15819
|
+
for (const entry of entries) {
|
|
15820
|
+
const [docId] = entry;
|
|
15821
|
+
if (isValidDocId(docId)) {
|
|
15822
|
+
filtered.push(entry);
|
|
15823
|
+
continue;
|
|
15824
|
+
}
|
|
15825
|
+
if (!this._warnedInvalidIds.has(docId)) {
|
|
15826
|
+
this._warnedInvalidIds.add(docId);
|
|
15827
|
+
console.warn(`[BackgroundSyncManager] skipping non-UUID doc id "${docId}" in doc-tree — server would reject it (422). Likely a stale entry from an earlier seeder; remove it from the tree to silence this.`);
|
|
15828
|
+
}
|
|
15829
|
+
}
|
|
15830
|
+
const items = filtered.map(([docId, v]) => {
|
|
15432
15831
|
const state = this.syncStates.get(docId);
|
|
15433
15832
|
const updatedAt = v?.updatedAt ?? v?.createdAt ?? 0;
|
|
15434
15833
|
let priority;
|
|
@@ -17811,6 +18210,42 @@ function toPlain(val) {
|
|
|
17811
18210
|
return val instanceof yjs.Map ? val.toJSON() : val;
|
|
17812
18211
|
}
|
|
17813
18212
|
|
|
18213
|
+
//#endregion
|
|
18214
|
+
//#region packages/provider/src/SchemaTypes.ts
|
|
18215
|
+
/**
|
|
18216
|
+
* Thrown by typed write methods on `TypedDocsClient` when the stored
|
|
18217
|
+
* entry's `type` doesn't match the caller's `expectedType`. Distinct from
|
|
18218
|
+
* `MetaValidationError` (which signals schema-content mismatches).
|
|
18219
|
+
*/
|
|
18220
|
+
var TypedDocTypeMismatchError = class extends Error {
|
|
18221
|
+
constructor(docId, expectedType, actualType) {
|
|
18222
|
+
super(`Typed write on document ${docId} expected type "${expectedType}" but stored type is ${actualType === void 0 ? "(none)" : `"${actualType}"`}`);
|
|
18223
|
+
this.name = "TypedDocTypeMismatchError";
|
|
18224
|
+
this.docId = docId;
|
|
18225
|
+
this.expectedType = expectedType;
|
|
18226
|
+
this.actualType = actualType;
|
|
18227
|
+
}
|
|
18228
|
+
};
|
|
18229
|
+
/**
|
|
18230
|
+
* Project a raw `TreeEntry` to a `TypedTreeEntry<TMap, N>` if its `type`
|
|
18231
|
+
* matches; otherwise return `null`. Used by both `TreeManager.getTyped`
|
|
18232
|
+
* and `TypedDocsClient.{get,narrow}` to keep semantics consistent.
|
|
18233
|
+
*/
|
|
18234
|
+
function projectTreeEntry(entry, expectedType) {
|
|
18235
|
+
if (!entry) return null;
|
|
18236
|
+
if (entry.type !== expectedType) return null;
|
|
18237
|
+
return {
|
|
18238
|
+
id: entry.id,
|
|
18239
|
+
type: expectedType,
|
|
18240
|
+
label: entry.label,
|
|
18241
|
+
parentId: entry.parentId,
|
|
18242
|
+
order: entry.order,
|
|
18243
|
+
meta: entry.meta,
|
|
18244
|
+
createdAt: entry.createdAt,
|
|
18245
|
+
updatedAt: entry.updatedAt
|
|
18246
|
+
};
|
|
18247
|
+
}
|
|
18248
|
+
|
|
17814
18249
|
//#endregion
|
|
17815
18250
|
//#region packages/provider/src/TreeManager.ts
|
|
17816
18251
|
/**
|
|
@@ -17888,6 +18323,20 @@ var TreeManager = class {
|
|
|
17888
18323
|
};
|
|
17889
18324
|
});
|
|
17890
18325
|
}
|
|
18326
|
+
/**
|
|
18327
|
+
* Schema-typed lookup. Returns a `TypedTreeEntry<TMap, N>` when the
|
|
18328
|
+
* entry's `type` matches `expectedType`, else `null`. Pure projection
|
|
18329
|
+
* over the existing untyped tree — no schema validation is performed
|
|
18330
|
+
* here (the entry's data is whatever was last synced; meta correctness
|
|
18331
|
+
* is the writer's responsibility, optionally enforced via
|
|
18332
|
+
* `MetaManager.setSchema`).
|
|
18333
|
+
*
|
|
18334
|
+
* Rule 4 alignment: when the entry's type doesn't match, returns null
|
|
18335
|
+
* rather than throwing — callers branch on the result.
|
|
18336
|
+
*/
|
|
18337
|
+
getTyped(_schema, expectedType, docId) {
|
|
18338
|
+
return projectTreeEntry(this.get(docId), expectedType);
|
|
18339
|
+
}
|
|
17891
18340
|
/** Find a single entry by ID. */
|
|
17892
18341
|
get(docId) {
|
|
17893
18342
|
const treeMap = this.dm.getTreeMap();
|
|
@@ -19618,9 +20067,29 @@ var ContentManager = class {
|
|
|
19618
20067
|
|
|
19619
20068
|
//#endregion
|
|
19620
20069
|
//#region packages/provider/src/MetaManager.ts
|
|
20070
|
+
var MetaValidationError = class extends Error {
|
|
20071
|
+
constructor(docId, docType, errors) {
|
|
20072
|
+
const detail = errors.map((e) => `${e.path.join(".") || "(root)"}: ${e.message}`).join("; ");
|
|
20073
|
+
super(`Meta validation failed for document ${docId} of type "${docType}": ${detail}`);
|
|
20074
|
+
this.name = "MetaValidationError";
|
|
20075
|
+
this.docId = docId;
|
|
20076
|
+
this.docType = docType;
|
|
20077
|
+
this.errors = errors;
|
|
20078
|
+
}
|
|
20079
|
+
};
|
|
19621
20080
|
var MetaManager = class {
|
|
19622
20081
|
constructor(dm) {
|
|
19623
20082
|
this.dm = dm;
|
|
20083
|
+
this.schema = null;
|
|
20084
|
+
}
|
|
20085
|
+
/**
|
|
20086
|
+
* Attach (or detach with `null`) a schema validator. When set, every
|
|
20087
|
+
* `update` / `set` validates the post-write meta against the entry's
|
|
20088
|
+
* declared `type` before writing to the doc-tree. Entries without a
|
|
20089
|
+
* `type` field pass through unconditionally.
|
|
20090
|
+
*/
|
|
20091
|
+
setSchema(schema) {
|
|
20092
|
+
this.schema = schema;
|
|
19624
20093
|
}
|
|
19625
20094
|
/** Read metadata for a document. Returns null if not found. */
|
|
19626
20095
|
get(docId) {
|
|
@@ -19639,6 +20108,9 @@ var MetaManager = class {
|
|
|
19639
20108
|
/**
|
|
19640
20109
|
* Merge fields into a document's metadata.
|
|
19641
20110
|
* Existing keys not in the update are preserved.
|
|
20111
|
+
*
|
|
20112
|
+
* @throws {MetaValidationError} when a schema is attached and the
|
|
20113
|
+
* merged meta fails validation for the entry's declared type.
|
|
19642
20114
|
*/
|
|
19643
20115
|
update(docId, meta) {
|
|
19644
20116
|
const treeMap = this.dm.getTreeMap();
|
|
@@ -19646,18 +20118,23 @@ var MetaManager = class {
|
|
|
19646
20118
|
const raw = treeMap.get(docId);
|
|
19647
20119
|
if (!raw) throw new Error(`Document ${docId} not found`);
|
|
19648
20120
|
const entry = toPlain(raw);
|
|
20121
|
+
const mergedMeta = {
|
|
20122
|
+
...entry.meta ?? {},
|
|
20123
|
+
...meta
|
|
20124
|
+
};
|
|
20125
|
+
this.validateOrThrow(docId, entry, mergedMeta);
|
|
19649
20126
|
treeMap.set(docId, {
|
|
19650
20127
|
...entry,
|
|
19651
|
-
meta:
|
|
19652
|
-
...entry.meta ?? {},
|
|
19653
|
-
...meta
|
|
19654
|
-
},
|
|
20128
|
+
meta: mergedMeta,
|
|
19655
20129
|
updatedAt: Date.now()
|
|
19656
20130
|
});
|
|
19657
20131
|
}
|
|
19658
20132
|
/**
|
|
19659
20133
|
* Replace all metadata on a document.
|
|
19660
20134
|
* This overwrites the entire meta object.
|
|
20135
|
+
*
|
|
20136
|
+
* @throws {MetaValidationError} when a schema is attached and the
|
|
20137
|
+
* replacement meta fails validation for the entry's declared type.
|
|
19661
20138
|
*/
|
|
19662
20139
|
set(docId, meta) {
|
|
19663
20140
|
const treeMap = this.dm.getTreeMap();
|
|
@@ -19665,6 +20142,7 @@ var MetaManager = class {
|
|
|
19665
20142
|
const raw = treeMap.get(docId);
|
|
19666
20143
|
if (!raw) throw new Error(`Document ${docId} not found`);
|
|
19667
20144
|
const entry = toPlain(raw);
|
|
20145
|
+
this.validateOrThrow(docId, entry, meta);
|
|
19668
20146
|
treeMap.set(docId, {
|
|
19669
20147
|
...entry,
|
|
19670
20148
|
meta,
|
|
@@ -19682,12 +20160,20 @@ var MetaManager = class {
|
|
|
19682
20160
|
const entry = toPlain(raw);
|
|
19683
20161
|
const updated = { ...entry.meta ?? {} };
|
|
19684
20162
|
for (const key of keys) delete updated[key];
|
|
20163
|
+
this.validateOrThrow(docId, entry, updated);
|
|
19685
20164
|
treeMap.set(docId, {
|
|
19686
20165
|
...entry,
|
|
19687
20166
|
meta: updated,
|
|
19688
20167
|
updatedAt: Date.now()
|
|
19689
20168
|
});
|
|
19690
20169
|
}
|
|
20170
|
+
validateOrThrow(docId, entry, meta) {
|
|
20171
|
+
if (!this.schema) return;
|
|
20172
|
+
const docType = entry.type;
|
|
20173
|
+
if (!docType) return;
|
|
20174
|
+
const result = this.schema.validateMeta(docType, meta);
|
|
20175
|
+
if (!result.ok) throw new MetaValidationError(docId, docType, result.errors);
|
|
20176
|
+
}
|
|
19691
20177
|
};
|
|
19692
20178
|
|
|
19693
20179
|
//#endregion
|
|
@@ -19733,6 +20219,44 @@ var DocumentManager = class {
|
|
|
19733
20219
|
this.content = new ContentManager(this);
|
|
19734
20220
|
this.meta = new MetaManager(this);
|
|
19735
20221
|
}
|
|
20222
|
+
/**
|
|
20223
|
+
* Bind a schema registry to a typed accessor surface. The returned
|
|
20224
|
+
* client offers `.get(type, id)` with the registry's meta types
|
|
20225
|
+
* inferred at the call site, removing the need to pass `schema`
|
|
20226
|
+
* to every lookup. The provider remains schema-free at runtime —
|
|
20227
|
+
* `schema` is only used to drive type inference (see
|
|
20228
|
+
* `SchemaRegistryLike` for the structural witness).
|
|
20229
|
+
*
|
|
20230
|
+
* @example
|
|
20231
|
+
* const docs = dm.docs(kanbanSchema);
|
|
20232
|
+
* const board = docs.get("kanban", id);
|
|
20233
|
+
* if (board) console.log(board.meta.kanbanColumnWidth); // typed
|
|
20234
|
+
*/
|
|
20235
|
+
docs(_schema) {
|
|
20236
|
+
const tree = this.tree;
|
|
20237
|
+
const meta = this.meta;
|
|
20238
|
+
const expectType = (expected, id) => {
|
|
20239
|
+
const actual = tree.get(id)?.type;
|
|
20240
|
+
if (actual !== expected) throw new TypedDocTypeMismatchError(id, expected, actual);
|
|
20241
|
+
};
|
|
20242
|
+
return {
|
|
20243
|
+
get: (expectedType, id) => projectTreeEntry(tree.get(id), expectedType),
|
|
20244
|
+
getEntry: (id) => tree.get(id),
|
|
20245
|
+
narrow: (expectedType, entry) => projectTreeEntry(entry ?? null, expectedType),
|
|
20246
|
+
update: (expectedType, id, patch) => {
|
|
20247
|
+
expectType(expectedType, id);
|
|
20248
|
+
meta.update(id, patch);
|
|
20249
|
+
},
|
|
20250
|
+
set: (expectedType, id, value) => {
|
|
20251
|
+
expectType(expectedType, id);
|
|
20252
|
+
meta.set(id, value);
|
|
20253
|
+
},
|
|
20254
|
+
clear: (expectedType, id, keys) => {
|
|
20255
|
+
expectType(expectedType, id);
|
|
20256
|
+
meta.clear(id, keys);
|
|
20257
|
+
}
|
|
20258
|
+
};
|
|
20259
|
+
}
|
|
19736
20260
|
get displayName() {
|
|
19737
20261
|
return this._config.name || "DocumentManager";
|
|
19738
20262
|
}
|
|
@@ -19914,14 +20438,19 @@ exports.HocuspocusProviderWebsocket = HocuspocusProviderWebsocket;
|
|
|
19914
20438
|
exports.IdentityDocProvider = IdentityDocProvider;
|
|
19915
20439
|
exports.KEY_EXCHANGE_CHANNEL = KEY_EXCHANGE_CHANNEL;
|
|
19916
20440
|
exports.Kind = Kind;
|
|
20441
|
+
exports.LocalStorageDeviceSessionStorage = LocalStorageDeviceSessionStorage;
|
|
19917
20442
|
exports.ManualSignaling = ManualSignaling;
|
|
19918
20443
|
exports.MessageTooBig = MessageTooBig;
|
|
19919
20444
|
exports.MessageType = MessageType;
|
|
19920
20445
|
exports.MetaManager = MetaManager;
|
|
20446
|
+
exports.MetaValidationError = MetaValidationError;
|
|
19921
20447
|
exports.NotificationsClient = NotificationsClient;
|
|
19922
20448
|
exports.OfflineStore = OfflineStore;
|
|
19923
20449
|
exports.PAGE_TYPES = PAGE_TYPES;
|
|
19924
20450
|
exports.PeerConnection = PeerConnection;
|
|
20451
|
+
exports.QUERY_PREFIX = QUERY_PREFIX;
|
|
20452
|
+
exports.QueryClient = QueryClient;
|
|
20453
|
+
exports.QueryError = QueryError;
|
|
19925
20454
|
exports.RPC_PREFIX = RPC_PREFIX;
|
|
19926
20455
|
exports.ResetConnection = ResetConnection;
|
|
19927
20456
|
exports.RpcClient = RpcClient;
|
|
@@ -19931,7 +20460,9 @@ exports.SearchIndex = SearchIndex;
|
|
|
19931
20460
|
exports.SignalingSocket = SignalingSocket;
|
|
19932
20461
|
exports.SubdocMessage = SubdocMessage;
|
|
19933
20462
|
exports.TYPE_ALIASES = TYPE_ALIASES;
|
|
20463
|
+
exports.TokenManager = TokenManager;
|
|
19934
20464
|
exports.TreeManager = TreeManager;
|
|
20465
|
+
exports.TypedDocTypeMismatchError = TypedDocTypeMismatchError;
|
|
19935
20466
|
exports.Unauthorized = Unauthorized;
|
|
19936
20467
|
exports.WebSocketStatus = WebSocketStatus;
|
|
19937
20468
|
exports.WsReadyStates = WsReadyStates;
|