@xcitedbs/client 0.2.23 → 0.2.25

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.
@@ -0,0 +1,14 @@
1
+ /** URI prefix for asset identifiers embedded in documents (`xcitedb:asset:/path/...`). */
2
+ export declare const ASSET_URI_PREFIX: "xcitedb:asset:";
3
+ /**
4
+ * Parses `xcitedb:asset:<identifier>` where identifier must start with `/`.
5
+ * Percent-decodes the path portion then normalizes slashes.
6
+ */
7
+ export declare function parseAssetUri(s: string): string | null;
8
+ /** Build `xcitedb:asset:<identifier>`; identifier must begin with `/` after normalization. */
9
+ export declare function formatAssetUri(identifier: string): string;
10
+ /**
11
+ * Scan arbitrary text for `xcitedb:asset:` paths (GC / reference discovery).
12
+ * Paths are normalized the same way as {@link parseAssetUri} would accept.
13
+ */
14
+ export declare function collectIdentifiersFromText(text: string, out: Set<string>): void;
@@ -0,0 +1,119 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ASSET_URI_PREFIX = void 0;
4
+ exports.parseAssetUri = parseAssetUri;
5
+ exports.formatAssetUri = formatAssetUri;
6
+ exports.collectIdentifiersFromText = collectIdentifiersFromText;
7
+ /** URI prefix for asset identifiers embedded in documents (`xcitedb:asset:/path/...`). */
8
+ exports.ASSET_URI_PREFIX = 'xcitedb:asset:';
9
+ function isPchar(c) {
10
+ if ((c >= 0x41 && c <= 0x5a) || (c >= 0x61 && c <= 0x7a) || (c >= 0x30 && c <= 0x39)) {
11
+ return true;
12
+ }
13
+ switch (c) {
14
+ case 0x2d: // -
15
+ case 0x2e: // .
16
+ case 0x5f: // _
17
+ case 0x7e: // ~
18
+ case 0x25: // %
19
+ case 0x3a: // :
20
+ case 0x40: // @
21
+ case 0x21: // !
22
+ case 0x24: // $
23
+ case 0x26: // &
24
+ case 0x27: // '
25
+ case 0x28: // (
26
+ case 0x29: // )
27
+ case 0x2a: // *
28
+ case 0x2b: // +
29
+ case 0x2c: // ,
30
+ case 0x3b: // ;
31
+ case 0x3d: // =
32
+ return true;
33
+ default:
34
+ return false;
35
+ }
36
+ }
37
+ /** Normalize path: collapse slashes, ensure leading `/`. */
38
+ function normalizeIdentifierPath(p) {
39
+ let s = p.trim().replace(/\/+/g, '/');
40
+ if (!s.startsWith('/')) {
41
+ s = `/${s}`;
42
+ }
43
+ return s;
44
+ }
45
+ /**
46
+ * Parses `xcitedb:asset:<identifier>` where identifier must start with `/`.
47
+ * Percent-decodes the path portion then normalizes slashes.
48
+ */
49
+ function parseAssetUri(s) {
50
+ const t = s.trim();
51
+ if (!t.startsWith(exports.ASSET_URI_PREFIX)) {
52
+ return null;
53
+ }
54
+ let rest = t.slice(exports.ASSET_URI_PREFIX.length);
55
+ if (!rest.startsWith('/')) {
56
+ return null;
57
+ }
58
+ try {
59
+ rest = decodeURIComponent(rest);
60
+ }
61
+ catch {
62
+ return null;
63
+ }
64
+ const id = normalizeIdentifierPath(rest);
65
+ if (id === '/' || id.length < 2) {
66
+ return null;
67
+ }
68
+ return id;
69
+ }
70
+ /** Build `xcitedb:asset:<identifier>`; identifier must begin with `/` after normalization. */
71
+ function formatAssetUri(identifier) {
72
+ const id = normalizeIdentifierPath(identifier);
73
+ if (id === '/' || !id.startsWith('/')) {
74
+ throw new Error('formatAssetUri: identifier must be a non-root absolute path');
75
+ }
76
+ return `${exports.ASSET_URI_PREFIX}${id}`;
77
+ }
78
+ /**
79
+ * Scan arbitrary text for `xcitedb:asset:` paths (GC / reference discovery).
80
+ * Paths are normalized the same way as {@link parseAssetUri} would accept.
81
+ */
82
+ function collectIdentifiersFromText(text, out) {
83
+ const p = exports.ASSET_URI_PREFIX;
84
+ let pos = 0;
85
+ while (pos < text.length) {
86
+ const f = text.indexOf(p, pos);
87
+ if (f < 0) {
88
+ break;
89
+ }
90
+ let i = f + p.length;
91
+ if (i >= text.length || text.charCodeAt(i) !== 0x2f) {
92
+ pos = f + 1;
93
+ continue;
94
+ }
95
+ let j = i;
96
+ while (j < text.length) {
97
+ const c = text.charCodeAt(j);
98
+ if (c === 0x2f || isPchar(c)) {
99
+ j += 1;
100
+ continue;
101
+ }
102
+ break;
103
+ }
104
+ if (j > i) {
105
+ const raw = text.slice(i, j);
106
+ try {
107
+ const decoded = decodeURIComponent(raw);
108
+ const id = normalizeIdentifierPath(decoded);
109
+ if (id.length > 1) {
110
+ out.add(id);
111
+ }
112
+ }
113
+ catch {
114
+ // skip malformed percent-encoding
115
+ }
116
+ }
117
+ pos = j;
118
+ }
119
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,20 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const node_test_1 = __importDefault(require("node:test"));
7
+ const strict_1 = __importDefault(require("node:assert/strict"));
8
+ const assetUri_1 = require("./assetUri");
9
+ (0, node_test_1.default)('parseAssetUri accepts xcitedb:asset path', () => {
10
+ strict_1.default.equal((0, assetUri_1.parseAssetUri)(`${assetUri_1.ASSET_URI_PREFIX}/users/u1/assets/a.png`), '/users/u1/assets/a.png');
11
+ });
12
+ (0, node_test_1.default)('formatAssetUri round-trip', () => {
13
+ const id = '/assets/photos/x';
14
+ strict_1.default.equal((0, assetUri_1.parseAssetUri)((0, assetUri_1.formatAssetUri)(id)), id);
15
+ });
16
+ (0, node_test_1.default)('collectIdentifiersFromText', () => {
17
+ const s = new Set();
18
+ (0, assetUri_1.collectIdentifiersFromText)(`see ${assetUri_1.ASSET_URI_PREFIX}/a/b and done`, s);
19
+ strict_1.default.ok(s.has('/a/b'));
20
+ });
package/dist/client.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { AccessCheckResult, AppAuthConfig, AppEmailConfig, AppEmailTemplates, AppUser, AppUserTokenPair, EmailTestResponse, ForgotPasswordResponse, SendVerificationResponse, BranchInfo, BookmarkRecord, CheckpointRecord, CommitRecord, CompareRef, CompareResult, DatabaseContext, DiffRef, DiffResult, DocumentBatchResponse, DocumentExportFormat, ExportDocumentResult, Flags, JsonDocumentBatchItem, ImportDocumentOptions, ImportDocumentResult, IdentifierChildNode, ListIdentifierChildrenResult, ListIdentifiersResult, LockInfo, AcquireLockOptions, LogEntry, MergeResult, PublishResult, RebaseUserWorkspaceResult, WorkspaceInfo, MetaValue, PlatformRegisterResult, PolicySubjectInput, UnqueryResult, UnqueryTemplate, PolicyUpdateResponse, RealtimeEvent, SecurityConfig, SecurityPolicy, StoredTriggerResponse, TriggerDefinition, StoredPolicyResponse, SubscriptionOptions, TagRecord, TextSearchQuery, TextSearchResult, ProjectSearchSettings, ProjectSearchSettingsUpdate, ProjectDocConfResponse, PlatformDefaultDocConfResponse, VectorIndexEstimate, RagQueryOptions, RagQueryResult, RagStreamEvent, OAuthProvidersResponse, ProjectInfo, PlatformRegistrationConfig, PlatformWorkspacesResponse, TokenPair, UserInfo, ApiKeyInfo, WriteDocumentOptions, XmlDocumentBatchItem, CreateTestSessionOptions, XCiteDBClientOptions, XCiteDBJwtClaims, TestSessionBootstrapSummary, TestSessionInfo, XCiteQuery, UserIsolationConfig, UserIsolationCreateShareParams, UserIsolationShareResult } from './types';
1
+ import { AccessCheckResult, AppAuthConfig, AppEmailConfig, AppEmailTemplates, AppUser, AppUserTokenPair, EmailTestResponse, ForgotPasswordResponse, SendVerificationResponse, BranchInfo, BookmarkRecord, CheckpointRecord, CommitRecord, CompareRef, CompareResult, DatabaseContext, DiffRef, DiffResult, DocumentBatchResponse, DocumentExportFormat, ExportDocumentResult, Flags, JsonDocumentBatchItem, ImportDocumentOptions, ImportDocumentResult, ListIdentifierChildrenResult, ListIdentifiersResult, LockInfo, AcquireLockOptions, LogEntry, MergeResult, PublishResult, RebaseUserWorkspaceResult, WorkspaceInfo, MetaValue, PlatformRegisterResult, PolicySubjectInput, UnqueryResult, UnqueryTemplate, PolicyUpdateResponse, RealtimeEvent, SecurityConfig, SecurityPolicy, StoredTriggerResponse, TriggerDefinition, StoredPolicyResponse, SubscriptionOptions, TagRecord, TextSearchQuery, TextSearchResult, ProjectSearchSettings, ProjectSearchSettingsUpdate, ProjectDocConfResponse, AssetGcDryRunResult, AssetHeadResult, AssetMagicLinkListResponse, AssetMagicLinkResult, AssetShareListResponse, AssetShareRequest, AssetUnshareRequest, AssetUploadResult, CreateAssetMagicLinkRequest, ProjectAssetStorageConfig, UploadAssetOptions, PlatformDefaultDocConfResponse, VectorIndexEstimate, RagQueryOptions, RagQueryResult, RagStreamEvent, OAuthProvidersResponse, ProjectInfo, PlatformRegistrationConfig, PlatformWorkspacesResponse, TokenPair, UserInfo, ApiKeyInfo, WriteDocumentOptions, XmlDocumentBatchItem, CreateTestSessionOptions, XCiteDBClientOptions, XCiteDBJwtClaims, TestSessionBootstrapSummary, TestSessionInfo, XCiteQuery, UserIsolationConfig, UserIsolationCreateShareParams, UserIsolationShareResult } from './types';
2
2
  import { WebSocketSubscription } from './websocket';
3
3
  export declare class XCiteDBClient {
4
4
  private baseUrl;
@@ -327,6 +327,21 @@ export declare class XCiteDBClient {
327
327
  * When {@link XCiteDBClient.enableUserIsolation} / `userIsolation` is active, `identifier` is prefixed like other document APIs.
328
328
  */
329
329
  createUserIsolationShare(params: UserIsolationCreateShareParams): Promise<UserIsolationShareResult>;
330
+ /** Share an asset path via prefix alias (`POST /api/v1/security/user-isolation/asset-shares`). */
331
+ createAssetShare(params: AssetShareRequest): Promise<UserIsolationShareResult>;
332
+ /** Remove an asset share (`DELETE /api/v1/security/user-isolation/asset-shares`). */
333
+ deleteAssetShare(params: AssetUnshareRequest): Promise<void>;
334
+ /** List incoming/public asset share aliases (`GET /api/v1/security/user-isolation/asset-shares`). */
335
+ listAssetShares(opts?: {
336
+ direction?: 'incoming' | 'outgoing' | 'public';
337
+ scope?: string;
338
+ }): Promise<AssetShareListResponse>;
339
+ /** Issue a time-limited magic link for one asset identifier (`POST /api/v1/security/asset-magic-links`). */
340
+ createAssetMagicLink(body: CreateAssetMagicLinkRequest): Promise<AssetMagicLinkResult>;
341
+ /** List issued magic links (no secrets) (`GET /api/v1/security/asset-magic-links`). */
342
+ listAssetMagicLinks(): Promise<AssetMagicLinkListResponse>;
343
+ /** Revoke a magic link (`DELETE /api/v1/security/asset-magic-links/{token_id}`). */
344
+ revokeAssetMagicLink(tokenId: string): Promise<void>;
330
345
  createWorkspace(name: string, fromBranch?: string, fromDate?: string): Promise<void>;
331
346
  /** @deprecated Use {@link createWorkspace}. */
332
347
  createBranch(name: string, fromBranch?: string, fromDate?: string): Promise<void>;
@@ -494,12 +509,18 @@ export declare class XCiteDBClient {
494
509
  * Write an **XML** document using a JSON request body (`xml` field). The identifier is taken from `db:identifier` on the root element.
495
510
  * For storing JSON data by key, use `writeJsonDocument`.
496
511
  *
512
+ * **Pruned writes:** you may include **`db:stub="true"`** (or legacy **`<db:N…/>`**) under a parent to preserve shredded children
513
+ * without inlining their bodies; stubs must be empty and reference an existing row (see product `llms.txt` / OpenAPI notes).
514
+ * Malformed hierarchical identifiers return **400** `invalid_identifier`; invalid stub/placeholder XML returns **400** `xml_write_rejected`.
515
+ *
497
516
  * On cooperative-lock conflict the server returns **423** with a {@link LockConflictBody} shape in {@link XCiteDBError.body}.
498
517
  */
499
518
  writeXmlDocument(xml: string, options?: WriteDocumentOptions): Promise<void>;
500
519
  /**
501
520
  * Best-effort batch XML writes (`POST /api/v1/documents/batch`). Each item is independent; check `results[].ok`.
502
521
  * Rows with `error === 'lock_conflict'` include `current_lock` (code **423** per row).
522
+ * **`409`** precheck rows may include **`detail`** (`identifier` for duplicates; `parent` + `inlined_child` for parent inline).
523
+ * Identifiers on **`db:stub="true"`** only are batch references and do not count as inlined bodies for precheck.
503
524
  */
504
525
  writeXmlDocumentsBatch(items: XmlDocumentBatchItem[]): Promise<DocumentBatchResponse>;
505
526
  /**
@@ -519,20 +540,21 @@ export declare class XCiteDBClient {
519
540
  writeDocumentJson(xml: string, options?: WriteDocumentOptions): Promise<void>;
520
541
  queryByIdentifier(identifier: string, flags?: Flags, filter?: string, pathFilter?: string): Promise<string[]>;
521
542
  /**
522
- * Shallow read: root element with inline leaves plus `db:N*` placeholders for shredded child slots
523
- * (`flags=NoChildren,KeepIndexNodes,FirstMatch` on `GET /api/v1/documents/by-id`). Use with {@link listChildIdentifiers}
524
- * or {@link queryByIdentifierWithChildren} for sidebar / AST navigation without loading the full subtree.
543
+ * Shallow read (`flags=NoChildren,KeepIndexNodes,FirstMatch` on `GET /api/v1/documents/by-id`):
544
+ * root element with inline leaves plus **identifier-bearing stub elements** (`db:stub="true"`) for shredded
545
+ * child slots instead of opaque `db:N*` placeholders. Safe to round-trip with {@link writeXmlDocument}.
546
+ * For sidebar / AST navigation, pair with {@link listIdentifierChildren} (e.g. `Promise.all` of shallow + children).
525
547
  */
526
548
  queryByIdentifierShallow(identifier: string, filter?: string, pathFilter?: string): Promise<string[]>;
527
- /** Alias of {@link listIdentifierChildren} — immediate child segments under `parentPath` (identifier hierarchy). */
528
- listChildIdentifiers(parentPath?: string): Promise<ListIdentifierChildrenResult>;
529
549
  /**
530
- * Parallel shallow node + one level of identifier children (`GET /by-id` + `GET /identifier-children`).
550
+ * Load a document with all shredded children inlined (`FirstMatch,IncludeChildren` on `GET /by-id`).
551
+ * The returned array has **length 0 or 1**; when present, index `0` is the full XML string.
552
+ * Use this for editor round-trips. For navigation without loading the full subtree, use
553
+ * {@link queryByIdentifierShallow} plus {@link listIdentifierChildren}.
531
554
  */
532
- queryByIdentifierWithChildren(identifier: string, filter?: string, pathFilter?: string): Promise<{
533
- node: string[];
534
- children: IdentifierChildNode[];
535
- }>;
555
+ queryByIdentifierFull(identifier: string, filter?: string, pathFilter?: string): Promise<string[]>;
556
+ /** Alias of {@link listIdentifierChildren} — immediate child segments under `parentPath` (identifier hierarchy). */
557
+ listChildIdentifiers(parentPath?: string): Promise<ListIdentifierChildrenResult>;
536
558
  queryDocuments(query: XCiteQuery, flags?: Flags, filter?: string, pathFilter?: string): Promise<string[]>;
537
559
  deleteDocument(identifier: string): Promise<void>;
538
560
  addIdentifier(identifier: string): Promise<boolean>;
@@ -636,6 +658,38 @@ export declare class XCiteDBClient {
636
658
  }): Promise<ProjectDocConfResponse>;
637
659
  /** Remove project override; server uses platform default (`DELETE /api/v1/project/settings/doc-conf`). */
638
660
  deleteProjectDocConf(): Promise<ProjectDocConfResponse>;
661
+ /** Admin: asset storage v2 (`GET /api/v1/project/settings/asset-storage`). */
662
+ getProjectAssetStorage(): Promise<ProjectAssetStorageConfig>;
663
+ /** Admin: save asset storage v2 (`PUT /api/v1/project/settings/asset-storage`). */
664
+ updateProjectAssetStorage(body: ProjectAssetStorageConfig): Promise<ProjectAssetStorageConfig>;
665
+ private resolveAssetIdentifier;
666
+ /** URL path under `/api/v1/assets/…` with per-segment encoding. */
667
+ private assetRequestPath;
668
+ private assetQuerySuffix;
669
+ /**
670
+ * Upload raw bytes (`POST /api/v1/assets`). Returns canonical `identifier` and `xcitedb:asset:…` `uri`.
671
+ */
672
+ uploadAsset(bytes: Blob | ArrayBuffer | Uint8Array, opts: UploadAssetOptions): Promise<AssetUploadResult>;
673
+ /**
674
+ * Download asset bytes (`GET /api/v1/assets/…`). Follows `302` to presigned S3 URLs.
675
+ * Pass `ml` for magic-link access (same token as `Authorization: MagicLink …` when not using cookies).
676
+ */
677
+ getAsset(uriOrIdentifier: string, opts?: {
678
+ ifNoneMatch?: string;
679
+ ml?: string;
680
+ }): Promise<Blob>;
681
+ /** Delete an asset (`DELETE /api/v1/assets/…`). */
682
+ deleteAsset(uriOrIdentifier: string, opts?: {
683
+ ml?: string;
684
+ }): Promise<void>;
685
+ /** `HEAD /api/v1/assets/…` — metadata only. */
686
+ headAsset(uriOrIdentifier: string, opts?: {
687
+ ml?: string;
688
+ }): Promise<AssetHeadResult>;
689
+ /** Admin: GC dry-run — manifest vs live meta references (`POST /api/v1/admin/assets/gc/dry-run`). */
690
+ adminAssetsGcDryRun(opts?: {
691
+ ownerUserId?: string;
692
+ }): Promise<AssetGcDryRunResult>;
639
693
  /** Embedded platform default `document.conf` text (`GET /api/v1/platform/default-doc-conf`). */
640
694
  getPlatformDefaultDocConf(): Promise<PlatformDefaultDocConfResponse>;
641
695
  /** Blocking full DB scan (admin; no calls to embedding API). Prefer {@link postVectorIndexEstimateSession} for UI. */
package/dist/client.js CHANGED
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.XCiteDBClient = void 0;
4
4
  const types_1 = require("./types");
5
5
  const websocket_1 = require("./websocket");
6
+ const assetUri_1 = require("./assetUri");
6
7
  function joinUrl(base, path) {
7
8
  const b = base.replace(/\/+$/, '');
8
9
  const p = path.startsWith('/') ? path : `/${path}`;
@@ -1214,6 +1215,64 @@ class XCiteDBClient {
1214
1215
  mode: params.mode,
1215
1216
  });
1216
1217
  }
1218
+ /** Share an asset path via prefix alias (`POST /api/v1/security/user-isolation/asset-shares`). */
1219
+ async createAssetShare(params) {
1220
+ const body = {
1221
+ target_user_id: params.target_user_id,
1222
+ mode: params.mode,
1223
+ };
1224
+ if (params.source_uri !== undefined && params.source_uri !== '') {
1225
+ body.source_uri = params.source_uri;
1226
+ }
1227
+ else if (params.identifier !== undefined && params.identifier !== '') {
1228
+ body.identifier = this.isoPrefixId(params.identifier);
1229
+ }
1230
+ else {
1231
+ throw new types_1.XCiteDBError('createAssetShare requires identifier or source_uri', 400, null);
1232
+ }
1233
+ return this.request('POST', '/api/v1/security/user-isolation/asset-shares', body);
1234
+ }
1235
+ /** Remove an asset share (`DELETE /api/v1/security/user-isolation/asset-shares`). */
1236
+ async deleteAssetShare(params) {
1237
+ const body = {};
1238
+ if (params.source_uri !== undefined && params.source_uri !== '') {
1239
+ body.source_uri = params.source_uri;
1240
+ }
1241
+ else if (params.identifier !== undefined && params.identifier !== '') {
1242
+ body.identifier = this.isoPrefixId(params.identifier);
1243
+ }
1244
+ else {
1245
+ throw new types_1.XCiteDBError('deleteAssetShare requires identifier or source_uri', 400, null);
1246
+ }
1247
+ if (params.target_user_id !== undefined) {
1248
+ body.target_user_id = params.target_user_id;
1249
+ }
1250
+ if (params.sharer_user_id !== undefined) {
1251
+ body.sharer_user_id = params.sharer_user_id;
1252
+ }
1253
+ await this.request('DELETE', '/api/v1/security/user-isolation/asset-shares', body);
1254
+ }
1255
+ /** List incoming/public asset share aliases (`GET /api/v1/security/user-isolation/asset-shares`). */
1256
+ async listAssetShares(opts) {
1257
+ const q = buildQuery({
1258
+ direction: opts?.direction ?? 'incoming',
1259
+ ...(opts?.scope ? { scope: opts.scope } : {}),
1260
+ });
1261
+ return this.request('GET', `/api/v1/security/user-isolation/asset-shares${q}`);
1262
+ }
1263
+ /** Issue a time-limited magic link for one asset identifier (`POST /api/v1/security/asset-magic-links`). */
1264
+ async createAssetMagicLink(body) {
1265
+ return this.request('POST', '/api/v1/security/asset-magic-links', body);
1266
+ }
1267
+ /** List issued magic links (no secrets) (`GET /api/v1/security/asset-magic-links`). */
1268
+ async listAssetMagicLinks() {
1269
+ return this.request('GET', '/api/v1/security/asset-magic-links');
1270
+ }
1271
+ /** Revoke a magic link (`DELETE /api/v1/security/asset-magic-links/{token_id}`). */
1272
+ async revokeAssetMagicLink(tokenId) {
1273
+ const id = encodeURIComponent(tokenId.trim());
1274
+ await this.request('DELETE', `/api/v1/security/asset-magic-links/${id}`);
1275
+ }
1217
1276
  async createWorkspace(name, fromBranch, fromDate) {
1218
1277
  const body = { name };
1219
1278
  if (fromBranch)
@@ -1479,6 +1538,10 @@ class XCiteDBClient {
1479
1538
  * Write an **XML** document using a JSON request body (`xml` field). The identifier is taken from `db:identifier` on the root element.
1480
1539
  * For storing JSON data by key, use `writeJsonDocument`.
1481
1540
  *
1541
+ * **Pruned writes:** you may include **`db:stub="true"`** (or legacy **`<db:N…/>`**) under a parent to preserve shredded children
1542
+ * without inlining their bodies; stubs must be empty and reference an existing row (see product `llms.txt` / OpenAPI notes).
1543
+ * Malformed hierarchical identifiers return **400** `invalid_identifier`; invalid stub/placeholder XML returns **400** `xml_write_rejected`.
1544
+ *
1482
1545
  * On cooperative-lock conflict the server returns **423** with a {@link LockConflictBody} shape in {@link XCiteDBError.body}.
1483
1546
  */
1484
1547
  async writeXmlDocument(xml, options) {
@@ -1491,6 +1554,8 @@ class XCiteDBClient {
1491
1554
  /**
1492
1555
  * Best-effort batch XML writes (`POST /api/v1/documents/batch`). Each item is independent; check `results[].ok`.
1493
1556
  * Rows with `error === 'lock_conflict'` include `current_lock` (code **423** per row).
1557
+ * **`409`** precheck rows may include **`detail`** (`identifier` for duplicates; `parent` + `inlined_child` for parent inline).
1558
+ * Identifiers on **`db:stub="true"`** only are batch references and do not count as inlined bodies for precheck.
1494
1559
  */
1495
1560
  async writeXmlDocumentsBatch(items) {
1496
1561
  const body = {
@@ -1575,27 +1640,27 @@ class XCiteDBClient {
1575
1640
  return Array.isArray(rows) ? rows.map((x) => this.isoUnprefixId(String(x))) : rows;
1576
1641
  }
1577
1642
  /**
1578
- * Shallow read: root element with inline leaves plus `db:N*` placeholders for shredded child slots
1579
- * (`flags=NoChildren,KeepIndexNodes,FirstMatch` on `GET /api/v1/documents/by-id`). Use with {@link listChildIdentifiers}
1580
- * or {@link queryByIdentifierWithChildren} for sidebar / AST navigation without loading the full subtree.
1643
+ * Shallow read (`flags=NoChildren,KeepIndexNodes,FirstMatch` on `GET /api/v1/documents/by-id`):
1644
+ * root element with inline leaves plus **identifier-bearing stub elements** (`db:stub="true"`) for shredded
1645
+ * child slots instead of opaque `db:N*` placeholders. Safe to round-trip with {@link writeXmlDocument}.
1646
+ * For sidebar / AST navigation, pair with {@link listIdentifierChildren} (e.g. `Promise.all` of shallow + children).
1581
1647
  */
1582
1648
  async queryByIdentifierShallow(identifier, filter, pathFilter) {
1583
1649
  return this.queryByIdentifier(identifier, 'NoChildren,KeepIndexNodes,FirstMatch', filter, pathFilter);
1584
1650
  }
1651
+ /**
1652
+ * Load a document with all shredded children inlined (`FirstMatch,IncludeChildren` on `GET /by-id`).
1653
+ * The returned array has **length 0 or 1**; when present, index `0` is the full XML string.
1654
+ * Use this for editor round-trips. For navigation without loading the full subtree, use
1655
+ * {@link queryByIdentifierShallow} plus {@link listIdentifierChildren}.
1656
+ */
1657
+ async queryByIdentifierFull(identifier, filter, pathFilter) {
1658
+ return this.queryByIdentifier(identifier, 'FirstMatch,IncludeChildren', filter, pathFilter);
1659
+ }
1585
1660
  /** Alias of {@link listIdentifierChildren} — immediate child segments under `parentPath` (identifier hierarchy). */
1586
1661
  async listChildIdentifiers(parentPath) {
1587
1662
  return this.listIdentifierChildren(parentPath);
1588
1663
  }
1589
- /**
1590
- * Parallel shallow node + one level of identifier children (`GET /by-id` + `GET /identifier-children`).
1591
- */
1592
- async queryByIdentifierWithChildren(identifier, filter, pathFilter) {
1593
- const [node, listed] = await Promise.all([
1594
- this.queryByIdentifierShallow(identifier, filter, pathFilter),
1595
- this.listIdentifierChildren(identifier),
1596
- ]);
1597
- return { node, children: listed.children };
1598
- }
1599
1664
  async queryDocuments(query, flags, filter, pathFilter) {
1600
1665
  const pq = this.isoPrefixQuery(query);
1601
1666
  const params = {
@@ -1953,6 +2018,260 @@ class XCiteDBClient {
1953
2018
  async deleteProjectDocConf() {
1954
2019
  return this.request('DELETE', '/api/v1/project/settings/doc-conf');
1955
2020
  }
2021
+ /** Admin: asset storage v2 (`GET /api/v1/project/settings/asset-storage`). */
2022
+ async getProjectAssetStorage() {
2023
+ return this.request('GET', '/api/v1/project/settings/asset-storage');
2024
+ }
2025
+ /** Admin: save asset storage v2 (`PUT /api/v1/project/settings/asset-storage`). */
2026
+ async updateProjectAssetStorage(body) {
2027
+ return this.request('PUT', '/api/v1/project/settings/asset-storage', body);
2028
+ }
2029
+ resolveAssetIdentifier(uriOrIdentifier) {
2030
+ const trimmed = uriOrIdentifier.trim();
2031
+ const fromUri = (0, assetUri_1.parseAssetUri)(trimmed);
2032
+ if (fromUri) {
2033
+ return this.canonicalId(fromUri);
2034
+ }
2035
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(trimmed)) {
2036
+ return `/assets/${trimmed.toLowerCase()}`;
2037
+ }
2038
+ return this.canonicalId(trimmed);
2039
+ }
2040
+ /** URL path under `/api/v1/assets/…` with per-segment encoding. */
2041
+ assetRequestPath(uriOrIdentifier) {
2042
+ const id = this.resolveAssetIdentifier(uriOrIdentifier);
2043
+ const segments = id.split('/').filter((s) => s.length > 0).map((s) => encodeURIComponent(s));
2044
+ return `/api/v1/assets/${segments.join('/')}`;
2045
+ }
2046
+ assetQuerySuffix(opts) {
2047
+ const ml = opts?.ml?.trim();
2048
+ if (!ml) {
2049
+ return '';
2050
+ }
2051
+ return `?ml=${encodeURIComponent(ml)}`;
2052
+ }
2053
+ /**
2054
+ * Upload raw bytes (`POST /api/v1/assets`). Returns canonical `identifier` and `xcitedb:asset:…` `uri`.
2055
+ */
2056
+ async uploadAsset(bytes, opts) {
2057
+ let bodyBytes;
2058
+ if (bytes instanceof Blob) {
2059
+ bodyBytes = await bytes.arrayBuffer();
2060
+ }
2061
+ else if (bytes instanceof ArrayBuffer) {
2062
+ bodyBytes = bytes;
2063
+ }
2064
+ else {
2065
+ bodyBytes = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
2066
+ }
2067
+ const ct = opts.contentType.trim() || 'application/octet-stream';
2068
+ const qParts = [];
2069
+ if (opts.scope === 'public') {
2070
+ qParts.push('scope=public');
2071
+ }
2072
+ if (opts.identifier !== undefined && opts.identifier !== '') {
2073
+ qParts.push(`identifier=${encodeURIComponent(opts.identifier)}`);
2074
+ }
2075
+ const q = qParts.length ? `?${qParts.join('&')}` : '';
2076
+ const no401Retry = false;
2077
+ const suppressTestSessionHeader = false;
2078
+ for (let attempt = 0; attempt < 2; attempt++) {
2079
+ const url = joinUrl(this.baseUrl, `/api/v1/assets${q}`);
2080
+ const outgoingRequestId = newClientRequestId();
2081
+ const headers = {
2082
+ ...this.authHeaders(),
2083
+ ...this.contextHeaders(),
2084
+ ...(suppressTestSessionHeader ? {} : this.testHeaders()),
2085
+ 'X-Request-Id': outgoingRequestId,
2086
+ 'Content-Type': ct,
2087
+ };
2088
+ const init = { method: 'POST', headers, body: bodyBytes };
2089
+ const sig = requestTimeoutSignal(this.requestTimeoutMs);
2090
+ if (sig)
2091
+ init.signal = sig;
2092
+ let res;
2093
+ try {
2094
+ res = await fetch(url, init);
2095
+ }
2096
+ catch (e) {
2097
+ const m = e instanceof Error ? e.message : 'Network error';
2098
+ throw new types_1.XCiteDBError(m, 0, null, { clientRequestId: outgoingRequestId });
2099
+ }
2100
+ const text = await res.text();
2101
+ let data;
2102
+ try {
2103
+ data = text ? JSON.parse(text) : null;
2104
+ }
2105
+ catch {
2106
+ data = text;
2107
+ }
2108
+ if (res.ok) {
2109
+ if (!data || typeof data !== 'object') {
2110
+ throw new types_1.XCiteDBError('Invalid asset upload response', res.status, data);
2111
+ }
2112
+ const o = data;
2113
+ const identifier = typeof o.identifier === 'string' ? o.identifier : '';
2114
+ const uri = typeof o.uri === 'string' ? o.uri : '';
2115
+ if (!identifier || !uri) {
2116
+ throw new types_1.XCiteDBError('Invalid asset upload response', res.status, data);
2117
+ }
2118
+ const out = { identifier, uri };
2119
+ if (typeof o.target_name === 'string')
2120
+ out.target_name = o.target_name;
2121
+ if (typeof o.content_type === 'string')
2122
+ out.content_type = o.content_type;
2123
+ if (typeof o.size === 'number')
2124
+ out.size = o.size;
2125
+ if (typeof o.sha256 === 'string')
2126
+ out.sha256 = o.sha256;
2127
+ if (typeof o.etag === 'string')
2128
+ out.etag = o.etag;
2129
+ return out;
2130
+ }
2131
+ if (res.status === 401 &&
2132
+ attempt === 0 &&
2133
+ !no401Retry &&
2134
+ (await this.tryRefreshSessionAfter401())) {
2135
+ continue;
2136
+ }
2137
+ const msg = typeof data === 'object' && data !== null && 'message' in data
2138
+ ? String(data.message)
2139
+ : res.statusText;
2140
+ this.notifySessionInvalidIfNeeded('/api/v1/assets', res.status);
2141
+ throwForFailedHttp(res.status, '/api/v1/assets', data, msg || `HTTP ${res.status}`, res.headers.get('X-Request-Id') ?? undefined, res.headers.get('X-Client-Request-Id') ?? undefined);
2142
+ }
2143
+ this.notifySessionInvalidIfNeeded('/api/v1/assets', 401);
2144
+ throw new types_1.XCiteDBError('Request failed after retry', 401, null);
2145
+ }
2146
+ /**
2147
+ * Download asset bytes (`GET /api/v1/assets/…`). Follows `302` to presigned S3 URLs.
2148
+ * Pass `ml` for magic-link access (same token as `Authorization: MagicLink …` when not using cookies).
2149
+ */
2150
+ async getAsset(uriOrIdentifier, opts) {
2151
+ const path = `${this.assetRequestPath(uriOrIdentifier)}${this.assetQuerySuffix({ ml: opts?.ml })}`;
2152
+ const no401Retry = false;
2153
+ const suppressTestSessionHeader = false;
2154
+ for (let attempt = 0; attempt < 2; attempt++) {
2155
+ const url = joinUrl(this.baseUrl, path);
2156
+ const outgoingRequestId = newClientRequestId();
2157
+ const headers = {
2158
+ ...this.authHeaders(),
2159
+ ...this.contextHeaders(),
2160
+ ...(suppressTestSessionHeader ? {} : this.testHeaders()),
2161
+ 'X-Request-Id': outgoingRequestId,
2162
+ };
2163
+ if (opts?.ifNoneMatch) {
2164
+ headers['If-None-Match'] = opts.ifNoneMatch;
2165
+ }
2166
+ const init = { method: 'GET', headers, redirect: 'follow' };
2167
+ const sig = requestTimeoutSignal(this.requestTimeoutMs);
2168
+ if (sig)
2169
+ init.signal = sig;
2170
+ let res;
2171
+ try {
2172
+ res = await fetch(url, init);
2173
+ }
2174
+ catch (e) {
2175
+ const m = e instanceof Error ? e.message : 'Network error';
2176
+ throw new types_1.XCiteDBError(m, 0, null, { clientRequestId: outgoingRequestId });
2177
+ }
2178
+ if (res.status === 304) {
2179
+ return new Blob();
2180
+ }
2181
+ if (res.ok) {
2182
+ return await res.blob();
2183
+ }
2184
+ if (res.status === 401 &&
2185
+ attempt === 0 &&
2186
+ !no401Retry &&
2187
+ (await this.tryRefreshSessionAfter401())) {
2188
+ continue;
2189
+ }
2190
+ const text = await res.text();
2191
+ let data;
2192
+ try {
2193
+ data = text ? JSON.parse(text) : null;
2194
+ }
2195
+ catch {
2196
+ data = text;
2197
+ }
2198
+ const msg = typeof data === 'object' && data !== null && 'message' in data
2199
+ ? String(data.message)
2200
+ : res.statusText;
2201
+ this.notifySessionInvalidIfNeeded(path, res.status);
2202
+ throwForFailedHttp(res.status, path, data, msg || `HTTP ${res.status}`, res.headers.get('X-Request-Id') ?? undefined, res.headers.get('X-Client-Request-Id') ?? undefined);
2203
+ }
2204
+ this.notifySessionInvalidIfNeeded(path, 401);
2205
+ throw new types_1.XCiteDBError('Request failed after retry', 401, null);
2206
+ }
2207
+ /** Delete an asset (`DELETE /api/v1/assets/…`). */
2208
+ async deleteAsset(uriOrIdentifier, opts) {
2209
+ const path = `${this.assetRequestPath(uriOrIdentifier)}${this.assetQuerySuffix({ ml: opts?.ml })}`;
2210
+ await this.request('DELETE', path);
2211
+ }
2212
+ /** `HEAD /api/v1/assets/…` — metadata only. */
2213
+ async headAsset(uriOrIdentifier, opts) {
2214
+ const path = `${this.assetRequestPath(uriOrIdentifier)}${this.assetQuerySuffix({ ml: opts?.ml })}`;
2215
+ const no401Retry = false;
2216
+ const suppressTestSessionHeader = false;
2217
+ for (let attempt = 0; attempt < 2; attempt++) {
2218
+ const url = joinUrl(this.baseUrl, path);
2219
+ const outgoingRequestId = newClientRequestId();
2220
+ const headers = {
2221
+ ...this.authHeaders(),
2222
+ ...this.contextHeaders(),
2223
+ ...(suppressTestSessionHeader ? {} : this.testHeaders()),
2224
+ 'X-Request-Id': outgoingRequestId,
2225
+ };
2226
+ const init = { method: 'HEAD', headers };
2227
+ const sig = requestTimeoutSignal(this.requestTimeoutMs);
2228
+ if (sig)
2229
+ init.signal = sig;
2230
+ let res;
2231
+ try {
2232
+ res = await fetch(url, init);
2233
+ }
2234
+ catch (e) {
2235
+ const m = e instanceof Error ? e.message : 'Network error';
2236
+ throw new types_1.XCiteDBError(m, 0, null, { clientRequestId: outgoingRequestId });
2237
+ }
2238
+ if (res.ok) {
2239
+ const etagRaw = res.headers.get('ETag') ?? undefined;
2240
+ const ct = res.headers.get('Content-Type') ?? 'application/octet-stream';
2241
+ const len = res.headers.get('Content-Length');
2242
+ const size = len ? parseInt(len, 10) : 0;
2243
+ return { contentType: ct, size: Number.isFinite(size) ? size : 0, etag: etagRaw };
2244
+ }
2245
+ if (res.status === 401 &&
2246
+ attempt === 0 &&
2247
+ !no401Retry &&
2248
+ (await this.tryRefreshSessionAfter401())) {
2249
+ continue;
2250
+ }
2251
+ const text = await res.text();
2252
+ let data;
2253
+ try {
2254
+ data = text ? JSON.parse(text) : null;
2255
+ }
2256
+ catch {
2257
+ data = text;
2258
+ }
2259
+ const msg = typeof data === 'object' && data !== null && 'message' in data
2260
+ ? String(data.message)
2261
+ : res.statusText;
2262
+ this.notifySessionInvalidIfNeeded(path, res.status);
2263
+ throwForFailedHttp(res.status, path, data, msg || `HTTP ${res.status}`, res.headers.get('X-Request-Id') ?? undefined, res.headers.get('X-Client-Request-Id') ?? undefined);
2264
+ }
2265
+ this.notifySessionInvalidIfNeeded(path, 401);
2266
+ throw new types_1.XCiteDBError('Request failed after retry', 401, null);
2267
+ }
2268
+ /** Admin: GC dry-run — manifest vs live meta references (`POST /api/v1/admin/assets/gc/dry-run`). */
2269
+ async adminAssetsGcDryRun(opts) {
2270
+ const q = opts?.ownerUserId && opts.ownerUserId.trim()
2271
+ ? `?owner=${encodeURIComponent(opts.ownerUserId.trim())}`
2272
+ : '';
2273
+ return this.request('POST', `/api/v1/admin/assets/gc/dry-run${q}`, {});
2274
+ }
1956
2275
  /** Embedded platform default `document.conf` text (`GET /api/v1/platform/default-doc-conf`). */
1957
2276
  async getPlatformDefaultDocConf() {
1958
2277
  return this.request('GET', '/api/v1/platform/default-doc-conf');
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { XCiteDBClient } from './client';
2
+ export { parseAssetUri, formatAssetUri, collectIdentifiersFromText, ASSET_URI_PREFIX } from './assetUri';
2
3
  export { WebSocketSubscription } from './websocket';
3
- export type { AccessCheckResult, ApiKeyInfo, AppAuthConfig, AppEmailConfig, AppEmailSmtpConfig, AppEmailTemplateEntry, AppEmailTemplates, AppEmailWebhookConfig, AppUser, AppUserTokenPair, EmailTestResponse, ForgotPasswordResponse, SendVerificationResponse, BookmarkRecord, BranchInfo, BranchListItem, CheckpointRecord, CommitRecord, CompareEntry, CompareRef, CompareResult, DatabaseContext, DiffEntry, DiffRef, DiffResult, DocumentBatchResponse, DocumentBatchResultRow, DocumentExportFormat, DocumentImportFormat, ExportDocumentResult, Flags, ImportDocumentOptions, ImportDocumentResult, JsonDocumentData, JsonDocumentBatchItem, IdentifierChildNode, ListIdentifierChildrenResult, ListIdentifiersResult, LockInfo, AcquireLockOptions, LockConflictBody, LockExpiredBody, LockUnknownBody, MergeConflict, MergeResult, OAuthProviderInfo, OAuthProvidersResponse, OwnedTenantInfo, ProjectInfo, PlatformRegistrationConfig, PlatformWorkspaceOrg, PlatformWorkspacesResponse, ProjectSearchSettings, ProjectSearchSettingsUpdate, ProjectDocConfResponse, PlatformDefaultDocConfResponse, LogEntry, MetaValue, PlatformRegisterResult, PolicyUpdateResponse, PublishConflict, PublishResult, RebaseUserWorkspaceResult, PolicyConditions, PolicyIdentifierPattern, PolicyResources, PolicySubjectInput, PolicySubjects, RagQueryOptions, RagQueryResult, RagStreamEvent, RealtimeEvent, SearchIndexingProgress, SecurityConfig, SecurityPolicy, StoredPolicyResponse, StoredTriggerResponse, SubscriptionOptions, TagRecord, TextSearchHit, TextSearchQuery, TextSearchResult, TriggerDefinition, TokenPair, UserInfo, UserIsolationConfig, UserIsolationCreateShareParams, UserIsolationOptions, UserIsolationShareMode, UserIsolationShareResult, WorkspaceInfo, WriteDocumentOptions, XmlDocumentBatchItem, CreateTestSessionOptions, TestSessionBootstrap, TestSessionBootstrapSummary, TestSessionInfo, XCiteDBClientOptions, XCiteDBErrorExtras, XCiteDBJwtClaims, UnqueryResult, UnqueryTemplate, XCiteQuery, } from './types';
4
+ export type { AccessCheckResult, ApiKeyInfo, AppAuthConfig, AppEmailConfig, AppEmailSmtpConfig, AppEmailTemplateEntry, AppEmailTemplates, AppEmailWebhookConfig, AppUser, AppUserTokenPair, EmailTestResponse, ForgotPasswordResponse, SendVerificationResponse, BookmarkRecord, BranchInfo, BranchListItem, CheckpointRecord, CommitRecord, CompareEntry, CompareRef, CompareResult, DatabaseContext, DiffEntry, DiffRef, DiffResult, DocumentBatchResponse, DocumentBatchResultRow, DocumentExportFormat, DocumentImportFormat, ExportDocumentResult, Flags, ImportDocumentOptions, ImportDocumentResult, JsonDocumentData, JsonDocumentBatchItem, IdentifierChildNode, ListIdentifierChildrenResult, ListIdentifiersResult, LockInfo, AcquireLockOptions, LockConflictBody, LockExpiredBody, LockUnknownBody, MergeConflict, MergeResult, OAuthProviderInfo, OAuthProvidersResponse, OwnedTenantInfo, ProjectInfo, PlatformRegistrationConfig, PlatformWorkspaceOrg, PlatformWorkspacesResponse, ProjectSearchSettings, ProjectSearchSettingsUpdate, ProjectDocConfResponse, AssetGcDryRunResult, AssetHeadResult, AssetMagicLinkListResponse, AssetMagicLinkRecord, AssetMagicLinkResult, AssetShareListEntry, AssetShareListResponse, AssetShareRequest, AssetStorageImport, AssetStorageMount, AssetStorageTarget, AssetStorageTargetType, AssetUnshareRequest, AssetUploadResult, CreateAssetMagicLinkRequest, ProjectAssetStorageConfig, UploadAssetOptions, PlatformDefaultDocConfResponse, LogEntry, MetaValue, PlatformRegisterResult, PolicyUpdateResponse, PublishConflict, PublishResult, RebaseUserWorkspaceResult, PolicyConditions, PolicyIdentifierPattern, PolicyResources, PolicySubjectInput, PolicySubjects, RagQueryOptions, RagQueryResult, RagStreamEvent, RealtimeEvent, SearchIndexingProgress, SecurityConfig, SecurityPolicy, StoredPolicyResponse, StoredTriggerResponse, SubscriptionOptions, TagRecord, TextSearchHit, TextSearchQuery, TextSearchResult, TriggerDefinition, TokenPair, UserInfo, UserIsolationConfig, UserIsolationCreateShareParams, UserIsolationOptions, UserIsolationShareMode, UserIsolationShareResult, WorkspaceInfo, WriteDocumentOptions, XmlDocumentBatchItem, CreateTestSessionOptions, TestSessionBootstrap, TestSessionBootstrapSummary, TestSessionInfo, XCiteDBClientOptions, XCiteDBErrorExtras, XCiteDBJwtClaims, UnqueryResult, UnqueryTemplate, XCiteQuery, } from './types';
4
5
  export { XCiteDBError, XCiteDBForbiddenError, XCiteDBNotFoundError, XCiteDBAuthError, XCiteDBLockConflictError, } from './types';
package/dist/index.js CHANGED
@@ -1,8 +1,13 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.XCiteDBLockConflictError = exports.XCiteDBAuthError = exports.XCiteDBNotFoundError = exports.XCiteDBForbiddenError = exports.XCiteDBError = exports.WebSocketSubscription = exports.XCiteDBClient = void 0;
3
+ exports.XCiteDBLockConflictError = exports.XCiteDBAuthError = exports.XCiteDBNotFoundError = exports.XCiteDBForbiddenError = exports.XCiteDBError = exports.WebSocketSubscription = exports.ASSET_URI_PREFIX = exports.collectIdentifiersFromText = exports.formatAssetUri = exports.parseAssetUri = exports.XCiteDBClient = void 0;
4
4
  var client_1 = require("./client");
5
5
  Object.defineProperty(exports, "XCiteDBClient", { enumerable: true, get: function () { return client_1.XCiteDBClient; } });
6
+ var assetUri_1 = require("./assetUri");
7
+ Object.defineProperty(exports, "parseAssetUri", { enumerable: true, get: function () { return assetUri_1.parseAssetUri; } });
8
+ Object.defineProperty(exports, "formatAssetUri", { enumerable: true, get: function () { return assetUri_1.formatAssetUri; } });
9
+ Object.defineProperty(exports, "collectIdentifiersFromText", { enumerable: true, get: function () { return assetUri_1.collectIdentifiersFromText; } });
10
+ Object.defineProperty(exports, "ASSET_URI_PREFIX", { enumerable: true, get: function () { return assetUri_1.ASSET_URI_PREFIX; } });
6
11
  var websocket_1 = require("./websocket");
7
12
  Object.defineProperty(exports, "WebSocketSubscription", { enumerable: true, get: function () { return websocket_1.WebSocketSubscription; } });
8
13
  var types_1 = require("./types");
package/dist/types.d.ts CHANGED
@@ -170,6 +170,125 @@ export interface ProjectDocConfResponse {
170
170
  has_project_override: boolean;
171
171
  doc_conf_text: string | null;
172
172
  }
173
+ /** `POST /api/v1/assets` success body (`201`). */
174
+ export interface AssetUploadResult {
175
+ identifier: string;
176
+ uri: string;
177
+ target_name?: string;
178
+ content_type?: string;
179
+ size?: number;
180
+ sha256?: string;
181
+ etag?: string;
182
+ }
183
+ /** Options for {@link XCiteDBClient.uploadAsset}. */
184
+ export interface UploadAssetOptions {
185
+ contentType: string;
186
+ /** When set (with default scope), uploaded to this path after user-isolation prefixing rules on the server. */
187
+ identifier?: string;
188
+ /** `public` writes under `/public/assets/…`. Default project/user scope when omitted. */
189
+ scope?: 'project' | 'public';
190
+ }
191
+ /** Result of {@link XCiteDBClient.headAsset}. */
192
+ export interface AssetHeadResult {
193
+ contentType: string;
194
+ size: number;
195
+ etag?: string;
196
+ }
197
+ /** `POST /api/v1/admin/assets/gc/dry-run` (admin). */
198
+ export interface AssetGcDryRunResult {
199
+ manifest_count: number;
200
+ referenced_asset_count: number;
201
+ orphan_count: number;
202
+ orphan_sample: string[];
203
+ }
204
+ export type AssetStorageTargetType = 'internal' | 's3';
205
+ /** One named storage target in `asset_storage_v2` (`GET/PUT /api/v1/project/settings/asset-storage`). */
206
+ export interface AssetStorageTarget {
207
+ type: AssetStorageTargetType;
208
+ writable?: boolean;
209
+ s3_endpoint_url?: string;
210
+ s3_bucket?: string;
211
+ s3_region?: string;
212
+ access_key_id?: string;
213
+ /** On GET, redacted as `***` when set. */
214
+ secret_access_key?: string;
215
+ small_file_threshold_bytes?: number;
216
+ redirect_threshold_bytes?: number;
217
+ import_head_cache_ttl_seconds?: number;
218
+ [key: string]: unknown;
219
+ }
220
+ export interface AssetStorageMount {
221
+ path: string;
222
+ target: string;
223
+ key_template: string;
224
+ }
225
+ export interface AssetStorageImport {
226
+ path: string;
227
+ target: string;
228
+ key: string;
229
+ tree?: boolean;
230
+ content_type?: string;
231
+ }
232
+ /** Project asset storage v2 (`GET/PUT /api/v1/project/settings/asset-storage`). */
233
+ export interface ProjectAssetStorageConfig {
234
+ targets: Record<string, AssetStorageTarget>;
235
+ mounts: AssetStorageMount[];
236
+ imports: AssetStorageImport[];
237
+ }
238
+ /** Body for `POST /api/v1/security/user-isolation/asset-shares`. */
239
+ export interface AssetShareRequest {
240
+ /** Canonical or app-relative asset path (subject to user-isolation prefixing when enabled). */
241
+ identifier?: string;
242
+ /** `xcitedb:asset:/…` or raw `/…` path; not prefixed by the client. */
243
+ source_uri?: string;
244
+ target_user_id: string;
245
+ mode: UserIsolationShareMode;
246
+ }
247
+ /** Body for `DELETE /api/v1/security/user-isolation/asset-shares`. */
248
+ export interface AssetUnshareRequest {
249
+ identifier?: string;
250
+ source_uri?: string;
251
+ target_user_id?: string;
252
+ sharer_user_id?: string;
253
+ }
254
+ export interface AssetShareListEntry {
255
+ identifier: string;
256
+ mode: string;
257
+ }
258
+ /** `GET /api/v1/security/user-isolation/asset-shares` */
259
+ export interface AssetShareListResponse {
260
+ direction: string;
261
+ identifiers: AssetShareListEntry[];
262
+ scope?: string;
263
+ note?: string;
264
+ }
265
+ /** `POST /api/v1/security/asset-magic-links` */
266
+ export interface CreateAssetMagicLinkRequest {
267
+ source_uri: string;
268
+ actions: ('read' | 'write')[];
269
+ expires_in_seconds: number;
270
+ max_uses?: number;
271
+ }
272
+ export interface AssetMagicLinkResult {
273
+ token: string;
274
+ token_id?: string;
275
+ expires_at: string;
276
+ link_url: string;
277
+ }
278
+ export interface AssetMagicLinkRecord {
279
+ token_id: string;
280
+ identifier: string;
281
+ actions?: unknown;
282
+ issued_by_user_id?: string;
283
+ issued_at?: string;
284
+ expires_at?: string;
285
+ max_uses?: number;
286
+ uses?: number;
287
+ revoked?: boolean;
288
+ }
289
+ export interface AssetMagicLinkListResponse {
290
+ tokens: AssetMagicLinkRecord[];
291
+ }
173
292
  /** `GET /api/v1/platform/default-doc-conf` */
174
293
  export interface PlatformDefaultDocConfResponse {
175
294
  doc_conf_text: string;
@@ -429,8 +548,18 @@ export interface DocumentBatchResultRow {
429
548
  index: number;
430
549
  identifier: string;
431
550
  ok: boolean;
551
+ /**
552
+ * When `ok` is false, server batch precheck / write reason. Includes:
553
+ * `duplicate_identifier_in_batch` (409), `parent_inlines_batch_member` (409),
554
+ * `lock_conflict` (423), and other parse/policy strings.
555
+ */
432
556
  error?: string;
433
557
  code?: number;
558
+ /**
559
+ * Structured context for XML batch **`409`** precheck rows, e.g. `{ identifier }` for
560
+ * `duplicate_identifier_in_batch` or `{ parent, inlined_child }` for `parent_inlines_batch_member`.
561
+ */
562
+ detail?: Record<string, string>;
434
563
  /** Present when `error === 'lock_conflict'` (HTTP-style code 423 in batch row). */
435
564
  current_lock?: LockInfo;
436
565
  }
package/llms-full.txt CHANGED
@@ -12,7 +12,7 @@ Before reading the full reference, note these critical differences from typical
12
12
 
13
13
  2. **Identifiers are hierarchical, path-like strings** — e.g. `/us/bills/hr1`, `/manual/v2/chapter3`. They are NOT auto-generated UUIDs. The leading `/` is part of the identifier. Parent/child relationships are derived from the path structure.
14
14
 
15
- 3. **Documents are XML or JSON, not arbitrary blobs.** XML documents are the primary document type. JSON documents are a parallel store. Both are fully versioned.
15
+ 3. **Documents are XML or JSON, not arbitrary blobs.** XML documents are the primary document type. JSON documents are a parallel store. Both are fully versioned. **Binary files** use the separate **assets** subsystem (not document shredding): canonical paths like `/assets/<uuid>`, stable **`xcitedb:asset:/…`** URIs, **`POST` / `GET` / `HEAD` / `DELETE /api/v1/assets/…`**, project **`asset-storage` v2** config, optional user-isolation **asset-shares**, and **asset magic links** (`?ml=`).
16
16
 
17
17
  4. **Context (workspace + date) travels as HTTP headers** (`X-Workspace` preferred, `X-Branch` alias, `X-Date`, and optionally `X-Unversioned` for explicit flat writes), not URL path segments.
18
18
 
@@ -28,7 +28,9 @@ Before reading the full reference, note these critical differences from typical
28
28
 
29
29
  10. **Ephemeral test sessions.** `POST /api/v1/test/sessions` (authenticated) returns a UUID **`session_token`**. Clients send **`X-Test-Session: <token>`** on API calls to use an isolated, TTL- and quota-limited LMDB instead of production project data. Unless **`X-Test-Auth: required`** is set, **developer** JWT/API-key checks are bypassed (synthetic admin for wet tests), but **app-user** identity via **`X-App-User-Token`** or Bearer app-user JWT is still recognized. Management routes under **`/api/v1/test/*`** must not include `X-Test-Session`. With a default body (omit or `{}`), the test LMDB starts **empty** (no cloned production project config).
30
30
 
31
- 11. **Overlay test sessions.** Same **`POST`**, with JSON **`{"overlay":true}`**, while authenticated for the **project to debug** (project-scoped API key, or platform Bearer + **`X-Project-Id`**). The session metadata records overlay mode; subsequent requests need only **`X-Test-Session`**. The server opens **`XCiteDB(_test/<uuid>/data, <production data path>)`**: production is used as a **read-only base**; reads merge overlay + base; **writes never modify production**. If the production data directory is missing, opening the session database fails. JS **`createTestSession({ …, overlay: true })`**, C++ **`test_session_overlay`** + **`create_test_session`**, MCP **`create_test_session`** tool **`overlay: true`**.
31
+ 11. **Overlay test sessions.** Same **`POST`**, with JSON **`{"overlay":true}`**, while authenticated for the **project to debug** (project-scoped API key, or platform Bearer + **`X-Project-Id`**). The session metadata records overlay mode; subsequent requests need only **`X-Test-Session`**. The server opens **`XCiteDB(_test/<uuid>/data, <production data path>)`**: production is used as a **read-only base**; reads merge overlay + base; **writes never modify production**. If the production data directory is missing, opening the session database fails. JS **`createTestSession({ …, overlay: true })`**, C++ **`test_session_overlay`** + **`create_test_session`**, MCP **`create_test_session`** tool **`overlay: true`**. **Locks:** cooperative lock metadata is read from the **base** layer as well as the overlay, but lock acquire/release only affects the overlay—so **`lock_conflict`** during overlay tests can reflect a lock still held in production; use **`findLocks` / `forceReleaseLock`** on your paths or a non-overlay session when testing locks.
32
+
33
+ 12. **SDK asset helpers.** JavaScript: `uploadAsset`, `getAsset`, `headAsset`, `deleteAsset`, `resolveAssetIdentifier`, `formatAssetUri`, `collectIdentifiersFromText`, plus `createAssetShare` / `createAssetMagicLink` and related APIs. Python: `upload_asset`, `get_asset`, `head_asset`, `xcitedb.asset_uri`. C++: `xcitedb/asset_uri.hpp`, `XCiteDBClient::upload_asset`, `get_asset_raw`, `head_asset_raw`, `delete_asset`, and security helpers mirroring the other SDKs.
32
34
 
33
35
  ## Choosing the Right Versioning Approach
34
36
 
@@ -63,6 +65,8 @@ Legacy REST paths under `/api/v1/branches`, `/commits`, `/tags`, `/diff` remain
63
65
 
64
66
  7. **Do not mock XciteDB in tests — use ephemeral test sessions instead.** Unlike most BaaS platforms, XciteDB has built-in support for isolated, throwaway database sessions specifically designed for wet integration tests. Mocking the client skips the actual storage, versioning, querying, and ABAC behavior, producing tests that don't catch real integration issues. Use `createTestSession()` / `test_session()` / `create_test_session()` (SDK helpers) or `POST /api/v1/test/sessions` directly to get a real LMDB under `_test/<uuid>/` (empty by default, or **overlay** on read-only production with **`{"overlay":true}`** / **`overlay: true`** / **`test_session_overlay`**). See "Ephemeral test sessions" below.
65
67
 
68
+ 8. **Identifier strings (minting).** The API canonicalizes hierarchical paths (**leading `/`**, **no trailing `/`**). **`POST /api/v1/documents`** rejects malformed ids with **`400`** JSON **`{ "error": "invalid_identifier", "reason", "identifier", "detail" }`**: no commas, no ASCII control characters (including **`0x01`**), no `//` or empty segments, no adjacent duplicate segments (e.g. `/a/b/b/c`). Reserved namespaces include **`/_xcitedb/`**, **`/assets/`**, **`/public/assets/`**, and **`/public/shared/<tenant>/assets/`**. **`identifier_hierarchy_max_depth`** (default **4**) tunes hierarchy indexing; deeper paths still work but **`GET …/identifier-children`** may scan.
69
+
66
70
  ---
67
71
 
68
72
  # Part 1: Product Overview
@@ -426,7 +430,7 @@ With `Content-Type: application/json`:
426
430
 
427
431
  With `Content-Type: application/xml`: send raw XML body.
428
432
 
429
- The identifier is extracted from the `db:identifier` attribute in the root XML element.
433
+ The identifier is extracted from the `db:identifier` attribute in the root XML element. Malformed root identifiers return **`400`** (`invalid_identifier`). Invalid stub/placeholder XML on write returns **`400`** with `error: "xml_write_rejected"` and a `message` string.
430
434
 
431
435
  ## Get document by identifier
432
436
 
@@ -436,14 +440,57 @@ Query parameters: `identifier` (required), `flags` (optional: `FirstMatch`, `Inc
436
440
 
437
441
  ### Shallow node + subtree navigation
438
442
 
439
- - **`flags=NoChildren,KeepIndexNodes,FirstMatch`**: returns the matching node with inline leaf content plus **`db:N1`**, **`db:N2`**, … placeholders for shredded child slots (avoids loading the full deep subtree in one response).
443
+ - **`flags=NoChildren,KeepIndexNodes,FirstMatch`**: returns the matching node with inline leaf content plus **identifier-bearing stub elements** **`db:stub="true"`** for shredded child slots (real element names; avoids loading the full deep subtree in one response). The engine still stores internal **`<db:N…/>`** placeholders on disk.
444
+ - **`GET …/by-id`** with **`FirstMatch,IncludeChildren`** returns a JSON array of **length 0 or 1**; index `0` is the full assembled XML when present.
440
445
  - Combine with **`GET /api/v1/documents/identifier-children?parent_path=…`** for the next level of hierarchy (`segment`, `full_path`, `is_identifier`, `has_children`).
441
- - **JavaScript SDK:** `queryByIdentifierShallow(id)`, `listChildIdentifiers(parentPath?)`, `queryByIdentifierWithChildren(id)` (shallow XML + children in parallel).
446
+ - **JavaScript SDK:** `queryByIdentifierFull(id)` loads **`FirstMatch,IncludeChildren`** for editor round-trips; **`queryByIdentifierShallow(id)`** + **`listChildIdentifiers(id)`** (e.g. `Promise.all` client-side) for sidebar / tree expansion.
442
447
 
443
448
  ### Standalone subtree write (shredded model)
444
449
 
445
450
  You may `POST /api/v1/documents` with a root element whose `db:identifier` is a nested path (e.g. `/spaces/u/docs/doc1/sec-1`) without re-posting the parent document: the engine writes that subtree only and updates the identifier hierarchy index for the parent automatically. Keep `is_top: true` (default) on the write.
446
451
 
452
+ ### XML shredding semantics (must read)
453
+
454
+ - **Auto-shred.** Any element with **`db:identifier`** is stored as its own LMDB row. The parent’s stored XML keeps an internal **`<db:N…/>`** placeholder for that slot (engine-only). There is **no** size threshold and **no** opt-out.
455
+ - **Pruned writes.** Clients may send **stub** elements (**`db:stub="true"`**) or legacy **`<db:N…/>`** under a parent to preserve subtrees without inlining full XML. Stubs must be empty, reference an **existing** identifier, and must not appear at the document root; violations are **`400`** (`xml_write_rejected`).
456
+ - **`is_top` is a marker only.** It registers a **`TOP:<xcitepath>`** alias for tooling; it does **not** change shredding, merge, or overwrite behavior.
457
+ - **Batch ordering.** **`POST /api/v1/documents/batch`** processes **`items[]`** **sequentially**. Each item’s XML is shredded so **every** `db:identifier` in that payload becomes (or overwrites) its own row **except** ids that appear **only** on **`db:stub="true"`** elements (references, not inlined bodies). Precheck returns per-row **`409`**: **`duplicate_identifier_in_batch`** when the same canonical root id appears in more than one item (the **later** row is rejected), and **`parent_inlines_batch_member`** when one item’s XML still **fully inlines** another item’s **root** id in the same batch (stubs referencing another batch member are allowed).
458
+ - **Idempotency (retries).** Re-sending the same XML with the same **`X-Date`** (or unversioned mode) is a no-op when the revision is unchanged; reuse the date key on retries.
459
+
460
+ **Round-trip:** Element structure, child order, and text are preserved. The server may add **`db:order`**, **`db:xcitepath`**, **`xmlns:db`**. Attribute order and insignificant whitespace are **not** byte-stable—compare by DOM, not raw bytes.
461
+
462
+ ### Batch XML writes
463
+
464
+ **`POST /api/v1/documents/batch`** — JSON body **`{ "items": [ { "xml": "<…>", "is_top": true, "compare_attributes": false, "identifier": "/optional/match" }, … ] }`**. Response **`200`** with **`{ "results": [ { "index", "ok", "identifier", "error"?, "code"?, "detail"?, "current_lock"? } ] }`** (`detail` for **`409`** precheck rows: duplicate → `{ "identifier": "<root>" }`; parent inline → `{ "parent": "<root>", "inlined_child": "<other>" }`). Best-effort: later rows still run when earlier rows fail. JavaScript: **`writeXmlDocumentsBatch`**. Python: **`write_xml_documents_batch`**. C++: **`write_xml_documents_batch`**.
465
+
466
+ ### Minimal SPA editor recipe
467
+
468
+ ```typescript
469
+ await client.enableUserIsolation();
470
+ client.setContext({ workspace: '', project_id: projectId });
471
+
472
+ async function loadDocForEditor(id: string) {
473
+ return client.queryByIdentifierFull(id);
474
+ }
475
+
476
+ async function sidebarRow(id: string) {
477
+ const [node, listed] = await Promise.all([
478
+ client.queryByIdentifierShallow(id),
479
+ client.listIdentifierChildren(id),
480
+ ]);
481
+ return { node, children: listed.children };
482
+ }
483
+
484
+ async function saveDirty(items: XmlDocumentBatchItem[]) {
485
+ const { results } = await client.writeXmlDocumentsBatch(items);
486
+ results.forEach((r, i) => {
487
+ if (!r.ok) console.warn(i, r.error, r.code);
488
+ });
489
+ }
490
+ ```
491
+
492
+ **Batch save rules:** (1) At most **one** batch item per flush may use a given root **`db:identifier`** (`duplicate_identifier_in_batch`). (2) Do not ship a parent that **fully inlines** another item’s root id in the same batch (`parent_inlines_batch_member`); **`db:stub="true"`** references to another batch member are **allowed**. (3) **`is_top`** does not relax (1) or (2).
493
+
447
494
  ## Delete document
448
495
 
449
496
  **`DELETE /api/v1/documents/by-id?identifier=/book/ch1`**
@@ -1538,9 +1585,10 @@ interface DatabaseContext {
1538
1585
  - `importDocument(file, options?)` → `ImportDocumentResult` — multipart `POST /api/v1/documents/import`
1539
1586
  - `exportDocument(identifier, format?, options?)` → `ExportDocumentResult` — binary `GET /api/v1/documents/export`
1540
1587
  - `queryByIdentifier(identifier, flags?, filter?, pathFilter?)` → `string[]`
1541
- - `queryByIdentifierShallow(identifier, filter?, pathFilter?)` → `string[]` — `NoChildren,KeepIndexNodes,FirstMatch`
1588
+ - `queryByIdentifierFull(identifier, filter?, pathFilter?)` → `string[]` — `FirstMatch,IncludeChildren` (editor load); array length **0 or 1**
1589
+ - `queryByIdentifierShallow(identifier, filter?, pathFilter?)` → `string[]` — `NoChildren,KeepIndexNodes,FirstMatch` (skeleton with **`db:stub="true"`** child markers)
1542
1590
  - `listChildIdentifiers(parentPath?)` → `ListIdentifierChildrenResult` — alias of `listIdentifierChildren`
1543
- - `queryByIdentifierWithChildren(identifier, filter?, pathFilter?)` → `{ node, children }`
1591
+ - `writeXmlDocumentsBatch(items)` → `DocumentBatchResponse` `POST /api/v1/documents/batch` (per-row `409` + optional **`detail`**; stubs exempt from duplicate-root / parent-inline checks per semantics above)
1544
1592
  - `queryDocuments(query: XCiteQuery, flags?, filter?, pathFilter?)` → `string[]`
1545
1593
  - `deleteDocument(identifier)` → `void`
1546
1594
  - `listIdentifiers(query: XCiteQuery)` → `ListIdentifiersResult`
package/llms.txt CHANGED
@@ -22,7 +22,9 @@ These are the most common sources of confusion for developers and AI assistants:
22
22
 
23
23
  8. **Project vs tenant id.** In the SDK, prefer `context.project_id` (and `listMyProjects` / `switchProject`). Many JSON bodies and JWT claims still use the field name `tenant_id` for the same value — the client sends that wire name automatically.
24
24
 
25
- 9. **Ephemeral test sessions (wet tests).** Call **`POST /api/v1/test/sessions`** with a normal API key or Bearer token to get a `session_token` (UUID). Send **`X-Test-Session: <token>`** on subsequent document/API calls to use an isolated, short-lived LMDB instead of production data. By default **developer** auth (API key / platform JWT) is bypassed, but **app-user** identity (`X-App-User-Token` or Bearer app-user JWT) is still recognized. Send **`X-Test-Auth: required`** to exercise full developer JWT/API-key auth and ABAC against the same test database. Do not send `X-Test-Session` on `/api/v1/test/*` management routes. Server limits apply (`test.session_ttl_seconds`, `test.max_sessions_per_key`, `test.max_test_db_size_bytes` in config). By default the test LMDB starts **empty** (no production data). **Overlay mode:** same **`POST`** with JSON body **`{"overlay":true}`** (while authenticated for the project you want to inspect—project-scoped API key, or platform Bearer + **`X-Project-Id`**). The server opens a **writable** LMDB under `_test/<uuid>/` with that project’s on-disk store as a **read-only base** (dual LMDB): reads see production + overlay deltas; **writes never touch production**. **`XCiteDBClient.createTestSession({ …, overlay: true })`** sends that body. After creation, only **`X-Test-Session`** is required on requests (overlay is stored in session metadata).
25
+ 9. **Ephemeral test sessions (wet tests).** Call **`POST /api/v1/test/sessions`** with a normal API key or Bearer token to get a `session_token` (UUID). Send **`X-Test-Session: <token>`** on subsequent document/API calls to use an isolated, short-lived LMDB instead of production data. By default **developer** auth (API key / platform JWT) is bypassed, but **app-user** identity (`X-App-User-Token` or Bearer app-user JWT) is still recognized. Send **`X-Test-Auth: required`** to exercise full developer JWT/API-key auth and ABAC against the same test database. Do not send `X-Test-Session` on `/api/v1/test/*` management routes. Server limits apply (`test.session_ttl_seconds`, `test.max_sessions_per_key`, `test.max_test_db_size_bytes` in config). By default the test LMDB starts **empty** (no production data). **Overlay mode:** same **`POST`** with JSON body **`{"overlay":true}`** (while authenticated for the project you want to inspect—project-scoped API key, or platform Bearer + **`X-Project-Id`**). The server opens a **writable** LMDB under `_test/<uuid>/` with that project’s on-disk store as a **read-only base** (dual LMDB): reads see production + overlay deltas; **writes never touch production**. **`XCiteDBClient.createTestSession({ …, overlay: true })`** sends that body. After creation, only **`X-Test-Session`** is required on requests (overlay is stored in session metadata). **Overlay + locks:** cooperative locks are stored in LMDB meta; overlay sessions **read through** to **base-layer** locks, but **`acquireLock` / `releaseLock`** only mutate the overlay—so a `lock_conflict` in an overlay test can still mean a lock held in production; clear with **`findLocks`** / **`forceReleaseLock`** or use a non-overlay session for lock-sensitive tests.
26
+
27
+ 10. **Binary assets (v2) are not XML/JSON documents.** Files live under canonical asset identifiers (e.g. `/assets/<uuid>`) and stable **`xcitedb:asset:/…`** URIs. **`POST /api/v1/assets`** uploads raw bytes; **`GET` / `HEAD` / `DELETE /api/v1/assets/…`** use per-segment URL encoding (optional **`?ml=`** magic-link bearer). Project routing is **`GET/PUT /api/v1/project/settings/asset-storage`** (`targets`, `mounts`, `imports`). User-isolation **asset-shares** and **asset magic links** live under **`/api/v1/security/user-isolation/asset-shares`** and **`/api/v1/security/asset-magic-links`**. JS/Python/C++ SDKs expose upload/get/head/delete + URI helpers (`parseAssetUri`, `asset_uri.parse`, `asset_uri::parse`) and share/magic-link clients.
26
28
 
27
29
  ## Choosing the Right Versioning Approach
28
30
 
@@ -72,6 +74,8 @@ Legacy REST paths (`/api/v1/branches`, `/commits`, `/tags`, `/diff`) remain as *
72
74
 
73
75
  12. **`X-Date` / `context.date` is not “server clock only.”** Revisions are keyed by the **instant you send**, not an implicit “now” when you set the header. To record a document under a **business date** (published, approved, effective), set **`X-Date`** or **`context.date`** to that instant before writing. Omitting **`X-Date`** does **not** substitute the current time on the write path — it selects **flat** writes (see convention 4).
74
76
 
77
+ 13. **Identifier strings (minting).** The API canonicalizes paths (**leading `/`**, **no trailing `/`**). **`POST /api/v1/documents`** rejects malformed hierarchical ids with **`400`** and JSON **`{ "error": "invalid_identifier", "reason", "identifier", "detail" }`**: no commas, no ASCII control characters (including **`0x01`** LMDB separator), no `//` or empty path segments, no **adjacent duplicate** path segments (e.g. `/a/b/b/c`). Reserved namespaces include **`/_xcitedb/`**, **`/assets/`**, **`/public/assets/`**, and **`/public/shared/<tenant>/assets/`**. Server config **`identifier_hierarchy_max_depth`** defaults to **4**—deeper paths still work, but **`identifier-children`** listing may fall back to a scan.
78
+
75
79
  ## API key capability matrix (typical)
76
80
 
77
81
  | Capability | API key `role` | Public key allowed? |
@@ -162,6 +166,48 @@ When you build a backend that calls XCiteDB on behalf of users:
162
166
 
163
167
  - **Standalone subtree root.** Because XCiteDB shreds XML per identifier, you can `writeXmlDocument('<sec db:identifier="/spaces/u/docs/doc1/sec-1">…</sec>')` and the engine writes only that subtree — you do **not** need to re-send the parent document. The parent's children index (`id_hier`) is updated automatically. Keep `is_top: true` (default) on `POST /api/v1/documents` even when the `db:identifier` is nested under another path. Use `compare_attributes: true` only when you need attribute-level diffing for triggers.
164
168
 
169
+ ## XML shredding semantics (must read)
170
+
171
+ - **Auto-shred.** Any element with **`db:identifier`** is stored as its own LMDB row. The parent’s stored XML keeps an internal **`<db:N…/>`** placeholder for that slot (engine-only). There is **no** size threshold and **no** opt-out.
172
+ - **Shallow reads (`NoChildren,KeepIndexNodes`).** The API returns **identifier-bearing stubs** instead of opaque placeholders: **`<elem db:identifier="…" db:xcitepath="…" db:order="…" db:stub="true"/>`** (real element name, empty element). Clients may round-trip these stubs through **`writeXmlDocument`**; preserved subtrees are not deleted. You may also send legacy **`<db:N…/>`** placeholders from older clients; the engine resolves them under the parent’s **`db:xcitepath`**.
173
+ - **Pruned writes.** You may mix **full** identified subtrees and **stub** markers (or placeholders) under one parent. Stubs must be **empty** (no element children, no non-whitespace text), carry **`db:identifier`** (or legacy **`identifier`**) pointing at an **existing** row, and optional **`db:order`**, **`db:xcitepath`**, **`xmlns:db`** only. The document root must not be a stub. Violations yield **`400`** with `error: "xml_write_rejected"` and a **`message`** string (`stub_*` / `placeholder_*`).
174
+ - **`is_top` is a marker only.** It registers a **`TOP:<xcitepath>`** alias for tooling; it does **not** change shredding, merge, or overwrite behavior. Leaving **`true`** on every write is normal.
175
+ - **`POST /api/v1/documents/batch`** runs items **in order**; each item’s write replaces LMDB rows for **every** `db:identifier` present in that item’s XML **except** identifiers that appear only on **`db:stub="true"`** elements (those are references, not inlined document bodies). The server prechecks each row and may return **`409`** with **`duplicate_identifier_in_batch`** (same root id in two items) or **`parent_inlines_batch_member`** (a parent’s XML still **fully inlines** another item’s root id). Failed rows include a JSON **`detail`** object: for duplicates **`{ "identifier": "<root>" }`**; for parent inline **`{ "parent": "<root>", "inlined_child": "<other root>" }`**. Check **`results[i].ok`** on the JSON response.
176
+ - **Idempotency (retries).** Re-sending the same XML body with the same **`X-Date`** (or unversioned mode) is a safe no-op when the stored revision is unchanged; use the same date key on client retries to avoid duplicate logical revisions.
177
+
178
+ **Round-trip:** Element structure, child order, and text are preserved. The server may add **`db:order`**, **`db:xcitepath`**, **`xmlns:db`**. Any app-specific structural metadata that must survive write→read (e.g. numbering aligned with an editor) must live **inside the stored XML** (or JSON metadata via **`addMeta`**); the server does not sync arbitrary sidecar fields. Attribute order and insignificant whitespace are **not** byte-stable—compare by DOM, not raw bytes.
179
+
180
+ ## Minimal SPA editor recipe
181
+
182
+ ```typescript
183
+ await client.enableUserIsolation(); // if app users edit under namespaced paths
184
+ client.setContext({ workspace: '', project_id: projectId });
185
+
186
+ /** Full document for the editor (all shredded children inlined). */
187
+ async function loadDocForEditor(id: string) {
188
+ return client.queryByIdentifierFull(id);
189
+ // Returns JSON array length 0 or 1: when present, index 0 is one fully assembled XML string (document order).
190
+ }
191
+
192
+ /** Sidebar / tree: shallow shell + one hierarchy level (two parallel requests). */
193
+ async function sidebarRow(id: string) {
194
+ const [node, listed] = await Promise.all([
195
+ client.queryByIdentifierShallow(id),
196
+ client.listIdentifierChildren(id),
197
+ ]);
198
+ return { node, children: listed.children };
199
+ }
200
+
201
+ async function saveDirty(items: XmlDocumentBatchItem[]) {
202
+ const { results } = await client.writeXmlDocumentsBatch(items);
203
+ results.forEach((r, i) => {
204
+ if (!r.ok) console.warn(i, r.error, r.code);
205
+ });
206
+ }
207
+ ```
208
+
209
+ **Batch save rules:** (1) Each intended root **`db:identifier`** appears as the **root** of **at most one** batch item per flush (**`duplicate_identifier_in_batch`**). (2) Do not ship a parent’s **full** subtree for a child id that is also its **own** batch row in the same request (**`parent_inlines_batch_member`**). Stubs **`db:stub="true"`** referencing another batch member are **allowed** and do not trigger (2). (3) **`is_top`** does not relax (1) or (2).
210
+
165
211
  ## JavaScript/TypeScript SDK (`@xcitedbs/client`)
166
212
 
167
213
  Install: `npm install @xcitedbs/client`
@@ -282,13 +328,14 @@ interface XCiteDBClientOptions {
282
328
 
283
329
  **XML Documents:**
284
330
  - `writeXmlDocument(xml, options?)` — Store XML via JSON body (identifier inside XML). Deprecated alias: `writeDocumentJson`.
331
+ - `writeXmlDocumentsBatch(items)` — `POST /api/v1/documents/batch`; per-row `ok` / `error` / `code` / optional `detail` (see shredding section for `409` batch conflicts)
285
332
  - `writeXML(xml)` — Store raw XML with `Content-Type: application/xml`
286
333
  - `importDocument(file, options?)` — `POST /api/v1/documents/import` (multipart `file`). Server converts DOCX, ODF, RTF, PDF, Markdown, AsciiDoc, or plain text into shredded XML. Optional `identifier` query override; optional `filename` for sniffing when not a `File`.
287
334
  - `exportDocument(id, format?, options?)` — `GET /api/v1/documents/export` binary (`xml` | `docx` | `odt` | `pdf` | `txt` | `md` | `adoc`). Optional `strict` for text exports.
288
335
  - `queryByIdentifier(id, flags?, filter?)` — Get document(s) by identifier
289
- - `queryByIdentifierShallow(id, filter?, pathFilter?)` — Same as `flags=NoChildren,KeepIndexNodes,FirstMatch` (skeleton: placeholders `db:N*` for shredded slots; avoids full subtree)
290
- - `listChildIdentifiers(parentPath?)` — Alias of `listIdentifierChildren`; next level of identifier hierarchy
291
- - `queryByIdentifierWithChildren(id, filter?, pathFilter?)` — `Promise.all` of shallow node + `identifier-children` for one expansion step
336
+ - `queryByIdentifierFull(id, filter?, pathFilter?)` — Same as `flags=FirstMatch,IncludeChildren` (editor load: full subtree). Response array is **length 0 or 1**; the single string is the full document.
337
+ - `queryByIdentifierShallow(id, filter?, pathFilter?)` — Same as `flags=NoChildren,KeepIndexNodes,FirstMatch` (skeleton: **`db:stub="true"`** elements for shredded child slots; avoids full subtree)
338
+ - `listChildIdentifiers(parentPath?)` — Alias of `listIdentifierChildren`; next level of identifier hierarchy (pair with `queryByIdentifierShallow` for sidebars—`Promise.all` both if you want one round-trip client-side)
292
339
  - `queryDocuments(query, flags?)` — List/filter documents
293
340
  - `deleteDocument(identifier)` — Delete by identifier
294
341
  - `listIdentifiers(query)` — List known identifiers with pagination
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xcitedbs/client",
3
- "version": "0.2.23",
3
+ "version": "0.2.25",
4
4
  "description": "XCiteDB BaaS client SDK",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -4,6 +4,8 @@
4
4
 
5
5
  Unquery is a declarative language for querying and transforming structured documents (JSON and XML). It is the query language of XCiteDB and the `unq` command-line tool. Every query is itself a JSON document. The result is also JSON.
6
6
 
7
+ > **Maintainers — parse regression tests:** Fenced `json` / `text` examples in this file, plus Unquery listings in [`web/src/docs/content/unquery-tutorial.html`](../web/src/docs/content/unquery-tutorial.html), feed the generated fixture [`XCiteDB2/tests/fixtures/unquery_tutorial_parse_cases.json`](../XCiteDB2/tests/fixtures/unquery_tutorial_parse_cases.json) (do not edit by hand). After changing examples here or in that HTML file, regenerate from the repo root with `python3 scripts/gen_unquery_tutorial_parse_cases.py`, then commit the updated JSON. CI / local builds run `unquery_tutorial_parse_test` (see [`XCiteDB2/tests/run_tests.sh`](../XCiteDB2/tests/run_tests.sh)).
8
+
7
9
  ---
8
10
 
9
11
  ## 0. AI preamble — must-read before writing a query