@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.
@@ -73,6 +73,19 @@ class Semaphore {
73
73
  }
74
74
  }
75
75
 
76
+ /**
77
+ * Server-accepted doc-id format. The Rust server's `DocId::from_string`
78
+ * is a strict UUID parse; any other string returns 422. Stale entries in
79
+ * the doc-tree map (legacy slug ids from earlier demos, manual experiments)
80
+ * would otherwise emit one 422 per `syncAll()` forever — see the warn
81
+ * below in `_buildQueue`.
82
+ */
83
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
84
+
85
+ function isValidDocId(id: string): boolean {
86
+ return UUID_RE.test(id);
87
+ }
88
+
76
89
  export class BackgroundSyncManager extends EventEmitter {
77
90
  private readonly rootProvider: AbracadabraProvider;
78
91
  private readonly client: AbracadabraClient;
@@ -82,6 +95,8 @@ export class BackgroundSyncManager extends EventEmitter {
82
95
  private readonly persistence: BackgroundSyncPersistence;
83
96
  private readonly semaphore: Semaphore;
84
97
  private readonly syncStates = new Map<string, DocSyncState>();
98
+ /** Doc ids we've already warned about; keeps the log to one line per id. */
99
+ private readonly _warnedInvalidIds = new Set<string>();
85
100
 
86
101
  private _destroyed = false;
87
102
  private _initPromise: Promise<void> | null = null;
@@ -298,7 +313,25 @@ export class BackgroundSyncManager extends EventEmitter {
298
313
  private _buildQueue(entries: Array<[string, any]>): string[] {
299
314
  type Sortable = { docId: string; priority: number };
300
315
 
301
- const items: Sortable[] = entries.map(([docId, v]) => {
316
+ // Drop ids the server can't parse (non-UUID). One-time warn per id
317
+ // so the operator can chase down where the stale entry is, but no
318
+ // log storm on every sync run.
319
+ const filtered: Array<[string, any]> = [];
320
+ for (const entry of entries) {
321
+ const [docId] = entry;
322
+ if (isValidDocId(docId)) {
323
+ filtered.push(entry);
324
+ continue;
325
+ }
326
+ if (!this._warnedInvalidIds.has(docId)) {
327
+ this._warnedInvalidIds.add(docId);
328
+ console.warn(
329
+ `[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.`,
330
+ );
331
+ }
332
+ }
333
+
334
+ const items: Sortable[] = filtered.map(([docId, v]) => {
302
335
  const state = this.syncStates.get(docId);
303
336
  const updatedAt: number = v?.updatedAt ?? v?.createdAt ?? 0;
304
337
 
@@ -31,6 +31,13 @@ import { TreeManager } from "./TreeManager.ts";
31
31
  import { ContentManager } from "./ContentManager.ts";
32
32
  import { MetaManager } from "./MetaManager.ts";
33
33
  import { waitForSync } from "./DocUtils.ts";
34
+ import type { TreeEntry, PageMeta } from "./DocTypes.ts";
35
+ import {
36
+ projectTreeEntry,
37
+ TypedDocTypeMismatchError,
38
+ type SchemaRegistryLike,
39
+ type TypedDocsClient,
40
+ } from "./SchemaTypes.ts";
34
41
 
35
42
  export interface DocumentManagerConfig {
36
43
  /** Server base URL (http or https). */
@@ -107,6 +114,69 @@ export class DocumentManager {
107
114
  this.meta = new MetaManager(this);
108
115
  }
109
116
 
117
+ /**
118
+ * Bind a schema registry to a typed accessor surface. The returned
119
+ * client offers `.get(type, id)` with the registry's meta types
120
+ * inferred at the call site, removing the need to pass `schema`
121
+ * to every lookup. The provider remains schema-free at runtime —
122
+ * `schema` is only used to drive type inference (see
123
+ * `SchemaRegistryLike` for the structural witness).
124
+ *
125
+ * @example
126
+ * const docs = dm.docs(kanbanSchema);
127
+ * const board = docs.get("kanban", id);
128
+ * if (board) console.log(board.meta.kanbanColumnWidth); // typed
129
+ */
130
+ docs<TMap extends Record<string, unknown>>(
131
+ _schema: SchemaRegistryLike<TMap>,
132
+ ): TypedDocsClient<TMap> {
133
+ const tree = this.tree;
134
+ const meta = this.meta;
135
+ const expectType = <N extends keyof TMap & string>(
136
+ expected: N,
137
+ id: string,
138
+ ): void => {
139
+ const entry = tree.get(id);
140
+ const actual = entry?.type;
141
+ if (actual !== expected) {
142
+ throw new TypedDocTypeMismatchError(id, expected, actual);
143
+ }
144
+ };
145
+ return {
146
+ get: <N extends keyof TMap & string>(expectedType: N, id: string) =>
147
+ projectTreeEntry<TMap, N>(tree.get(id), expectedType),
148
+ getEntry: (id: string) => tree.get(id),
149
+ narrow: <N extends keyof TMap & string>(
150
+ expectedType: N,
151
+ entry: TreeEntry | null | undefined,
152
+ ) => projectTreeEntry<TMap, N>(entry ?? null, expectedType),
153
+ update: <N extends keyof TMap & string>(
154
+ expectedType: N,
155
+ id: string,
156
+ patch: Partial<TMap[N]>,
157
+ ) => {
158
+ expectType(expectedType, id);
159
+ meta.update(id, patch as Partial<PageMeta>);
160
+ },
161
+ set: <N extends keyof TMap & string>(
162
+ expectedType: N,
163
+ id: string,
164
+ value: TMap[N],
165
+ ) => {
166
+ expectType(expectedType, id);
167
+ meta.set(id, value as unknown as PageMeta);
168
+ },
169
+ clear: <N extends keyof TMap & string>(
170
+ expectedType: N,
171
+ id: string,
172
+ keys: ReadonlyArray<keyof TMap[N] & string>,
173
+ ) => {
174
+ expectType(expectedType, id);
175
+ meta.clear(id, keys as ReadonlyArray<string> as string[]);
176
+ },
177
+ };
178
+ }
179
+
110
180
  // ── Accessors ─────────────────────────────────────────────────────────────
111
181
 
112
182
  get displayName(): string {
@@ -2,6 +2,14 @@
2
2
  * MetaManager — read/write PageMeta on tree entries.
3
3
  *
4
4
  * Extracted from `mcp/tools/meta.ts` and `cli/commands/meta.ts`.
5
+ *
6
+ * Optionally validates writes against a JSON-Schema-backed validator
7
+ * (e.g. `@abraca/schema`'s `SchemaRegistry`). The validator is supplied
8
+ * via {@link MetaManager.setSchema} as a structural value matching
9
+ * {@link SchemaValidatorLike}; the provider package never imports
10
+ * `@abraca/schema` directly so core stays schema-free
11
+ * (`feedback_schema_free_core` Rule 1). When no schema is set,
12
+ * `update`/`set` behave exactly as before (Rule 4).
5
13
  */
6
14
  import type { PageMeta } from "./DocTypes.ts";
7
15
  import { toPlain } from "./DocUtils.ts";
@@ -14,9 +22,74 @@ export interface DocumentMetaInfo {
14
22
  meta: PageMeta;
15
23
  }
16
24
 
25
+ /**
26
+ * Structural shape of a schema validator MetaManager can consult before
27
+ * writes. Compatible with `@abraca/schema`'s `SchemaRegistry`.
28
+ *
29
+ * Implementations MUST treat unknown doc-types as unconstrained
30
+ * (return `{ ok: true }`) — Rule 4: existing-app traffic for doc-types
31
+ * not in the validator's bundle is never rejected.
32
+ */
33
+ export interface SchemaValidatorLike {
34
+ validateMeta(
35
+ docType: string,
36
+ meta: unknown,
37
+ ): { ok: true; value?: unknown } | {
38
+ ok: false;
39
+ errors: ReadonlyArray<{
40
+ path: ReadonlyArray<PropertyKey>;
41
+ message: string;
42
+ code?: string;
43
+ }>;
44
+ };
45
+ }
46
+
47
+ export class MetaValidationError extends Error {
48
+ readonly docId: string;
49
+ readonly docType: string;
50
+ readonly errors: ReadonlyArray<{
51
+ path: ReadonlyArray<PropertyKey>;
52
+ message: string;
53
+ code?: string;
54
+ }>;
55
+
56
+ constructor(
57
+ docId: string,
58
+ docType: string,
59
+ errors: ReadonlyArray<{
60
+ path: ReadonlyArray<PropertyKey>;
61
+ message: string;
62
+ code?: string;
63
+ }>,
64
+ ) {
65
+ const detail = errors
66
+ .map((e) => `${e.path.join(".") || "(root)"}: ${e.message}`)
67
+ .join("; ");
68
+ super(
69
+ `Meta validation failed for document ${docId} of type "${docType}": ${detail}`,
70
+ );
71
+ this.name = "MetaValidationError";
72
+ this.docId = docId;
73
+ this.docType = docType;
74
+ this.errors = errors;
75
+ }
76
+ }
77
+
17
78
  export class MetaManager {
79
+ private schema: SchemaValidatorLike | null = null;
80
+
18
81
  constructor(private dm: DocumentManager) {}
19
82
 
83
+ /**
84
+ * Attach (or detach with `null`) a schema validator. When set, every
85
+ * `update` / `set` validates the post-write meta against the entry's
86
+ * declared `type` before writing to the doc-tree. Entries without a
87
+ * `type` field pass through unconditionally.
88
+ */
89
+ setSchema(schema: SchemaValidatorLike | null): void {
90
+ this.schema = schema;
91
+ }
92
+
20
93
  /** Read metadata for a document. Returns null if not found. */
21
94
  get(docId: string): DocumentMetaInfo | null {
22
95
  const treeMap = this.dm.getTreeMap();
@@ -37,6 +110,9 @@ export class MetaManager {
37
110
  /**
38
111
  * Merge fields into a document's metadata.
39
112
  * Existing keys not in the update are preserved.
113
+ *
114
+ * @throws {MetaValidationError} when a schema is attached and the
115
+ * merged meta fails validation for the entry's declared type.
40
116
  */
41
117
  update(docId: string, meta: Partial<PageMeta>): void {
42
118
  const treeMap = this.dm.getTreeMap();
@@ -46,9 +122,11 @@ export class MetaManager {
46
122
  if (!raw) throw new Error(`Document ${docId} not found`);
47
123
 
48
124
  const entry = toPlain(raw) as Record<string, unknown>;
125
+ const mergedMeta = { ...((entry.meta as PageMeta) ?? {}), ...meta };
126
+ this.validateOrThrow(docId, entry, mergedMeta);
49
127
  treeMap.set(docId, {
50
128
  ...entry,
51
- meta: { ...((entry.meta as PageMeta) ?? {}), ...meta },
129
+ meta: mergedMeta,
52
130
  updatedAt: Date.now(),
53
131
  });
54
132
  }
@@ -56,6 +134,9 @@ export class MetaManager {
56
134
  /**
57
135
  * Replace all metadata on a document.
58
136
  * This overwrites the entire meta object.
137
+ *
138
+ * @throws {MetaValidationError} when a schema is attached and the
139
+ * replacement meta fails validation for the entry's declared type.
59
140
  */
60
141
  set(docId: string, meta: PageMeta): void {
61
142
  const treeMap = this.dm.getTreeMap();
@@ -65,6 +146,7 @@ export class MetaManager {
65
146
  if (!raw) throw new Error(`Document ${docId} not found`);
66
147
 
67
148
  const entry = toPlain(raw) as Record<string, unknown>;
149
+ this.validateOrThrow(docId, entry, meta);
68
150
  treeMap.set(docId, {
69
151
  ...entry,
70
152
  meta,
@@ -91,10 +173,25 @@ export class MetaManager {
91
173
  for (const key of keys) {
92
174
  delete updated[key];
93
175
  }
176
+ this.validateOrThrow(docId, entry, updated as PageMeta);
94
177
  treeMap.set(docId, {
95
178
  ...entry,
96
179
  meta: updated,
97
180
  updatedAt: Date.now(),
98
181
  });
99
182
  }
183
+
184
+ private validateOrThrow(
185
+ docId: string,
186
+ entry: Record<string, unknown>,
187
+ meta: PageMeta,
188
+ ): void {
189
+ if (!this.schema) return;
190
+ const docType = entry.type as string | undefined;
191
+ if (!docType) return;
192
+ const result = this.schema.validateMeta(docType, meta);
193
+ if (!result.ok) {
194
+ throw new MetaValidationError(docId, docType, result.errors);
195
+ }
196
+ }
100
197
  }
@@ -0,0 +1,209 @@
1
+ import EventEmitter from "./EventEmitter.ts";
2
+
3
+ /**
4
+ * V2 query layer — live subscription client.
5
+ *
6
+ * Mirrors the server in `crates/abracadabra/src/query_stream.rs`. Frames
7
+ * travel as `MSG_STATELESS` payloads with the literal `query:v1:` prefix
8
+ * followed by a JSON envelope.
9
+ *
10
+ * Each call to `subscribeQuery(...)` opens a long-lived subscription:
11
+ * 1. The client sends `req` with a fresh correlation id.
12
+ * 2. The server replies `ack` with an initial snapshot
13
+ * (`DocumentMeta[]`).
14
+ * 3. The server emits `delta` frames as the doc-tree projection
15
+ * drifts under the predicate.
16
+ * 4. The client sends `cancel` to tear it down (or relies on the
17
+ * WebSocket close path to free server state).
18
+ *
19
+ * The shape of the `query` field mirrors the REST `POST /docs/query`
20
+ * body — `type`, `parentId`, `labelContains`, `where`, `limit`.
21
+ */
22
+
23
+ export const QUERY_PREFIX = "query:v1:";
24
+
25
+ export type QueryKind = "req" | "ack" | "delta" | "cancel" | "err";
26
+
27
+ /** Mirrors `crate::query_stream::QuerySpec`. */
28
+ export interface QuerySpec {
29
+ /** Match `documents.kind` exactly. */
30
+ type?: string;
31
+ /** Narrow to children of this doc. */
32
+ parentId?: string;
33
+ /** Case-insensitive substring on `documents.label`. */
34
+ labelContains?: string;
35
+ /** Maximum rows returned (default 50, hard cap 500 server-side). */
36
+ limit?: number;
37
+ /** Predicate AST. See `@abraca/schema/src/query.ts` for the shape. */
38
+ where?: unknown;
39
+ }
40
+
41
+ export interface DocumentMetaWire {
42
+ id: string;
43
+ parent_id?: string | null;
44
+ label?: string | null;
45
+ kind?: string | null;
46
+ doc_type?: string | null;
47
+ public_access?: string | null;
48
+ description?: string | null;
49
+ owner_id?: string | null;
50
+ }
51
+
52
+ export interface QueryFrame {
53
+ kind: QueryKind;
54
+ id: string;
55
+ query?: QuerySpec;
56
+ initial?: DocumentMetaWire[];
57
+ added?: DocumentMetaWire[];
58
+ updated?: DocumentMetaWire[];
59
+ removed?: string[];
60
+ error?: { code: string; message: string };
61
+ }
62
+
63
+ export class QueryError extends Error {
64
+ code: string;
65
+ constructor(code: string, message: string) {
66
+ super(message);
67
+ this.name = "QueryError";
68
+ this.code = code;
69
+ }
70
+ }
71
+
72
+ export interface QuerySubscriptionHandlers {
73
+ /** Fired once with the initial snapshot from the server's `ack`. */
74
+ onSnapshot?: (rows: DocumentMetaWire[]) => void;
75
+ /**
76
+ * Fired on every `delta` after the initial ack. `added` are rows
77
+ * newly matching the predicate; `removed` are doc_ids that no
78
+ * longer match (or are no longer readable). v2 does not emit
79
+ * `updated` — clients see updates as remove + add today; that
80
+ * gap closes in v3 when row-level updates are tracked.
81
+ */
82
+ onDelta?: (delta: {
83
+ added: DocumentMetaWire[];
84
+ removed: string[];
85
+ }) => void;
86
+ /** Fired on `err` frames from the server. */
87
+ onError?: (err: QueryError) => void;
88
+ }
89
+
90
+ export interface QuerySubscriptionHandle {
91
+ /** Correlation id assigned when the request was sent. */
92
+ readonly id: string;
93
+ /** Send a `cancel` to the server and stop dispatching to handlers. */
94
+ cancel(): void;
95
+ }
96
+
97
+ /**
98
+ * Minimal transport surface — matches `RpcTransport` so the provider
99
+ * can satisfy both without extra glue.
100
+ */
101
+ export interface QueryTransport {
102
+ sendStateless(payload: string): void;
103
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
104
+ on(event: string, fn: Function): unknown;
105
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
106
+ off(event: string, fn?: Function): unknown;
107
+ }
108
+
109
+ interface ActiveSubscription {
110
+ id: string;
111
+ handlers: QuerySubscriptionHandlers;
112
+ cancelled: boolean;
113
+ }
114
+
115
+ export class QueryClient extends EventEmitter {
116
+ private readonly transport: QueryTransport;
117
+ private readonly subs = new Map<string, ActiveSubscription>();
118
+ private readonly onStatelessBound: (data: { payload: string }) => void;
119
+ private idCounter = 0;
120
+
121
+ constructor(transport: QueryTransport) {
122
+ super();
123
+ this.transport = transport;
124
+ this.onStatelessBound = (data) => this.receive(data.payload);
125
+ this.transport.on("stateless", this.onStatelessBound);
126
+ }
127
+
128
+ destroy(): void {
129
+ // Best-effort cancel of every open sub. The WebSocket close
130
+ // path also frees server state, so this is belt-and-suspenders.
131
+ for (const sub of this.subs.values()) {
132
+ if (!sub.cancelled) {
133
+ this.send({ kind: "cancel", id: sub.id });
134
+ sub.cancelled = true;
135
+ }
136
+ }
137
+ this.subs.clear();
138
+ this.transport.off("stateless", this.onStatelessBound);
139
+ }
140
+
141
+ subscribeQuery(
142
+ spec: QuerySpec,
143
+ handlers: QuerySubscriptionHandlers = {},
144
+ ): QuerySubscriptionHandle {
145
+ const id = this.nextId();
146
+ const sub: ActiveSubscription = {
147
+ id,
148
+ handlers,
149
+ cancelled: false,
150
+ };
151
+ this.subs.set(id, sub);
152
+ this.send({ kind: "req", id, query: spec });
153
+ const self = this;
154
+ return {
155
+ id,
156
+ cancel(): void {
157
+ if (sub.cancelled) return;
158
+ sub.cancelled = true;
159
+ self.subs.delete(id);
160
+ self.send({ kind: "cancel", id });
161
+ },
162
+ };
163
+ }
164
+
165
+ private nextId(): string {
166
+ this.idCounter += 1;
167
+ return `q-${Date.now()}-${this.idCounter}`;
168
+ }
169
+
170
+ private send(frame: QueryFrame): void {
171
+ this.transport.sendStateless(`${QUERY_PREFIX}${JSON.stringify(frame)}`);
172
+ }
173
+
174
+ private receive(payload: string): void {
175
+ if (!payload.startsWith(QUERY_PREFIX)) return;
176
+ const rest = payload.slice(QUERY_PREFIX.length);
177
+ let frame: QueryFrame;
178
+ try {
179
+ frame = JSON.parse(rest) as QueryFrame;
180
+ } catch {
181
+ return;
182
+ }
183
+ const sub = this.subs.get(frame.id);
184
+ if (!sub || sub.cancelled) return;
185
+ switch (frame.kind) {
186
+ case "ack":
187
+ sub.handlers.onSnapshot?.(frame.initial ?? []);
188
+ break;
189
+ case "delta":
190
+ sub.handlers.onDelta?.({
191
+ added: frame.added ?? [],
192
+ removed: frame.removed ?? [],
193
+ });
194
+ break;
195
+ case "err":
196
+ if (frame.error) {
197
+ sub.handlers.onError?.(
198
+ new QueryError(frame.error.code, frame.error.message),
199
+ );
200
+ }
201
+ // Server treats `err` as terminal — drop the sub.
202
+ this.subs.delete(frame.id);
203
+ break;
204
+ default:
205
+ // `req` / `cancel` are client→server only. Ignore.
206
+ break;
207
+ }
208
+ }
209
+ }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Phase 2 Day 1 — typed projection types for the provider.
3
+ *
4
+ * The provider package never imports `@abraca/schema` directly (Rule 1 —
5
+ * core stays schema-free). Instead, it declares a structural witness that
6
+ * any registry — including `@abraca/schema`'s `SchemaRegistry<TMap>` — can
7
+ * satisfy.
8
+ *
9
+ * Consumers parameterise typed accessors with the registry value:
10
+ *
11
+ * const board = dm.tree.getTyped(kanbanSchema, "kanban", id);
12
+ * // board.meta.kanbanColumnWidth is typed as the kanban enum | undefined
13
+ *
14
+ * The phantom `__metaMap` carries the per-doc-type meta map so the
15
+ * generics below can extract it without a runtime dependency.
16
+ */
17
+ import type { PageMeta, TreeEntry } from "./DocTypes.ts";
18
+
19
+ /**
20
+ * Structural shape of a typed schema registry. The `TMap` parameter is the
21
+ * meta-type map keyed by doc-type name (e.g. `{ "kanban": KanbanMeta, ... }`).
22
+ * `@abraca/schema`'s `SchemaRegistry<TMap>` satisfies this shape.
23
+ */
24
+ export interface SchemaRegistryLike<
25
+ TMap extends Record<string, unknown> = Record<string, unknown>,
26
+ > {
27
+ readonly types: ReadonlyMap<string, unknown>;
28
+ readonly __metaMap?: TMap;
29
+ }
30
+
31
+ /** Extract the doc-type names from a registry. */
32
+ export type SchemaDocTypeName<S> =
33
+ S extends SchemaRegistryLike<infer M> ? keyof M & string : never;
34
+
35
+ /** Extract the meta type for a given doc-type name from a registry. */
36
+ export type SchemaMetaOf<S, N extends string> =
37
+ S extends SchemaRegistryLike<infer M>
38
+ ? N extends keyof M
39
+ ? M[N]
40
+ : never
41
+ : never;
42
+
43
+ /**
44
+ * A tree entry projected against a typed registry. The `meta` field is
45
+ * narrowed to `TMap[N] | undefined` so consumers get full type inference
46
+ * without an explicit cast.
47
+ *
48
+ * Note: `meta` is optional even when the registry mandates fields — the
49
+ * tree may contain entries written before the schema was authored, and
50
+ * Rule 4 means we don't reject them at read time.
51
+ */
52
+ export interface TypedTreeEntry<
53
+ TMap extends Record<string, unknown>,
54
+ N extends keyof TMap & string,
55
+ > {
56
+ readonly id: string;
57
+ readonly type: N;
58
+ readonly label: string;
59
+ readonly parentId: string | null;
60
+ readonly order: number;
61
+ readonly meta: TMap[N] | undefined;
62
+ readonly createdAt: number | undefined;
63
+ readonly updatedAt: number | undefined;
64
+ }
65
+
66
+ /**
67
+ * A typed surface bound to a schema. Returned by
68
+ * `DocumentManager.docs(schema)` so the schema doesn't have to be repeated
69
+ * at every call site.
70
+ *
71
+ * Read methods (`get`, `getEntry`, `narrow`) project the existing
72
+ * untyped tree without performing schema validation. Write methods
73
+ * (`update`, `set`, `clear`) delegate to `MetaManager` and are subject
74
+ * to whatever validator was attached via `MetaManager.setSchema` —
75
+ * they do NOT auto-attach the registry passed to `dm.docs()`.
76
+ */
77
+ export interface TypedDocsClient<TMap extends Record<string, unknown>> {
78
+ /**
79
+ * Fetch a tree entry projected as the requested doc-type. Returns
80
+ * `null` when the entry doesn't exist OR when its `type` field doesn't
81
+ * match `expectedType`.
82
+ */
83
+ get<N extends keyof TMap & string>(
84
+ expectedType: N,
85
+ id: string,
86
+ ): TypedTreeEntry<TMap, N> | null;
87
+ /**
88
+ * Fetch the raw, untyped entry (no type-narrowing applied). Useful when
89
+ * the caller wants the underlying record without the projection.
90
+ */
91
+ getEntry(id: string): TreeEntry | null;
92
+ /**
93
+ * Discriminator helper: narrows an arbitrary `TreeEntry` to the
94
+ * requested doc-type, returning a typed projection or `null` on mismatch.
95
+ * No runtime read — pure type-guard helper.
96
+ */
97
+ narrow<N extends keyof TMap & string>(
98
+ expectedType: N,
99
+ entry: TreeEntry | null | undefined,
100
+ ): TypedTreeEntry<TMap, N> | null;
101
+ /**
102
+ * Merge typed meta into a document's metadata. Throws
103
+ * `TypedDocTypeMismatchError` when the stored entry's `type` doesn't
104
+ * match `expectedType`; throws `MetaValidationError` when a validator
105
+ * is attached and the merged meta fails validation.
106
+ */
107
+ update<N extends keyof TMap & string>(
108
+ expectedType: N,
109
+ id: string,
110
+ meta: Partial<TMap[N]>,
111
+ ): void;
112
+ /**
113
+ * Replace all metadata on a document with a fully-typed payload.
114
+ * Same throw semantics as `update`.
115
+ */
116
+ set<N extends keyof TMap & string>(
117
+ expectedType: N,
118
+ id: string,
119
+ meta: TMap[N],
120
+ ): void;
121
+ /**
122
+ * Delete specific metadata keys. Constrained to the typed key-set of
123
+ * `TMap[N]` so typos surface at compile time.
124
+ */
125
+ clear<N extends keyof TMap & string>(
126
+ expectedType: N,
127
+ id: string,
128
+ keys: ReadonlyArray<keyof TMap[N] & string>,
129
+ ): void;
130
+ }
131
+
132
+ /**
133
+ * Thrown by typed write methods on `TypedDocsClient` when the stored
134
+ * entry's `type` doesn't match the caller's `expectedType`. Distinct from
135
+ * `MetaValidationError` (which signals schema-content mismatches).
136
+ */
137
+ export class TypedDocTypeMismatchError extends Error {
138
+ readonly docId: string;
139
+ readonly expectedType: string;
140
+ readonly actualType: string | undefined;
141
+ constructor(docId: string, expectedType: string, actualType: string | undefined) {
142
+ super(
143
+ `Typed write on document ${docId} expected type "${expectedType}" but stored type is ${
144
+ actualType === undefined ? "(none)" : `"${actualType}"`
145
+ }`,
146
+ );
147
+ this.name = "TypedDocTypeMismatchError";
148
+ this.docId = docId;
149
+ this.expectedType = expectedType;
150
+ this.actualType = actualType;
151
+ }
152
+ }
153
+
154
+ // ── Internal: shared projection helper ──────────────────────────────────────
155
+
156
+ /**
157
+ * Project a raw `TreeEntry` to a `TypedTreeEntry<TMap, N>` if its `type`
158
+ * matches; otherwise return `null`. Used by both `TreeManager.getTyped`
159
+ * and `TypedDocsClient.{get,narrow}` to keep semantics consistent.
160
+ */
161
+ export function projectTreeEntry<
162
+ TMap extends Record<string, unknown>,
163
+ N extends keyof TMap & string,
164
+ >(entry: TreeEntry | null | undefined, expectedType: N):
165
+ TypedTreeEntry<TMap, N> | null
166
+ {
167
+ if (!entry) return null;
168
+ if (entry.type !== expectedType) return null;
169
+ return {
170
+ id: entry.id,
171
+ type: expectedType,
172
+ label: entry.label,
173
+ parentId: entry.parentId,
174
+ order: entry.order,
175
+ meta: entry.meta as (TMap[N] & PageMeta) | undefined,
176
+ createdAt: entry.createdAt,
177
+ updatedAt: entry.updatedAt,
178
+ };
179
+ }