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