@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.
@@ -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 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]) => {
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;