@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 +65 -9
- package/dist/client.js +116 -48
- package/dist/client.test.js +279 -0
- package/dist/types.d.ts +63 -0
- package/dist/user-isolation.test.js +7 -0
- package/llms.txt +18 -0
- package/package.json +1 -1
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,
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
825
|
-
|
|
826
|
-
|
|
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
|
-
|
|
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
|
|
2071
|
-
|
|
2072
|
-
|
|
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
|
-
|
|
2075
|
-
await
|
|
2076
|
-
|
|
2077
|
-
|
|
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,
|
|
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
|
-
|
|
2417
|
-
|
|
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
|
|
2420
|
-
return this.request('
|
|
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
|
|
2423
|
-
|
|
2424
|
-
|
|
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
|
-
|
|
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 };
|
package/dist/client.test.js
CHANGED
|
@@ -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:
|