@xcitedbs/client 0.3.9 → 0.3.11

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
@@ -143,6 +143,22 @@ export declare class XCiteDBClient {
143
143
  message: string;
144
144
  session_token: string;
145
145
  }>;
146
+ /**
147
+ * Seal a *standalone* sandbox as a fixture base (`POST /api/v1/sandboxes/{name}/seal`).
148
+ *
149
+ * Stamps the supplied fingerprint on the fixture's metadata. Test sessions and dev sandboxes
150
+ * can then attach to this fixture by passing the matching `expectedBaseFingerprint`. Mismatch
151
+ * is `409 fingerprint_mismatch` — consumers MUST re-run setup, not retry.
152
+ */
153
+ sealSandbox(name: string, opts: import('./types').SealSandboxOptions): Promise<import('./types').SandboxInfo>;
154
+ /**
155
+ * Unseal a fixture sandbox (`POST /api/v1/sandboxes/{name}/unseal`).
156
+ *
157
+ * Clears the seal + fingerprint and bumps `reset_generation` so any cached overlay routing
158
+ * forces a fresh validation. Typical re-seed flow: `unseal → resetSandbox → re-populate →
159
+ * sealSandbox` at a new fingerprint.
160
+ */
161
+ unsealSandbox(name: string): Promise<import('./types').SandboxInfo>;
146
162
  /** Destroy a sandbox; revokes bound API keys and removes membership rows. Owner only. */
