@xcitedbs/client 0.3.8 → 0.3.10

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 CHANGED
@@ -188,6 +188,19 @@ export declare class XCiteDBClient {
188
188
  /** Sets active project for platform console mode (`X-Project-Id`). */
189
189
  setProjectId(projectId: string): void;
190
190
  setContext(ctx: DatabaseContext): void;
191
+ private static mergeContext;
192
+ /**
193
+ * Create a child client that shares transport/auth wiring with this one but has an
194
+ * independent context. Forking does not mutate the parent; child and parent can be used
195
+ * concurrently with different workspaces, dates, or prefixes. Use this in place of
196
+ * `setContext → do work → setContext(prev)`.
197
+ *
198
+ * Auth tokens (api key, access token, app-user tokens) are copied at fork time. Subsequent
199
+ * token rotation via `setTokens` / `setAppUserTokens` (or callbacks) updates only the
200
+ * instance that triggered it; if you fork long-lived clients, keep token state in sync via
201
+ * the `onSessionTokensUpdated` / `onAppUserTokensUpdated` callbacks supplied at construction.
202
+ */
203
+ fork(partial?: Partial<DatabaseContext>): XCiteDBClient;
191
204
  setTokens(access: string, refresh?: string): void;
192
205
  /** End-user (app) tokens. With developer `accessToken`/`apiKey`, sent as `X-App-User-Token`. */
193
206
  setAppUserTokens(access: string, refresh?: string): void;
@@ -541,6 +554,12 @@ export declare class XCiteDBClient {
541
554
  getGroup(id: string): Promise<AppUserGroup>;
542
555
  /** Delete a group (`DELETE /api/v1/security/user-isolation/groups/{id}`). Owner or admin only. */
543
556
  deleteGroup(id: string): Promise<void>;
557
+ /**
558
+ * Rename a group (`PATCH /api/v1/security/user-isolation/groups/{id}`). Owner or admin only.
559
+ * Returns the updated group record. The `group_id` is stable across renames, so existing shares
560
+ * targeted at this group continue to work.
561
+ */
562
+ renameGroup(id: string, name: string): Promise<AppUserGroup>;
544
563
  /**
545
564
  * Add an app user to a group (`POST /api/v1/security/user-isolation/groups/{id}/members`).
546
565
  * Owner or admin only. Returns the updated group record.
@@ -745,7 +764,7 @@ export declare class XCiteDBClient {
745
764
  merge?: MergeResult;
746
765
  }>;
747
766
  /** Send raw XML body (`Content-Type: application/xml`). For JSON wrapper + options use `writeXmlDocument`. */
748
- writeXML(xml: string, _options?: WriteDocumentOptions): Promise<void>;
767
+ writeXML(xml: string, options?: WriteDocumentOptions): Promise<void>;
749
768
  /**
750
769
  * Write an **XML** document using a JSON request body (`xml` field). The identifier is taken from `db:identifier` on the root element.
751
770
  * For storing JSON data by key, use `writeJsonDocument`.
@@ -779,21 +798,27 @@ export declare class XCiteDBClient {
779
798
  * @deprecated Use {@link writeXmlDocument}. This name was misleading: it writes **XML** via a JSON wrapper, not a JSON document.
780
799
  */
781
800
  writeDocumentJson(xml: string, options?: WriteDocumentOptions): Promise<void>;
782
- queryByIdentifier(identifier: string, flags?: Flags, filter?: string, pathFilter?: string): Promise<string[]>;
801
+ queryByIdentifier(identifier: string, flags?: Flags, filter?: string, pathFilter?: string, opts?: {
802
+ context?: Partial<DatabaseContext>;
803
+ }): Promise<string[]>;
783
804
  /**
784
805
  * Shallow read (`flags=NoChildren,KeepIndexNodes,FirstMatch` on `GET /api/v1/documents/by-id`):
785
806
  * root element with inline leaves plus **identifier-bearing stub elements** (`db:stub="true"`) for shredded
786
807
  * child slots instead of opaque `db:N*` placeholders. Safe to round-trip with {@link writeXmlDocument}.
787
808
  * For sidebar / AST navigation, pair with {@link listIdentifierChildren} (e.g. `Promise.all` of shallow + children).
788
809
  */
789
- queryByIdentifierShallow(identifier: string, filter?: string, pathFilter?: string): Promise<string[]>;
810
+ queryByIdentifierShallow(identifier: string, filter?: string, pathFilter?: string, opts?: {
811
+ context?: Partial<DatabaseContext>;
812
+ }): Promise<string[]>;
790
813
  /**
791
814
  * Load a document with all shredded children inlined (`FirstMatch,IncludeChildren` on `GET /by-id`).
792
815
  * The returned array has **length 0 or 1**; when present, index `0` is the full XML string.
793
816
  * Use this for editor round-trips. For navigation without loading the full subtree, use
794
817
  * {@link queryByIdentifierShallow} plus {@link listIdentifierChildren}.
795
818
  */
796
- queryByIdentifierFull(identifier: string, filter?: string, pathFilter?: string): Promise<string[]>;
819
+ queryByIdentifierFull(identifier: string, filter?: string, pathFilter?: string, opts?: {
820
+ context?: Partial<DatabaseContext>;
821
+ }): Promise<string[]>;
797
822
  /** Alias of {@link listIdentifierChildren} — immediate child segments under `parentPath` (identifier hierarchy). */
798
823
  listChildIdentifiers(parentPath?: string): Promise<ListIdentifierChildrenResult>;
799
824
  queryDocuments(query: XCiteQuery, flags?: Flags, filter?: string, pathFilter?: string): Promise<string[]>;
@@ -809,10 +834,12 @@ export declare class XCiteDBClient {
809
834
  addMeta(identifier: string, value: unknown, path?: string, opts?: {
810
835
  mode?: 'set' | 'append' | 'merge_append';
811
836
  overwrite?: boolean;
837
+ context?: Partial<DatabaseContext>;
812
838
  }): Promise<boolean>;
813
839
  addMetaByQuery(query: XCiteQuery, value: unknown, path?: string, firstMatch?: boolean, opts?: {
814
840
  mode?: 'set' | 'append' | 'merge_append';
815
841
  overwrite?: boolean;
842
+ context?: Partial<DatabaseContext>;
816
843
  }): Promise<boolean>;
817
844
  /**
818
845
  * Strict array-extend: `value` must be an array; the stored target at `path` must be an array
@@ -822,9 +849,11 @@ export declare class XCiteDBClient {
822
849
  */
823
850
  appendMeta(identifier: string, value: unknown[], path?: string, opts?: {
824
851
  overwrite?: boolean;
852
+ context?: Partial<DatabaseContext>;
825
853
  }): Promise<boolean>;
826
854
  appendMetaByQuery(query: XCiteQuery, value: unknown[], path?: string, firstMatch?: boolean, opts?: {
827
855
  overwrite?: boolean;
856
+ context?: Partial<DatabaseContext>;
828
857
  }): Promise<boolean>;
829
858
  /**
830
859
  * Deep-extend: walks the payload and extends any nested arrays found in storage. Scalars and
@@ -833,17 +862,31 @@ export declare class XCiteDBClient {
833
862
  */
834
863
  mergeAppendMeta(identifier: string, value: unknown, path?: string, opts?: {
835
864
  overwrite?: boolean;
865
+ context?: Partial<DatabaseContext>;
836
866
  }): Promise<boolean>;
837
867
  mergeAppendMetaByQuery(query: XCiteQuery, value: unknown, path?: string, firstMatch?: boolean, opts?: {
838
868
  overwrite?: boolean;
869
+ context?: Partial<DatabaseContext>;
839
870
  }): Promise<boolean>;
840
871
  /** Convenience: push a single item. Wraps `item` in `[item]` and calls {@link appendMeta}. */
841
- appendItem(identifier: string, item: unknown, path?: string, opts?: {
872
+ appendItem<T = unknown>(identifier: string, item: T, path?: string, opts?: {
842
873
  overwrite?: boolean;
874
+ context?: Partial<DatabaseContext>;
875
+ }): Promise<boolean>;
876
+ /** Convenience: push a single item via query. Wraps `item` in `[item]` and calls {@link appendMetaByQuery}. */
877
+ appendItemByQuery<T = unknown>(query: XCiteQuery, item: T, path?: string, firstMatch?: boolean, opts?: {
878
+ overwrite?: boolean;
879
+ context?: Partial<DatabaseContext>;
880
+ }): Promise<boolean>;
881
+ queryMeta<T = MetaValue>(identifier: string, path?: string, opts?: {
882
+ context?: Partial<DatabaseContext>;
883
+ }): Promise<T>;
884
+ queryMetaByQuery<T = MetaValue>(query: XCiteQuery, path?: string, opts?: {
885
+ context?: Partial<DatabaseContext>;
886
+ }): Promise<T>;
887
+ clearMeta(query: XCiteQuery, opts?: {
888
+ context?: Partial<DatabaseContext>;
843
889
  }): Promise<boolean>;
844
- queryMeta<T = MetaValue>(identifier: string, path?: string): Promise<T>;
845
- queryMetaByQuery<T = MetaValue>(query: XCiteQuery, path?: string): Promise<T>;
846
- clearMeta(query: XCiteQuery): Promise<boolean>;
847
890
  /**
848
891
  * Acquire a cooperative lock. On exclusive conflict the server returns HTTP 409 with a
849
892
  * {@link LockConflictBody} body (thrown as {@link XCiteDBError} with `.status === 409`).
@@ -896,7 +939,10 @@ export declare class XCiteDBClient {
896
939
  * );
897
940
  * ```
898
941
  */
899
- unquery<T = UnqueryResult>(query: XCiteQuery, unquery: UnqueryTemplate): Promise<T>;
942
+ unquery<T = UnqueryResult>(query: XCiteQuery, unquery: UnqueryTemplate, opts?: {
943
+ context?: Partial<DatabaseContext>;
944
+ strict?: boolean;
945
+ }): Promise<T>;
900
946
  search(q: TextSearchQuery): Promise<TextSearchResult>;
901
947
  reindex(): Promise<{
902
948
  status: string;
package/dist/client.js CHANGED
@@ -629,6 +629,15 @@ class XCiteDBClient {
629
629
  if (canonical.startsWith('/users/') && !canonical.startsWith(`${ns}/`) && canonical !== ns) {
630
630
  return canonical;
631
631
  }
632
+ // Tenant-managed share-alias roots are never under any user namespace; pass them through
633
+ // unchanged so app users can read documents shared with them via group / public / anonymous
634
+ // share aliases (server alias paths produced by UserIsolationService::compute*ShareAliasPath).
635
+ if (canonical.startsWith('/groups/') ||
636
+ canonical.startsWith('/shared/') ||
637
+ canonical.startsWith('/shared-readonly/') ||
638
+ canonical.startsWith('/public/')) {
639
+ return canonical;
640
+ }
632
641
  const combined = `${ns}${canonical === '/' ? '/' : canonical}`;
633
642
  const finalId = combined.replace(/\/+/g, '/');
634
643
  if (!finalId.startsWith(ns) || (finalId.length > ns.length && finalId.charAt(ns.length) !== '/')) {
@@ -821,11 +830,33 @@ class XCiteDBClient {
821
830
  this.projectId = projectId;
822
831
  }
823
832
  setContext(ctx) {
824
- const next = { ...this.defaultContext, ...ctx };
825
- if (ctx.workspace !== undefined) {
826
- next.branch = ctx.workspace;
833
+ this.defaultContext = XCiteDBClient.mergeContext(this.defaultContext, ctx);
834
+ }
835
+ static mergeContext(prev, partial) {
836
+ const next = { ...prev, ...partial };
837
+ if (partial.workspace !== undefined) {
838
+ next.branch = partial.workspace;
827
839
  }
828
- this.defaultContext = next;
840
+ return next;
841
+ }
842
+ /**
843
+ * Create a child client that shares transport/auth wiring with this one but has an
844
+ * independent context. Forking does not mutate the parent; child and parent can be used
845
+ * concurrently with different workspaces, dates, or prefixes. Use this in place of
846
+ * `setContext → do work → setContext(prev)`.
847
+ *
848
+ * Auth tokens (api key, access token, app-user tokens) are copied at fork time. Subsequent
849
+ * token rotation via `setTokens` / `setAppUserTokens` (or callbacks) updates only the
850
+ * instance that triggered it; if you fork long-lived clients, keep token state in sync via
851
+ * the `onSessionTokensUpdated` / `onAppUserTokensUpdated` callbacks supplied at construction.
852
+ */
853
+ fork(partial) {
854
+ const child = Object.create(XCiteDBClient.prototype);
855
+ Object.assign(child, this);
856
+ child.defaultContext = XCiteDBClient.mergeContext(this.defaultContext, partial ?? {});
857
+ // Context can shift the user-isolation prefix; the parent's cached app-user id no longer applies.
858
+ child.cachedAppUserId = undefined;
859
+ return child;
829
860
  }
830
861
  setTokens(access, refresh) {
831
862
  this.accessToken = access;
@@ -844,9 +875,9 @@ class XCiteDBClient {
844
875
  this.appUserRefreshToken = undefined;
845
876
  this.clearAppUserIdCache();
846
877
  }
847
- contextHeaders() {
878
+ contextHeaders(override) {
848
879
  const h = {};
849
- const c = this.defaultContext;
880
+ const c = override ? XCiteDBClient.mergeContext(this.defaultContext, override) : this.defaultContext;
850
881
  const ws = c.workspace ?? c.branch;
851
882
  if (ws)
852
883
  h['X-Workspace'] = ws;
@@ -907,11 +938,12 @@ class XCiteDBClient {
907
938
  async request(method, path, body, extraHeaders, opts) {
908
939
  const no401Retry = opts?.no401Retry === true;
909
940
  const suppressTestSessionHeader = opts?.suppressTestSessionHeader === true;
941
+ const contextOverride = opts?.contextOverride;
910
942
  for (let attempt = 0; attempt < 2; attempt++) {
911
943
  const url = joinUrl(this.baseUrl, path);
912
944
  const headers = {
913
945
  ...this.authHeaders(),
914
- ...this.contextHeaders(),
946
+ ...this.contextHeaders(contextOverride),
915
947
  ...(suppressTestSessionHeader ? {} : this.testHeaders()),
916
948
  ...extraHeaders,
917
949
  };
@@ -1773,6 +1805,14 @@ class XCiteDBClient {
1773
1805
  async deleteGroup(id) {
1774
1806
  await this.request('DELETE', `/api/v1/security/user-isolation/groups/${encodeURIComponent(id)}`);
1775
1807
  }
1808
+ /**
1809
+ * Rename a group (`PATCH /api/v1/security/user-isolation/groups/{id}`). Owner or admin only.
1810
+ * Returns the updated group record. The `group_id` is stable across renames, so existing shares
1811
+ * targeted at this group continue to work.
1812
+ */
1813
+ async renameGroup(id, name) {
1814
+ return this.request('PATCH', `/api/v1/security/user-isolation/groups/${encodeURIComponent(id)}`, { name });
1815
+ }
1776
1816
  /**
1777
1817
  * Add an app user to a group (`POST /api/v1/security/user-isolation/groups/{id}/members`).
1778
1818
  * Owner or admin only. Returns the updated group record.
@@ -2059,25 +2099,18 @@ class XCiteDBClient {
2059
2099
  * create a checkpoint, then publish back unless {@link options.autoMerge} is `false`.
2060
2100
  */
2061
2101
  async withWorkspace(workspaceName, fn, options) {
2062
- const prev = { ...this.defaultContext };
2063
- const fromWs = options?.fromBranch ?? prev.branch ?? prev.workspace ?? '';
2064
- let checkpoint;
2102
+ const fromWs = options?.fromBranch ?? this.defaultContext.branch ?? this.defaultContext.workspace ?? '';
2103
+ await this.createWorkspace(workspaceName, fromWs || undefined, this.defaultContext.date || undefined);
2104
+ const child = this.fork({ workspace: workspaceName, branch: workspaceName });
2105
+ const result = await fn(child);
2106
+ const checkpoint = await child.createCheckpoint(options?.message ?? `Workspace ${workspaceName}`, options?.author);
2065
2107
  let publish;
2066
- try {
2067
- await this.createWorkspace(workspaceName, fromWs || undefined, prev.date || undefined);
2068
- this.setContext({ workspace: workspaceName, branch: workspaceName });
2069
- const result = await fn(this);
2070
- checkpoint = await this.createCheckpoint(options?.message ?? `Workspace ${workspaceName}`, options?.author);
2071
- if (options?.autoMerge !== false) {
2072
- publish = await this.publishWorkspace(fromWs, workspaceName, {
2073
- message: options?.message,
2074
- });
2075
- }
2076
- return { result, checkpoint, publish };
2077
- }
2078
- finally {
2079
- this.setContext(prev);
2108
+ if (options?.autoMerge !== false) {
2109
+ publish = await child.publishWorkspace(fromWs, workspaceName, {
2110
+ message: options?.message,
2111
+ });
2080
2112
  }
2113
+ return { result, checkpoint, publish };
2081
2114
  }
2082
2115
  /** @deprecated Use {@link withWorkspace}. */
2083
2116
  async withBranch(branchName, fn, options) {
@@ -2085,11 +2118,9 @@ class XCiteDBClient {
2085
2118
  return { result: r.result, commit: r.checkpoint, merge: r.publish };
2086
2119
  }
2087
2120
  /** Send raw XML body (`Content-Type: application/xml`). For JSON wrapper + options use `writeXmlDocument`. */
2088
- async writeXML(xml, _options) {
2121
+ async writeXML(xml, options) {
2089
2122
  const payload = this.isoApplyXmlDbIdentifier(xml);
2090
- await this.request('POST', '/api/v1/documents', payload, {
2091
- 'Content-Type': 'application/xml',
2092
- });
2123
+ await this.request('POST', '/api/v1/documents', payload, { 'Content-Type': 'application/xml' }, { contextOverride: options?.context });
2093
2124
  }
2094
2125
  /**
2095
2126
  * Write an **XML** document using a JSON request body (`xml` field). The identifier is taken from `db:identifier` on the root element.
@@ -2106,7 +2137,7 @@ class XCiteDBClient {
2106
2137
  xml: this.isoApplyXmlDbIdentifier(xml),
2107
2138
  is_top: options?.is_top ?? true,
2108
2139
  compare_attributes: options?.compare_attributes ?? false,
2109
- });
2140
+ }, undefined, { contextOverride: options?.context });
2110
2141
  }
2111
2142
  /**
2112
2143
  * Best-effort batch XML writes (`POST /api/v1/documents/batch`). Each item is independent; check `results[].ok`.
@@ -2186,14 +2217,16 @@ class XCiteDBClient {
2186
2217
  async writeDocumentJson(xml, options) {
2187
2218
  return this.writeXmlDocument(xml, options);
2188
2219
  }
2189
- async queryByIdentifier(identifier, flags, filter, pathFilter) {
2220
+ async queryByIdentifier(identifier, flags, filter, pathFilter, opts) {
2190
2221
  const q = buildQuery({
2191
2222
  identifier: this.isoPrefixId(identifier),
2192
2223
  flags: flags,
2193
2224
  filter,
2194
2225
  path_filter: pathFilter,
2195
2226
  });
2196
- return this.request('GET', `/api/v1/documents/by-id${q}`);
2227
+ return this.request('GET', `/api/v1/documents/by-id${q}`, undefined, undefined, {
2228
+ contextOverride: opts?.context,
2229
+ });
2197
2230
  }
2198
2231
  /**
2199
2232
  * Shallow read (`flags=NoChildren,KeepIndexNodes,FirstMatch` on `GET /api/v1/documents/by-id`):
@@ -2201,8 +2234,8 @@ class XCiteDBClient {
2201
2234
  * child slots instead of opaque `db:N*` placeholders. Safe to round-trip with {@link writeXmlDocument}.
2202
2235
  * For sidebar / AST navigation, pair with {@link listIdentifierChildren} (e.g. `Promise.all` of shallow + children).
2203
2236
  */
2204
- async queryByIdentifierShallow(identifier, filter, pathFilter) {
2205
- return this.queryByIdentifier(identifier, 'NoChildren,KeepIndexNodes,FirstMatch', filter, pathFilter);
2237
+ async queryByIdentifierShallow(identifier, filter, pathFilter, opts) {
2238
+ return this.queryByIdentifier(identifier, 'NoChildren,KeepIndexNodes,FirstMatch', filter, pathFilter, opts);
2206
2239
  }
2207
2240
  /**
2208
2241
  * Load a document with all shredded children inlined (`FirstMatch,IncludeChildren` on `GET /by-id`).
@@ -2210,8 +2243,8 @@ class XCiteDBClient {
2210
2243
  * Use this for editor round-trips. For navigation without loading the full subtree, use
2211
2244
  * {@link queryByIdentifierShallow} plus {@link listIdentifierChildren}.
2212
2245
  */
2213
- async queryByIdentifierFull(identifier, filter, pathFilter) {
2214
- return this.queryByIdentifier(identifier, 'FirstMatch,IncludeChildren', filter, pathFilter);
2246
+ async queryByIdentifierFull(identifier, filter, pathFilter, opts) {
2247
+ return this.queryByIdentifier(identifier, 'FirstMatch,IncludeChildren', filter, pathFilter, opts);
2215
2248
  }
2216
2249
  /** Alias of {@link listIdentifierChildren} — immediate child segments under `parentPath` (identifier hierarchy). */
2217
2250
  async listChildIdentifiers(parentPath) {
@@ -2361,7 +2394,9 @@ class XCiteDBClient {
2361
2394
  body.mode = opts.mode;
2362
2395
  if (opts?.overwrite)
2363
2396
  body.overwrite = true;
2364
- const r = await this.request('POST', '/api/v1/meta', body);
2397
+ const r = await this.request('POST', '/api/v1/meta', body, undefined, {
2398
+ contextOverride: opts?.context,
2399
+ });
2365
2400
  return r?.ok !== false;
2366
2401
  }
2367
2402
  async addMetaByQuery(query, value, path = '', firstMatch = false, opts) {
@@ -2375,7 +2410,9 @@ class XCiteDBClient {
2375
2410
  body.mode = opts.mode;
2376
2411
  if (opts?.overwrite)
2377
2412
  body.overwrite = true;
2378
- const r = await this.request('POST', '/api/v1/meta', body);
2413
+ const r = await this.request('POST', '/api/v1/meta', body, undefined, {
2414
+ contextOverride: opts?.context,
2415
+ });
2379
2416
  return r?.ok !== false;
2380
2417
  }
2381
2418
  /**
@@ -2405,16 +2442,18 @@ class XCiteDBClient {
2405
2442
  async appendItem(identifier, item, path = '', opts) {
2406
2443
  return this.appendMeta(identifier, [item], path, opts);
2407
2444
  }
2408
- async queryMeta(identifier, path = '') {
2409
- return this.request('GET', `/api/v1/meta${buildQuery({ identifier: this.isoPrefixId(identifier), path })}`);
2445
+ /** Convenience: push a single item via query. Wraps `item` in `[item]` and calls {@link appendMetaByQuery}. */
2446
+ async appendItemByQuery(query, item, path = '', firstMatch = false, opts) {
2447
+ return this.appendMetaByQuery(query, [item], path, firstMatch, opts);
2410
2448
  }
2411
- async queryMetaByQuery(query, path = '') {
2412
- return this.request('POST', '/api/v1/meta/query', { query: this.isoPrefixQuery(query), path });
2449
+ async queryMeta(identifier, path = '', opts) {
2450
+ return this.request('GET', `/api/v1/meta${buildQuery({ identifier: this.isoPrefixId(identifier), path })}`, undefined, undefined, { contextOverride: opts?.context });
2413
2451
  }
2414
- async clearMeta(query) {
2415
- const r = await this.request('DELETE', '/api/v1/meta', {
2416
- query: this.isoPrefixQuery(query),
2417
- });
2452
+ async queryMetaByQuery(query, path = '', opts) {
2453
+ return this.request('POST', '/api/v1/meta/query', { query: this.isoPrefixQuery(query), path }, undefined, { contextOverride: opts?.context });
2454
+ }
2455
+ async clearMeta(query, opts) {
2456
+ const r = await this.request('DELETE', '/api/v1/meta', { query: this.isoPrefixQuery(query) }, undefined, { contextOverride: opts?.context });
2418
2457
  return r?.ok !== false;
2419
2458
  }
2420
2459
  /**
@@ -2507,8 +2546,9 @@ class XCiteDBClient {
2507
2546
  * );
2508
2547
  * ```
2509
2548
  */
2510
- async unquery(query, unquery) {
2511
- return this.request('POST', '/api/v1/unquery', { query: this.isoPrefixQuery(query), unquery });
2549
+ async unquery(query, unquery, opts) {
2550
+ const extraHeaders = opts?.strict ? { 'X-Unquery-Strict': 'true' } : undefined;
2551
+ return this.request('POST', '/api/v1/unquery', { query: this.isoPrefixQuery(query), unquery }, extraHeaders, { contextOverride: opts?.context });
2512
2552
  }
2513
2553
  async search(q) {
2514
2554
  const body = { query: q.query };
@@ -611,6 +611,285 @@ const types_js_1 = require("./types.js");
611
611
  }
612
612
  });
613
613
  });
614
+ (0, node_test_1.describe)('client.fork()', () => {
615
+ (0, node_test_1.it)('child sends overridden workspace; parent keeps original on parallel calls', async () => {
616
+ const seen = [];
617
+ const orig = globalThis.fetch;
618
+ globalThis.fetch = node_test_1.mock.fn(async (input, init) => {
619
+ const h = new Headers(init?.headers);
620
+ seen.push({ url: String(input), workspace: h.get('X-Workspace') });
621
+ return new Response(JSON.stringify({ ok: true }), { status: 200 });
622
+ });
623
+ try {
624
+ const parent = new client_js_1.XCiteDBClient({
625
+ baseUrl: 'http://127.0.0.1:9',
626
+ apiKey: 'k',
627
+ context: { workspace: 'main' },
628
+ });
629
+ const child = parent.fork({ workspace: 'feature-x' });
630
+ await Promise.all([
631
+ parent.queryMeta('/d1'),
632
+ child.queryMeta('/d2'),
633
+ parent.queryMeta('/d3'),
634
+ ]);
635
+ strict_1.default.equal(seen.length, 3);
636
+ const byUrl = new Map(seen.map((s) => [s.url, s.workspace]));
637
+ strict_1.default.equal([...byUrl.keys()].some((u) => u.includes('%2Fd1')), true);
638
+ const ws = (id) => seen.find((s) => s.url.includes(id)).workspace;
639
+ strict_1.default.equal(ws('%2Fd1'), 'main');
640
+ strict_1.default.equal(ws('%2Fd2'), 'feature-x');
641
+ strict_1.default.equal(ws('%2Fd3'), 'main');
642
+ }
643
+ finally {
644
+ globalThis.fetch = orig;
645
+ }
646
+ });
647
+ (0, node_test_1.it)('child setContext does not mutate parent', async () => {
648
+ const c = new client_js_1.XCiteDBClient({
649
+ baseUrl: 'http://127.0.0.1:9',
650
+ apiKey: 'k',
651
+ context: { workspace: 'main', date: '2025-01-01' },
652
+ });
653
+ const child = c.fork({ workspace: 'feat' });
654
+ child.setContext({ workspace: 'feat-v2', date: '2025-12-31' });
655
+ // Capture parent's headers via a single request.
656
+ const orig = globalThis.fetch;
657
+ let parentWs = null;
658
+ let parentDate = null;
659
+ globalThis.fetch = node_test_1.mock.fn(async (_i, init) => {
660
+ const h = new Headers(init?.headers);
661
+ parentWs = h.get('X-Workspace');
662
+ parentDate = h.get('X-Date');
663
+ return new Response(JSON.stringify({}), { status: 200 });
664
+ });
665
+ try {
666
+ await c.queryMeta('/p');
667
+ strict_1.default.equal(parentWs, 'main');
668
+ strict_1.default.equal(parentDate, '2025-01-01');
669
+ }
670
+ finally {
671
+ globalThis.fetch = orig;
672
+ }
673
+ });
674
+ (0, node_test_1.it)('fork() with no arg copies parent context verbatim', async () => {
675
+ const c = new client_js_1.XCiteDBClient({
676
+ baseUrl: 'http://127.0.0.1:9',
677
+ apiKey: 'k',
678
+ context: { workspace: 'w', date: '2025-06-01', prefix: '/p' },
679
+ });
680
+ const child = c.fork();
681
+ let h = null;
682
+ const orig = globalThis.fetch;
683
+ globalThis.fetch = node_test_1.mock.fn(async (_i, init) => {
684
+ h = new Headers(init?.headers);
685
+ return new Response(JSON.stringify({}), { status: 200 });
686
+ });
687
+ try {
688
+ await child.queryMeta('/d');
689
+ strict_1.default.equal(h.get('X-Workspace'), 'w');
690
+ strict_1.default.equal(h.get('X-Date'), '2025-06-01');
691
+ strict_1.default.equal(h.get('X-Prefix'), '/p');
692
+ }
693
+ finally {
694
+ globalThis.fetch = orig;
695
+ }
696
+ });
697
+ (0, node_test_1.it)('fork({workspace}) mirrors workspace into branch (back-compat alias)', async () => {
698
+ const c = new client_js_1.XCiteDBClient({
699
+ baseUrl: 'http://127.0.0.1:9',
700
+ apiKey: 'k',
701
+ context: { workspace: 'main', branch: 'main' },
702
+ });
703
+ const child = c.fork({ workspace: 'feature-y' });
704
+ // Inspect via a request: server only sees X-Workspace, but the deprecated branch alias
705
+ // must update too so legacy code paths inside the SDK that read `branch` see the new value.
706
+ let h = null;
707
+ const orig = globalThis.fetch;
708
+ globalThis.fetch = node_test_1.mock.fn(async (_i, init) => {
709
+ h = new Headers(init?.headers);
710
+ return new Response(JSON.stringify({}), { status: 200 });
711
+ });
712
+ try {
713
+ await child.queryMeta('/d');
714
+ strict_1.default.equal(h.get('X-Workspace'), 'feature-y');
715
+ }
716
+ finally {
717
+ globalThis.fetch = orig;
718
+ }
719
+ });
720
+ });
721
+ (0, node_test_1.describe)('per-call opts.context override', () => {
722
+ (0, node_test_1.it)('queryMeta {context: {workspace}} sends overridden X-Workspace', async () => {
723
+ let captured = null;
724
+ const orig = globalThis.fetch;
725
+ globalThis.fetch = node_test_1.mock.fn(async (_i, init) => {
726
+ captured = new Headers(init?.headers);
727
+ return new Response(JSON.stringify({}), { status: 200 });
728
+ });
729
+ try {
730
+ const c = new client_js_1.XCiteDBClient({
731
+ baseUrl: 'http://127.0.0.1:9',
732
+ apiKey: 'k',
733
+ context: { workspace: 'main' },
734
+ });
735
+ await c.queryMeta('/d', '', { context: { workspace: 'feature-x' } });
736
+ strict_1.default.equal(captured.get('X-Workspace'), 'feature-x');
737
+ }
738
+ finally {
739
+ globalThis.fetch = orig;
740
+ }
741
+ });
742
+ (0, node_test_1.it)('opts.context does NOT mutate the client default context', async () => {
743
+ const seen = [];
744
+ const orig = globalThis.fetch;
745
+ globalThis.fetch = node_test_1.mock.fn(async (_i, init) => {
746
+ const h = new Headers(init?.headers);
747
+ seen.push(h.get('X-Workspace') ?? '');
748
+ return new Response(JSON.stringify({}), { status: 200 });
749
+ });
750
+ try {
751
+ const c = new client_js_1.XCiteDBClient({
752
+ baseUrl: 'http://127.0.0.1:9',
753
+ apiKey: 'k',
754
+ context: { workspace: 'main' },
755
+ });
756
+ await c.queryMeta('/a', '', { context: { workspace: 'one-off' } });
757
+ await c.queryMeta('/b'); // should fall back to default 'main'
758
+ strict_1.default.deepEqual(seen, ['one-off', 'main']);
759
+ }
760
+ finally {
761
+ globalThis.fetch = orig;
762
+ }
763
+ });
764
+ (0, node_test_1.it)('addMeta opts.context overrides workspace and date', async () => {
765
+ let captured = null;
766
+ const orig = globalThis.fetch;
767
+ globalThis.fetch = node_test_1.mock.fn(async (_i, init) => {
768
+ captured = new Headers(init?.headers);
769
+ return new Response(JSON.stringify({ ok: true }), { status: 200 });
770
+ });
771
+ try {
772
+ const c = new client_js_1.XCiteDBClient({
773
+ baseUrl: 'http://127.0.0.1:9',
774
+ apiKey: 'k',
775
+ context: { workspace: 'main', date: '2025-01-01' },
776
+ });
777
+ await c.addMeta('/d', { x: 1 }, '', {
778
+ context: { workspace: 'feat', date: '2025-12-31' },
779
+ });
780
+ strict_1.default.equal(captured.get('X-Workspace'), 'feat');
781
+ strict_1.default.equal(captured.get('X-Date'), '2025-12-31');
782
+ }
783
+ finally {
784
+ globalThis.fetch = orig;
785
+ }
786
+ });
787
+ (0, node_test_1.it)('appendItem opts.context flows through to the wrapped appendMeta call', async () => {
788
+ let captured = null;
789
+ const orig = globalThis.fetch;
790
+ globalThis.fetch = node_test_1.mock.fn(async (_i, init) => {
791
+ captured = new Headers(init?.headers);
792
+ return new Response(JSON.stringify({ ok: true }), { status: 200 });
793
+ });
794
+ try {
795
+ const c = new client_js_1.XCiteDBClient({ baseUrl: 'http://127.0.0.1:9', apiKey: 'k' });
796
+ await c.appendItem('/d', 1, 'list', { context: { workspace: 'w-pc' } });
797
+ strict_1.default.equal(captured.get('X-Workspace'), 'w-pc');
798
+ }
799
+ finally {
800
+ globalThis.fetch = orig;
801
+ }
802
+ });
803
+ (0, node_test_1.it)('unquery opts.context overrides workspace', async () => {
804
+ let captured = null;
805
+ const orig = globalThis.fetch;
806
+ globalThis.fetch = node_test_1.mock.fn(async (_i, init) => {
807
+ captured = new Headers(init?.headers);
808
+ return new Response(JSON.stringify({}), { status: 200 });
809
+ });
810
+ try {
811
+ const c = new client_js_1.XCiteDBClient({
812
+ baseUrl: 'http://127.0.0.1:9',
813
+ apiKey: 'k',
814
+ context: { workspace: 'main' },
815
+ });
816
+ await c.unquery({ match_start: '/x/' }, { id: '$identifier' }, { context: { workspace: 'unq' } });
817
+ strict_1.default.equal(captured.get('X-Workspace'), 'unq');
818
+ }
819
+ finally {
820
+ globalThis.fetch = orig;
821
+ }
822
+ });
823
+ (0, node_test_1.it)('writeXmlDocument options.context overrides workspace', async () => {
824
+ let captured = null;
825
+ const orig = globalThis.fetch;
826
+ globalThis.fetch = node_test_1.mock.fn(async (_i, init) => {
827
+ captured = new Headers(init?.headers);
828
+ return new Response(JSON.stringify({}), { status: 200 });
829
+ });
830
+ try {
831
+ const c = new client_js_1.XCiteDBClient({
832
+ baseUrl: 'http://127.0.0.1:9',
833
+ apiKey: 'k',
834
+ context: { workspace: 'main' },
835
+ });
836
+ await c.writeXmlDocument('<?xml version="1.0"?><doc xmlns:db="http://www.xcitedb.com/schema" db:identifier="/x"/>', { context: { workspace: 'wxd' } });
837
+ strict_1.default.equal(captured.get('X-Workspace'), 'wxd');
838
+ }
839
+ finally {
840
+ globalThis.fetch = orig;
841
+ }
842
+ });
843
+ });
844
+ (0, node_test_1.describe)('appendItem / appendItemByQuery', () => {
845
+ (0, node_test_1.it)('appendItem wraps a typed item in [item] and POSTs mode=append', async () => {
846
+ const captured = { value: null };
847
+ const orig = globalThis.fetch;
848
+ globalThis.fetch = node_test_1.mock.fn(async (input, init) => {
849
+ captured.value = {
850
+ url: String(input),
851
+ body: JSON.parse(String(init?.body ?? '{}')),
852
+ };
853
+ return new Response(JSON.stringify({ ok: true }), { status: 200 });
854
+ });
855
+ try {
856
+ const c = new client_js_1.XCiteDBClient({ baseUrl: 'http://127.0.0.1:9', apiKey: 'k' });
857
+ const review = { stars: 5, text: 'great' };
858
+ const ok = await c.appendItem('/products/p1', review, 'reviews');
859
+ strict_1.default.equal(ok, true);
860
+ strict_1.default.match(captured.value.url, /\/api\/v1\/meta$/);
861
+ const body = captured.value.body;
862
+ strict_1.default.equal(body.identifier, '/products/p1');
863
+ strict_1.default.equal(body.path, 'reviews');
864
+ strict_1.default.equal(body.mode, 'append');
865
+ strict_1.default.deepEqual(body.value, [review]);
866
+ }
867
+ finally {
868
+ globalThis.fetch = orig;
869
+ }
870
+ });
871
+ (0, node_test_1.it)('appendItemByQuery wraps a typed item in [item] and includes first_match', async () => {
872
+ const captured = { value: null };
873
+ const orig = globalThis.fetch;
874
+ globalThis.fetch = node_test_1.mock.fn(async (_i, init) => {
875
+ captured.value = { body: JSON.parse(String(init?.body ?? '{}')) };
876
+ return new Response(JSON.stringify({ ok: true }), { status: 200 });
877
+ });
878
+ try {
879
+ const c = new client_js_1.XCiteDBClient({ baseUrl: 'http://127.0.0.1:9', apiKey: 'k' });
880
+ const ok = await c.appendItemByQuery({ match_start: '/products/' }, 42, 'tags', true);
881
+ strict_1.default.equal(ok, true);
882
+ const body = captured.value.body;
883
+ strict_1.default.equal(body.path, 'tags');
884
+ strict_1.default.equal(body.first_match, true);
885
+ strict_1.default.equal(body.mode, 'append');
886
+ strict_1.default.deepEqual(body.value, [42]);
887
+ }
888
+ finally {
889
+ globalThis.fetch = orig;
890
+ }
891
+ });
892
+ });
614
893
  (0, node_test_1.describe)('listTriggerEvents', () => {
615
894
  (0, node_test_1.it)('passes limit and parses events', async () => {
616
895
  let url = '';
package/dist/types.d.ts CHANGED
@@ -569,6 +569,8 @@ export interface DatabaseContext {
569
569
  export interface WriteDocumentOptions {
570
570
  is_top?: boolean;
571
571
  compare_attributes?: boolean;
572
+ /** Per-call context override. Merged on top of the client's default context for this request only. */
573
+ context?: Partial<DatabaseContext>;
572
574
  }
573
575
  /** Supported source formats for `POST /api/v1/documents/import` (server detects from bytes / filename). */
574
576
  export type DocumentImportFormat = 'docx' | 'odt' | 'rtf' | 'pdf' | 'txt' | 'md' | 'adoc';
@@ -699,6 +701,14 @@ export interface UserIsolationShareResult {
699
701
  export interface UserIsolationShareListEntry {
700
702
  identifier: string;
701
703
  mode: UserIsolationShareMode;
704
+ /** App user id of the user who created this share, parsed from the alias path. */
705
+ granter_user_id?: string;
706
+ /** Display name of the granter, looked up at list time. */
707
+ granter_display_name?: string;
708
+ /** Email of the granter, looked up at list time. */
709
+ granter_email?: string;
710
+ /** Epoch seconds. Omitted for shares created before share-meta storage existed. */
711
+ created_at?: number;
702
712
  }
703
713
  /** Response from `GET /api/v1/security/user-isolation/shares?direction=…`. */
704
714
  export interface UserIsolationShareListResponse {
@@ -118,6 +118,8 @@ wd('user isolation (wet)', () => {
118
118
  const slug = `js-iso-doc-${suffix}`;
119
119
  try {
120
120
  await admin.setUserIsolationConfig({ enabled: true, namespace_pattern: '/users/${user.id}' });
121
+ // Default-deny ABAC requires bypass for the platform admin to do data-doc ops in this project.
122
+ await admin.updateSecurityConfig({ developer_bypass: true });
121
123
  const u = await admin.createAppUser(email, password, undefined, [
122
124
  client_js_1.XCiteDBClient.buildProjectGroup(e.tenantId, 'editor'),
123
125
  ]);
@@ -134,6 +136,7 @@ wd('user isolation (wet)', () => {
134
136
  await admin.deleteAppUser(u.user_id);
135
137
  }
136
138
  finally {
139
+ await admin.updateSecurityConfig({ developer_bypass: false }).catch(() => { });
137
140
  await admin.disableUserIsolation().catch(() => { });
138
141
  }
139
142
  });
@@ -153,6 +156,7 @@ wd('user isolation (wet)', () => {
153
156
  namespace_pattern: '/users/${user.id}',
154
157
  shared_read_paths: [sharedPath],
155
158
  });
159
+ await admin.updateSecurityConfig({ developer_bypass: true });
156
160
  await admin.writeJsonDocument(docPath, { _xcite_json_doc: true, tag: 'shared' });
157
161
  const created = await admin.createAppUser(email, password, undefined, [
158
162
  client_js_1.XCiteDBClient.buildProjectGroup(e.tenantId, 'editor'),
@@ -169,6 +173,7 @@ wd('user isolation (wet)', () => {
169
173
  await admin.deleteAppUser(created.user_id);
170
174
  }
171
175
  finally {
176
+ await admin.updateSecurityConfig({ developer_bypass: false }).catch(() => { });
172
177
  await admin.disableUserIsolation().catch(() => { });
173
178
  }
174
179
  });
@@ -198,6 +203,7 @@ wd('user isolation (wet)', () => {
198
203
  enabled: true,
199
204
  namespace_pattern: '/users/${user.id}',
200
205
  });
206
+ await admin.updateSecurityConfig({ developer_bypass: true });
201
207
  userA = await admin.createAppUser(emailA, password, undefined, [
202
208
  client_js_1.XCiteDBClient.buildProjectGroup(e.tenantId, 'editor'),
203
209
  ]);
@@ -249,6 +255,7 @@ wd('user isolation (wet)', () => {
249
255
  await admin.deleteAppUser(userB.user_id).catch(() => { });
250
256
  if (userA)
251
257
  await admin.deleteAppUser(userA.user_id).catch(() => { });
258
+ await admin.updateSecurityConfig({ developer_bypass: false }).catch(() => { });
252
259
  await admin.disableUserIsolation().catch(() => { });
253
260
  }
254
261
  });
@@ -310,6 +317,13 @@ wd('app-user groups + group-aware shares (wet)', () => {
310
317
  const status = err.status;
311
318
  return status === 403;
312
319
  }, 'non-owner deleteGroup must be rejected with 403');
320
+ // Rename: owner can; non-owner cannot.
321
+ const newName = `g-${suffix}-renamed`;
322
+ const renamed = await ownerClient.renameGroup(groupId, newName);
323
+ strict_1.default.equal(renamed.name, newName);
324
+ strict_1.default.equal(renamed.group_id, groupId, 'group_id must be stable across rename');
325
+ strict_1.default.ok(renamed.updated_at >= created.updated_at, 'updated_at should advance on rename');
326
+ await strict_1.default.rejects(() => memberClient.renameGroup(groupId, `g-${suffix}-hijack`), (err) => err.status === 403, 'non-owner renameGroup must be rejected with 403');
313
327
  await ownerClient.removeGroupMember(groupId, member.user_id);
314
328
  const afterRemove = await ownerClient.getGroup(groupId);
315
329
  strict_1.default.ok(!afterRemove.member_ids.includes(member.user_id), 'member_ids should not include removed user');
@@ -378,7 +392,11 @@ wd('app-user groups + group-aware shares (wet)', () => {
378
392
  });
379
393
  await memberClient.loginAppUser(memberEmail, password);
380
394
  const incoming = await memberClient.listUserIsolationShares({ direction: 'incoming' });
381
- strict_1.default.ok(incoming.identifiers.some((row) => row.identifier === share.alias), `incoming list should include alias ${share.alias}; got ${JSON.stringify(incoming.identifiers)}`);
395
+ const matched = incoming.identifiers.find((row) => row.identifier === share.alias);
396
+ strict_1.default.ok(matched, `incoming list should include alias ${share.alias}; got ${JSON.stringify(incoming.identifiers)}`);
397
+ strict_1.default.equal(matched.granter_user_id, owner.user_id, 'granter_user_id should match the sharer');
398
+ strict_1.default.equal(matched.granter_email, ownerEmail, 'granter_email should be the sharer email');
399
+ strict_1.default.ok(typeof matched.created_at === 'number' && matched.created_at > 0, `created_at should be a positive number; got ${matched.created_at}`);
382
400
  const doc = await memberClient.readJsonDocument(share.alias);
383
401
  strict_1.default.equal(doc.who, 'owner');
384
402
  strict_1.default.equal(doc.v, 1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xcitedbs/client",
3
- "version": "0.3.8",
3
+ "version": "0.3.10",
4
4
  "description": "XCiteDB BaaS client SDK",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",