@littlebigbrain/client 0.1.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/README.md +127 -0
- package/dist/client.d.ts +764 -0
- package/dist/client.js +878 -0
- package/dist/client.test.d.ts +1 -0
- package/dist/client.test.js +591 -0
- package/dist/contract-routes.test.d.ts +1 -0
- package/dist/contract-routes.test.js +74 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1 -0
- package/dist/schema.d.ts +13864 -0
- package/dist/schema.js +5 -0
- package/package.json +28 -0
- package/src/client.test.ts +673 -0
- package/src/client.ts +1428 -0
- package/src/contract-routes.test.ts +91 -0
- package/src/index.ts +16 -0
- package/src/node-test-shim.d.ts +16 -0
- package/src/schema.ts +13865 -0
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { LbbClient, LbbError, parseSparqlResults, type FetchLike } from "./client.js";
|
|
4
|
+
|
|
5
|
+
type FetchCall = {
|
|
6
|
+
input: string;
|
|
7
|
+
init: { method?: string; headers?: Record<string, string>; body?: string };
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
type FetchResponse = {
|
|
11
|
+
status?: number;
|
|
12
|
+
body?: string;
|
|
13
|
+
headers?: Record<string, string>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function recordingFetch(
|
|
17
|
+
responses: FetchResponse | FetchResponse[] = { status: 200, body: "{}" },
|
|
18
|
+
): { fetch: FetchLike; calls: FetchCall[] } {
|
|
19
|
+
const calls: FetchCall[] = [];
|
|
20
|
+
const queue = Array.isArray(responses) ? [...responses] : [responses];
|
|
21
|
+
const fallback = Array.isArray(responses) ? responses[responses.length - 1] : responses;
|
|
22
|
+
const fetch: FetchLike = async (input, init) => {
|
|
23
|
+
calls.push({ input, init: init ?? {} });
|
|
24
|
+
const next = queue.shift() ?? fallback ?? {};
|
|
25
|
+
const status = next.status ?? 200;
|
|
26
|
+
const headers = new Map(
|
|
27
|
+
Object.entries(next.headers ?? {}).map(([key, value]) => [key.toLowerCase(), value]),
|
|
28
|
+
);
|
|
29
|
+
return {
|
|
30
|
+
ok: status >= 200 && status < 300,
|
|
31
|
+
status,
|
|
32
|
+
headers: { get: (name: string) => headers.get(name.toLowerCase()) ?? null },
|
|
33
|
+
text: async () => next.body ?? "{}",
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
return { fetch, calls };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
test("namespace facts.create injects auth, scope, version, and idempotency", async () => {
|
|
40
|
+
const { fetch, calls } = recordingFetch({
|
|
41
|
+
body: JSON.stringify({
|
|
42
|
+
graph_created: false,
|
|
43
|
+
idempotency_key: "ik_test_1",
|
|
44
|
+
commit: {
|
|
45
|
+
commit_seq: 1,
|
|
46
|
+
visibility_token: "snap:1",
|
|
47
|
+
observation_ids: [],
|
|
48
|
+
entity_ids: [],
|
|
49
|
+
edge_event_ids: [],
|
|
50
|
+
indexed: false,
|
|
51
|
+
idempotent_replay: false,
|
|
52
|
+
no_op: true,
|
|
53
|
+
skipped_edges: [],
|
|
54
|
+
},
|
|
55
|
+
}),
|
|
56
|
+
});
|
|
57
|
+
const client = new LbbClient({
|
|
58
|
+
baseUrl: "http://h:7400/",
|
|
59
|
+
apiKey: "lbb_sk_test_client",
|
|
60
|
+
fetch,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const result = await client.graph("main", { branch: "b" }).facts.create(
|
|
64
|
+
{ triplets: [] },
|
|
65
|
+
{ idempotencyKey: "ik_test_1" },
|
|
66
|
+
);
|
|
67
|
+
assert.equal(result.commit.commit_seq, 1);
|
|
68
|
+
assert.equal(result.commit.no_op, true);
|
|
69
|
+
assert.deepEqual(result.commit.skipped_edges, []);
|
|
70
|
+
|
|
71
|
+
const [call] = calls;
|
|
72
|
+
assert.equal(call.input, "http://h:7400/v1/graph/commit?graph=main&branch=b");
|
|
73
|
+
assert.equal(call.init.method, "POST");
|
|
74
|
+
assert.equal(call.init.headers?.authorization, "Bearer lbb_sk_test_client");
|
|
75
|
+
assert.equal(call.init.headers?.["content-type"], "application/json");
|
|
76
|
+
assert.equal(call.init.headers?.["lbb-version"], "2026-06-22");
|
|
77
|
+
assert.equal(call.init.headers?.["idempotency-key"], "ik_test_1");
|
|
78
|
+
assert.deepEqual(JSON.parse(call.init.body ?? ""), { triplets: [] });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("sparqlText sends row paging fields and exposes row_page", async () => {
|
|
82
|
+
const { fetch, calls } = recordingFetch({
|
|
83
|
+
body: JSON.stringify({
|
|
84
|
+
results: JSON.stringify({ head: { vars: ["s"] }, results: { bindings: [] } }),
|
|
85
|
+
row_page: {
|
|
86
|
+
returned: 50,
|
|
87
|
+
total: 611,
|
|
88
|
+
offset: 100,
|
|
89
|
+
limit: 50,
|
|
90
|
+
has_more: true,
|
|
91
|
+
next_offset: 150,
|
|
92
|
+
},
|
|
93
|
+
}),
|
|
94
|
+
});
|
|
95
|
+
const client = new LbbClient({ baseUrl: "http://h", graph: "main", fetch });
|
|
96
|
+
|
|
97
|
+
const result = await client.sparqlText({
|
|
98
|
+
query: "SELECT ?s WHERE { ?s ?p ?o }",
|
|
99
|
+
limit: 50,
|
|
100
|
+
offset: 100,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
assert.equal(calls[0].input, "http://h/v1/query/sparql-text?graph=main");
|
|
104
|
+
assert.deepEqual(JSON.parse(calls[0].init.body ?? ""), {
|
|
105
|
+
query: "SELECT ?s WHERE { ?s ?p ?o }",
|
|
106
|
+
limit: 50,
|
|
107
|
+
offset: 100,
|
|
108
|
+
});
|
|
109
|
+
assert.equal(result.row_page.returned, 50);
|
|
110
|
+
assert.equal(result.row_page.total, 611);
|
|
111
|
+
assert.equal(result.row_page.next_offset, 150);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("graphEdges scopes and pages an entity's edges", async () => {
|
|
115
|
+
const { fetch, calls } = recordingFetch({
|
|
116
|
+
body: JSON.stringify({
|
|
117
|
+
object: "list",
|
|
118
|
+
data: [],
|
|
119
|
+
has_more: true,
|
|
120
|
+
next_cursor: "300",
|
|
121
|
+
snapshot: {},
|
|
122
|
+
total_count: 782,
|
|
123
|
+
}),
|
|
124
|
+
});
|
|
125
|
+
const client = new LbbClient({ baseUrl: "http://h", graph: "main", fetch });
|
|
126
|
+
|
|
127
|
+
const result = await client.graphEdges({
|
|
128
|
+
type: "PERSON",
|
|
129
|
+
name: "ada",
|
|
130
|
+
direction: "out",
|
|
131
|
+
limit: 150,
|
|
132
|
+
cursor: 150,
|
|
133
|
+
asOfCommitSeq: 42,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const input = calls[0].input;
|
|
137
|
+
assert.match(input, /^http:\/\/h\/v1\/graph\/edges\?/);
|
|
138
|
+
assert.match(input, /type=PERSON/);
|
|
139
|
+
assert.match(input, /name=ada/);
|
|
140
|
+
assert.match(input, /direction=out/);
|
|
141
|
+
assert.match(input, /cursor=150/);
|
|
142
|
+
assert.match(input, /as_of_commit_seq=42/);
|
|
143
|
+
assert.equal(result.total_count, 782);
|
|
144
|
+
assert.equal(result.next_cursor, "300");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("commitDryRun sends dry_run=true and no idempotency key", async () => {
|
|
148
|
+
const { fetch, calls } = recordingFetch({
|
|
149
|
+
body: JSON.stringify({ dry_run: true, op_count: 2, triplets: 1, entity_properties: 0 }),
|
|
150
|
+
});
|
|
151
|
+
const client = new LbbClient({ baseUrl: "http://h", graph: "main", fetch });
|
|
152
|
+
|
|
153
|
+
const result = await client.commitDryRun({ triplets: [] });
|
|
154
|
+
|
|
155
|
+
assert.match(calls[0].input, /dry_run=true/);
|
|
156
|
+
assert.equal((calls[0].init.headers as Record<string, string>)["idempotency-key"], undefined);
|
|
157
|
+
assert.equal(result.dry_run, true);
|
|
158
|
+
assert.equal(result.op_count, 2);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("entities.list projects fields and bulk-fetches ids in one call", async () => {
|
|
162
|
+
const { fetch, calls } = recordingFetch({
|
|
163
|
+
body: JSON.stringify({
|
|
164
|
+
object: "list",
|
|
165
|
+
data: [{ name: "ada", attributes: { title: "VP" } }],
|
|
166
|
+
has_more: false,
|
|
167
|
+
next_cursor: null,
|
|
168
|
+
snapshot: {},
|
|
169
|
+
total_count: 1,
|
|
170
|
+
}),
|
|
171
|
+
});
|
|
172
|
+
const client = new LbbClient({ baseUrl: "http://h", graph: "main", fetch });
|
|
173
|
+
|
|
174
|
+
const page = await client.entities.list({ fields: ["title", "status"], ids: ["abc", "def"] });
|
|
175
|
+
|
|
176
|
+
assert.match(calls[0].input, /fields=title%2Cstatus/);
|
|
177
|
+
assert.match(calls[0].input, /ids=abc%2Cdef/);
|
|
178
|
+
assert.equal(page.total_count, 1);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("entities.filterByAttributes builds structured SPARQL property filters", async () => {
|
|
182
|
+
const { fetch, calls } = recordingFetch({
|
|
183
|
+
body: JSON.stringify({
|
|
184
|
+
vars: ["svc"],
|
|
185
|
+
solutions: [],
|
|
186
|
+
groups: [],
|
|
187
|
+
snapshot: {},
|
|
188
|
+
row_page: { returned: 0, total: 0, offset: 0, limit: 50, has_more: false },
|
|
189
|
+
}),
|
|
190
|
+
});
|
|
191
|
+
const client = new LbbClient({ baseUrl: "http://h", graph: "main", fetch });
|
|
192
|
+
|
|
193
|
+
await client.entities.filterByAttributes({
|
|
194
|
+
patterns: [
|
|
195
|
+
{
|
|
196
|
+
subject: { var: "svc" },
|
|
197
|
+
predicate: "WRITES_TO",
|
|
198
|
+
object: { var: "db" },
|
|
199
|
+
},
|
|
200
|
+
],
|
|
201
|
+
where: [
|
|
202
|
+
{ field: "slo", op: "ge", value: 0.99 },
|
|
203
|
+
{ var: "db", field: "tier", value: "prod" },
|
|
204
|
+
],
|
|
205
|
+
select: ["svc"],
|
|
206
|
+
limit: 25,
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
assert.equal(calls[0].input, "http://h/v1/query/sparql?graph=main");
|
|
210
|
+
assert.deepEqual(JSON.parse(calls[0].init.body ?? ""), {
|
|
211
|
+
patterns: [
|
|
212
|
+
{
|
|
213
|
+
subject: { var: "svc" },
|
|
214
|
+
predicate: "WRITES_TO",
|
|
215
|
+
object: { var: "db" },
|
|
216
|
+
},
|
|
217
|
+
],
|
|
218
|
+
filters: [
|
|
219
|
+
{
|
|
220
|
+
compare: {
|
|
221
|
+
op: "ge",
|
|
222
|
+
left: { property: { var: "svc", field: "slo" } },
|
|
223
|
+
right: { value: { f64: 0.99 } },
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
compare: {
|
|
228
|
+
op: "eq",
|
|
229
|
+
left: { property: { var: "db", field: "tier" } },
|
|
230
|
+
right: { value: { str: "prod" } },
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
],
|
|
234
|
+
select: ["svc"],
|
|
235
|
+
limit: 25,
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("ontologyView opts into per-relation counts only when asked", async () => {
|
|
240
|
+
const { fetch, calls } = recordingFetch({
|
|
241
|
+
body: JSON.stringify({ relation_defs: [] }),
|
|
242
|
+
});
|
|
243
|
+
const client = new LbbClient({ baseUrl: "http://h", graph: "main", fetch });
|
|
244
|
+
|
|
245
|
+
await client.ontologyView();
|
|
246
|
+
await client.ontologyView({ counts: true });
|
|
247
|
+
|
|
248
|
+
assert.equal(calls[0].input, "http://h/v1/ontology?graph=main");
|
|
249
|
+
assert.match(calls[1].input, /^http:\/\/h\/v1\/ontology\?/);
|
|
250
|
+
assert.match(calls[1].input, /counts=true/);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("sparqlRows parses SELECT bindings into typed terms and flat rows", async () => {
|
|
254
|
+
const { fetch } = recordingFetch({
|
|
255
|
+
body: JSON.stringify({
|
|
256
|
+
results: JSON.stringify({
|
|
257
|
+
head: { vars: ["s", "o"] },
|
|
258
|
+
results: {
|
|
259
|
+
bindings: [
|
|
260
|
+
{
|
|
261
|
+
s: { type: "uri", value: "https://littlebigbrain.com/e/a" },
|
|
262
|
+
o: { type: "literal", value: "Acme" },
|
|
263
|
+
},
|
|
264
|
+
// Sparse row: `o` unbound and omitted, per the spec.
|
|
265
|
+
{ s: { type: "uri", value: "https://littlebigbrain.com/e/b" } },
|
|
266
|
+
],
|
|
267
|
+
},
|
|
268
|
+
}),
|
|
269
|
+
row_page: { returned: 2, total: 2, offset: 0, limit: 50, has_more: false },
|
|
270
|
+
}),
|
|
271
|
+
});
|
|
272
|
+
const client = new LbbClient({ baseUrl: "http://h", graph: "main", fetch });
|
|
273
|
+
|
|
274
|
+
const result = await client.sparqlRows({ query: "SELECT ?s ?o WHERE { ?s ?p ?o }" });
|
|
275
|
+
assert.deepEqual(result.vars, ["s", "o"]);
|
|
276
|
+
assert.equal(result.boolean, null);
|
|
277
|
+
assert.equal(result.bindings.length, 2);
|
|
278
|
+
assert.equal(result.bindings[0].s.type, "uri");
|
|
279
|
+
assert.deepEqual(result.rows, [
|
|
280
|
+
{ s: "https://littlebigbrain.com/e/a", o: "Acme" },
|
|
281
|
+
{ s: "https://littlebigbrain.com/e/b" },
|
|
282
|
+
]);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("parseSparqlResults surfaces the ASK boolean", () => {
|
|
286
|
+
const parsed = parseSparqlResults({
|
|
287
|
+
results: JSON.stringify({ head: {}, boolean: true }),
|
|
288
|
+
row_page: { returned: 0, total: 0, offset: 0, limit: 0, has_more: false },
|
|
289
|
+
});
|
|
290
|
+
assert.equal(parsed.boolean, true);
|
|
291
|
+
assert.deepEqual(parsed.rows, []);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("search.hybrid encodes the v1 GET search route", async () => {
|
|
295
|
+
const { fetch, calls } = recordingFetch();
|
|
296
|
+
const client = new LbbClient({ baseUrl: "http://h", fetch });
|
|
297
|
+
await client.search.hybrid("customer identity", {
|
|
298
|
+
topK: 5,
|
|
299
|
+
source: "persisted",
|
|
300
|
+
consistency: "strong",
|
|
301
|
+
lexical: false,
|
|
302
|
+
bm25: true,
|
|
303
|
+
vector: true,
|
|
304
|
+
targets: ["concepts", "entities"],
|
|
305
|
+
});
|
|
306
|
+
const input = calls[0].input;
|
|
307
|
+
assert.match(input, /^http:\/\/h\/v1\/search\?/);
|
|
308
|
+
assert.match(input, /query=customer%20identity/);
|
|
309
|
+
assert.match(input, /top_k=5/);
|
|
310
|
+
assert.match(input, /source=persisted/);
|
|
311
|
+
assert.match(input, /consistency=strong/);
|
|
312
|
+
assert.match(input, /lexical=false/);
|
|
313
|
+
assert.match(input, /bm25=true/);
|
|
314
|
+
assert.match(input, /vector=true/);
|
|
315
|
+
assert.match(input, /targets=concepts%2Centities/);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("search.feedback posts labels with idempotency", async () => {
|
|
319
|
+
const { fetch, calls } = recordingFetch({
|
|
320
|
+
body: JSON.stringify({
|
|
321
|
+
accepted: 1,
|
|
322
|
+
commit_seq: 2,
|
|
323
|
+
visibility_token: "acme:main:main:0:2",
|
|
324
|
+
feedback_graph: { tenant_id: "acme", graph_id: "__lbb_feedback", branch_id: "main" },
|
|
325
|
+
idempotent_replay: false,
|
|
326
|
+
}),
|
|
327
|
+
});
|
|
328
|
+
const client = new LbbClient({ baseUrl: "http://h", graph: "crm", branch: "main", fetch });
|
|
329
|
+
await client.search.feedback(
|
|
330
|
+
{
|
|
331
|
+
query: "identity records",
|
|
332
|
+
labels: [
|
|
333
|
+
{
|
|
334
|
+
target: {
|
|
335
|
+
kind: "entity",
|
|
336
|
+
entity: { entity_type: "ACCOUNT", name: "Acme" },
|
|
337
|
+
},
|
|
338
|
+
grade: 3,
|
|
339
|
+
},
|
|
340
|
+
],
|
|
341
|
+
},
|
|
342
|
+
{ idempotencyKey: "fb_1" },
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
const [call] = calls;
|
|
346
|
+
assert.equal(call.input, "http://h/v1/search/feedback?graph=crm&branch=main");
|
|
347
|
+
assert.equal(call.init.method, "POST");
|
|
348
|
+
assert.equal(call.init.headers?.["idempotency-key"], "fb_1");
|
|
349
|
+
assert.deepEqual(JSON.parse(call.init.body ?? ""), {
|
|
350
|
+
query: "identity records",
|
|
351
|
+
labels: [
|
|
352
|
+
{
|
|
353
|
+
target: {
|
|
354
|
+
kind: "entity",
|
|
355
|
+
entity: { entity_type: "ACCOUNT", name: "Acme" },
|
|
356
|
+
},
|
|
357
|
+
grade: 3,
|
|
358
|
+
},
|
|
359
|
+
],
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test("search.feedbackExport reads scoped feedback rows", async () => {
|
|
364
|
+
const { fetch, calls } = recordingFetch({
|
|
365
|
+
body: JSON.stringify({ rows: [], counts: {}, graph: {}, feedback_graph: {} }),
|
|
366
|
+
});
|
|
367
|
+
const client = new LbbClient({ baseUrl: "http://h", graph: "crm", branch: "main", fetch });
|
|
368
|
+
await client.search.feedbackExport();
|
|
369
|
+
assert.equal(calls[0].input, "http://h/v1/search/feedback/export?graph=crm&branch=main");
|
|
370
|
+
assert.equal(calls[0].init.method, "GET");
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test("entities namespace encodes list filters", async () => {
|
|
374
|
+
const { fetch, calls } = recordingFetch({ body: JSON.stringify({ entities: [] }) });
|
|
375
|
+
const client = new LbbClient({ baseUrl: "http://h", graph: "main", branch: "b", fetch });
|
|
376
|
+
await client.entities.list({ type: "SERVICE", limit: 10, query: "billing" });
|
|
377
|
+
|
|
378
|
+
const input = calls[0].input;
|
|
379
|
+
assert.match(input, /^http:\/\/h\/v1\/graph\/entities\?/);
|
|
380
|
+
assert.match(input, /graph=main/);
|
|
381
|
+
assert.match(input, /branch=b/);
|
|
382
|
+
assert.match(input, /type=SERVICE/);
|
|
383
|
+
assert.match(input, /limit=10/);
|
|
384
|
+
assert.match(input, /q=billing/);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test("entities namespace encodes detail lookups", async () => {
|
|
388
|
+
const entity = { id: "01", type: "SERVICE", name: "billing-api" };
|
|
389
|
+
const snapshot = { commit_seq: 3, indexed_seq: 0 };
|
|
390
|
+
const { fetch, calls } = recordingFetch({
|
|
391
|
+
body: JSON.stringify({
|
|
392
|
+
snapshot,
|
|
393
|
+
entity,
|
|
394
|
+
attributes: { status: "synced" },
|
|
395
|
+
metadata: {
|
|
396
|
+
snapshot,
|
|
397
|
+
entity,
|
|
398
|
+
last_commit: 3,
|
|
399
|
+
object_hash: "blake3:test",
|
|
400
|
+
object_key: "wal/test.graphwal.zst",
|
|
401
|
+
object_kind: "wal",
|
|
402
|
+
},
|
|
403
|
+
current_state: [],
|
|
404
|
+
outgoing: [],
|
|
405
|
+
incoming: [],
|
|
406
|
+
history: [],
|
|
407
|
+
observations: [],
|
|
408
|
+
}),
|
|
409
|
+
});
|
|
410
|
+
const client = new LbbClient({ baseUrl: "http://h", graph: "main", branch: "b", fetch });
|
|
411
|
+
const detail = await client.entities.detail({ type: "SERVICE", name: "billing-api" });
|
|
412
|
+
|
|
413
|
+
const input = calls[0].input;
|
|
414
|
+
assert.match(input, /^http:\/\/h\/v1\/graph\/entity\?/);
|
|
415
|
+
assert.match(input, /graph=main/);
|
|
416
|
+
assert.match(input, /branch=b/);
|
|
417
|
+
assert.match(input, /type=SERVICE/);
|
|
418
|
+
assert.match(input, /name=billing-api/);
|
|
419
|
+
assert.equal(detail.attributes?.status, "synced");
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
test("schema namespace uses v1 schema routes", async () => {
|
|
423
|
+
const { fetch, calls } = recordingFetch({ body: JSON.stringify({ conforms: true }) });
|
|
424
|
+
const client = new LbbClient({ baseUrl: "http://h", graph: "main", fetch });
|
|
425
|
+
|
|
426
|
+
await client.schema.view();
|
|
427
|
+
await client.schema.view({ audit: true });
|
|
428
|
+
await client.schema.preview({ desired_mode: "warn" });
|
|
429
|
+
await client.schema.publish({ preview_digest: "sha256:test", desired_mode: "warn" });
|
|
430
|
+
await client.schema.audit();
|
|
431
|
+
|
|
432
|
+
assert.equal(calls[0].input, "http://h/v1/schema?graph=main");
|
|
433
|
+
assert.equal(calls[1].input, "http://h/v1/schema?graph=main&audit=true");
|
|
434
|
+
assert.equal(calls[2].input, "http://h/v1/schema/preview?graph=main");
|
|
435
|
+
assert.equal(calls[2].init.method, "POST");
|
|
436
|
+
assert.deepEqual(JSON.parse(calls[2].init.body ?? ""), { desired_mode: "warn" });
|
|
437
|
+
assert.equal(calls[3].input, "http://h/v1/schema/publish?graph=main");
|
|
438
|
+
assert.equal(calls[4].input, "http://h/v1/schema/audit?graph=main");
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
test("creates graph and forks branch with scoped v1 URLs", async () => {
|
|
442
|
+
const { fetch, calls } = recordingFetch();
|
|
443
|
+
const client = new LbbClient({
|
|
444
|
+
baseUrl: "http://h",
|
|
445
|
+
graph: "research",
|
|
446
|
+
branch: "analysis",
|
|
447
|
+
fetch,
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
await client.createGraph();
|
|
451
|
+
await client.createBranch({ from_branch: "main" });
|
|
452
|
+
|
|
453
|
+
assert.equal(calls[0].input, "http://h/v1/graph/create?graph=research&branch=analysis");
|
|
454
|
+
assert.equal(calls[1].input, "http://h/v1/graph/branch?graph=research&branch=analysis");
|
|
455
|
+
assert.equal(calls[1].init.method, "POST");
|
|
456
|
+
assert.deepEqual(JSON.parse(calls[1].init.body ?? ""), { from_branch: "main" });
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
test("rawRequest returns request metadata", async () => {
|
|
460
|
+
const { fetch } = recordingFetch({
|
|
461
|
+
body: JSON.stringify({ ok: true }),
|
|
462
|
+
headers: { "x-request-id": "req_123", "lbb-version": "2026-06-22" },
|
|
463
|
+
});
|
|
464
|
+
const client = new LbbClient({ baseUrl: "http://h", fetch });
|
|
465
|
+
const response = await client.rawRequest<{ ok: boolean }>("GET", "/v1/status");
|
|
466
|
+
|
|
467
|
+
assert.deepEqual(response.data, { ok: true });
|
|
468
|
+
assert.equal(response.status, 200);
|
|
469
|
+
assert.equal(response.requestId, "req_123");
|
|
470
|
+
assert.equal(response.version, "2026-06-22");
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
test("retries retryable failures", async () => {
|
|
474
|
+
const { fetch, calls } = recordingFetch([
|
|
475
|
+
{ status: 503, body: JSON.stringify({ error: { message: "retry", code: "api_error" } }) },
|
|
476
|
+
{ status: 200, body: JSON.stringify({ ok: true }) },
|
|
477
|
+
]);
|
|
478
|
+
const client = new LbbClient({ baseUrl: "http://h", fetch, maxRetries: 1, retryDelayMs: 0 });
|
|
479
|
+
|
|
480
|
+
await client.status();
|
|
481
|
+
assert.equal(calls.length, 2);
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
test("does not retry unsafe writes without an idempotency key", async () => {
|
|
485
|
+
const { fetch, calls } = recordingFetch([
|
|
486
|
+
{ status: 503, body: JSON.stringify({ error: { message: "retry", code: "api_error" } }) },
|
|
487
|
+
{ status: 200, body: JSON.stringify({ ok: true }) },
|
|
488
|
+
]);
|
|
489
|
+
const client = new LbbClient({ baseUrl: "http://h", fetch, maxRetries: 1, retryDelayMs: 0 });
|
|
490
|
+
|
|
491
|
+
await assert.rejects(() => client.graph("main").create());
|
|
492
|
+
assert.equal(calls.length, 1);
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
test("retries idempotency-keyed writes", async () => {
|
|
496
|
+
const { fetch, calls } = recordingFetch([
|
|
497
|
+
{ status: 503, body: JSON.stringify({ error: { message: "retry", code: "api_error" } }) },
|
|
498
|
+
{ status: 200, body: JSON.stringify({ ok: true }) },
|
|
499
|
+
]);
|
|
500
|
+
const client = new LbbClient({ baseUrl: "http://h", fetch, maxRetries: 1, retryDelayMs: 0 });
|
|
501
|
+
|
|
502
|
+
await client.graph("main").facts.create({ triplets: [] }, { idempotencyKey: "retry-safe" });
|
|
503
|
+
assert.equal(calls.length, 2);
|
|
504
|
+
assert.equal(calls[0].init.headers?.["idempotency-key"], "retry-safe");
|
|
505
|
+
assert.equal(calls[1].init.headers?.["idempotency-key"], "retry-safe");
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
test("throws typed LbbError on structured errors", async () => {
|
|
509
|
+
const { fetch } = recordingFetch({
|
|
510
|
+
status: 401,
|
|
511
|
+
body: JSON.stringify({
|
|
512
|
+
error: {
|
|
513
|
+
type: "auth_error",
|
|
514
|
+
code: "unauthorized",
|
|
515
|
+
message: "missing bearer",
|
|
516
|
+
param: null,
|
|
517
|
+
request_id: "req_body",
|
|
518
|
+
doc_url: "https://littlebigbrain.com/errors/unauthorized",
|
|
519
|
+
},
|
|
520
|
+
}),
|
|
521
|
+
});
|
|
522
|
+
const client = new LbbClient({ baseUrl: "http://h", fetch });
|
|
523
|
+
await assert.rejects(
|
|
524
|
+
() => client.status(),
|
|
525
|
+
(error: unknown) =>
|
|
526
|
+
error instanceof LbbError &&
|
|
527
|
+
error.status === 401 &&
|
|
528
|
+
error.code === "unauthorized" &&
|
|
529
|
+
error.type === "auth_error" &&
|
|
530
|
+
error.requestId === "req_body" &&
|
|
531
|
+
error.message === "missing bearer",
|
|
532
|
+
);
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
test("database admin helpers stay on admin routes and bearer auth", async () => {
|
|
536
|
+
const { fetch, calls } = recordingFetch({
|
|
537
|
+
body: JSON.stringify({
|
|
538
|
+
ok: true,
|
|
539
|
+
stack: {
|
|
540
|
+
slug: "acme",
|
|
541
|
+
stack_id: "stk",
|
|
542
|
+
tenant_id: "acme",
|
|
543
|
+
name: "Acme",
|
|
544
|
+
default_graph: "main",
|
|
545
|
+
default_branch: "main",
|
|
546
|
+
created_at_micros: 1,
|
|
547
|
+
api_key_hint: "…",
|
|
548
|
+
api_key_rotated_at_micros: 1,
|
|
549
|
+
},
|
|
550
|
+
api_key: "lbb_sk_test_client",
|
|
551
|
+
}),
|
|
552
|
+
});
|
|
553
|
+
const client = new LbbClient({ baseUrl: "http://h", apiKey: "admin", fetch });
|
|
554
|
+
|
|
555
|
+
await client.adminCreateStack({ owner_id: "acct_1", name: "Acme", slug: "acme" });
|
|
556
|
+
await client.adminRotateStackKey("acme");
|
|
557
|
+
await client.adminStackActivity("acme", "4h");
|
|
558
|
+
await client.stackActivity("1h");
|
|
559
|
+
|
|
560
|
+
assert.equal(calls[0].input, "http://h/api/admin/stacks");
|
|
561
|
+
assert.equal(calls[1].input, "http://h/api/admin/stacks/rotate-key?stack=acme");
|
|
562
|
+
assert.equal(calls[2].input, "http://h/api/admin/stacks/activity?stack=acme&window=4h");
|
|
563
|
+
assert.equal(calls[3].input, "http://h/v1/stack/activity?window=1h");
|
|
564
|
+
assert.equal(calls[0].init.headers?.authorization, "Bearer admin");
|
|
565
|
+
assert.deepEqual(JSON.parse(calls[0].init.body ?? ""), {
|
|
566
|
+
owner_id: "acct_1",
|
|
567
|
+
name: "Acme",
|
|
568
|
+
slug: "acme",
|
|
569
|
+
});
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
test("facts.import serializes lines to NDJSON with batch/strict params", async () => {
|
|
573
|
+
const { fetch, calls } = recordingFetch({
|
|
574
|
+
status: 200,
|
|
575
|
+
body: JSON.stringify({
|
|
576
|
+
graph_created: false,
|
|
577
|
+
lines_read: 2,
|
|
578
|
+
triplets: 1,
|
|
579
|
+
properties: 1,
|
|
580
|
+
batches: 1,
|
|
581
|
+
indexed: false,
|
|
582
|
+
error_count: 0,
|
|
583
|
+
}),
|
|
584
|
+
});
|
|
585
|
+
const client = new LbbClient({ baseUrl: "http://h", graph: "main", fetch });
|
|
586
|
+
const result = await client.graph("main").facts.import(
|
|
587
|
+
[
|
|
588
|
+
{
|
|
589
|
+
source: { type: "Author", name: "Ada", key: "orcid:1" },
|
|
590
|
+
relation: "AFFILIATED_WITH",
|
|
591
|
+
target: { type: "University", name: "Cambridge", key: "ror:1" },
|
|
592
|
+
},
|
|
593
|
+
{ type: "Author", name: "Ada", key: "orcid:1", properties: { h_index: 52 } },
|
|
594
|
+
],
|
|
595
|
+
{ batch: 500, strict: true },
|
|
596
|
+
);
|
|
597
|
+
assert.equal(result.triplets, 1);
|
|
598
|
+
const call = calls[0];
|
|
599
|
+
assert.match(call.input, /\/v1\/graph\/import\?/);
|
|
600
|
+
assert.match(call.input, /batch=500/);
|
|
601
|
+
assert.match(call.input, /strict=true/);
|
|
602
|
+
assert.equal(call.init.headers?.["content-type"], "application/x-ndjson");
|
|
603
|
+
// Body is newline-delimited JSON, one object per line.
|
|
604
|
+
const lines = (call.init.body ?? "").split("\n");
|
|
605
|
+
assert.equal(lines.length, 2);
|
|
606
|
+
assert.equal(JSON.parse(lines[0]).relation, "AFFILIATED_WITH");
|
|
607
|
+
assert.equal(JSON.parse(lines[1]).properties.h_index, 52);
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
test("facts.importRdf posts N-Triples through the native RDF endpoint", async () => {
|
|
611
|
+
const { fetch, calls } = recordingFetch({
|
|
612
|
+
status: 200,
|
|
613
|
+
body: JSON.stringify({
|
|
614
|
+
graph_created: false,
|
|
615
|
+
lines_read: 1,
|
|
616
|
+
triples_read: 1,
|
|
617
|
+
resource_triples: 1,
|
|
618
|
+
literal_triples: 0,
|
|
619
|
+
duplicate_resource_triples: 0,
|
|
620
|
+
duplicate_literal_triples: 0,
|
|
621
|
+
imported_triplets: 1,
|
|
622
|
+
batches: 1,
|
|
623
|
+
indexed: false,
|
|
624
|
+
predicate_count: 1,
|
|
625
|
+
predicates: [],
|
|
626
|
+
error_count: 0,
|
|
627
|
+
}),
|
|
628
|
+
});
|
|
629
|
+
const client = new LbbClient({ baseUrl: "http://h", graph: "main", fetch });
|
|
630
|
+
const body = "<http://ex/s> <http://ex/p> <http://ex/o> .\n";
|
|
631
|
+
const result = await client.graph("main").facts.importRdf(body, {
|
|
632
|
+
batch: 500,
|
|
633
|
+
strict: true,
|
|
634
|
+
resourceType: "RdfResource",
|
|
635
|
+
edgeIdempotency: "append",
|
|
636
|
+
});
|
|
637
|
+
assert.equal(result.imported_triplets, 1);
|
|
638
|
+
const call = calls[0];
|
|
639
|
+
assert.match(call.input, /\/v1\/graph\/import\/rdf\?/);
|
|
640
|
+
assert.match(call.input, /batch=500/);
|
|
641
|
+
assert.match(call.input, /strict=true/);
|
|
642
|
+
assert.match(call.input, /format=ntriples/);
|
|
643
|
+
assert.match(call.input, /resource_type=RdfResource/);
|
|
644
|
+
assert.match(call.input, /edge_idempotency=append/);
|
|
645
|
+
assert.equal(call.init.headers?.["content-type"], "application/n-triples");
|
|
646
|
+
assert.equal(call.init.body, body);
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
test("graph.retract posts edge/entity retractions", async () => {
|
|
650
|
+
const { fetch, calls } = recordingFetch({
|
|
651
|
+
status: 200,
|
|
652
|
+
body: JSON.stringify({
|
|
653
|
+
commit_seq: 4,
|
|
654
|
+
visibility_token: "tok",
|
|
655
|
+
retracted_edges: 1,
|
|
656
|
+
retracted_entities: 0,
|
|
657
|
+
}),
|
|
658
|
+
});
|
|
659
|
+
const client = new LbbClient({ baseUrl: "http://h", graph: "main", fetch });
|
|
660
|
+
const result = await client.graph("main").retract({
|
|
661
|
+
edges: [
|
|
662
|
+
{
|
|
663
|
+
source: { type: "Author", name: "Ada" },
|
|
664
|
+
relation: "AFFILIATED_WITH",
|
|
665
|
+
target: { type: "University", name: "Cambridge" },
|
|
666
|
+
},
|
|
667
|
+
],
|
|
668
|
+
});
|
|
669
|
+
assert.equal(result.retracted_edges, 1);
|
|
670
|
+
assert.match(calls[0].input, /\/v1\/graph\/retract\?graph=main/);
|
|
671
|
+
assert.equal(calls[0].init.method, "POST");
|
|
672
|
+
assert.ok(calls[0].init.headers?.["idempotency-key"], "retract gets an idempotency key");
|
|
673
|
+
});
|