@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.
@@ -2185,6 +2185,119 @@ function serializeTarget(t) {
2185
2185
  }
2186
2186
  }
2187
2187
 
2188
+ //#endregion
2189
+ //#region packages/provider/src/QueryClient.ts
2190
+ /**
2191
+ * V2 query layer — live subscription client.
2192
+ *
2193
+ * Mirrors the server in `crates/abracadabra/src/query_stream.rs`. Frames
2194
+ * travel as `MSG_STATELESS` payloads with the literal `query:v1:` prefix
2195
+ * followed by a JSON envelope.
2196
+ *
2197
+ * Each call to `subscribeQuery(...)` opens a long-lived subscription:
2198
+ * 1. The client sends `req` with a fresh correlation id.
2199
+ * 2. The server replies `ack` with an initial snapshot
2200
+ * (`DocumentMeta[]`).
2201
+ * 3. The server emits `delta` frames as the doc-tree projection
2202
+ * drifts under the predicate.
2203
+ * 4. The client sends `cancel` to tear it down (or relies on the
2204
+ * WebSocket close path to free server state).
2205
+ *
2206
+ * The shape of the `query` field mirrors the REST `POST /docs/query`
2207
+ * body — `type`, `parentId`, `labelContains`, `where`, `limit`.
2208
+ */
2209
+ const QUERY_PREFIX = "query:v1:";
2210
+ var QueryError = class extends Error {
2211
+ constructor(code, message) {
2212
+ super(message);
2213
+ this.name = "QueryError";
2214
+ this.code = code;
2215
+ }
2216
+ };
2217
+ var QueryClient = class extends EventEmitter {
2218
+ constructor(transport) {
2219
+ super();
2220
+ this.subs = /* @__PURE__ */ new Map();
2221
+ this.idCounter = 0;
2222
+ this.transport = transport;
2223
+ this.onStatelessBound = (data) => this.receive(data.payload);
2224
+ this.transport.on("stateless", this.onStatelessBound);
2225
+ }
2226
+ destroy() {
2227
+ for (const sub of this.subs.values()) if (!sub.cancelled) {
2228
+ this.send({
2229
+ kind: "cancel",
2230
+ id: sub.id
2231
+ });
2232
+ sub.cancelled = true;
2233
+ }
2234
+ this.subs.clear();
2235
+ this.transport.off("stateless", this.onStatelessBound);
2236
+ }
2237
+ subscribeQuery(spec, handlers = {}) {
2238
+ const id = this.nextId();
2239
+ const sub = {
2240
+ id,
2241
+ handlers,
2242
+ cancelled: false
2243
+ };
2244
+ this.subs.set(id, sub);
2245
+ this.send({
2246
+ kind: "req",
2247
+ id,
2248
+ query: spec
2249
+ });
2250
+ const self = this;
2251
+ return {
2252
+ id,
2253
+ cancel() {
2254
+ if (sub.cancelled) return;
2255
+ sub.cancelled = true;
2256
+ self.subs.delete(id);
2257
+ self.send({
2258
+ kind: "cancel",
2259
+ id
2260
+ });
2261
+ }
2262
+ };
2263
+ }
2264
+ nextId() {
2265
+ this.idCounter += 1;
2266
+ return `q-${Date.now()}-${this.idCounter}`;
2267
+ }
2268
+ send(frame) {
2269
+ this.transport.sendStateless(`${QUERY_PREFIX}${JSON.stringify(frame)}`);
2270
+ }
2271
+ receive(payload) {
2272
+ if (!payload.startsWith(QUERY_PREFIX)) return;
2273
+ const rest = payload.slice(9);
2274
+ let frame;
2275
+ try {
2276
+ frame = JSON.parse(rest);
2277
+ } catch {
2278
+ return;
2279
+ }
2280
+ const sub = this.subs.get(frame.id);
2281
+ if (!sub || sub.cancelled) return;
2282
+ switch (frame.kind) {
2283
+ case "ack":
2284
+ sub.handlers.onSnapshot?.(frame.initial ?? []);
2285
+ break;
2286
+ case "delta":
2287
+ sub.handlers.onDelta?.({
2288
+ added: frame.added ?? [],
2289
+ removed: frame.removed ?? []
2290
+ });
2291
+ break;
2292
+ case "err":
2293
+ if (frame.error) sub.handlers.onError?.(new QueryError(frame.error.code, frame.error.message));
2294
+ this.subs.delete(frame.id);
2295
+ break;
2296
+ default: break;
2297
+ }
2298
+ }
2299
+ };
2300
+
2188
2301
  //#endregion
