@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.
- package/dist/abracadabra-provider.cjs +538 -8
- package/dist/abracadabra-provider.cjs.map +1 -1
- package/dist/abracadabra-provider.esm.js +532 -9
- package/dist/abracadabra-provider.esm.js.map +1 -1
- package/dist/index.d.ts +432 -1
- package/package.json +8 -2
- package/src/AbracadabraBaseProvider.ts +28 -0
- package/src/AbracadabraClient.ts +32 -0
- package/src/BackgroundSyncManager.ts +34 -1
- package/src/DocumentManager.ts +70 -0
- package/src/MetaManager.ts +98 -1
- package/src/QueryClient.ts +209 -0
- package/src/SchemaTypes.ts +179 -0
- package/src/TokenManager.ts +329 -0
- package/src/TreeManager.ts +27 -0
- package/src/index.ts +32 -2
|
@@ -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
|
-
|
|
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
|
|
package/src/DocumentManager.ts
CHANGED
|
@@ -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 {
|
package/src/MetaManager.ts
CHANGED
|
@@ -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:
|
|
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
|
+
}
|