@abraca/dabra 2.0.10 → 2.4.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 +538 -8
- package/dist/abracadabra-provider.cjs.map +1 -1
- package/dist/abracadabra-provider.esm.js +532 -9
- package/dist/abracadabra-provider.esm.js.map +1 -1
- package/dist/index.d.ts +432 -1
- 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)) {
|
|
@@ -10615,6 +10740,27 @@ var AbracadabraClient = class {
|
|
|
10615
10740
|
return (await this.request("GET", `/docs/search?${params.toString()}`)).results;
|
|
10616
10741
|
}
|
|
10617
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
|
+
/**
|
|
10618
10764
|
* List the direct children of a document, returning full metadata. Pass
|
|
10619
10765
|
* no argument to list the children of the server root — what the
|
|
10620
10766
|
* dashboard renders as the Spaces sidebar.
|
|
@@ -11150,6 +11296,234 @@ var AbracadabraClient = class {
|
|
|
11150
11296
|
}
|
|
11151
11297
|
};
|
|
11152
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
|
+
|
|
11153
11527
|
//#endregion
|
|
11154
11528
|
//#region packages/provider/src/CloseEvents.ts
|
|
11155
11529
|
/**
|
|
@@ -15284,10 +15658,22 @@ var Semaphore = class {
|
|
|
15284
15658
|
else this.slots++;
|
|
15285
15659
|
}
|
|
15286
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
|
+
}
|
|
15287
15672
|
var BackgroundSyncManager = class extends EventEmitter {
|
|
15288
15673
|
constructor(rootProvider, client, fileBlobStore, opts) {
|
|
15289
15674
|
super();
|
|
15290
15675
|
this.syncStates = /* @__PURE__ */ new Map();
|
|
15676
|
+
this._warnedInvalidIds = /* @__PURE__ */ new Set();
|
|
15291
15677
|
this._destroyed = false;
|
|
15292
15678
|
this._initPromise = null;
|
|
15293
15679
|
this.rootProvider = rootProvider;
|
|
@@ -15429,7 +15815,19 @@ var BackgroundSyncManager = class extends EventEmitter {
|
|
|
15429
15815
|
* 3. Errored docs last
|
|
15430
15816
|
*/
|
|
15431
15817
|
_buildQueue(entries) {
|
|
15432
|
-
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]) => {
|
|
15433
15831
|
const state = this.syncStates.get(docId);
|
|
15434
15832
|
const updatedAt = v?.updatedAt ?? v?.createdAt ?? 0;
|
|
15435
15833
|
let priority;
|
|
@@ -17812,6 +18210,42 @@ function toPlain(val) {
|
|
|
17812
18210
|
return val instanceof yjs.Map ? val.toJSON() : val;
|
|
17813
18211
|
}
|
|
17814
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
|
+
|
|
17815
18249
|
//#endregion
|
|
17816
18250
|
//#region packages/provider/src/TreeManager.ts
|
|
17817
18251
|
/**
|
|
@@ -17889,6 +18323,20 @@ var TreeManager = class {
|
|
|
17889
18323
|
};
|
|
17890
18324
|
});
|
|
17891
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
|
+
}
|
|
17892
18340
|
/** Find a single entry by ID. */
|
|
17893
18341
|
get(docId) {
|
|
17894
18342
|
const treeMap = this.dm.getTreeMap();
|
|
@@ -19619,9 +20067,29 @@ var ContentManager = class {
|
|
|
19619
20067
|
|
|
19620
20068
|
//#endregion
|
|
19621
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
|
+
};
|
|
19622
20080
|
var MetaManager = class {
|
|
19623
20081
|
constructor(dm) {
|
|
19624
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;
|
|
19625
20093
|
}
|
|
19626
20094
|
/** Read metadata for a document. Returns null if not found. */
|
|
19627
20095
|
get(docId) {
|
|
@@ -19640,6 +20108,9 @@ var MetaManager = class {
|
|
|
19640
20108
|
/**
|
|
19641
20109
|
* Merge fields into a document's metadata.
|
|
19642
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.
|
|
19643
20114
|
*/
|
|
19644
20115
|
update(docId, meta) {
|
|
19645
20116
|
const treeMap = this.dm.getTreeMap();
|
|
@@ -19647,18 +20118,23 @@ var MetaManager = class {
|
|
|
19647
20118
|
const raw = treeMap.get(docId);
|
|
19648
20119
|
if (!raw) throw new Error(`Document ${docId} not found`);
|
|
19649
20120
|
const entry = toPlain(raw);
|
|
20121
|
+
const mergedMeta = {
|
|
20122
|
+
...entry.meta ?? {},
|
|
20123
|
+
...meta
|
|
20124
|
+
};
|
|
20125
|
+
this.validateOrThrow(docId, entry, mergedMeta);
|
|
19650
20126
|
treeMap.set(docId, {
|
|
19651
20127
|
...entry,
|
|
19652
|
-
meta:
|
|
19653
|
-
...entry.meta ?? {},
|
|
19654
|
-
...meta
|
|
19655
|
-
},
|
|
20128
|
+
meta: mergedMeta,
|
|
19656
20129
|
updatedAt: Date.now()
|
|
19657
20130
|
});
|
|
19658
20131
|
}
|
|
19659
20132
|
/**
|
|
19660
20133
|
* Replace all metadata on a document.
|
|
19661
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.
|
|
19662
20138
|
*/
|
|
19663
20139
|
set(docId, meta) {
|
|
19664
20140
|
const treeMap = this.dm.getTreeMap();
|
|
@@ -19666,6 +20142,7 @@ var MetaManager = class {
|
|
|
19666
20142
|
const raw = treeMap.get(docId);
|
|
19667
20143
|
if (!raw) throw new Error(`Document ${docId} not found`);
|
|
19668
20144
|
const entry = toPlain(raw);
|
|
20145
|
+
this.validateOrThrow(docId, entry, meta);
|
|
19669
20146
|
treeMap.set(docId, {
|
|
19670
20147
|
...entry,
|
|
19671
20148
|
meta,
|
|
@@ -19683,12 +20160,20 @@ var MetaManager = class {
|
|
|
19683
20160
|
const entry = toPlain(raw);
|
|
19684
20161
|
const updated = { ...entry.meta ?? {} };
|
|
19685
20162
|
for (const key of keys) delete updated[key];
|
|
20163
|
+
this.validateOrThrow(docId, entry, updated);
|
|
19686
20164
|
treeMap.set(docId, {
|
|
19687
20165
|
...entry,
|
|
19688
20166
|
meta: updated,
|
|
19689
20167
|
updatedAt: Date.now()
|
|
19690
20168
|
});
|
|
19691
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
|
+
}
|
|
19692
20177
|
};
|
|
19693
20178
|
|
|
19694
20179
|
//#endregion
|
|
@@ -19734,6 +20219,44 @@ var DocumentManager = class {
|
|
|
19734
20219
|
this.content = new ContentManager(this);
|
|
19735
20220
|
this.meta = new MetaManager(this);
|
|
19736
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
|
+
}
|
|
19737
20260
|
get displayName() {
|
|
19738
20261
|
return this._config.name || "DocumentManager";
|
|
19739
20262
|
}
|
|
@@ -19915,14 +20438,19 @@ exports.HocuspocusProviderWebsocket = HocuspocusProviderWebsocket;
|
|
|
19915
20438
|
exports.IdentityDocProvider = IdentityDocProvider;
|
|
19916
20439
|
exports.KEY_EXCHANGE_CHANNEL = KEY_EXCHANGE_CHANNEL;
|
|
19917
20440
|
exports.Kind = Kind;
|
|
20441
|
+
exports.LocalStorageDeviceSessionStorage = LocalStorageDeviceSessionStorage;
|
|
19918
20442
|
exports.ManualSignaling = ManualSignaling;
|
|
19919
20443
|
exports.MessageTooBig = MessageTooBig;
|
|
19920
20444
|
exports.MessageType = MessageType;
|
|
19921
20445
|
exports.MetaManager = MetaManager;
|
|
20446
|
+
exports.MetaValidationError = MetaValidationError;
|
|
19922
20447
|
exports.NotificationsClient = NotificationsClient;
|
|
19923
20448
|
exports.OfflineStore = OfflineStore;
|
|
19924
20449
|
exports.PAGE_TYPES = PAGE_TYPES;
|
|
19925
20450
|
exports.PeerConnection = PeerConnection;
|
|
20451
|
+
exports.QUERY_PREFIX = QUERY_PREFIX;
|
|
20452
|
+
exports.QueryClient = QueryClient;
|
|
20453
|
+
exports.QueryError = QueryError;
|
|
19926
20454
|
exports.RPC_PREFIX = RPC_PREFIX;
|
|
19927
20455
|
exports.ResetConnection = ResetConnection;
|
|
19928
20456
|
exports.RpcClient = RpcClient;
|
|
@@ -19932,7 +20460,9 @@ exports.SearchIndex = SearchIndex;
|
|
|
19932
20460
|
exports.SignalingSocket = SignalingSocket;
|
|
19933
20461
|
exports.SubdocMessage = SubdocMessage;
|
|
19934
20462
|
exports.TYPE_ALIASES = TYPE_ALIASES;
|
|
20463
|
+
exports.TokenManager = TokenManager;
|
|
19935
20464
|
exports.TreeManager = TreeManager;
|
|
20465
|
+
exports.TypedDocTypeMismatchError = TypedDocTypeMismatchError;
|
|
19936
20466
|
exports.Unauthorized = Unauthorized;
|
|
19937
20467
|
exports.WebSocketStatus = WebSocketStatus;
|
|
19938
20468
|
exports.WsReadyStates = WsReadyStates;
|