@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.
@@ -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 items = entries.map(([docId, v]) => {
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;