@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.
@@ -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)) {
@@ -10265,7 +10390,8 @@ var AbracadabraClient = class {
10265
10390
  deviceName: opts.deviceName,
10266
10391
  displayName: opts.displayName,
10267
10392
  email: opts.email,
10268
- inviteCode: opts.inviteCode
10393
+ inviteCode: opts.inviteCode,
10394
+ x25519Key: opts.x25519Key
10269
10395
  },
10270
10396
  auth: false
10271
10397
  });
@@ -10584,6 +10710,27 @@ var AbracadabraClient = class {
10584
10710
  return (await this.request("GET", `/docs/search?${params.toString()}`)).results;
10585
10711
  }
10586
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
+ /**
10587
10734
  * List the direct children of a document, returning full metadata. Pass
10588
10735
  * no argument to list the children of the server root — what the
10589
10736
  * dashboard renders as the Spaces sidebar.
@@ -11119,6 +11266,234 @@ var AbracadabraClient = class {
11119
11266
  }
11120
11267
  };
11121
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
+
11122
11497
  //#endregion
11123
11498
  //#region packages/provider/src/CloseEvents.ts
11124
11499
  /**
@@ -15244,10 +15619,22 @@ var Semaphore = class {
15244
15619
  else this.slots++;
15245
15620
  }
15246
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
+ }
15247
15633
  var BackgroundSyncManager = class extends EventEmitter {
15248
15634
  constructor(rootProvider, client, fileBlobStore, opts) {
15249
15635
  super();
15250
15636
  this.syncStates = /* @__PURE__ */ new Map();
15637
+ this._warnedInvalidIds = /* @__PURE__ */ new Set();
15251
15638
  this._destroyed = false;
15252
15639
  this._initPromise = null;
15253
15640
  this.rootProvider = rootProvider;
@@ -15389,7 +15776,19 @@ var BackgroundSyncManager = class extends EventEmitter {
15389
15776
  * 3. Errored docs last
15390
15777
  */
15391
15778
  _buildQueue(entries) {
15392
- 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]) => {
15393
15792
  const state = this.syncStates.get(docId);
15394
15793
  const updatedAt = v?.updatedAt ?? v?.createdAt ?? 0;
15395
15794
  let priority;
@@ -17772,6 +18171,42 @@ function toPlain(val) {
17772
18171
  return val instanceof Y.Map ? val.toJSON() : val;
17773
18172
  }
17774
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
+
17775
18210
  //#endregion
17776
18211
  //#region packages/provider/src/TreeManager.ts
17777
18212
  var TreeManager = class {
@@ -17842,6 +18277,20 @@ var TreeManager = class {
17842
18277
  };
17843
18278
  });
17844
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
+ }
17845
18294
  /** Find a single entry by ID. */
17846
18295
  get(docId) {
17847
18296
  const treeMap = this.dm.getTreeMap();
@@ -19567,9 +20016,29 @@ var ContentManager = class {
19567
20016
 
19568
20017
  //#endregion
19569
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
+ };
19570
20029
  var MetaManager = class {
19571
20030
  constructor(dm) {
19572
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;
19573
20042
  }
19574
20043
  /** Read metadata for a document. Returns null if not found. */
19575
20044
  get(docId) {
@@ -19588,6 +20057,9 @@ var MetaManager = class {
19588
20057
  /**
19589
20058
  * Merge fields into a document's metadata.
19590
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.
19591
20063
  */
19592
20064
  update(docId, meta) {
19593
20065
  const treeMap = this.dm.getTreeMap();
@@ -19595,18 +20067,23 @@ var MetaManager = class {
19595
20067
  const raw = treeMap.get(docId);
19596
20068
  if (!raw) throw new Error(`Document ${docId} not found`);
19597
20069
  const entry = toPlain(raw);
20070
+ const mergedMeta = {
20071
+ ...entry.meta ?? {},
20072
+ ...meta
20073
+ };
20074
+ this.validateOrThrow(docId, entry, mergedMeta);
19598
20075
  treeMap.set(docId, {
19599
20076
  ...entry,
19600
- meta: {
19601
- ...entry.meta ?? {},
19602
- ...meta
19603
- },
20077
+ meta: mergedMeta,
19604
20078
  updatedAt: Date.now()
19605
20079
  });
19606
20080
  }
19607
20081
  /**
19608
20082
  * Replace all metadata on a document.
19609
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.
19610
20087
  */
19611
20088
  set(docId, meta) {
19612
20089
  const treeMap = this.dm.getTreeMap();
@@ -19614,6 +20091,7 @@ var MetaManager = class {
19614
20091
  const raw = treeMap.get(docId);
19615
20092
  if (!raw) throw new Error(`Document ${docId} not found`);
19616
20093
  const entry = toPlain(raw);
20094
+ this.validateOrThrow(docId, entry, meta);
19617
20095
  treeMap.set(docId, {
19618
20096
  ...entry,
19619
20097
  meta,
@@ -19631,12 +20109,20 @@ var MetaManager = class {
19631
20109
  const entry = toPlain(raw);
19632
20110
  const updated = { ...entry.meta ?? {} };
19633
20111
  for (const key of keys) delete updated[key];
20112
+ this.validateOrThrow(docId, entry, updated);
19634
20113
  treeMap.set(docId, {
19635
20114
  ...entry,
19636
20115
  meta: updated,
19637
20116
  updatedAt: Date.now()
19638
20117
  });
19639
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
+ }
19640
20126
  };
19641
20127
 
19642
20128
  //#endregion
@@ -19682,6 +20168,44 @@ var DocumentManager = class {
19682
20168
  this.content = new ContentManager(this);
19683
20169
  this.meta = new MetaManager(this);
19684
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
+ }
19685
20209
  get displayName() {
19686
20210
  return this._config.name || "DocumentManager";
19687
20211
  }
@@ -19823,5 +20347,5 @@ var DocumentManager = class {
19823
20347
  };
19824
20348
 
19825
20349
  //#endregion
19826
- 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 };
19827
20351
  //# sourceMappingURL=abracadabra-provider.esm.js.map