2189
2302
  //#region packages/provider/src/OutgoingMessages/SyncStepOneMessage.ts
2190
2303
  var SyncStepOneMessage = class extends OutgoingMessage {
@@ -2235,6 +2348,18 @@ var AbracadabraBaseProvider = class extends EventEmitter {
2235
2348
  if (!this._rpc) this._rpc = new RpcClient(this);
2236
2349
  return this._rpc;
2237
2350
  }
2351
+ getQueryClient() {
2352
+ if (!this._queryClient) this._queryClient = new QueryClient(this);
2353
+ return this._queryClient;
2354
+ }
2355
+ /**
2356
+ * Open a live query subscription. Mirrors the v1 REST shape
2357
+ * (`POST /docs/query`) but pushes deltas as the server-side
2358
+ * projection drifts under the predicate.
2359
+ */
2360
+ subscribeQuery(spec, handlers = {}) {
2361
+ return this.getQueryClient().subscribeQuery(spec, handlers);
2362
+ }
2238
2363
  constructor(configuration) {
2239
2364
  super();
2240
2365
  this.configuration = {
@@ -2750,7 +2875,7 @@ var SubdocMessage = class extends OutgoingMessage {
2750
2875
  //#endregion
2751
2876
  //#region packages/provider/src/AbracadabraProvider.ts
2752
2877
  /** Validate that a string is a UUID acceptable by the server's DocId parser. */
2753
- function isValidDocId(id) {
2878
+ function isValidDocId$1(id) {
2754
2879
  return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id);
2755
2880
  }
2756
2881
  /**
@@ -2959,7 +3084,7 @@ var AbracadabraProvider = class AbracadabraProvider extends AbracadabraBaseProvi
2959
3084
  */
2960
3085
  handleYSubdocsChange({ added, removed }) {
2961
3086
  for (const subdoc of added) {
2962
- if (!isValidDocId(subdoc.guid)) continue;
3087
+ if (!isValidDocId$1(subdoc.guid)) continue;
2963
3088
  this.registerSubdoc(subdoc);
2964
3089
  }
2965
3090
  for (const subdoc of removed) this.unloadChild(subdoc.guid);
@@ -3003,7 +3128,7 @@ var AbracadabraProvider = class AbracadabraProvider extends AbracadabraBaseProvi
3003
3128
  * explicit `unloadChild(id)` once they're done.
3004
3129
  */
3005
3130
  loadChild(childId, options = {}) {
3006
- 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.`));
3131
+ 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.`));
3007
3132
  if (childId === this.configuration.name) return Promise.resolve(this);
3008
3133
  const evictable = options.evictable !== false;
3009
3134
  if (this.childProviders.has(childId)) {
@@ -10585,6 +10710,27 @@ var AbracadabraClient = class {
10585
10710
  return (await this.request("GET", `/docs/search?${params.toString()}`)).results;
10586
10711
  }
10587
10712
  /**
10713
+ * Query for documents matching a structural predicate. v1 of the
10714
+ * indexed-query layer described in
10715
+ * `ARCHITECTURE/20-kickoff-phase3-queries.md`.
10716
+ *
10717
+ * The wire shape accepts a `where` field so v2 (meta-predicate
10718
+ * filtering on top of the `doc_meta_index` projection) can light up
10719
+ * without a compatibility break. Sending a non-empty `where` today
10720
+ * returns a 400 — callers should leave it absent.
10721
+ *
10722
+ * Returns docs the requester can read at viewer-or-above, filtered
10723
+ * through the cascading permission resolver server-side.
10724
+ */
10725
+ async queryDocs(opts) {
10726
+ const body = {};
10727
+ if (opts.type != null) body.type = opts.type;
10728
+ if (opts.parentId != null) body.parent_id = opts.parentId;
10729
+ if (opts.labelContains != null) body.label_contains = opts.labelContains;
10730
+ if (opts.limit != null) body.limit = opts.limit;
10731
+ return (await this.request("POST", "/docs/query", { body })).documents;
10732
+ }
10733
+ /**
10588
10734
  * List the direct children of a document, returning full metadata. Pass
10589
10735
  * no argument to list the children of the server root — what the
10590
10736
  * dashboard renders as the Spaces sidebar.
@@ -11120,6 +11266,234 @@ var AbracadabraClient = class {
11120
11266
  }
11121
11267
  };
11122
11268
 
11269
+ //#endregion
11270
+ //#region packages/provider/src/TokenManager.ts
11271
+ const DEFAULT_STORAGE_KEY = "abracadabra:device-session";
11272
+ /** localStorage-backed storage; safe in SSR/Node (no-ops without localStorage). */
11273
+ var LocalStorageDeviceSessionStorage = class {
11274
+ constructor(key = DEFAULT_STORAGE_KEY) {
11275
+ this.key = key;
11276
+ }
11277
+ load() {
11278
+ try {
11279
+ const raw = localStorage.getItem(this.key);
11280
+ if (!raw) return null;
11281
+ const parsed = JSON.parse(raw);
11282
+ if (typeof parsed?.sessionId === "string" && typeof parsed?.sessionToken === "string" && typeof parsed?.expiresAt === "number") return parsed;
11283
+ return null;
11284
+ } catch {
11285
+ return null;
11286
+ }
11287
+ }
11288
+ save(record) {
11289
+ try {
11290
+ localStorage.setItem(this.key, JSON.stringify(record));
11291
+ } catch {}
11292
+ }
11293
+ clear() {
11294
+ try {
11295
+ localStorage.removeItem(this.key);
11296
+ } catch {}
11297
+ }
11298
+ };
11299
+ /**
11300
+ * Manages the lifetime of the short-lived JWT against a long-lived device
11301
+ * session token.
11302
+ *
11303
+ * Flow:
11304
+ * - Bootstrap: if a stored device session exists, exchange it for a fresh
11305
+ * JWT immediately (`bootstrap()`). Otherwise the consumer must run a
11306
+ * full crypto-auth handshake and then call `attachSession(record)` once
11307
+ * the server returns a device-session token.
11308
+ * - Steady state: `getValidToken()` returns the JWT, refreshing if the
11309
+ * JWT is within `refreshLeadMs` of expiry. Concurrent callers share one
11310
+ * in-flight refresh.
11311
+ * - Background: a proactive timer fires `refreshLeadMs` before `exp`.
11312
+ * `visibilitychange` and `online` events trigger an opportunistic
11313
+ * refresh in case the timer was suppressed (hidden tab, sleeping
11314
+ * laptop). The combination is what restores the WS without a reload.
11315
+ * - Failure: refresh errors with status 401/403 mean the device session
11316
+ * itself is dead (revoked or 30 d expired). The record is cleared and
11317
+ * `session-expired` fires so the consumer can prompt re-auth. Other
11318
+ * errors (network) are transparent — the existing JWT is returned and
11319
+ * the caller's normal retry loop covers it.
11320
+ */
11321
+ var TokenManager = class extends EventEmitter {
11322
+ constructor(options) {
11323
+ super();
11324
+ this.refreshing = null;
11325
+ this.proactiveTimer = null;
11326
+ this.resumeHandlersInstalled = false;
11327
+ this.boundOnResume = null;
11328
+ this.disposed = false;
11329
+ this.client = options.client;
11330
+ this.storage = options.storage ?? new LocalStorageDeviceSessionStorage();
11331
+ this.refreshLeadMs = options.refreshLeadMs ?? 300 * 1e3;
11332
+ this.session = this.storage.load();
11333
+ if (options.installResumeHandlers !== false) this.installResumeHandlers();
11334
+ this.scheduleProactiveRefresh();
11335
+ }
11336
+ get hasSession() {
11337
+ return this.session !== null;
11338
+ }
11339
+ get currentSession() {
11340
+ return this.session;
11341
+ }
11342
+ /**
11343
+ * Persist a freshly-issued device session and exchange it immediately
11344
+ * for a JWT. Call this once after a successful crypto-auth login when
11345
+ * the server returns a device-session token.
11346
+ */
11347
+ async attachSession(record) {
11348
+ this.session = record;
11349
+ this.storage.save(record);
11350
+ return await this.runRefresh();
11351
+ }
11352
+ /**
11353
+ * Drop the local device session (no server call). Use after a logout
11354
+ * or when `session-expired` fires. To revoke server-side as well, call
11355
+ * `client.revokeDeviceSession(sessionId)` first.
11356
+ */
11357
+ clearSession() {
11358
+ this.session = null;
11359
+ this.storage.clear();
11360
+ if (this.proactiveTimer) {
11361
+ clearTimeout(this.proactiveTimer);
11362
+ this.proactiveTimer = null;
11363
+ }
11364
+ }
11365
+ /**
11366
+ * Return a valid JWT, refreshing if it is missing or within
11367
+ * `refreshLeadMs` of expiry. Safe to call concurrently — one
11368
+ * refresh is in flight at a time.
11369
+ *
11370
+ * Transient refresh errors (network) fall back to the cached JWT so
11371
+ * the WS auth attempt can still proceed; the server will reject and
11372
+ * the next reconnect tries again. Only 401/403 from the refresh
11373
+ * endpoint surfaces as a thrown error here, since those mean the
11374
+ * device session itself is dead.
11375
+ */
11376
+ async getValidToken() {
11377
+ if (this.refreshing) return this.refreshing;
11378
+ const current = this.client.token;
11379
+ if (current && this.tokenIsFresh(current)) return current;
11380
+ if (!this.session) return current ?? "";
11381
+ try {
11382
+ return await this.runRefresh();
11383
+ } catch (err) {
11384
+ const status = err?.status;
11385
+ if (status === 401 || status === 403) throw err;
11386
+ return current ?? "";
11387
+ }
11388
+ }
11389
+ /**
11390
+ * If a stored device session exists, exchange it for a fresh JWT now.
11391
+ * Returns the JWT (or null if there is no session to bootstrap from).
11392
+ * Throws on 401/403 — the stored session is invalid and the caller
11393
+ * should fall through to a full crypto-auth flow.
11394
+ */
11395
+ async bootstrap() {
11396
+ if (!this.session) return null;
11397
+ try {
11398
+ return await this.runRefresh();
11399
+ } catch (err) {
11400
+ const status = err?.status;
11401
+ if (status === 401 || status === 403) throw err;
11402
+ return null;
11403
+ }
11404
+ }
11405
+ dispose() {
11406
+ if (this.disposed) return;
11407
+ this.disposed = true;
11408
+ if (this.proactiveTimer) {
11409
+ clearTimeout(this.proactiveTimer);
11410
+ this.proactiveTimer = null;
11411
+ }
11412
+ if (this.boundOnResume) {
11413
+ try {
11414
+ if (typeof document !== "undefined") document.removeEventListener("visibilitychange", this.boundOnResume);
11415
+ if (typeof window !== "undefined") window.removeEventListener("online", this.boundOnResume);
11416
+ } catch {}
11417
+ this.boundOnResume = null;
11418
+ }
11419
+ this.removeAllListeners();
11420
+ }
11421
+ runRefresh() {
11422
+ if (this.refreshing) return this.refreshing;
11423
+ if (!this.session) return Promise.reject(/* @__PURE__ */ new Error("No device session"));
11424
+ const sessionToken = this.session.sessionToken;
11425
+ this.refreshing = (async () => {
11426
+ try {
11427
+ const jwt = await this.client.refreshWithDeviceSession(sessionToken);
11428
+ this.scheduleProactiveRefresh();
11429
+ this.emit("refresh", jwt);
11430
+ return jwt;
11431
+ } catch (err) {
11432
+ const status = err?.status;
11433
+ if (status === 401 || status === 403) {
11434
+ const message = err?.message ?? "device session rejected";
11435
+ this.clearSession();
11436
+ this.emit("session-expired", {
11437
+ status,
11438
+ message
11439
+ });
11440
+ }
11441
+ throw err;
11442
+ } finally {
11443
+ this.refreshing = null;
11444
+ }
11445
+ })();
11446
+ return this.refreshing;
11447
+ }
11448
+ tokenIsFresh(jwt) {
11449
+ const exp = this.parseExp(jwt);
11450
+ if (exp == null) return false;
11451
+ return exp * 1e3 - Date.now() > this.refreshLeadMs;
11452
+ }
11453
+ parseExp(jwt) {
11454
+ try {
11455
+ const [, payload] = jwt.split(".");
11456
+ if (!payload) return null;
11457
+ const { exp } = JSON.parse(atob(payload));
11458
+ return typeof exp === "number" ? exp : null;
11459
+ } catch {
11460
+ return null;
11461
+ }
11462
+ }
11463
+ scheduleProactiveRefresh() {
11464
+ if (this.proactiveTimer) {
11465
+ clearTimeout(this.proactiveTimer);
11466
+ this.proactiveTimer = null;
11467
+ }
11468
+ if (!this.session || this.disposed) return;
11469
+ const jwt = this.client.token;
11470
+ const exp = jwt ? this.parseExp(jwt) : null;
11471
+ if (exp == null) return;
11472
+ const delay = Math.max(3e4, exp * 1e3 - Date.now() - this.refreshLeadMs);
11473
+ this.proactiveTimer = setTimeout(() => {
11474
+ this.runRefresh().catch(() => {});
11475
+ }, delay);
11476
+ }
11477
+ installResumeHandlers() {
11478
+ if (this.resumeHandlersInstalled) return;
11479
+ if (typeof document === "undefined" && typeof window === "undefined") return;
11480
+ const onResume = () => {
11481
+ if (this.disposed) return;
11482
+ if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
11483
+ if (!this.session) return;
11484
+ const jwt = this.client.token;
11485
+ if (jwt && this.tokenIsFresh(jwt)) return;
11486
+ this.runRefresh().catch(() => {});
11487
+ };
11488
+ this.boundOnResume = onResume;
11489
+ try {
11490
+ if (typeof document !== "undefined") document.addEventListener("visibilitychange", onResume);
11491
+ if (typeof window !== "undefined") window.addEventListener("online", onResume);
11492
+ this.resumeHandlersInstalled = true;
11493
+ } catch {}
11494
+ }
11495
+ };
11496
+
11123
11497
  //#endregion
11124
11498
  //#region packages/provider/src/CloseEvents.ts
11125
11499
  /**
@@ -15245,10 +15619,22 @@ var Semaphore = class {
15245
15619
  else this.slots++;
15246
15620
  }
15247
15621
  };
15622
+ /**
15623
+ * Server-accepted doc-id format. The Rust server's `DocId::from_string`
15624
+ * is a strict UUID parse; any other string returns 422. Stale entries in
15625
+ * the doc-tree map (legacy slug ids from earlier demos, manual experiments)
15626
+ * would otherwise emit one 422 per `syncAll()` forever — see the warn
15627
+ * below in `_buildQueue`.
15628
+ */
15629
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
15630
+ function isValidDocId(id) {
15631
+ return UUID_RE.test(id);
15632
+ }
15248
15633
  var BackgroundSyncManager = class extends EventEmitter {
15249
15634
  constructor(rootProvider, client, fileBlobStore, opts) {
15250
15635
  super();
15251
15636
  this.syncStates = /* @__PURE__ */ new Map();
15637
+ this._warnedInvalidIds = /* @__PURE__ */ new Set();
15252
15638
  this._destroyed = false;
15253
15639
  this._initPromise = null;
15254
15640
  this.rootProvider = rootProvider;
@@ -15390,7 +15776,19 @@ var BackgroundSyncManager = class extends EventEmitter {
15390
15776
  * 3. Errored docs last
15391
15777
  */
15392
15778
  _buildQueue(entries) {
15393
- const items = entries.map(([docId, v]) => {
15779
+ const filtered = [];
15780
+ for (const entry of entries) {
15781
+ const [docId] = entry;
15782
+ if (isValidDocId(docId)) {
15783
+ filtered.push(entry);
15784
+ continue;
15785
+ }
15786
+ if (!this._warnedInvalidIds.has(docId)) {
15787
+ this._warnedInvalidIds.add(docId);
15788
+ 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.`);
15789
+ }
15790
+ }
15791
+ const items = filtered.map(([docId, v]) => {
15394
15792
  const state = this.syncStates.get(docId);
15395
15793
  const updatedAt = v?.updatedAt ?? v?.createdAt ?? 0;
15396
15794
  let priority;
@@ -17773,6 +18171,42 @@ function toPlain(val) {
17773
18171
  return val instanceof Y.Map ? val.toJSON() : val;
17774
18172
  }
17775
18173
 
18174
+ //#endregion
18175
+ //#region packages/provider/src/SchemaTypes.ts
18176
+ /**
18177
+ * Thrown by typed write methods on `TypedDocsClient` when the stored
18178
+ * entry's `type` doesn't match the caller's `expectedType`. Distinct from
18179
+ * `MetaValidationError` (which signals schema-content mismatches).
18180
+ */
18181
+ var TypedDocTypeMismatchError = class extends Error {
18182
+ constructor(docId, expectedType, actualType) {
18183
+ super(`Typed write on document ${docId} expected type "${expectedType}" but stored type is ${actualType === void 0 ? "(none)" : `"${actualType}"`}`);
18184
+ this.name = "TypedDocTypeMismatchError";
18185
+ this.docId = docId;
18186
+ this.expectedType = expectedType;
18187
+ this.actualType = actualType;
18188
+ }
18189
+ };
18190
+ /**
18191
+ * Project a raw `TreeEntry` to a `TypedTreeEntry<TMap, N>` if its `type`
18192
+ * matches; otherwise return `null`. Used by both `TreeManager.getTyped`
18193
+ * and `TypedDocsClient.{get,narrow}` to keep semantics consistent.
18194
+ */
18195
+ function projectTreeEntry(entry, expectedType) {
18196
+ if (!entry) return null;
18197
+ if (entry.type !== expectedType) return null;
18198
+ return {
18199
+ id: entry.id,
18200
+ type: expectedType,
18201
+ label: entry.label,
18202
+ parentId: entry.parentId,
18203
+ order: entry.order,
18204
+ meta: entry.meta,
18205
+ createdAt: entry.createdAt,
18206
+ updatedAt: entry.updatedAt
18207
+ };
18208
+ }
18209
+
17776
18210
  //#endregion
17777
18211
  //#region packages/provider/src/TreeManager.ts
17778
18212
  var TreeManager = class {
@@ -17843,6 +18277,20 @@ var TreeManager = class {
17843
18277
  };
17844
18278
  });
17845
18279
  }
18280
+ /**
18281
+ * Schema-typed lookup. Returns a `TypedTreeEntry<TMap, N>` when the
18282
+ * entry's `type` matches `expectedType`, else `null`. Pure projection
18283
+ * over the existing untyped tree — no schema validation is performed
18284
+ * here (the entry's data is whatever was last synced; meta correctness
18285
+ * is the writer's responsibility, optionally enforced via
18286
+ * `MetaManager.setSchema`).
18287
+ *
18288
+ * Rule 4 alignment: when the entry's type doesn't match, returns null
18289
+ * rather than throwing — callers branch on the result.
18290
+ */
18291
+ getTyped(_schema, expectedType, docId) {
18292
+ return projectTreeEntry(this.get(docId), expectedType);
18293
+ }
17846
18294
  /** Find a single entry by ID. */
17847
18295
  get(docId) {
17848
18296
  const treeMap = this.dm.getTreeMap();
@@ -19568,9 +20016,29 @@ var ContentManager = class {
19568
20016
 
19569
20017
  //#endregion
19570
20018
  //#region packages/provider/src/MetaManager.ts
20019
+ var MetaValidationError = class extends Error {
20020
+ constructor(docId, docType, errors) {
20021
+ const detail = errors.map((e) => `${e.path.join(".") || "(root)"}: ${e.message}`).join("; ");
20022
+ super(`Meta validation failed for document ${docId} of type "${docType}": ${detail}`);
20023
+ this.name = "MetaValidationError";
20024
+ this.docId = docId;
20025
+ this.docType = docType;
20026
+ this.errors = errors;
20027
+ }
20028
+ };
19571
20029
  var MetaManager = class {
19572
20030
  constructor(dm) {
19573
20031
  this.dm = dm;
20032
+ this.schema = null;
20033
+ }
20034
+ /**
20035
+ * Attach (or detach with `null`) a schema validator. When set, every
20036
+ * `update` / `set` validates the post-write meta against the entry's
20037
+ * declared `type` before writing to the doc-tree. Entries without a
20038
+ * `type` field pass through unconditionally.
20039
+ */
20040
+ setSchema(schema) {
20041
+ this.schema = schema;
19574
20042
  }
19575
20043
  /** Read metadata for a document. Returns null if not found. */
19576
20044
  get(docId) {
@@ -19589,6 +20057,9 @@ var MetaManager = class {
19589
20057
  /**
19590
20058
  * Merge fields into a document's metadata.
19591
20059
  * Existing keys not in the update are preserved.
20060
+ *
20061
+ * @throws {MetaValidationError} when a schema is attached and the
20062
+ * merged meta fails validation for the entry's declared type.
19592
20063
  */
19593
20064
  update(docId, meta) {
19594
20065
  const treeMap = this.dm.getTreeMap();
@@ -19596,18 +20067,23 @@ var MetaManager = class {
19596
20067
  const raw = treeMap.get(docId);
19597
20068
  if (!raw) throw new Error(`Document ${docId} not found`);
19598
20069
  const entry = toPlain(raw);
20070
+ const mergedMeta = {
20071
+ ...entry.meta ?? {},
20072
+ ...meta
20073
+ };
20074
+ this.validateOrThrow(docId, entry, mergedMeta);
19599
20075
  treeMap.set(docId, {
19600
20076
  ...entry,
19601
- meta: {
19602
- ...entry.meta ?? {},
19603
- ...meta
19604
- },
20077
+ meta: mergedMeta,
19605
20078
  updatedAt: Date.now()
19606
20079
  });
19607
20080
  }
19608
20081
  /**
19609
20082
  * Replace all metadata on a document.
19610
20083
  * This overwrites the entire meta object.
20084
+ *
20085
+ * @throws {MetaValidationError} when a schema is attached and the
20086
+ * replacement meta fails validation for the entry's declared type.
19611
20087
  */
19612
20088
  set(docId, meta) {
19613
20089
  const treeMap = this.dm.getTreeMap();
@@ -19615,6 +20091,7 @@ var MetaManager = class {
19615
20091
  const raw = treeMap.get(docId);
19616
20092
  if (!raw) throw new Error(`Document ${docId} not found`);
19617
20093
  const entry = toPlain(raw);
20094
+ this.validateOrThrow(docId, entry, meta);
19618
20095
  treeMap.set(docId, {
19619
20096
  ...entry,
19620
20097
  meta,
@@ -19632,12 +20109,20 @@ var MetaManager = class {
19632
20109
  const entry = toPlain(raw);
19633
20110
  const updated = { ...entry.meta ?? {} };
19634
20111
  for (const key of keys) delete updated[key];
20112
+ this.validateOrThrow(docId, entry, updated);
19635
20113
  treeMap.set(docId, {
19636
20114
  ...entry,
19637
20115
  meta: updated,
19638
20116
  updatedAt: Date.now()
19639
20117
  });
19640
20118
  }
20119
+ validateOrThrow(docId, entry, meta) {
20120
+ if (!this.schema) return;
20121
+ const docType = entry.type;
20122
+ if (!docType) return;
20123
+ const result = this.schema.validateMeta(docType, meta);
20124
+ if (!result.ok) throw new MetaValidationError(docId, docType, result.errors);
20125
+ }
19641
20126
  };
19642
20127
 
19643
20128
  //#endregion
@@ -19683,6 +20168,44 @@ var DocumentManager = class {
19683
20168
  this.content = new ContentManager(this);
19684
20169
  this.meta = new MetaManager(this);
19685
20170
  }
20171
+ /**
20172
+ * Bind a schema registry to a typed accessor surface. The returned
20173
+ * client offers `.get(type, id)` with the registry's meta types
20174
+ * inferred at the call site, removing the need to pass `schema`
20175
+ * to every lookup. The provider remains schema-free at runtime —
20176
+ * `schema` is only used to drive type inference (see
20177
+ * `SchemaRegistryLike` for the structural witness).
20178
+ *
20179
+ * @example
20180
+ * const docs = dm.docs(kanbanSchema);
20181
+ * const board = docs.get("kanban", id);
20182
+ * if (board) console.log(board.meta.kanbanColumnWidth); // typed
20183
+ */
20184
+ docs(_schema) {
20185
+ const tree = this.tree;
20186
+ const meta = this.meta;
20187
+ const expectType = (expected, id) => {
20188
+ const actual = tree.get(id)?.type;
20189
+ if (actual !== expected) throw new TypedDocTypeMismatchError(id, expected, actual);
20190
+ };
20191
+ return {
20192
+ get: (expectedType, id) => projectTreeEntry(tree.get(id), expectedType),
20193
+ getEntry: (id) => tree.get(id),
20194
+ narrow: (expectedType, entry) => projectTreeEntry(entry ?? null, expectedType),
20195
+ update: (expectedType, id, patch) => {
20196
+ expectType(expectedType, id);
20197
+ meta.update(id, patch);
20198
+ },
20199
+ set: (expectedType, id, value) => {
20200
+ expectType(expectedType, id);
20201
+ meta.set(id, value);
20202
+ },
20203
+ clear: (expectedType, id, keys) => {
20204
+ expectType(expectedType, id);
20205
+ meta.clear(id, keys);
20206
+ }
20207
+ };
20208
+ }
19686
20209
  get displayName() {
19687
20210
  return this._config.name || "DocumentManager";
19688
20211
  }
@@ -19824,5 +20347,5 @@ var DocumentManager = class {
19824
20347
  };
19825
20348
 
19826
20349
  //#endregion
19827
- export { AbracadabraBaseProvider, AbracadabraClient, AbracadabraProvider, AbracadabraWS, AbracadabraWebRTC, AuthMessageType, AwarenessError, BackgroundSyncManager, BackgroundSyncPersistence, BroadcastChannelSync, CHANNEL_NAMES, ChannelKeyResolver, ChatClient, ConnectionTimeout, ContentManager, CryptoIdentityKeystore, DEFAULT_FILE_CHUNK_SIZE, DEFAULT_ICE_SERVERS, DataChannelRouter, DevicePairingChannel, DeviceRegistrationService, DocKeyManager, DocumentCache, DocumentManager, E2EAbracadabraProvider, E2EEChannel, E2EOfflineStore, EncryptedChatClient, EncryptedYMap, EncryptedYText, FileBlobStore, FileTransferChannel, FileTransferHandle, Forbidden, GEO_TYPE_META_SCHEMAS, HocuspocusProvider, HocuspocusProviderWebsocket, IdentityDocProvider, KEY_EXCHANGE_CHANNEL, Kind, ManualSignaling, MessageTooBig, MessageType, MetaManager, NotificationsClient, OfflineStore, PAGE_TYPES, PeerConnection, RPC_PREFIX, ResetConnection, RpcClient, RpcError, SERVER_ROOT_ID, SearchIndex, SignalingSocket, SubdocMessage, TYPE_ALIASES, TreeManager, Unauthorized, WebSocketStatus, WsReadyStates, YjsDataChannel, attachUpdatedAtObserver, awarenessStatesToArray, wordlist as bip39Wordlist, buildBlockquoteElement, buildBlocksFromMarkdown, buildBulletListElement, buildCodeBlockElement, buildHeadingElement, buildHorizontalRuleElement, buildOrderedListElement, buildParagraphElement, buildTaskListElement, decryptChatContent, decryptField, deriveIdentityDocId, deriveSeedWrappingKey, encryptChatContent, encryptField, filenameToLabel, foldRecords, generateMnemonic, isEncryptedContent, makeEncryptedYMap, makeEncryptedYText, mnemonicToEd25519Seed, mnemonicToKeyPair, normalizeRootId, parseFrontmatter, populateYDocFromMarkdown, readAuthMessage, readBlocksFromFragment, recordFromYAny, resolvePageType, toPlain, unwrapSeed, validateMnemonic, waitForSync, withTimeout, wrapSeed, writeAuthenticated, writeAuthentication, writePermissionDenied, writeTokenSyncRequest, yjsToMarkdown };
20350
+ export { AbracadabraBaseProvider, AbracadabraClient, AbracadabraProvider, AbracadabraWS, AbracadabraWebRTC, AuthMessageType, AwarenessError, BackgroundSyncManager, BackgroundSyncPersistence, BroadcastChannelSync, CHANNEL_NAMES, ChannelKeyResolver, ChatClient, ConnectionTimeout, ContentManager, CryptoIdentityKeystore, DEFAULT_FILE_CHUNK_SIZE, DEFAULT_ICE_SERVERS, DataChannelRouter, DevicePairingChannel, DeviceRegistrationService, DocKeyManager, DocumentCache, DocumentManager, E2EAbracadabraProvider, E2EEChannel, E2EOfflineStore, EncryptedChatClient, EncryptedYMap, EncryptedYText, FileBlobStore, FileTransferChannel, FileTransferHandle, Forbidden, GEO_TYPE_META_SCHEMAS, HocuspocusProvider, HocuspocusProviderWebsocket, IdentityDocProvider, KEY_EXCHANGE_CHANNEL, Kind, LocalStorageDeviceSessionStorage, ManualSignaling, MessageTooBig, MessageType, MetaManager, MetaValidationError, NotificationsClient, OfflineStore, PAGE_TYPES, PeerConnection, QUERY_PREFIX, QueryClient, QueryError, RPC_PREFIX, ResetConnection, RpcClient, RpcError, SERVER_ROOT_ID, SearchIndex, SignalingSocket, SubdocMessage, TYPE_ALIASES, TokenManager, TreeManager, TypedDocTypeMismatchError, Unauthorized, WebSocketStatus, WsReadyStates, YjsDataChannel, attachUpdatedAtObserver, awarenessStatesToArray, wordlist as bip39Wordlist, buildBlockquoteElement, buildBlocksFromMarkdown, buildBulletListElement, buildCodeBlockElement, buildHeadingElement, buildHorizontalRuleElement, buildOrderedListElement, buildParagraphElement, buildTaskListElement, decryptChatContent, decryptField, deriveIdentityDocId, deriveSeedWrappingKey, encryptChatContent, encryptField, filenameToLabel, foldRecords, generateMnemonic, isEncryptedContent, makeEncryptedYMap, makeEncryptedYText, mnemonicToEd25519Seed, mnemonicToKeyPair, normalizeRootId, parseFrontmatter, populateYDocFromMarkdown, readAuthMessage, readBlocksFromFragment, recordFromYAny, resolvePageType, toPlain, unwrapSeed, validateMnemonic, waitForSync, withTimeout, wrapSeed, writeAuthenticated, writeAuthentication, writePermissionDenied, writeTokenSyncRequest, yjsToMarkdown };
19828
20351
  //# sourceMappingURL=abracadabra-provider.esm.js.map