@xcitedbs/client 0.3.4 → 0.3.5
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/client.d.ts +18 -1
- package/dist/client.js +45 -9
- package/dist/client.test.js +74 -0
- package/dist/types.d.ts +12 -0
- package/dist/user-isolation.test.js +88 -0
- package/llms-full.txt +52 -0
- package/package.json +1 -1
- package/unquery-ai-guide.md +29 -1
- package/unquery-grammar.md +6 -4
package/dist/client.d.ts
CHANGED
|
@@ -82,8 +82,25 @@ export declare class XCiteDBClient {
|
|
|
82
82
|
private getAppUserId;
|
|
83
83
|
private normalizeIsolationNamespaceTemplate;
|
|
84
84
|
private canonicalId;
|
|
85
|
-
/**
|
|
85
|
+
/** Resolve the configured namespace template for an explicit user id. Returns null if isolation is off, no template, or `userId` is empty. */
|
|
86
|
+
private userIsolationNamespaceFor;
|
|
87
|
+
/** Resolved namespace root for the calling user, e.g. `/users/abc`, or `null` if isolation is off or no app user id. */
|
|
86
88
|
private userIsolationNamespace;
|
|
89
|
+
/**
|
|
90
|
+
* Namespace for the current request's identity context.
|
|
91
|
+
*
|
|
92
|
+
* On a user-workspace branch (`_uw/<owner>/<slug>`) where the calling user is
|
|
93
|
+
* a member but not the owner, returns the *owner's* namespace — docs in the
|
|
94
|
+
* workspace are stored at `<owner_ns>/<rel>`, not at the caller's
|
|
95
|
+
* `<self_ns>/<rel>`. For the owner, non-`_uw/` branches, malformed
|
|
96
|
+
* branches, or when isolation is off, falls back to the calling user's
|
|
97
|
+
* namespace.
|
|
98
|
+
*
|
|
99
|
+
* Mirrors the server-side branch-aware behavior in
|
|
100
|
+
* `UserIsolationService::prefixIdentifier` so the SDK's pre-prefixing
|
|
101
|
+
* lands on the right namespace before the request leaves.
|
|
102
|
+
*/
|
|
103
|
+
private userIsolationContextNamespace;
|
|
87
104
|
private allSharedPassthroughPrefixes;
|
|
88
105
|
private pathMatchesSharedPassthrough;
|
|
89
106
|
private isoPrefixId;
|
package/dist/client.js
CHANGED
|
@@ -185,6 +185,8 @@ class XCiteDBClient {
|
|
|
185
185
|
const sessionBody = {};
|
|
186
186
|
if (opts.overlay === true)
|
|
187
187
|
sessionBody.overlay = true;
|
|
188
|
+
if (opts.additionalKeys !== undefined)
|
|
189
|
+
sessionBody.additional_keys = opts.additionalKeys;
|
|
188
190
|
if (opts.bootstrap !== undefined)
|
|
189
191
|
sessionBody.bootstrap = opts.bootstrap;
|
|
190
192
|
const data = await temp.request('POST', '/api/v1/test/sessions', Object.keys(sessionBody).length ? sessionBody : undefined, undefined, { no401Retry: true });
|
|
@@ -521,13 +523,12 @@ class XCiteDBClient {
|
|
|
521
523
|
}
|
|
522
524
|
return t;
|
|
523
525
|
}
|
|
524
|
-
/**
|
|
525
|
-
|
|
526
|
+
/** Resolve the configured namespace template for an explicit user id. Returns null if isolation is off, no template, or `userId` is empty. */
|
|
527
|
+
userIsolationNamespaceFor(userId) {
|
|
526
528
|
if (!this.userIsolation?.enabled) {
|
|
527
529
|
return null;
|
|
528
530
|
}
|
|
529
|
-
|
|
530
|
-
if (!uid) {
|
|
531
|
+
if (!userId) {
|
|
531
532
|
return null;
|
|
532
533
|
}
|
|
533
534
|
const rawTpl = this.normalizeIsolationNamespaceTemplate(this.userIsolation.namespace ?? '/users/{userId}');
|
|
@@ -535,7 +536,42 @@ class XCiteDBClient {
|
|
|
535
536
|
if (!trimmed) {
|
|
536
537
|
return null;
|
|
537
538
|
}
|
|
538
|
-
return trimmed.replace(/{userId}/g,
|
|
539
|
+
return trimmed.replace(/{userId}/g, userId).replace(/{user_id}/g, userId);
|
|
540
|
+
}
|
|
541
|
+
/** Resolved namespace root for the calling user, e.g. `/users/abc`, or `null` if isolation is off or no app user id. */
|
|
542
|
+
userIsolationNamespace() {
|
|
543
|
+
return this.userIsolationNamespaceFor(this.getAppUserId());
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Namespace for the current request's identity context.
|
|
547
|
+
*
|
|
548
|
+
* On a user-workspace branch (`_uw/<owner>/<slug>`) where the calling user is
|
|
549
|
+
* a member but not the owner, returns the *owner's* namespace — docs in the
|
|
550
|
+
* workspace are stored at `<owner_ns>/<rel>`, not at the caller's
|
|
551
|
+
* `<self_ns>/<rel>`. For the owner, non-`_uw/` branches, malformed
|
|
552
|
+
* branches, or when isolation is off, falls back to the calling user's
|
|
553
|
+
* namespace.
|
|
554
|
+
*
|
|
555
|
+
* Mirrors the server-side branch-aware behavior in
|
|
556
|
+
* `UserIsolationService::prefixIdentifier` so the SDK's pre-prefixing
|
|
557
|
+
* lands on the right namespace before the request leaves.
|
|
558
|
+
*/
|
|
559
|
+
userIsolationContextNamespace() {
|
|
560
|
+
const selfId = this.getAppUserId();
|
|
561
|
+
const branch = this.defaultContext.workspace ?? this.defaultContext.branch;
|
|
562
|
+
if (branch && branch.startsWith('_uw/')) {
|
|
563
|
+
const m = branch.match(/^_uw\/([^/]+)\/[^/]+$/);
|
|
564
|
+
if (m) {
|
|
565
|
+
const ownerId = m[1];
|
|
566
|
+
if (ownerId && ownerId !== selfId) {
|
|
567
|
+
const ns = this.userIsolationNamespaceFor(ownerId);
|
|
568
|
+
if (ns) {
|
|
569
|
+
return ns;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
return this.userIsolationNamespaceFor(selfId);
|
|
539
575
|
}
|
|
540
576
|
allSharedPassthroughPrefixes() {
|
|
541
577
|
const o = this.userIsolation;
|
|
@@ -564,7 +600,7 @@ class XCiteDBClient {
|
|
|
564
600
|
return false;
|
|
565
601
|
}
|
|
566
602
|
isoPrefixId(id) {
|
|
567
|
-
const ns = this.
|
|
603
|
+
const ns = this.userIsolationContextNamespace();
|
|
568
604
|
if (!ns) {
|
|
569
605
|
return id;
|
|
570
606
|
}
|
|
@@ -589,7 +625,7 @@ class XCiteDBClient {
|
|
|
589
625
|
return finalId;
|
|
590
626
|
}
|
|
591
627
|
isoUnprefixId(id) {
|
|
592
|
-
const ns = this.
|
|
628
|
+
const ns = this.userIsolationContextNamespace();
|
|
593
629
|
if (!ns) {
|
|
594
630
|
return id;
|
|
595
631
|
}
|
|
@@ -606,7 +642,7 @@ class XCiteDBClient {
|
|
|
606
642
|
return canonical;
|
|
607
643
|
}
|
|
608
644
|
isoPrefixQuery(q) {
|
|
609
|
-
if (!this.
|
|
645
|
+
if (!this.userIsolationContextNamespace()) {
|
|
610
646
|
return q;
|
|
611
647
|
}
|
|
612
648
|
const out = { ...q };
|
|
@@ -622,7 +658,7 @@ class XCiteDBClient {
|
|
|
622
658
|
return out;
|
|
623
659
|
}
|
|
624
660
|
isoApplyXmlDbIdentifier(xml) {
|
|
625
|
-
if (!this.
|
|
661
|
+
if (!this.userIsolationContextNamespace()) {
|
|
626
662
|
return xml;
|
|
627
663
|
}
|
|
628
664
|
return xml.replace(/(db:identifier\s*=\s*")([^"]*)(")/, (_m, p1, mid, p3) => {
|
package/dist/client.test.js
CHANGED
|
@@ -32,6 +32,80 @@ const types_js_1 = require("./types.js");
|
|
|
32
32
|
globalThis.fetch = orig;
|
|
33
33
|
}
|
|
34
34
|
});
|
|
35
|
+
(0, node_test_1.it)('user-workspace branch (`_uw/<owner>/<slug>`) prefixes paths with owner namespace, not caller', async () => {
|
|
36
|
+
// Issue B repro at the unit level: an invitee on someone else's user
|
|
37
|
+
// workspace must namespace identifiers to the owner's user_id parsed
|
|
38
|
+
// from the branch, not their own. We assert the outbound URL — SDK
|
|
39
|
+
// routes prefixing through userIsolationContextNamespace() which
|
|
40
|
+
// checks `_uw/<owner>/<slug>` and substitutes the owner.
|
|
41
|
+
const seen = [];
|
|
42
|
+
const orig = globalThis.fetch;
|
|
43
|
+
globalThis.fetch = node_test_1.mock.fn(async (input) => {
|
|
44
|
+
seen.push(String(input));
|
|
45
|
+
return new Response(JSON.stringify({ ok: true, _xcite_json_doc: true }), { status: 200 });
|
|
46
|
+
});
|
|
47
|
+
try {
|
|
48
|
+
const c = new client_js_1.XCiteDBClient({
|
|
49
|
+
baseUrl: 'http://127.0.0.1:9',
|
|
50
|
+
apiKey: 'test-key',
|
|
51
|
+
userIsolation: { enabled: true, namespace: '/users/{userId}' },
|
|
52
|
+
});
|
|
53
|
+
// sub claim = "user-B"
|
|
54
|
+
c.setAppUserTokens('header.eyJzdWIiOiJ1c2VyLUIifQ.sig');
|
|
55
|
+
// Owner of the workspace is "user-A"; we are "user-B".
|
|
56
|
+
c.setContext({ branch: '_uw/user-A/shared', project_id: 'p1' });
|
|
57
|
+
await c.readJsonDocument('/drafts/x');
|
|
58
|
+
strict_1.default.equal(seen.length, 1);
|
|
59
|
+
strict_1.default.match(seen[0], /identifier=%2Fusers%2Fuser-A%2Fdrafts%2Fx/, `expected identifier prefixed with owner ns, got: ${seen[0]}`);
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
globalThis.fetch = orig;
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
(0, node_test_1.it)('non-`_uw` branch keeps caller-namespace prefixing (regression guard)', async () => {
|
|
66
|
+
const seen = [];
|
|
67
|
+
const orig = globalThis.fetch;
|
|
68
|
+
globalThis.fetch = node_test_1.mock.fn(async (input) => {
|
|
69
|
+
seen.push(String(input));
|
|
70
|
+
return new Response(JSON.stringify({ ok: true, _xcite_json_doc: true }), { status: 200 });
|
|
71
|
+
});
|
|
72
|
+
try {
|
|
73
|
+
const c = new client_js_1.XCiteDBClient({
|
|
74
|
+
baseUrl: 'http://127.0.0.1:9',
|
|
75
|
+
apiKey: 'test-key',
|
|
76
|
+
userIsolation: { enabled: true, namespace: '/users/{userId}' },
|
|
77
|
+
});
|
|
78
|
+
c.setAppUserTokens('header.eyJzdWIiOiJ1c2VyLUIifQ.sig');
|
|
79
|
+
c.setContext({ branch: 'main', project_id: 'p1' });
|
|
80
|
+
await c.readJsonDocument('/drafts/x');
|
|
81
|
+
strict_1.default.match(seen[0], /identifier=%2Fusers%2Fuser-B%2Fdrafts%2Fx/, `expected identifier prefixed with caller ns, got: ${seen[0]}`);
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
globalThis.fetch = orig;
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
(0, node_test_1.it)('owner of `_uw/<self>/<slug>` workspace uses own namespace (no-op for owner)', async () => {
|
|
88
|
+
const seen = [];
|
|
89
|
+
const orig = globalThis.fetch;
|
|
90
|
+
globalThis.fetch = node_test_1.mock.fn(async (input) => {
|
|
91
|
+
seen.push(String(input));
|
|
92
|
+
return new Response(JSON.stringify({ ok: true, _xcite_json_doc: true }), { status: 200 });
|
|
93
|
+
});
|
|
94
|
+
try {
|
|
95
|
+
const c = new client_js_1.XCiteDBClient({
|
|
96
|
+
baseUrl: 'http://127.0.0.1:9',
|
|
97
|
+
apiKey: 'test-key',
|
|
98
|
+
userIsolation: { enabled: true, namespace: '/users/{userId}' },
|
|
99
|
+
});
|
|
100
|
+
c.setAppUserTokens('header.eyJzdWIiOiJ1c2VyLUEifQ.sig'); // user-A
|
|
101
|
+
c.setContext({ branch: '_uw/user-A/shared', project_id: 'p1' });
|
|
102
|
+
await c.readJsonDocument('/drafts/x');
|
|
103
|
+
strict_1.default.match(seen[0], /identifier=%2Fusers%2Fuser-A%2Fdrafts%2Fx/, `expected owner to keep own ns, got: ${seen[0]}`);
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
globalThis.fetch = orig;
|
|
107
|
+
}
|
|
108
|
+
});
|
|
35
109
|
(0, node_test_1.it)('queryByIdentifier / queryDocuments return XML bodies unmodified under userIsolation', async () => {
|
|
36
110
|
const xml = '<?xml version="1.0"?><doc xmlns:db="http://www.xcitedb.com/schema"><a href="https://example.com//path">x</a></doc>';
|
|
37
111
|
const orig = globalThis.fetch;
|
package/dist/types.d.ts
CHANGED
|
@@ -761,6 +761,18 @@ export interface CreateTestSessionOptions {
|
|
|
761
761
|
* When true, creates an overlay test session: writable ephemeral LMDB with production project data as read-only base.
|
|
762
762
|
*/
|
|
763
763
|
overlay?: boolean;
|
|
764
|
+
/**
|
|
765
|
+
* API keys to snapshot into the new session's config DB so requests carrying any of these keys
|
|
766
|
+
* (alongside `X-Test-Session: <token>`) authenticate against the session, not production.
|
|
767
|
+
*
|
|
768
|
+
* Typical use: a backend-for-frontend (BFF) that performs admin-scoped writes on behalf of the
|
|
769
|
+
* SPA. Without registering the BFF's admin key here, the BFF would receive `401
|
|
770
|
+
* test_session_unknown_api_key` when forwarding `X-Test-Session` to XCiteDB.
|
|
771
|
+
*
|
|
772
|
+
* Each entry must be a valid production API key on the project hosting the session — otherwise
|
|
773
|
+
* the server returns `400 additional_key_invalid` (with the offending index) and aborts.
|
|
774
|
+
*/
|
|
775
|
+
additionalKeys?: string[];
|
|
764
776
|
/** Optional server-side bootstrap (user isolation, developer_bypass, policies, triggers). */
|
|
765
777
|
bootstrap?: TestSessionBootstrap;
|
|
766
778
|
/**
|
|
@@ -172,4 +172,92 @@ wd('user isolation (wet)', () => {
|
|
|
172
172
|
await admin.disableUserIsolation().catch(() => { });
|
|
173
173
|
}
|
|
174
174
|
});
|
|
175
|
+
(0, node_test_1.it)('user-workspace branch: invitee reads owner-namespaced docs through self-namespace API', async () => {
|
|
176
|
+
// Issue B repro: when user B is granted readwrite on user A's user
|
|
177
|
+
// workspace and reads a doc on the workspace branch, the SDK must
|
|
178
|
+
// namespace the path with A's user_id (parsed from the `_uw/<owner>/<slug>`
|
|
179
|
+
// branch), not B's. Server-side prefixIdentifier already passes the
|
|
180
|
+
// identifier through unchanged for workspace members; the SDK must put
|
|
181
|
+
// the owner's namespace on the path before sending.
|
|
182
|
+
const e = wetEnv();
|
|
183
|
+
if (!e)
|
|
184
|
+
throw new Error('missing env');
|
|
185
|
+
const admin = adminClient(e);
|
|
186
|
+
const suffix = (0, node_crypto_1.randomUUID)().slice(0, 8);
|
|
187
|
+
const emailA = `js_uwA_${suffix}@apitest.invalid`;
|
|
188
|
+
const emailB = `js_uwB_${suffix}@apitest.invalid`;
|
|
189
|
+
const password = `Js_${suffix}!aA1`;
|
|
190
|
+
const slug = `js-uw-doc-${suffix}`;
|
|
191
|
+
const wsName = `shared-${suffix}`;
|
|
192
|
+
let userA;
|
|
193
|
+
let userB;
|
|
194
|
+
let workspaceId;
|
|
195
|
+
let workspaceBranch;
|
|
196
|
+
try {
|
|
197
|
+
await admin.setUserIsolationConfig({
|
|
198
|
+
enabled: true,
|
|
199
|
+
namespace_pattern: '/users/${user.id}',
|
|
200
|
+
});
|
|
201
|
+
userA = await admin.createAppUser(emailA, password, undefined, [
|
|
202
|
+
client_js_1.XCiteDBClient.buildProjectGroup(e.tenantId, 'editor'),
|
|
203
|
+
]);
|
|
204
|
+
userB = await admin.createAppUser(emailB, password, undefined, [
|
|
205
|
+
client_js_1.XCiteDBClient.buildProjectGroup(e.tenantId, 'editor'),
|
|
206
|
+
]);
|
|
207
|
+
const appA = new client_js_1.XCiteDBClient({
|
|
208
|
+
baseUrl: e.baseUrl,
|
|
209
|
+
context: { branch: 'main', project_id: e.tenantId },
|
|
210
|
+
userIsolation: { enabled: true },
|
|
211
|
+
});
|
|
212
|
+
await appA.loginAppUser(emailA, password);
|
|
213
|
+
// A creates the workspace, captures the branch from the response,
|
|
214
|
+
// grants B readwrite, and writes a doc on the workspace branch.
|
|
215
|
+
const ws = (await appA.createUserWorkspace(wsName));
|
|
216
|
+
strict_1.default.ok(ws.id, 'workspace id missing');
|
|
217
|
+
strict_1.default.ok(ws.branch && ws.branch.startsWith('_uw/'), 'workspace branch missing');
|
|
218
|
+
workspaceId = ws.id;
|
|
219
|
+
workspaceBranch = ws.branch;
|
|
220
|
+
strict_1.default.equal(workspaceBranch, `_uw/${userA.user_id}/${slugifyForAssert(wsName)}`, 'branch should be _uw/<ownerId>/<slug>');
|
|
221
|
+
await appA.addUserWorkspaceGrant(workspaceId, { user_id: userB.user_id, mode: 'rw' });
|
|
222
|
+
appA.setContext({ branch: workspaceBranch });
|
|
223
|
+
await appA.writeJsonDocument(`/${slug}`, { _xcite_json_doc: true, who: 'A', v: 1 });
|
|
224
|
+
// B opens the workspace branch and reads the doc using the relative
|
|
225
|
+
// identifier — must resolve to A's namespace, not B's.
|
|
226
|
+
const appB = new client_js_1.XCiteDBClient({
|
|
227
|
+
baseUrl: e.baseUrl,
|
|
228
|
+
context: { branch: workspaceBranch, project_id: e.tenantId },
|
|
229
|
+
userIsolation: { enabled: true },
|
|
230
|
+
});
|
|
231
|
+
await appB.loginAppUser(emailB, password);
|
|
232
|
+
const doc = await appB.readJsonDocument(`/${slug}`);
|
|
233
|
+
strict_1.default.equal(doc.who, 'A');
|
|
234
|
+
strict_1.default.equal(doc.v, 1);
|
|
235
|
+
// List on the workspace branch — identifiers must come back stripped
|
|
236
|
+
// of the owner's namespace (isoUnprefixId symmetry).
|
|
237
|
+
const list = await appB.listJsonDocuments(`/${slug.split('-')[0]}`);
|
|
238
|
+
const ids = (list.identifiers ?? []);
|
|
239
|
+
strict_1.default.ok(ids.includes(`/${slug}`), `expected response to include /${slug}, got ${JSON.stringify(ids)}`);
|
|
240
|
+
}
|
|
241
|
+
finally {
|
|
242
|
+
if (workspaceId) {
|
|
243
|
+
await admin
|
|
244
|
+
.deleteJsonDocument(`/users/${userA?.user_id ?? ''}/${slug}`)
|
|
245
|
+
.catch(() => { });
|
|
246
|
+
await admin.deleteUserWorkspace(workspaceId).catch(() => { });
|
|
247
|
+
}
|
|
248
|
+
if (userB)
|
|
249
|
+
await admin.deleteAppUser(userB.user_id).catch(() => { });
|
|
250
|
+
if (userA)
|
|
251
|
+
await admin.deleteAppUser(userA.user_id).catch(() => { });
|
|
252
|
+
await admin.disableUserIsolation().catch(() => { });
|
|
253
|
+
}
|
|
254
|
+
});
|
|
175
255
|
});
|
|
256
|
+
// Mirrors UserWorkspaceService::slugify (lowercase, runs of non-alphanumerics → '-', trim '-').
|
|
257
|
+
function slugifyForAssert(name) {
|
|
258
|
+
return name
|
|
259
|
+
.toLowerCase()
|
|
260
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
261
|
+
.replace(/^-+|-+$/g, '')
|
|
262
|
+
.slice(0, 64);
|
|
263
|
+
}
|
package/llms-full.txt
CHANGED
|
@@ -377,6 +377,58 @@ TEST_CASE("XciteDB XML document round-trip") {
|
|
|
377
377
|
}
|
|
378
378
|
```
|
|
379
379
|
|
|
380
|
+
## Overlay sessions with a backend-for-frontend (BFF)
|
|
381
|
+
|
|
382
|
+
When a single-page app is in an overlay test session and an admin-scoped action is delegated to a server-side BFF (e.g. group grants, project-editor enrolment, anything the SPA cannot do with its own credentials), the BFF must route through the **same overlay** — otherwise admin writes land in production while the SPA still reads from the overlay, and the SPA sees stale state.
|
|
383
|
+
|
|
384
|
+
The mechanism is the same `X-Test-Session` header used for SPA requests, plus pre-registering the BFF's admin key on the session at create time so the server accepts it.
|
|
385
|
+
|
|
386
|
+
1. **Pre-register the BFF admin key** when the SPA creates the session (SDK option `additionalKeys`, wire field `additional_keys`):
|
|
387
|
+
|
|
388
|
+
```typescript
|
|
389
|
+
const client = await XCiteDBClient.createTestSession({
|
|
390
|
+
baseUrl, apiKey: SPA_PUBLIC_KEY,
|
|
391
|
+
overlay: true,
|
|
392
|
+
additionalKeys: [BFF_ADMIN_KEY], // snapshotted into the session config
|
|
393
|
+
bootstrap: { user_isolation: { /* ... */ } },
|
|
394
|
+
});
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
Each entry is a production API key the BFF will use. The server snapshots each into the session's config DB so requests carrying that key alongside `X-Test-Session: <token>` authenticate against the session, not production. Without this, the BFF gets `401 test_session_unknown_api_key`. An invalid key (one not present in production for the session's project) returns `400 additional_key_invalid` with the offending array index, and the session is rolled back.
|
|
398
|
+
|
|
399
|
+
2. **SPA forwards `X-Test-Session` to the BFF** as a normal request header:
|
|
400
|
+
|
|
401
|
+
```typescript
|
|
402
|
+
const r = await fetch('/api/app/grant-editor', {
|
|
403
|
+
method: 'POST',
|
|
404
|
+
headers: { 'X-Test-Session': client.testSessionToken ?? '' },
|
|
405
|
+
body: JSON.stringify({ user_id: u.user_id }),
|
|
406
|
+
});
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
3. **BFF reads the inbound header and propagates it on its outbound XCiteDB call.** With the JS SDK, instantiate a per-request admin client carrying the test-session token — don't reuse a long-lived admin client, since the same BFF process serves both real and test traffic:
|
|
410
|
+
|
|
411
|
+
```typescript
|
|
412
|
+
app.post('/api/app/grant-editor', async (req, res) => {
|
|
413
|
+
const testSession = req.header('X-Test-Session') || undefined;
|
|
414
|
+
const admin = new XCiteDBClient({
|
|
415
|
+
baseUrl: process.env.XCITEDB_URL!,
|
|
416
|
+
apiKey: process.env.XCITEDB_BFF_KEY!, // == BFF_ADMIN_KEY
|
|
417
|
+
testSessionToken: testSession, // undefined ⇒ hits production as today
|
|
418
|
+
});
|
|
419
|
+
await admin.updateAppUserGroups(req.body.user_id, [
|
|
420
|
+
XCiteDBClient.buildProjectGroup(process.env.XCITEDB_PROJECT_ID!, 'editor'),
|
|
421
|
+
]);
|
|
422
|
+
res.status(204).end();
|
|
423
|
+
});
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
The same shape works in any HTTP framework — the only requirement is propagating `X-Test-Session` from inbound to outbound. Production traffic doesn't carry the header, so `testSessionToken` falls back to `undefined` and the admin client behaves as before.
|
|
427
|
+
|
|
428
|
+
**Verification.** With this wired up, an end-to-end test that registers an app user, calls the BFF endpoint, then reads the user's groups through the SPA's overlay client should see the freshly-granted group on the next read. Without the propagation, the read returns the user's groups as they were at session creation (typically empty) and admin-gated routes return `403`.
|
|
429
|
+
|
|
430
|
+
The behavior is bolted to two server pieces: `additional_keys` snapshotting in `TestController::create` (`src/controllers/TestController.cpp`) and the test-session header dispatch in `AuthFilterShared` (`src/filters/AuthFilterShared.cpp`) — there's no separate "attach admin client to existing session" API; the snapshot + header pair is sufficient.
|
|
431
|
+
|
|
380
432
|
---
|
|
381
433
|
|
|
382
434
|
# Health, version & discovery
|
package/package.json
CHANGED
package/unquery-ai-guide.md
CHANGED
|
@@ -85,7 +85,7 @@ A path selects a value from the current context.
|
|
|
85
85
|
| `[n]` | Array index (0-based) | `"Dependants[0].FirstName"` |
|
|
86
86
|
| `[expr]` | Computed index | `"Field1[$index+1]"` |
|
|
87
87
|
| `[]` | Whole array (projection over all elements) | `"Array1[].Field1"` |
|
|
88
|
-
| `[a:b]` | Array **slice** in a context modifier (half-open; negative counts from end) | `"scores:[0:3]"`, `"scores
|
|
88
|
+
| `[a:b]` | Array **slice** in a context modifier or path expression (half-open; negative counts from end) | `"scores:[0:3]"`, `"scores[2:5]"`, `"$size(scores[:3])"` |
|
|
89
89
|
| `/Field` | Absolute from document root | `"/employees.$(.)"` |
|
|
90
90
|
| `../Field` | Up one path level (skips array indices) | `"../DBInstanceIdentifier"` |
|
|
91
91
|
| `<<Field` | Read same field in *previous* document context | `"<<Field1"` after a `->$file(...)` |
|
|
@@ -710,6 +710,34 @@ Allowed only against literals (see §0.8 and §4.8):
|
|
|
710
710
|
|
|
711
711
|
Each recipe shows a small input-shape sketch, the Unquery template, and (where useful) the equivalent jq one-liner for cross-reference.
|
|
712
712
|
|
|
713
|
+
### 10.0 Materializing many rows from a prefix walk — pick a multi-row shape
|
|
714
|
+
|
|
715
|
+
Common gotcha for prefix-walk queries (`{ match_start: '/index/users/' }` and similar) feeding a search dropdown or candidate list: if the template root is a **single object**, every document iteration *overwrites* the same fields and you keep only the **last** match's values.
|
|
716
|
+
|
|
717
|
+
`{ "name": "name", "email": "email" }` — *wrong* for multi-row output. Returns one document's fields, not a list. (See §1's "evaluate once vs. per pass".)
|
|
718
|
+
|
|
719
|
+
Three correct shapes for "give me one row per matching document":
|
|
720
|
+
|
|
721
|
+
**Array of objects** (most common — preserves order, easy to iterate client-side):
|
|
722
|
+
|
|
723
|
+
```json
|
|
724
|
+
[{ "id": "$identifier", "name": "name", "email": "email" }]
|
|
725
|
+
```
|
|
726
|
+
|
|
727
|
+
**Map keyed by identifier** (constant-time lookup by id, no duplicates):
|
|
728
|
+
|
|
729
|
+
```json
|
|
730
|
+
{ "$($identifier)": { "name": "name", "email": "email" } }
|
|
731
|
+
```
|
|
732
|
+
|
|
733
|
+
**Array of identifiers only** (just need the keys, e.g. for a count or follow-up reads):
|
|
734
|
+
|
|
735
|
+
```json
|
|
736
|
+
["$identifier"]
|
|
737
|
+
```
|
|
738
|
+
|
|
739
|
+
The bare-object form is correct *only* when the prefix narrows to exactly one document or you genuinely want "last match wins" (e.g. picking a single config row). For dropdowns, search results, member lists, or any "show me everything matching" use case, default to the array-of-objects shape.
|
|
740
|
+
|
|
713
741
|
### 10.1 Pick one field per document → array
|
|
714
742
|
|
|
715
743
|
```json
|
package/unquery-grammar.md
CHANGED
|
@@ -128,7 +128,7 @@ Climbing parser levels:
|
|
|
128
128
|
|--------|-----------|------------|
|
|
129
129
|
| 0 | `+`, `-` | Left via loop: each new rhs parsed with `expression(1)`. |
|
|
130
130
|
| 1 | `*`, `/`, `mod` | Left; rhs parsed with `expression(2)`. |
|
|
131
|
-
| 3 | Postfix `.token`
|
|
131
|
+
| 3 | Postfix `.token`, `[ expr ]`, `[ slice ]` | Postfix chain on current `res`. |
|
|
132
132
|
|
|
133
133
|
Parentheses: `(` `expression(0)` `)` as primary.
|
|
134
134
|
|
|
@@ -138,12 +138,13 @@ Parentheses: `(` `expression(0)` `)` as primary.
|
|
|
138
138
|
expression ::= '(' expression ')'
|
|
139
139
|
| baseExpression ( ( '+' | '-' ) expression(1)
|
|
140
140
|
| ( '*' | '/' | 'mod' ) expression(2) )*
|
|
141
|
-
| baseExpression ( '.' fieldSegment | '[' expression ']' )*
|
|
141
|
+
| baseExpression ( '.' fieldSegment | '[' expression ']' | '[' slice ']' )*
|
|
142
142
|
|
|
143
|
+
slice ::= INT? ':' INT? (* INT may be `-` INT for negative-from-end *)
|
|
143
144
|
fieldSegment ::= IDENT | BACKTICK_STR | '$' IDENT ... (* lexer token after '.'; special case: if token starts with '$', subfield parse backs up — see pathWithBrackets *)
|
|
144
145
|
```
|
|
145
146
|
|
|
146
|
-
**Postfix** (prec ≤ 3): after `consume()` of `.` or `[`, if `[` then parse `expression(0)`
|
|
147
|
+
**Postfix** (prec ≤ 3): after `consume()` of `.` or `[`, if `[` then either (a) parse a slice `INT? ':' INT?` followed by `]` → `TExprSlice(res, …)`, or (b) parse `expression(0)` followed by `]` → `TExprSubfield(res, expr_or_empty, is_index)`. Empty `[]` builds `TExprSubfield` with no expression. Slice detection uses backtracking: a single integer with no `:` falls through to the expression form.
|
|
147
148
|
|
|
148
149
|
### 4.3 `baseExpression` — alternatives (first token disambiguation)
|
|
149
150
|
|
|
@@ -151,7 +152,8 @@ Roughly in parse order:
|
|
|
151
152
|
|
|
152
153
|
| Prefix / form | AST | Notes |
|
|
153
154
|
|-----------------|-----|--------|
|
|
154
|
-
| `[` optional `]` | `TExprSubfield(TExprField("."), expr?, is_index)` | `[]` = whole array on current `.`; `[e]` = index.
|
|
155
|
+
| `[` optional `]` | `TExprSubfield(TExprField("."), expr?, is_index)` | `[]` = whole array on current `.`; `[e]` = index. |
|
|
156
|
+
| `[` (INT)? `:` (INT)? `]` | `TExprSlice(TExprField("."), …)` | Half-open array slice on `.`. Negative ints count from end. Bounds must be integer literals (with optional `-`). Yields a JSON array. |
|
|
155
157
|
| `$` `(` `expression` `)` | `TExprField(expr)` | Evaluate / path from dynamic name (`$()`). |
|
|
156
158
|
| `$if` `(` `condition` `,` `expression` `,` `expression` `)` | `TExprITE` | |
|
|
157
159
|
| `$call` `(` IDENT `)` | `TExprCall(name)` | Zero-arg user function by name. |
|