147
163
  destroySandbox(name: string): Promise<{
148
164
  message: string;
@@ -188,6 +204,19 @@ export declare class XCiteDBClient {
188
204
  /** Sets active project for platform console mode (`X-Project-Id`). */
189
205
  setProjectId(projectId: string): void;
190
206
  setContext(ctx: DatabaseContext): void;
207
+ private static mergeContext;
208
+ /**
209
+ * Create a child client that shares transport/auth wiring with this one but has an
210
+ * independent context. Forking does not mutate the parent; child and parent can be used
211
+ * concurrently with different workspaces, dates, or prefixes. Use this in place of
212
+ * `setContext → do work → setContext(prev)`.
213
+ *
214
+ * Auth tokens (api key, access token, app-user tokens) are copied at fork time. Subsequent
215
+ * token rotation via `setTokens` / `setAppUserTokens` (or callbacks) updates only the
216
+ * instance that triggered it; if you fork long-lived clients, keep token state in sync via
217
+ * the `onSessionTokensUpdated` / `onAppUserTokensUpdated` callbacks supplied at construction.
218
+ */
219
+ fork(partial?: Partial<DatabaseContext>): XCiteDBClient;
191
220
  setTokens(access: string, refresh?: string): void;
192
221
  /** End-user (app) tokens. With developer `accessToken`/`apiKey`, sent as `X-App-User-Token`. */
193
222
  setAppUserTokens(access: string, refresh?: string): void;
@@ -751,7 +780,7 @@ export declare class XCiteDBClient {
751
780
  merge?: MergeResult;
752
781
  }>;
753
782
  /** Send raw XML body (`Content-Type: application/xml`). For JSON wrapper + options use `writeXmlDocument`. */
754
- writeXML(xml: string, _options?: WriteDocumentOptions): Promise<void>;
783
+ writeXML(xml: string, options?: WriteDocumentOptions): Promise<void>;
755
784
  /**
756
785
  * Write an **XML** document using a JSON request body (`xml` field). The identifier is taken from `db:identifier` on the root element.
757
786
  * For storing JSON data by key, use `writeJsonDocument`.
@@ -785,21 +814,27 @@ export declare class XCiteDBClient {
785
814
  * @deprecated Use {@link writeXmlDocument}. This name was misleading: it writes **XML** via a JSON wrapper, not a JSON document.
786
815
  */
787
816
  writeDocumentJson(xml: string, options?: WriteDocumentOptions): Promise<void>;
788
- queryByIdentifier(identifier: string, flags?: Flags, filter?: string, pathFilter?: string): Promise<string[]>;
817
+ queryByIdentifier(identifier: string, flags?: Flags, filter?: string, pathFilter?: string, opts?: {
818
+ context?: Partial<DatabaseContext>;
819
+ }): Promise<string[]>;
789
820
  /**
790
821
  * Shallow read (`flags=NoChildren,KeepIndexNodes,FirstMatch` on `GET /api/v1/documents/by-id`):
791
822
  * root element with inline leaves plus **identifier-bearing stub elements** (`db:stub="true"`) for shredded
792
823
  * child slots instead of opaque `db:N*` placeholders. Safe to round-trip with {@link writeXmlDocument}.
793
824
  * For sidebar / AST navigation, pair with {@link listIdentifierChildren} (e.g. `Promise.all` of shallow + children).
794
825
  */
795
- queryByIdentifierShallow(identifier: string, filter?: string, pathFilter?: string): Promise<string[]>;
826
+ queryByIdentifierShallow(identifier: string, filter?: string, pathFilter?: string, opts?: {
827
+ context?: Partial<DatabaseContext>;
828
+ }): Promise<string[]>;
796
829
  /**
797
830
  * Load a document with all shredded children inlined (`FirstMatch,IncludeChildren` on `GET /by-id`).
798
831
  * The returned array has **length 0 or 1**; when present, index `0` is the full XML string.
799
832
  * Use this for editor round-trips. For navigation without loading the full subtree, use
800
833
  * {@link queryByIdentifierShallow} plus {@link listIdentifierChildren}.
801
834
  */
802
- queryByIdentifierFull(identifier: string, filter?: string, pathFilter?: string): Promise<string[]>;
835
+ queryByIdentifierFull(identifier: string, filter?: string, pathFilter?: string, opts?: {
836
+ context?: Partial<DatabaseContext>;
837
+ }): Promise<string[]>;
803
838
  /** Alias of {@link listIdentifierChildren} — immediate child segments under `parentPath` (identifier hierarchy). */
804
839
  listChildIdentifiers(parentPath?: string): Promise<ListIdentifierChildrenResult>;
805
840
  queryDocuments(query: XCiteQuery, flags?: Flags, filter?: string, pathFilter?: string): Promise<string[]>;
@@ -815,10 +850,12 @@ export declare class XCiteDBClient {
815
850
  addMeta(identifier: string, value: unknown, path?: string, opts?: {
816
851
  mode?: 'set' | 'append' | 'merge_append';
817
852
  overwrite?: boolean;
853
+ context?: Partial<DatabaseContext>;
818
854
  }): Promise<boolean>;
819
855
  addMetaByQuery(query: XCiteQuery, value: unknown, path?: string, firstMatch?: boolean, opts?: {
820
856
  mode?: 'set' | 'append' | 'merge_append';
821
857
  overwrite?: boolean;
858
+ context?: Partial<DatabaseContext>;
822
859
  }): Promise<boolean>;
823
860
  /**
824
861
  * Strict array-extend: `value` must be an array; the stored target at `path` must be an array
@@ -828,9 +865,11 @@ export declare class XCiteDBClient {
828
865
  */
829
866
  appendMeta(identifier: string, value: unknown[], path?: string, opts?: {
830
867
  overwrite?: boolean;
868
+ context?: Partial<DatabaseContext>;
831
869
  }): Promise<boolean>;
832
870
  appendMetaByQuery(query: XCiteQuery, value: unknown[], path?: string, firstMatch?: boolean, opts?: {
833
871
  overwrite?: boolean;
872
+ context?: Partial<DatabaseContext>;
834
873
  }): Promise<boolean>;
835
874
  /**
836
875
  * Deep-extend: walks the payload and extends any nested arrays found in storage. Scalars and
@@ -839,17 +878,31 @@ export declare class XCiteDBClient {
839
878
  */
840
879
  mergeAppendMeta(identifier: string, value: unknown, path?: string, opts?: {
841
880
  overwrite?: boolean;
881
+ context?: Partial<DatabaseContext>;
842
882
  }): Promise<boolean>;
843
883
  mergeAppendMetaByQuery(query: XCiteQuery, value: unknown, path?: string, firstMatch?: boolean, opts?: {
844
884
  overwrite?: boolean;
885
+ context?: Partial<DatabaseContext>;
845
886
  }): Promise<boolean>;
846
887
  /** Convenience: push a single item. Wraps `item` in `[item]` and calls {@link appendMeta}. */
847
- appendItem(identifier: string, item: unknown, path?: string, opts?: {
888
+ appendItem<T = unknown>(identifier: string, item: T, path?: string, opts?: {
889
+ overwrite?: boolean;
890
+ context?: Partial<DatabaseContext>;
891
+ }): Promise<boolean>;
892
+ /** Convenience: push a single item via query. Wraps `item` in `[item]` and calls {@link appendMetaByQuery}. */
893
+ appendItemByQuery<T = unknown>(query: XCiteQuery, item: T, path?: string, firstMatch?: boolean, opts?: {
848
894
  overwrite?: boolean;
895
+ context?: Partial<DatabaseContext>;
896
+ }): Promise<boolean>;
897
+ queryMeta<T = MetaValue>(identifier: string, path?: string, opts?: {
898
+ context?: Partial<DatabaseContext>;
899
+ }): Promise<T>;
900
+ queryMetaByQuery<T = MetaValue>(query: XCiteQuery, path?: string, opts?: {
901
+ context?: Partial<DatabaseContext>;
902
+ }): Promise<T>;
903
+ clearMeta(query: XCiteQuery, opts?: {
904
+ context?: Partial<DatabaseContext>;
849
905
  }): Promise<boolean>;
850
- queryMeta<T = MetaValue>(identifier: string, path?: string): Promise<T>;
851
- queryMetaByQuery<T = MetaValue>(query: XCiteQuery, path?: string): Promise<T>;
852
- clearMeta(query: XCiteQuery): Promise<boolean>;
853
906
  /**
854
907
  * Acquire a cooperative lock. On exclusive conflict the server returns HTTP 409 with a
855
908
  * {@link LockConflictBody} body (thrown as {@link XCiteDBError} with `.status === 409`).
@@ -902,7 +955,10 @@ export declare class XCiteDBClient {
902
955
  * );
903
956
  * ```
904
957
  */
905
- unquery<T = UnqueryResult>(query: XCiteQuery, unquery: UnqueryTemplate): Promise<T>;
958
+ unquery<T = UnqueryResult>(query: XCiteQuery, unquery: UnqueryTemplate, opts?: {
959
+ context?: Partial<DatabaseContext>;
960
+ strict?: boolean;
961
+ }): Promise<T>;
906
962
  search(q: TextSearchQuery): Promise<TextSearchResult>;
907
963
  reindex(): Promise<{
908
964
  status: string;
package/dist/client.js CHANGED
@@ -201,6 +201,11 @@ class XCiteDBClient {
201
201
  sessionBody.additional_keys = opts.additionalKeys;
202
202
  if (opts.bootstrap !== undefined)
203
203
  sessionBody.bootstrap = opts.bootstrap;
204
+ if (opts.baseSandboxName)
205
+ sessionBody.base_sandbox_name = opts.baseSandboxName;
206
+ if (opts.expectedBaseFingerprint) {
207
+ sessionBody.expected_base_fingerprint = opts.expectedBaseFingerprint;
208
+ }
204
209
  const data = await temp.request('POST', '/api/v1/test/sessions', Object.keys(sessionBody).length ? sessionBody : undefined, undefined, { no401Retry: true });
205
210
  const mode = resolveTestAuthMode(opts.testAuth, opts.testRequireAuth) ?? 'required';
206
211
  const keepCreds = mode !== 'bypass';
@@ -629,6 +634,15 @@ class XCiteDBClient {
629
634
  if (canonical.startsWith('/users/') && !canonical.startsWith(`${ns}/`) && canonical !== ns) {
630
635
  return canonical;
631
636
  }
637
+ // Tenant-managed share-alias roots are never under any user namespace; pass them through
638
+ // unchanged so app users can read documents shared with them via group / public / anonymous
639
+ // share aliases (server alias paths produced by UserIsolationService::compute*ShareAliasPath).
640
+ if (canonical.startsWith('/groups/') ||
641
+ canonical.startsWith('/shared/') ||
642
+ canonical.startsWith('/shared-readonly/') ||
643
+ canonical.startsWith('/public/')) {
644
+ return canonical;
645
+ }
632
646
  const combined = `${ns}${canonical === '/' ? '/' : canonical}`;
633
647
  const finalId = combined.replace(/\/+/g, '/');
634
648
  if (!finalId.startsWith(ns) || (finalId.length > ns.length && finalId.charAt(ns.length) !== '/')) {
@@ -718,7 +732,18 @@ class XCiteDBClient {
718
732
  * a fresh client constructed with that key (no header threading needed).
719
733
  */
720
734
  async createSandbox(opts) {
721
- return this.request('POST', '/api/v1/sandboxes', opts, undefined, { suppressTestSessionHeader: true, no401Retry: true });
735
+ // Translate camelCase fixture-attach options to the snake_case wire format
736
+ // the server expects. Other fields pass through unchanged.
737
+ const body = { ...opts };
738
+ if (opts.baseSandboxName) {
739
+ body.base_sandbox_name = opts.baseSandboxName;
740
+ delete body.baseSandboxName;
741
+ }
742
+ if (opts.expectedBaseFingerprint) {
743
+ body.expected_base_fingerprint = opts.expectedBaseFingerprint;
744
+ delete body.expectedBaseFingerprint;
745
+ }
746
+ return this.request('POST', '/api/v1/sandboxes', body, undefined, { suppressTestSessionHeader: true, no401Retry: true });
722
747
  }
723
748
  /** List sandboxes for the current project (`GET /api/v1/sandboxes`). */
724
749
  async listSandboxes() {
@@ -737,6 +762,26 @@ class XCiteDBClient {
737
762
  async resetSandbox(name) {
738
763
  return this.request('POST', `/api/v1/sandboxes/${encodeURIComponent(name)}/reset`, undefined, undefined, { suppressTestSessionHeader: true, no401Retry: true });
739
764
  }
765
+ /**
766
+ * Seal a *standalone* sandbox as a fixture base (`POST /api/v1/sandboxes/{name}/seal`).
767
+ *
768
+ * Stamps the supplied fingerprint on the fixture's metadata. Test sessions and dev sandboxes
769
+ * can then attach to this fixture by passing the matching `expectedBaseFingerprint`. Mismatch
770
+ * is `409 fingerprint_mismatch` — consumers MUST re-run setup, not retry.
771
+ */
772
+ async sealSandbox(name, opts) {
773
+ return this.request('POST', `/api/v1/sandboxes/${encodeURIComponent(name)}/seal`, { fingerprint: opts.fingerprint }, undefined, { suppressTestSessionHeader: true, no401Retry: true });
774
+ }
775
+ /**
776
+ * Unseal a fixture sandbox (`POST /api/v1/sandboxes/{name}/unseal`).
777
+ *
778
+ * Clears the seal + fingerprint and bumps `reset_generation` so any cached overlay routing
779
+ * forces a fresh validation. Typical re-seed flow: `unseal → resetSandbox → re-populate →
780
+ * sealSandbox` at a new fingerprint.
781
+ */
782
+ async unsealSandbox(name) {
783
+ return this.request('POST', `/api/v1/sandboxes/${encodeURIComponent(name)}/unseal`, undefined, undefined, { suppressTestSessionHeader: true, no401Retry: true });
784
+ }
740
785
  /** Destroy a sandbox; revokes bound API keys and removes membership rows. Owner only. */
741
786
  async destroySandbox(name) {
742
787
  return this.request('DELETE', `/api/v1/sandboxes/${encodeURIComponent(name)}`, undefined, undefined, { suppressTestSessionHeader: true, no401Retry: true });
@@ -821,11 +866,33 @@ class XCiteDBClient {
821
866
  this.projectId = projectId;
822
867
  }
823
868
  setContext(ctx) {
824
- const next = { ...this.defaultContext, ...ctx };
825
- if (ctx.workspace !== undefined) {
826
- next.branch = ctx.workspace;
869
+ this.defaultContext = XCiteDBClient.mergeContext(this.defaultContext, ctx);
870
+ }
871
+ static mergeContext(prev, partial) {
872
+ const next = { ...prev, ...partial };
873
+ if (partial.workspace !== undefined) {
874
+ next.branch = partial.workspace;
827
875
  }
828
- this.defaultContext = next;
876
+ return next;
877
+ }
878
+ /**
879
+ * Create a child client that shares transport/auth wiring with this one but has an
880
+ * independent context. Forking does not mutate the parent; child and parent can be used
881
+ * concurrently with different workspaces, dates, or prefixes. Use this in place of
882
+ * `setContext → do work → setContext(prev)`.
883
+ *
884
+ * Auth tokens (api key, access token, app-user tokens) are copied at fork time. Subsequent
885
+ * token rotation via `setTokens` / `setAppUserTokens` (or callbacks) updates only the
886
+ * instance that triggered it; if you fork long-lived clients, keep token state in sync via
887
+ * the `onSessionTokensUpdated` / `onAppUserTokensUpdated` callbacks supplied at construction.
888
+ */
889
+ fork(partial) {
890
+ const child = Object.create(XCiteDBClient.prototype);
891
+ Object.assign(child, this);
892
+ child.defaultContext = XCiteDBClient.mergeContext(this.defaultContext, partial ?? {});
893
+ // Context can shift the user-isolation prefix; the parent's cached app-user id no longer applies.
894
+ child.cachedAppUserId = undefined;
895
+ return child;
829
896
  }
830
897
  setTokens(access, refresh) {
831
898
  this.accessToken = access;
@@ -844,9 +911,9 @@ class XCiteDBClient {
844
911
  this.appUserRefreshToken = undefined;
845
912
  this.clearAppUserIdCache();
846
913
  }
847
- contextHeaders() {
914
+ contextHeaders(override) {
848
915
  const h = {};
849
- const c = this.defaultContext;
916
+ const c = override ? XCiteDBClient.mergeContext(this.defaultContext, override) : this.defaultContext;
850
917
  const ws = c.workspace ?? c.branch;
851
918
  if (ws)
852
919
  h['X-Workspace'] = ws;
@@ -907,11 +974,12 @@ class XCiteDBClient {
907
974
  async request(method, path, body, extraHeaders, opts) {
908
975
  const no401Retry = opts?.no401Retry === true;
909
976
  const suppressTestSessionHeader = opts?.suppressTestSessionHeader === true;
977
+ const contextOverride = opts?.contextOverride;
910
978
  for (let attempt = 0; attempt < 2; attempt++) {
911
979
  const url = joinUrl(this.baseUrl, path);
912
980
  const headers = {
913
981
  ...this.authHeaders(),
914
- ...this.contextHeaders(),
982
+ ...this.contextHeaders(contextOverride),
915
983
  ...(suppressTestSessionHeader ? {} : this.testHeaders()),
916
984
  ...extraHeaders,
917
985
  };
@@ -2067,25 +2135,18 @@ class XCiteDBClient {
2067
2135
  * create a checkpoint, then publish back unless {@link options.autoMerge} is `false`.
2068
2136
  */
2069
2137
  async withWorkspace(workspaceName, fn, options) {
2070
- const prev = { ...this.defaultContext };
2071
- const fromWs = options?.fromBranch ?? prev.branch ?? prev.workspace ?? '';
2072
- let checkpoint;
2138
+ const fromWs = options?.fromBranch ?? this.defaultContext.branch ?? this.defaultContext.workspace ?? '';
2139
+ await this.createWorkspace(workspaceName, fromWs || undefined, this.defaultContext.date || undefined);
2140
+ const child = this.fork({ workspace: workspaceName, branch: workspaceName });
2141
+ const result = await fn(child);
2142
+ const checkpoint = await child.createCheckpoint(options?.message ?? `Workspace ${workspaceName}`, options?.author);
2073
2143
  let publish;
2074
- try {
2075
- await this.createWorkspace(workspaceName, fromWs || undefined, prev.date || undefined);
2076
- this.setContext({ workspace: workspaceName, branch: workspaceName });
2077
- const result = await fn(this);
2078
- checkpoint = await this.createCheckpoint(options?.message ?? `Workspace ${workspaceName}`, options?.author);
2079
- if (options?.autoMerge !== false) {
2080
- publish = await this.publishWorkspace(fromWs, workspaceName, {
2081
- message: options?.message,
2082
- });
2083
- }
2084
- return { result, checkpoint, publish };
2085
- }
2086
- finally {
2087
- this.setContext(prev);
2144
+ if (options?.autoMerge !== false) {
2145
+ publish = await child.publishWorkspace(fromWs, workspaceName, {
2146
+ message: options?.message,
2147
+ });
2088
2148
  }
2149
+ return { result, checkpoint, publish };
2089
2150
  }
2090
2151
  /** @deprecated Use {@link withWorkspace}. */
2091
2152
  async withBranch(branchName, fn, options) {
@@ -2093,11 +2154,9 @@ class XCiteDBClient {
2093
2154
  return { result: r.result, commit: r.checkpoint, merge: r.publish };
2094
2155
  }
2095
2156
  /** Send raw XML body (`Content-Type: application/xml`). For JSON wrapper + options use `writeXmlDocument`. */
2096
- async writeXML(xml, _options) {
2157
+ async writeXML(xml, options) {
2097
2158
  const payload = this.isoApplyXmlDbIdentifier(xml);
2098
- await this.request('POST', '/api/v1/documents', payload, {
2099
- 'Content-Type': 'application/xml',
2100
- });
2159
+ await this.request('POST', '/api/v1/documents', payload, { 'Content-Type': 'application/xml' }, { contextOverride: options?.context });
2101
2160
  }
2102
2161
  /**
2103
2162
  * Write an **XML** document using a JSON request body (`xml` field). The identifier is taken from `db:identifier` on the root element.
@@ -2114,7 +2173,7 @@ class XCiteDBClient {
2114
2173
  xml: this.isoApplyXmlDbIdentifier(xml),
2115
2174
  is_top: options?.is_top ?? true,
2116
2175
  compare_attributes: options?.compare_attributes ?? false,
2117
- });
2176
+ }, undefined, { contextOverride: options?.context });
2118
2177
  }
2119
2178
  /**
2120
2179
  * Best-effort batch XML writes (`POST /api/v1/documents/batch`). Each item is independent; check `results[].ok`.
@@ -2194,14 +2253,16 @@ class XCiteDBClient {
2194
2253
  async writeDocumentJson(xml, options) {
2195
2254
  return this.writeXmlDocument(xml, options);
2196
2255
  }
2197
- async queryByIdentifier(identifier, flags, filter, pathFilter) {
2256
+ async queryByIdentifier(identifier, flags, filter, pathFilter, opts) {
2198
2257
  const q = buildQuery({
2199
2258
  identifier: this.isoPrefixId(identifier),
2200
2259
  flags: flags,
2201
2260
  filter,
2202
2261
  path_filter: pathFilter,
2203
2262
  });
2204
- return this.request('GET', `/api/v1/documents/by-id${q}`);
2263
+ return this.request('GET', `/api/v1/documents/by-id${q}`, undefined, undefined, {
2264
+ contextOverride: opts?.context,
2265
+ });
2205
2266
  }
2206
2267
  /**
2207
2268
  * Shallow read (`flags=NoChildren,KeepIndexNodes,FirstMatch` on `GET /api/v1/documents/by-id`):
@@ -2209,8 +2270,8 @@ class XCiteDBClient {
2209
2270
  * child slots instead of opaque `db:N*` placeholders. Safe to round-trip with {@link writeXmlDocument}.
2210
2271
  * For sidebar / AST navigation, pair with {@link listIdentifierChildren} (e.g. `Promise.all` of shallow + children).
2211
2272
  */
2212
- async queryByIdentifierShallow(identifier, filter, pathFilter) {
2213
- return this.queryByIdentifier(identifier, 'NoChildren,KeepIndexNodes,FirstMatch', filter, pathFilter);
2273
+ async queryByIdentifierShallow(identifier, filter, pathFilter, opts) {
2274
+ return this.queryByIdentifier(identifier, 'NoChildren,KeepIndexNodes,FirstMatch', filter, pathFilter, opts);
2214
2275
  }
2215
2276
  /**
2216
2277
  * Load a document with all shredded children inlined (`FirstMatch,IncludeChildren` on `GET /by-id`).
@@ -2218,8 +2279,8 @@ class XCiteDBClient {
2218
2279
  * Use this for editor round-trips. For navigation without loading the full subtree, use
2219
2280
  * {@link queryByIdentifierShallow} plus {@link listIdentifierChildren}.
2220
2281
  */
2221
- async queryByIdentifierFull(identifier, filter, pathFilter) {
2222
- return this.queryByIdentifier(identifier, 'FirstMatch,IncludeChildren', filter, pathFilter);
2282
+ async queryByIdentifierFull(identifier, filter, pathFilter, opts) {
2283
+ return this.queryByIdentifier(identifier, 'FirstMatch,IncludeChildren', filter, pathFilter, opts);
2223
2284
  }
2224
2285
  /** Alias of {@link listIdentifierChildren} — immediate child segments under `parentPath` (identifier hierarchy). */
2225
2286
  async listChildIdentifiers(parentPath) {
@@ -2369,7 +2430,9 @@ class XCiteDBClient {
2369
2430
  body.mode = opts.mode;
2370
2431
  if (opts?.overwrite)
2371
2432
  body.overwrite = true;
2372
- const r = await this.request('POST', '/api/v1/meta', body);
2433
+ const r = await this.request('POST', '/api/v1/meta', body, undefined, {
2434
+ contextOverride: opts?.context,
2435
+ });
2373
2436
  return r?.ok !== false;
2374
2437
  }
2375
2438
  async addMetaByQuery(query, value, path = '', firstMatch = false, opts) {
@@ -2383,7 +2446,9 @@ class XCiteDBClient {
2383
2446
  body.mode = opts.mode;
2384
2447
  if (opts?.overwrite)
2385
2448
  body.overwrite = true;
2386
- const r = await this.request('POST', '/api/v1/meta', body);
2449
+ const r = await this.request('POST', '/api/v1/meta', body, undefined, {
2450
+ contextOverride: opts?.context,
2451
+ });
2387
2452
  return r?.ok !== false;
2388
2453
  }
2389
2454
  /**
@@ -2413,16 +2478,18 @@ class XCiteDBClient {
2413
2478
  async appendItem(identifier, item, path = '', opts) {
2414
2479
  return this.appendMeta(identifier, [item], path, opts);
2415
2480
  }
2416
- async queryMeta(identifier, path = '') {
2417
- return this.request('GET', `/api/v1/meta${buildQuery({ identifier: this.isoPrefixId(identifier), path })}`);
2481
+ /** Convenience: push a single item via query. Wraps `item` in `[item]` and calls {@link appendMetaByQuery}. */
2482
+ async appendItemByQuery(query, item, path = '', firstMatch = false, opts) {
2483
+ return this.appendMetaByQuery(query, [item], path, firstMatch, opts);
2418
2484
  }
2419
- async queryMetaByQuery(query, path = '') {
2420
- return this.request('POST', '/api/v1/meta/query', { query: this.isoPrefixQuery(query), path });
2485
+ async queryMeta(identifier, path = '', opts) {
2486
+ return this.request('GET', `/api/v1/meta${buildQuery({ identifier: this.isoPrefixId(identifier), path })}`, undefined, undefined, { contextOverride: opts?.context });
2421
2487
  }
2422
- async clearMeta(query) {
2423
- const r = await this.request('DELETE', '/api/v1/meta', {
2424
- query: this.isoPrefixQuery(query),
2425
- });
2488
+ async queryMetaByQuery(query, path = '', opts) {
2489
+ return this.request('POST', '/api/v1/meta/query', { query: this.isoPrefixQuery(query), path }, undefined, { contextOverride: opts?.context });
2490
+ }
2491
+ async clearMeta(query, opts) {
2492
+ const r = await this.request('DELETE', '/api/v1/meta', { query: this.isoPrefixQuery(query) }, undefined, { contextOverride: opts?.context });
2426
2493
  return r?.ok !== false;
2427
2494
  }
2428
2495
  /**
@@ -2515,8 +2582,9 @@ class XCiteDBClient {
2515
2582
  * );
2516
2583
  * ```
2517
2584
  */
2518
- async unquery(query, unquery) {
2519
- return this.request('POST', '/api/v1/unquery', { query: this.isoPrefixQuery(query), unquery });
2585
+ async unquery(query, unquery, opts) {
2586
+ const extraHeaders = opts?.strict ? { 'X-Unquery-Strict': 'true' } : undefined;
2587
+ return this.request('POST', '/api/v1/unquery', { query: this.isoPrefixQuery(query), unquery }, extraHeaders, { contextOverride: opts?.context });
2520
2588
  }
2521
2589
  async search(q) {
2522
2590
  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';
@@ -852,6 +854,26 @@ export interface CreateTestSessionOptions {
852
854
  * When true, creates an overlay test session: writable ephemeral LMDB with production project data as read-only base.
853
855
  */
854
856
  overlay?: boolean;
857
+ /**
858
+ * When set, attach the new test session to a sealed standalone sandbox (a "fixture") instead of the
859
+ * production project as the read-only base. Implies `overlay: true`.
860
+ *
861
+ * Use this for e2e suites where many tests share the same expensive setup (created users, seeded
862
+ * data, etc.) — set up the fixture once via the sandbox API, seal it with a content fingerprint,
863
+ * then point each test session at it. Multiple test sessions can attach to the same fixture
864
+ * concurrently; each gets its own ephemeral overlay layer.
865
+ */
866
+ baseSandboxName?: string;
867
+ /**
868
+ * Required iff {@link baseSandboxName} is set. The fingerprint your setup script stamped on the
869
+ * fixture (typically `hash(seed_inputs) + schema_version + git_sha`).
870
+ *
871
+ * On mismatch the server returns **`409 fingerprint_mismatch`** — the recovery action is to
872
+ * **re-run setup** to refresh the fixture, NOT to retry. The whole feature's safety property
873
+ * hinges on consumers handling this error correctly: a stale fixture must never silently serve
874
+ * old data.
875
+ */
876
+ expectedBaseFingerprint?: string;
855
877
  /**
856
878
  * API keys to snapshot into the new session's config DB so requests carrying any of these keys
857
879
  * (alongside `X-Test-Session: <token>`) authenticate against the session, not production.
@@ -908,6 +930,22 @@ export interface SandboxInfo {
908
930
  owner_member_id: string;
909
931
  /** Present in list responses: the requesting member's role in this sandbox, or "" if not a member. */
910
932
  my_role?: 'owner' | 'editor' | 'viewer' | '';
933
+ /**
934
+ * True for *fixture* sandboxes — empty initial DB, no production overlay underneath. Eligible
935
+ * to be sealed and used as a base for test sessions or other sandboxes. Mutually exclusive
936
+ * with `base_sandbox_name` on create.
937
+ */
938
+ standalone?: boolean;
939
+ /** True when this standalone sandbox has been sealed as a fixture base. */
940
+ sealed?: boolean;
941
+ /** Content fingerprint set when this sandbox was last sealed (only present on fixtures). */
942
+ base_fingerprint?: string;
943
+ /** Unix seconds at which the fixture was last sealed. */
944
+ sealed_at?: number;
945
+ /** First 8 chars of the parent fixture token, when this sandbox attached to a fixture base. */
946
+ base_sandbox_token_prefix?: string;
947
+ /** Echoed at create time when this sandbox is attached to a fixture base. */
948
+ expected_base_fingerprint?: string;
911
949
  }
912
950
  /** Options for {@link XCiteDBClient.createSandbox}. */
913
951
  export interface CreateSandboxOptions {
@@ -921,6 +959,31 @@ export interface CreateSandboxOptions {
921
959
  base_branch?: string;
922
960
  /** Defaults to "log" — webhooks/auth-emails get captured instead of firing. */
923
961
  effects_policy?: 'real' | 'log' | 'mock';
962
+ /**
963
+ * Fixture mode: an empty initial DB with no production overlay. Required for sealing the
964
+ * sandbox as a fixture base later. At most one standalone sandbox is allowed per
965
+ * `(org_id, project_id)`. Mutually exclusive with {@link baseSandboxName} and
966
+ * `effects_policy: 'real'`.
967
+ */
968
+ standalone?: boolean;
969
+ /**
970
+ * Attach this dev sandbox to a sealed standalone fixture as its base. The fixture provides the
971
+ * read-only baseline; this sandbox holds the writable overlay. Same fingerprint contract as
972
+ * {@link CreateTestSessionOptions.baseSandboxName} — mismatch is `409 fingerprint_mismatch`.
973
+ * Mutually exclusive with {@link standalone}.
974
+ */
975
+ baseSandboxName?: string;
976
+ /** Required iff {@link baseSandboxName} is set. */
977
+ expectedBaseFingerprint?: string;
978
+ }
979
+ /** Body for {@link XCiteDBClient.sealSandbox}. */
980
+ export interface SealSandboxOptions {
981
+ /**
982
+ * Content fingerprint to stamp on the fixture. Should be derived from your seed inputs +
983
+ * schema version + app version (typically git SHA). On a subsequent attach, consumers must
984
+ * pass the same fingerprint via `expectedBaseFingerprint` — mismatch is a hard failure.
985
+ */
986
+ fingerprint: string;
924
987
  }
925
988
  /** Response from {@link XCiteDBClient.mintSandboxApiKey}. The `api_key` value is shown once. */
926
989
  export interface SandboxApiKeyMintResult {
@@ -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
  });
package/llms.txt CHANGED
@@ -159,6 +159,24 @@ If you skip the bootstrap, an app-user JWT cannot read or write `/spaces/<userId
159
159
 
160
160
  **SDK one-liners for bootstrap:** **JS:** `XCiteDBClient.createTestSession({ baseUrl, apiKey, testRequireAuth: true, bootstrap: { user_isolation: { enabled: true, namespace_pattern: '/spaces/${user.id}' }, developer_bypass: true } })`. **Python:** `async with XCiteDBClient.test_session(base_url, api_key=sk, test_require_auth=True, bootstrap={...}) as c:`. **C++:** set `options.test_session_bootstrap = nlohmann::json::parse(R"(...)");` then `XCiteDBClient::create_test_session(options)`.
161
161
 
162
+ ### Fixture-base test sessions (e2e shared setup)
163
+
164
+ When an e2e suite (Playwright etc.) re-runs the same expensive setup before every test — create users, seed documents, configure policies — that setup dominates wall-clock time. **Fixture-base mode** lets you do the setup **once per CI run**, share it across many tests in parallel, and treat staleness as a hard failure rather than a silent bug.
165
+
166
+ Lifecycle:
167
+
168
+ 1. **Create a fixture sandbox** (one-time per project): `POST /api/v1/sandboxes` with `{"name":"e2e-fixture","standalone":true}`. A standalone sandbox has an empty initial DB and no production overlay underneath. **Only one standalone sandbox is allowed per `(org, project)`** — bumping fingerprints, not creating new fixtures, is the way to evolve.
169
+ 2. **Populate it** via the regular API (open a sandbox session against it, run your seed script, etc.). Effects policy `real` is rejected for standalone sandboxes; `log` (default) or `mock` are valid.
170
+ 3. **Seal it** with a content fingerprint: `POST /api/v1/sandboxes/e2e-fixture/seal` with `{"fingerprint":"<hash>"}`. The fingerprint should encode anything that affects stored shape: at minimum `hash(seed_inputs) + schema_version + git_sha` (every commit pessimistically invalidates the fixture — the friction of a manual version bump is worse than rerunning a fast API-only setup).
171
+ 4. **Attach test sessions to the fixture:** `POST /api/v1/test/sessions` with `{"overlay":true,"base_sandbox_name":"e2e-fixture","expected_base_fingerprint":"<hash>"}`. Many test sessions can attach concurrently; each gets its own ephemeral overlay layer; reads see the fixture's data, writes go to the test's overlay only. Mismatch on `expected_base_fingerprint` is **`409 fingerprint_mismatch` — the recovery action is to RE-RUN SETUP, not to retry**. This is the safety property of the cache: a stale fixture must never silently serve old data.
172
+ 5. **Re-seed** when the app or seed inputs change: `POST /api/v1/sandboxes/e2e-fixture/unseal` → `POST /api/v1/sandboxes/e2e-fixture/reset` → repopulate via API → `POST /api/v1/sandboxes/e2e-fixture/seal` at the new fingerprint.
173
+
174
+ **Dev sandboxes can also attach to a fixture** (interactive testing on top of fixture data instead of production): `POST /api/v1/sandboxes` with `{"name":"my-feature","base_sandbox_name":"e2e-fixture","expected_base_fingerprint":"<hash>"}`. Same fingerprint contract; if the fixture is later re-sealed, the dev sandbox's next request fails with `fixture_fingerprint_changed` — recovery in v1 is to delete and recreate the dev sandbox.
175
+
176
+ **Security:** the fixture-base resolver is keyed on the **caller's** `(org, project)`, not user input — there is no way for tenant A to attach to tenant B's fixture by name. The membership check (caller must be owner/editor of the fixture) returns a generic 404 for both not-found and not-a-member, so cross-tenant existence cannot be probed.
177
+
178
+ **SDK helpers:** **JS:** `client.createTestSession({ baseUrl, apiKey, baseSandboxName, expectedBaseFingerprint })`, `client.createSandbox({ name, baseSandboxName, expectedBaseFingerprint })`, `client.sealSandbox(name, { fingerprint })`, `client.unsealSandbox(name)`. **Python:** `XCiteDBClient.test_session(base_url, base_sandbox_name=..., expected_base_fingerprint=...)`, `client.seal_sandbox(name, fingerprint=...)`, `client.unseal_sandbox(name)`. **C++:** set `options.test_session_base_sandbox_name` + `options.test_session_expected_base_fingerprint` then `create_test_session`; `seal_sandbox(name, fingerprint)`, `unseal_sandbox(name)`. **MCP:** tools `seal_sandbox`, `unseal_sandbox`; the `create_sandbox` and `create_test_session` tools accept the new fields.
179
+
162
180
  ## Wrapping XCiteDB in a higher-level service
163
181
 
164
182
  When you build a backend that calls XCiteDB on behalf of users:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xcitedbs/client",
3
- "version": "0.3.9",
3
+ "version": "0.3.11",
4
4
  "description": "XCiteDB BaaS client SDK",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",