@xcitedbs/client 0.3.4 → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.d.ts CHANGED
@@ -82,8 +82,25 @@ export declare class XCiteDBClient {
82
82
  private getAppUserId;
83
83
  private normalizeIsolationNamespaceTemplate;
84
84
  private canonicalId;
85
- /** Resolved namespace root, e.g. `/users/abc`, or `null` if isolation is off or no app user id. */
85
+ /** Resolve the configured namespace template for an explicit user id. Returns null if isolation is off, no template, or `userId` is empty. */
86
+ private userIsolationNamespaceFor;
87
+ /** Resolved namespace root for the calling user, e.g. `/users/abc`, or `null` if isolation is off or no app user id. */
86
88
  private userIsolationNamespace;
89
+ /**
90
+ * Namespace for the current request's identity context.
91
+ *
92
+ * On a user-workspace branch (`_uw/<owner>/<slug>`) where the calling user is
93
+ * a member but not the owner, returns the *owner's* namespace — docs in the
94
+ * workspace are stored at `<owner_ns>/<rel>`, not at the caller's
95
+ * `<self_ns>/<rel>`. For the owner, non-`_uw/` branches, malformed
96
+ * branches, or when isolation is off, falls back to the calling user's
97
+ * namespace.
98
+ *
99
+ * Mirrors the server-side branch-aware behavior in
100
+ * `UserIsolationService::prefixIdentifier` so the SDK's pre-prefixing
101
+ * lands on the right namespace before the request leaves.
102
+ */
103
+ private userIsolationContextNamespace;
87
104
  private allSharedPassthroughPrefixes;
88
105
  private pathMatchesSharedPassthrough;
89
106
  private isoPrefixId;
@@ -108,6 +125,58 @@ export declare class XCiteDBClient {
108
125
  destroyTestSessionByToken(token: string): Promise<{
109
126
  message: string;
110
127
  }>;
128
+ /**
129
+ * Create a long-lived developer sandbox via `POST /api/v1/sandboxes`. Returns the server's
130
+ * `SandboxInfo`. To start using the sandbox immediately, follow up with `client.useSandbox(name)`,
131
+ * or mint a sandbox-bound API key with `mintSandboxApiKey` and route subsequent requests through
132
+ * a fresh client constructed with that key (no header threading needed).
133
+ */
134
+ createSandbox(opts: import('./types').CreateSandboxOptions): Promise<import('./types').SandboxInfo>;
135
+ /** List sandboxes for the current project (`GET /api/v1/sandboxes`). */
136
+ listSandboxes(): Promise<import('./types').SandboxInfo[]>;
137
+ /** Fetch a sandbox's detail by name (`GET /api/v1/sandboxes/{name}`). */
138
+ getSandbox(name: string): Promise<import('./types').SandboxInfo>;
139
+ /** Update mutable sandbox fields (`PATCH /api/v1/sandboxes/{name}`). */
140
+ updateSandbox(name: string, patch: Partial<Pick<import('./types').SandboxInfo, 'description' | 'pinned' | 'expires_at' | 'effects_policy'>>): Promise<import('./types').SandboxInfo>;
141
+ /** Drop overlay writes for a sandbox while preserving membership and bound keys. */
142
+ resetSandbox(name: string): Promise<{
143
+ message: string;
144
+ session_token: string;
145
+ }>;
146
+ /** Destroy a sandbox; revokes bound API keys and removes membership rows. Owner only. */
147
+ destroySandbox(name: string): Promise<{
148
+ message: string;
149
+ session_token: string;
150
+ }>;
151
+ listSandboxMembers(name: string): Promise<import('./types').SandboxMember[]>;
152
+ addSandboxMember(name: string, member_id: string, role: 'editor' | 'viewer'): Promise<{
153
+ message: string;
154
+ }>;
155
+ removeSandboxMember(name: string, member_id: string): Promise<{
156
+ message: string;
157
+ }>;
158
+ /**
159
+ * Mint an API key bound to the given sandbox. Requests presenting this key automatically route
160
+ * into the sandbox without an `X-Test-Session` header — paste it into your dev `.env` and
161
+ * existing client/curl/SDK calls just work. The `api_key` field is shown once; persist it now.
162
+ */
163
+ mintSandboxApiKey(name: string, opts?: {
164
+ name?: string;
165
+ role?: 'admin' | 'editor' | 'viewer';
166
+ key_type?: 'secret' | 'public';
167
+ expires_at?: number;
168
+ }): Promise<import('./types').SandboxApiKeyMintResult>;
169
+ /**
170
+ * Pin this client to a named sandbox: subsequent requests carry `X-Test-Session: <token>`.
171
+ * Returns the sandbox info. Throws if the name does not resolve.
172
+ *
173
+ * If you instead want zero-header DX, mint a sandbox-bound API key with
174
+ * `mintSandboxApiKey(name)` and construct a fresh client with that `apiKey` — the server
175
+ * pre-filter resolves the binding without touching the test-session header.
176
+ */
177
+ useSandbox(name: string): Promise<import('./types').SandboxInfo>;
178
+ /** Clear any pinned test/sandbox session token from this client (subsequent requests hit production). */
179
+ clearSandbox(): void;
111
180
  /** True if this client would send API key or Bearer credentials on a normal request. */
112
181
  private sentAuthCredentials;
113
182
  /** 401 on these paths is an expected auth flow outcome, not a dead session. */
package/dist/client.js CHANGED
@@ -185,6 +185,8 @@ class XCiteDBClient {
185
185
  const sessionBody = {};
186
186
  if (opts.overlay === true)
187
187
  sessionBody.overlay = true;
188
+ if (opts.additionalKeys !== undefined)
189
+ sessionBody.additional_keys = opts.additionalKeys;
188
190
  if (opts.bootstrap !== undefined)
189
191
  sessionBody.bootstrap = opts.bootstrap;
190
192
  const data = await temp.request('POST', '/api/v1/test/sessions', Object.keys(sessionBody).length ? sessionBody : undefined, undefined, { no401Retry: true });
@@ -521,13 +523,12 @@ class XCiteDBClient {
521
523
  }
522
524
  return t;
523
525
  }
524
- /** Resolved namespace root, e.g. `/users/abc`, or `null` if isolation is off or no app user id. */
525
- userIsolationNamespace() {
526
+ /** Resolve the configured namespace template for an explicit user id. Returns null if isolation is off, no template, or `userId` is empty. */
527
+ userIsolationNamespaceFor(userId) {
526
528
  if (!this.userIsolation?.enabled) {
527
529
  return null;
528
530
  }
529
- const uid = this.getAppUserId();
530
- if (!uid) {
531
+ if (!userId) {
531
532
  return null;
532
533
  }
533
534
  const rawTpl = this.normalizeIsolationNamespaceTemplate(this.userIsolation.namespace ?? '/users/{userId}');
@@ -535,7 +536,42 @@ class XCiteDBClient {
535
536
  if (!trimmed) {
536
537
  return null;
537
538
  }
538
- return trimmed.replace(/{userId}/g, uid).replace(/{user_id}/g, uid);
539
+ return trimmed.replace(/{userId}/g, userId).replace(/{user_id}/g, userId);
540
+ }
541
+ /** Resolved namespace root for the calling user, e.g. `/users/abc`, or `null` if isolation is off or no app user id. */
542
+ userIsolationNamespace() {
543
+ return this.userIsolationNamespaceFor(this.getAppUserId());
544
+ }
545
+ /**
546
+ * Namespace for the current request's identity context.
547
+ *
548
+ * On a user-workspace branch (`_uw/<owner>/<slug>`) where the calling user is
549
+ * a member but not the owner, returns the *owner's* namespace — docs in the
550
+ * workspace are stored at `<owner_ns>/<rel>`, not at the caller's
551
+ * `<self_ns>/<rel>`. For the owner, non-`_uw/` branches, malformed
552
+ * branches, or when isolation is off, falls back to the calling user's
553
+ * namespace.
554
+ *
555
+ * Mirrors the server-side branch-aware behavior in
556
+ * `UserIsolationService::prefixIdentifier` so the SDK's pre-prefixing
557
+ * lands on the right namespace before the request leaves.
558
+ */
559
+ userIsolationContextNamespace() {
560
+ const selfId = this.getAppUserId();
561
+ const branch = this.defaultContext.workspace ?? this.defaultContext.branch;
562
+ if (branch && branch.startsWith('_uw/')) {
563
+ const m = branch.match(/^_uw\/([^/]+)\/[^/]+$/);
564
+ if (m) {
565
+ const ownerId = m[1];
566
+ if (ownerId && ownerId !== selfId) {
567
+ const ns = this.userIsolationNamespaceFor(ownerId);
568
+ if (ns) {
569
+ return ns;
570
+ }
571
+ }
572
+ }
573
+ }
574
+ return this.userIsolationNamespaceFor(selfId);
539
575
  }
540
576
  allSharedPassthroughPrefixes() {
541
577
  const o = this.userIsolation;
@@ -564,7 +600,7 @@ class XCiteDBClient {
564
600
  return false;
565
601
  }
566
602
  isoPrefixId(id) {
567
- const ns = this.userIsolationNamespace();
603
+ const ns = this.userIsolationContextNamespace();
568
604
  if (!ns) {
569
605
  return id;
570
606
  }
@@ -589,7 +625,7 @@ class XCiteDBClient {
589
625
  return finalId;
590
626
  }
591
627
  isoUnprefixId(id) {
592
- const ns = this.userIsolationNamespace();
628
+ const ns = this.userIsolationContextNamespace();
593
629
  if (!ns) {
594
630
  return id;
595
631
  }
@@ -606,7 +642,7 @@ class XCiteDBClient {
606
642
  return canonical;
607
643
  }
608
644
  isoPrefixQuery(q) {
609
- if (!this.userIsolationNamespace()) {
645
+ if (!this.userIsolationContextNamespace()) {
610
646
  return q;
611
647
  }
612
648
  const out = { ...q };
@@ -622,7 +658,7 @@ class XCiteDBClient {
622
658
  return out;
623
659
  }
624
660
  isoApplyXmlDbIdentifier(xml) {
625
- if (!this.userIsolationNamespace()) {
661
+ if (!this.userIsolationContextNamespace()) {
626
662
  return xml;
627
663
  }
628
664
  return xml.replace(/(db:identifier\s*=\s*")([^"]*)(")/, (_m, p1, mid, p3) => {
@@ -663,6 +699,73 @@ class XCiteDBClient {
663
699
  const enc = encodeURIComponent(token);
664
700
  return this.request('DELETE', `/api/v1/test/sessions/${enc}`, undefined, undefined, { suppressTestSessionHeader: true, no401Retry: true });
665
701
  }
702
+ /**
703
+ * Create a long-lived developer sandbox via `POST /api/v1/sandboxes`. Returns the server's
704
+ * `SandboxInfo`. To start using the sandbox immediately, follow up with `client.useSandbox(name)`,
705
+ * or mint a sandbox-bound API key with `mintSandboxApiKey` and route subsequent requests through
706
+ * a fresh client constructed with that key (no header threading needed).
707
+ */
708
+ async createSandbox(opts) {
709
+ return this.request('POST', '/api/v1/sandboxes', opts, undefined, { suppressTestSessionHeader: true, no401Retry: true });
710
+ }
711
+ /** List sandboxes for the current project (`GET /api/v1/sandboxes`). */
712
+ async listSandboxes() {
713
+ const r = await this.request('GET', '/api/v1/sandboxes', undefined, undefined, { suppressTestSessionHeader: true, no401Retry: true });
714
+ return r.sandboxes ?? [];
715
+ }
716
+ /** Fetch a sandbox's detail by name (`GET /api/v1/sandboxes/{name}`). */
717
+ async getSandbox(name) {
718
+ return this.request('GET', `/api/v1/sandboxes/${encodeURIComponent(name)}`, undefined, undefined, { suppressTestSessionHeader: true, no401Retry: true });
719
+ }
720
+ /** Update mutable sandbox fields (`PATCH /api/v1/sandboxes/{name}`). */
721
+ async updateSandbox(name, patch) {
722
+ return this.request('PATCH', `/api/v1/sandboxes/${encodeURIComponent(name)}`, patch, undefined, { suppressTestSessionHeader: true, no401Retry: true });
723
+ }
724
+ /** Drop overlay writes for a sandbox while preserving membership and bound keys. */
725
+ async resetSandbox(name) {
726
+ return this.request('POST', `/api/v1/sandboxes/${encodeURIComponent(name)}/reset`, undefined, undefined, { suppressTestSessionHeader: true, no401Retry: true });
727
+ }
728
+ /** Destroy a sandbox; revokes bound API keys and removes membership rows. Owner only. */
729
+ async destroySandbox(name) {
730
+ return this.request('DELETE', `/api/v1/sandboxes/${encodeURIComponent(name)}`, undefined, undefined, { suppressTestSessionHeader: true, no401Retry: true });
731
+ }
732
+ async listSandboxMembers(name) {
733
+ const r = await this.request('GET', `/api/v1/sandboxes/${encodeURIComponent(name)}/members`, undefined, undefined, { suppressTestSessionHeader: true, no401Retry: true });
734
+ return r.members ?? [];
735
+ }
736
+ async addSandboxMember(name, member_id, role) {
737
+ return this.request('POST', `/api/v1/sandboxes/${encodeURIComponent(name)}/members`, { member_id, role }, undefined, { suppressTestSessionHeader: true, no401Retry: true });
738
+ }
739
+ async removeSandboxMember(name, member_id) {
740
+ return this.request('DELETE', `/api/v1/sandboxes/${encodeURIComponent(name)}/members/${encodeURIComponent(member_id)}`, undefined, undefined, { suppressTestSessionHeader: true, no401Retry: true });
741
+ }
742
+ /**
743
+ * Mint an API key bound to the given sandbox. Requests presenting this key automatically route
744
+ * into the sandbox without an `X-Test-Session` header — paste it into your dev `.env` and
745
+ * existing client/curl/SDK calls just work. The `api_key` field is shown once; persist it now.
746
+ */
747
+ async mintSandboxApiKey(name, opts = {}) {
748
+ return this.request('POST', `/api/v1/sandboxes/${encodeURIComponent(name)}/api-keys`, opts, undefined, { suppressTestSessionHeader: true, no401Retry: true });
749
+ }
750
+ /**
751
+ * Pin this client to a named sandbox: subsequent requests carry `X-Test-Session: <token>`.
752
+ * Returns the sandbox info. Throws if the name does not resolve.
753
+ *
754
+ * If you instead want zero-header DX, mint a sandbox-bound API key with
755
+ * `mintSandboxApiKey(name)` and construct a fresh client with that `apiKey` — the server
756
+ * pre-filter resolves the binding without touching the test-session header.
757
+ */
758
+ async useSandbox(name) {
759
+ const info = await this.getSandbox(name);
760
+ if (!info.session_token)
761
+ throw new Error(`useSandbox: server returned no session_token for "${name}"`);
762
+ this.testSessionToken = info.session_token;
763
+ return info;
764
+ }
765
+ /** Clear any pinned test/sandbox session token from this client (subsequent requests hit production). */
766
+ clearSandbox() {
767
+ this.testSessionToken = undefined;
768
+ }
666
769
  /** True if this client would send API key or Bearer credentials on a normal request. */
667
770
  sentAuthCredentials() {
668
771
  return !!(this.apiKey || this.accessToken || this.appUserAccessToken);
@@ -2461,8 +2564,8 @@ class XCiteDBClient {
2461
2564
  }
2462
2565
  const ct = opts.contentType.trim() || 'application/octet-stream';
2463
2566
  const qParts = [];
2464
- if (opts.scope === 'public') {
2465
- qParts.push('scope=public');
2567
+ if (opts.scope === 'shared') {
2568
+ qParts.push('scope=shared');
2466
2569
  }
2467
2570
  if (opts.identifier !== undefined && opts.identifier !== '') {
2468
2571
  qParts.push(`identifier=${encodeURIComponent(opts.identifier)}`);
@@ -32,6 +32,80 @@ const types_js_1 = require("./types.js");
32
32
  globalThis.fetch = orig;
33
33
  }
34
34
  });
35
+ (0, node_test_1.it)('user-workspace branch (`_uw/<owner>/<slug>`) prefixes paths with owner namespace, not caller', async () => {
36
+ // Issue B repro at the unit level: an invitee on someone else's user
37
+ // workspace must namespace identifiers to the owner's user_id parsed
38
+ // from the branch, not their own. We assert the outbound URL — SDK
39
+ // routes prefixing through userIsolationContextNamespace() which
40
+ // checks `_uw/<owner>/<slug>` and substitutes the owner.
41
+ const seen = [];
42
+ const orig = globalThis.fetch;
43
+ globalThis.fetch = node_test_1.mock.fn(async (input) => {
44
+ seen.push(String(input));
45
+ return new Response(JSON.stringify({ ok: true, _xcite_json_doc: true }), { status: 200 });
46
+ });
47
+ try {
48
+ const c = new client_js_1.XCiteDBClient({
49
+ baseUrl: 'http://127.0.0.1:9',
50
+ apiKey: 'test-key',
51
+ userIsolation: { enabled: true, namespace: '/users/{userId}' },
52
+ });
53
+ // sub claim = "user-B"
54
+ c.setAppUserTokens('header.eyJzdWIiOiJ1c2VyLUIifQ.sig');
55
+ // Owner of the workspace is "user-A"; we are "user-B".
56
+ c.setContext({ branch: '_uw/user-A/shared', project_id: 'p1' });
57
+ await c.readJsonDocument('/drafts/x');
58
+ strict_1.default.equal(seen.length, 1);
59
+ strict_1.default.match(seen[0], /identifier=%2Fusers%2Fuser-A%2Fdrafts%2Fx/, `expected identifier prefixed with owner ns, got: ${seen[0]}`);
60
+ }
61
+ finally {
62
+ globalThis.fetch = orig;
63
+ }
64
+ });
65
+ (0, node_test_1.it)('non-`_uw` branch keeps caller-namespace prefixing (regression guard)', async () => {
66
+ const seen = [];
67
+ const orig = globalThis.fetch;
68
+ globalThis.fetch = node_test_1.mock.fn(async (input) => {
69
+ seen.push(String(input));
70
+ return new Response(JSON.stringify({ ok: true, _xcite_json_doc: true }), { status: 200 });
71
+ });
72
+ try {
73
+ const c = new client_js_1.XCiteDBClient({
74
+ baseUrl: 'http://127.0.0.1:9',
75
+ apiKey: 'test-key',
76
+ userIsolation: { enabled: true, namespace: '/users/{userId}' },
77
+ });
78
+ c.setAppUserTokens('header.eyJzdWIiOiJ1c2VyLUIifQ.sig');
79
+ c.setContext({ branch: 'main', project_id: 'p1' });
80
+ await c.readJsonDocument('/drafts/x');
81
+ strict_1.default.match(seen[0], /identifier=%2Fusers%2Fuser-B%2Fdrafts%2Fx/, `expected identifier prefixed with caller ns, got: ${seen[0]}`);
82
+ }
83
+ finally {
84
+ globalThis.fetch = orig;
85
+ }
86
+ });
87
+ (0, node_test_1.it)('owner of `_uw/<self>/<slug>` workspace uses own namespace (no-op for owner)', async () => {
88
+ const seen = [];
89
+ const orig = globalThis.fetch;
90
+ globalThis.fetch = node_test_1.mock.fn(async (input) => {
91
+ seen.push(String(input));
92
+ return new Response(JSON.stringify({ ok: true, _xcite_json_doc: true }), { status: 200 });
93
+ });
94
+ try {
95
+ const c = new client_js_1.XCiteDBClient({
96
+ baseUrl: 'http://127.0.0.1:9',
97
+ apiKey: 'test-key',
98
+ userIsolation: { enabled: true, namespace: '/users/{userId}' },
99
+ });
100
+ c.setAppUserTokens('header.eyJzdWIiOiJ1c2VyLUEifQ.sig'); // user-A
101
+ c.setContext({ branch: '_uw/user-A/shared', project_id: 'p1' });
102
+ await c.readJsonDocument('/drafts/x');
103
+ strict_1.default.match(seen[0], /identifier=%2Fusers%2Fuser-A%2Fdrafts%2Fx/, `expected owner to keep own ns, got: ${seen[0]}`);
104
+ }
105
+ finally {
106
+ globalThis.fetch = orig;
107
+ }
108
+ });
35
109
  (0, node_test_1.it)('queryByIdentifier / queryDocuments return XML bodies unmodified under userIsolation', async () => {
36
110
  const xml = '<?xml version="1.0"?><doc xmlns:db="http://www.xcitedb.com/schema"><a href="https://example.com//path">x</a></doc>';
37
111
  const orig = globalThis.fetch;
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { XCiteDBClient } from './client';
2
2
  export { parseAssetUri, formatAssetUri, collectIdentifiersFromText, ASSET_URI_PREFIX } from './assetUri';
3
3
  export { WebSocketSubscription } from './websocket';
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, SmartDiffRef, SmartDiffResult, SmartDiffStats, 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, AssetListItem, AssetListResponse, AssetMagicLinkListResponse, AssetMagicLinkRecord, AssetMagicLinkResult, AssetShareListEntry, AssetShareListResponse, AssetShareRequest, AssetStorageImport, AssetStorageMount, AssetStorageTarget, AssetStorageTargetType, AssetUnshareRequest, AssetUploadResult, CreateAssetMagicLinkRequest, ListAssetsOptions, 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, TriggerEvent, TriggerEventsResponse, TxnOperation, TxnOperationResult, TxnPrecondition, TxnPreconditionResult, TxnRequest, TxnResponse, JwksKey, JwksResponse, VerifyAppUserTokenOptions, 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, SmartDiffRef, SmartDiffResult, SmartDiffStats, 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, AssetListItem, AssetListResponse, AssetMagicLinkListResponse, AssetMagicLinkRecord, AssetMagicLinkResult, AssetShareListEntry, AssetShareListResponse, AssetShareRequest, AssetStorageImport, AssetStorageMount, AssetStorageTarget, AssetStorageTargetType, AssetUnshareRequest, AssetUploadResult, CreateAssetMagicLinkRequest, ListAssetsOptions, ProjectAssetStorageConfig, UploadAssetOptions, PlatformDefaultDocConfResponse, LogEntry, MetaValue, PlatformRegisterResult, PolicyUpdateResponse, PublishConflict, PublishResult, RebaseUserWorkspaceResult, PolicyConditions, PolicyIdentifierPattern, PolicyResources, PolicySubjectInput, PolicySubjects, RagQueryOptions, RagQueryResult, RagStreamEvent, RealtimeEvent, SandboxApiKeyMintResult, SandboxInfo, SandboxMember, SearchIndexingProgress, SecurityConfig, SecurityPolicy, StoredPolicyResponse, StoredTriggerResponse, SubscriptionOptions, TagRecord, TextSearchHit, TextSearchQuery, TextSearchResult, TriggerDefinition, TriggerEvent, TriggerEventsResponse, TxnOperation, TxnOperationResult, TxnPrecondition, TxnPreconditionResult, TxnRequest, TxnResponse, JwksKey, JwksResponse, VerifyAppUserTokenOptions, TokenPair, UserInfo, UserIsolationConfig, UserIsolationCreateShareParams, UserIsolationOptions, UserIsolationShareMode, UserIsolationShareResult, WorkspaceInfo, WriteDocumentOptions, XmlDocumentBatchItem, CreateSandboxOptions, CreateTestSessionOptions, TestSessionBootstrap, TestSessionBootstrapSummary, TestSessionInfo, XCiteDBClientOptions, XCiteDBErrorExtras, XCiteDBJwtClaims, UnqueryResult, UnqueryTemplate, XCiteQuery, } from './types';
5
5
  export { XCiteDBError, XCiteDBForbiddenError, XCiteDBNotFoundError, XCiteDBAuthError, XCiteDBLockConflictError, } from './types';
package/dist/types.d.ts CHANGED
@@ -192,8 +192,10 @@ export interface UploadAssetOptions {
192
192
  contentType: string;
193
193
  /** When set (with default scope), uploaded to this path after user-isolation prefixing rules on the server. */
194
194
  identifier?: string;
195
- /** `public` writes under `/public/assets/…`. Default project/user scope when omitted. */
196
- scope?: 'project' | 'public';
195
+ /** `shared` writes under `/shared/assets/…` (tenant-common, readable by all logged-in app users).
196
+ * Default project/user scope when omitted. To publish anonymously, upload normally and use the
197
+ * share API with `target_user_id: "#anonymous"`. */
198
+ scope?: 'project' | 'shared';
197
199
  }
198
200
  /** Result of {@link XCiteDBClient.headAsset}. */
199
201
  export interface AssetHeadResult {
@@ -761,6 +763,18 @@ export interface CreateTestSessionOptions {
761
763
  * When true, creates an overlay test session: writable ephemeral LMDB with production project data as read-only base.
762
764
  */
763
765
  overlay?: boolean;
766
+ /**
767
+ * API keys to snapshot into the new session's config DB so requests carrying any of these keys
768
+ * (alongside `X-Test-Session: <token>`) authenticate against the session, not production.
769
+ *
770
+ * Typical use: a backend-for-frontend (BFF) that performs admin-scoped writes on behalf of the
771
+ * SPA. Without registering the BFF's admin key here, the BFF would receive `401
772
+ * test_session_unknown_api_key` when forwarding `X-Test-Session` to XCiteDB.
773
+ *
774
+ * Each entry must be a valid production API key on the project hosting the session — otherwise
775
+ * the server returns `400 additional_key_invalid` (with the offending index) and aborts.
776
+ */
777
+ additionalKeys?: string[];
764
778
  /** Optional server-side bootstrap (user isolation, developer_bypass, policies, triggers). */
765
779
  bootstrap?: TestSessionBootstrap;
766
780
  /**
@@ -788,6 +802,53 @@ export interface CreateTestSessionOptions {
788
802
  userIsolation?: UserIsolationOptions;
789
803
  requestTimeoutMs?: number;
790
804
  }
805
+ /** A long-lived developer sandbox: named overlay on a project + branch. */
806
+ export interface SandboxInfo {
807
+ session_token: string;
808
+ name: string;
809
+ description: string;
810
+ org_id: string;
811
+ project_id: string;
812
+ base_branch: string;
813
+ pinned: boolean;
814
+ effects_policy: 'real' | 'log' | 'mock';
815
+ created_at: number;
816
+ last_used: number;
817
+ expires_at: number;
818
+ reset_generation: number;
819
+ owner_member_id: string;
820
+ /** Present in list responses: the requesting member's role in this sandbox, or "" if not a member. */
821
+ my_role?: 'owner' | 'editor' | 'viewer' | '';
822
+ }
823
+ /** Options for {@link XCiteDBClient.createSandbox}. */
824
+ export interface CreateSandboxOptions {
825
+ /** Required. Kebab-case, 1-64 chars, must start with a-z, no consecutive hyphens. */
826
+ name: string;
827
+ description?: string;
828
+ /** Pinned sandboxes are not garbage-collected by idle TTL. */
829
+ pinned?: boolean;
830
+ /** Unix seconds; 0 = no explicit expiry. */
831
+ expires_at?: number;
832
+ base_branch?: string;
833
+ /** Defaults to "log" — webhooks/auth-emails get captured instead of firing. */
834
+ effects_policy?: 'real' | 'log' | 'mock';
835
+ }
836
+ /** Response from {@link XCiteDBClient.mintSandboxApiKey}. The `api_key` value is shown once. */
837
+ export interface SandboxApiKeyMintResult {
838
+ api_key: string;
839
+ key_id: string;
840
+ prefix: string;
841
+ key_type: 'secret' | 'public';
842
+ bound_sandbox_id: string;
843
+ sandbox_name: string;
844
+ message: string;
845
+ }
846
+ export interface SandboxMember {
847
+ member_id: string;
848
+ role: 'owner' | 'editor' | 'viewer';
849
+ added_at: number;
850
+ added_by: string;
851
+ }
791
852
  /** Application user (tenant-scoped), distinct from developer users. */
792
853
  export interface AppUser {
793
854
  user_id: string;
@@ -966,7 +1027,17 @@ export interface PolicyUpdateResponse {
966
1027
  export interface TriggerMatch {
967
1028
  /** Non-empty array of identifier patterns (same shape as policy `resources.identifiers`). */
968
1029
  identifiers: PolicyIdentifierPattern[];
1030
+ /**
1031
+ * Single meta path pattern (exact, or `prefix*`). Mutually exclusive with `match_meta_paths`;
1032
+ * the server rejects writes that set both fields.
1033
+ */
969
1034
  match_meta_path?: string;
1035
+ /**
1036
+ * Array of meta path patterns (anyOf). Each entry is exact or `prefix*`. Useful for one
1037
+ * trigger gating multiple fields of the same shape (e.g. role-restricted profile fields).
1038
+ * Empty array means no constraint. Mutually exclusive with `match_meta_path`.
1039
+ */
1040
+ match_meta_paths?: string[];
970
1041
  match_operation?: 'set' | 'append' | 'delete';
971
1042
  }
972
1043
  /** `action` block: run unquery and write result to target meta. */
@@ -980,10 +1051,21 @@ export interface TriggerAction {
980
1051
  /** Stored trigger document under /_xcitedb/triggers. */
981
1052
  export interface TriggerDefinition {
982
1053
  enabled?: boolean;
1054
+ /**
1055
+ * `"after"` (default): run after the write commits, may run actions. `"before"`:
1056
+ * predicate-only — runs before the write applies and rejects with HTTP 422 when matched.
1057
+ * BEFORE triggers must not have `action` or `actions`.
1058
+ */
1059
+ phase?: 'before' | 'after';
983
1060
  event: 'meta_changed' | 'document_written' | 'document_deleted';
984
1061
  match: TriggerMatch;
985
1062
  conditions?: PolicyConditions;
986
- action: TriggerAction;
1063
+ /** AFTER-trigger single action (legacy). Mutually exclusive with `actions`. Forbidden on BEFORE triggers. */
1064
+ action?: TriggerAction;
1065
+ /** AFTER-trigger multi-action variant. Mutually exclusive with `action`. Forbidden on BEFORE triggers. */
1066
+ actions?: TriggerAction[];
1067
+ /** BEFORE-trigger reject message surfaced to clients on HTTP 422. */
1068
+ reject_reason?: string;
987
1069
  }
988
1070
  export interface StoredTriggerResponse {
989
1071
  trigger_id: string;
@@ -172,4 +172,92 @@ wd('user isolation (wet)', () => {
172
172
  await admin.disableUserIsolation().catch(() => { });
173
173
  }
174
174
  });
175
+ (0, node_test_1.it)('user-workspace branch: invitee reads owner-namespaced docs through self-namespace API', async () => {
176
+ // Issue B repro: when user B is granted readwrite on user A's user
177
+ // workspace and reads a doc on the workspace branch, the SDK must
178
+ // namespace the path with A's user_id (parsed from the `_uw/<owner>/<slug>`
179
+ // branch), not B's. Server-side prefixIdentifier already passes the
180
+ // identifier through unchanged for workspace members; the SDK must put
181
+ // the owner's namespace on the path before sending.
182
+ const e = wetEnv();
183
+ if (!e)
184
+ throw new Error('missing env');
185
+ const admin = adminClient(e);
186
+ const suffix = (0, node_crypto_1.randomUUID)().slice(0, 8);
187
+ const emailA = `js_uwA_${suffix}@apitest.invalid`;
188
+ const emailB = `js_uwB_${suffix}@apitest.invalid`;
189
+ const password = `Js_${suffix}!aA1`;
190
+ const slug = `js-uw-doc-${suffix}`;
191
+ const wsName = `shared-${suffix}`;
192
+ let userA;
193
+ let userB;
194
+ let workspaceId;
195
+ let workspaceBranch;
196
+ try {
197
+ await admin.setUserIsolationConfig({
198
+ enabled: true,
199
+ namespace_pattern: '/users/${user.id}',
200
+ });
201
+ userA = await admin.createAppUser(emailA, password, undefined, [
202
+ client_js_1.XCiteDBClient.buildProjectGroup(e.tenantId, 'editor'),
203
+ ]);
204
+ userB = await admin.createAppUser(emailB, password, undefined, [
205
+ client_js_1.XCiteDBClient.buildProjectGroup(e.tenantId, 'editor'),
206
+ ]);
207
+ const appA = new client_js_1.XCiteDBClient({
208
+ baseUrl: e.baseUrl,
209
+ context: { branch: 'main', project_id: e.tenantId },
210
+ userIsolation: { enabled: true },
211
+ });
212
+ await appA.loginAppUser(emailA, password);
213
+ // A creates the workspace, captures the branch from the response,
214
+ // grants B readwrite, and writes a doc on the workspace branch.
215
+ const ws = (await appA.createUserWorkspace(wsName));
216
+ strict_1.default.ok(ws.id, 'workspace id missing');
217
+ strict_1.default.ok(ws.branch && ws.branch.startsWith('_uw/'), 'workspace branch missing');
218
+ workspaceId = ws.id;
219
+ workspaceBranch = ws.branch;
220
+ strict_1.default.equal(workspaceBranch, `_uw/${userA.user_id}/${slugifyForAssert(wsName)}`, 'branch should be _uw/<ownerId>/<slug>');
221
+ await appA.addUserWorkspaceGrant(workspaceId, { user_id: userB.user_id, mode: 'rw' });
222
+ appA.setContext({ branch: workspaceBranch });
223
+ await appA.writeJsonDocument(`/${slug}`, { _xcite_json_doc: true, who: 'A', v: 1 });
224
+ // B opens the workspace branch and reads the doc using the relative
225
+ // identifier — must resolve to A's namespace, not B's.
226
+ const appB = new client_js_1.XCiteDBClient({
227
+ baseUrl: e.baseUrl,
228
+ context: { branch: workspaceBranch, project_id: e.tenantId },
229
+ userIsolation: { enabled: true },
230
+ });
231
+ await appB.loginAppUser(emailB, password);
232
+ const doc = await appB.readJsonDocument(`/${slug}`);
233
+ strict_1.default.equal(doc.who, 'A');
234
+ strict_1.default.equal(doc.v, 1);
235
+ // List on the workspace branch — identifiers must come back stripped
236
+ // of the owner's namespace (isoUnprefixId symmetry).
237
+ const list = await appB.listJsonDocuments(`/${slug.split('-')[0]}`);
238
+ const ids = (list.identifiers ?? []);
239
+ strict_1.default.ok(ids.includes(`/${slug}`), `expected response to include /${slug}, got ${JSON.stringify(ids)}`);
240
+ }
241
+ finally {
242
+ if (workspaceId) {
243
+ await admin
244
+ .deleteJsonDocument(`/users/${userA?.user_id ?? ''}/${slug}`)
245
+ .catch(() => { });
246
+ await admin.deleteUserWorkspace(workspaceId).catch(() => { });
247
+ }
248
+ if (userB)
249
+ await admin.deleteAppUser(userB.user_id).catch(() => { });
250
+ if (userA)
251
+ await admin.deleteAppUser(userA.user_id).catch(() => { });
252
+ await admin.disableUserIsolation().catch(() => { });
253
+ }
254
+ });
175
255
  });
256
+ // Mirrors UserWorkspaceService::slugify (lowercase, runs of non-alphanumerics → '-', trim '-').
257
+ function slugifyForAssert(name) {
258
+ return name
259
+ .toLowerCase()
260
+ .replace(/[^a-z0-9]+/g, '-')
261
+ .replace(/^-+|-+$/g, '')
262
+ .slice(0, 64);
263
+ }
package/llms-full.txt CHANGED
@@ -377,6 +377,58 @@ TEST_CASE("XciteDB XML document round-trip") {
377
377
  }
378
378
  ```
379
379
 
380
+ ## Overlay sessions with a backend-for-frontend (BFF)
381
+
382
+ When a single-page app is in an overlay test session and an admin-scoped action is delegated to a server-side BFF (e.g. group grants, project-editor enrolment, anything the SPA cannot do with its own credentials), the BFF must route through the **same overlay** — otherwise admin writes land in production while the SPA still reads from the overlay, and the SPA sees stale state.
383
+
384
+ The mechanism is the same `X-Test-Session` header used for SPA requests, plus pre-registering the BFF's admin key on the session at create time so the server accepts it.
385
+
386
+ 1. **Pre-register the BFF admin key** when the SPA creates the session (SDK option `additionalKeys`, wire field `additional_keys`):
387
+
388
+ ```typescript
389
+ const client = await XCiteDBClient.createTestSession({
390
+ baseUrl, apiKey: SPA_PUBLIC_KEY,
391
+ overlay: true,
392
+ additionalKeys: [BFF_ADMIN_KEY], // snapshotted into the session config
393
+ bootstrap: { user_isolation: { /* ... */ } },
394
+ });
395
+ ```
396
+
397
+ Each entry is a production API key the BFF will use. The server snapshots each into the session's config DB so requests carrying that key alongside `X-Test-Session: <token>` authenticate against the session, not production. Without this, the BFF gets `401 test_session_unknown_api_key`. An invalid key (one not present in production for the session's project) returns `400 additional_key_invalid` with the offending array index, and the session is rolled back.
398
+
399
+ 2. **SPA forwards `X-Test-Session` to the BFF** as a normal request header:
400
+
401
+ ```typescript
402
+ const r = await fetch('/api/app/grant-editor', {
403
+ method: 'POST',
404
+ headers: { 'X-Test-Session': client.testSessionToken ?? '' },
405
+ body: JSON.stringify({ user_id: u.user_id }),
406
+ });
407
+ ```
408
+
409
+ 3. **BFF reads the inbound header and propagates it on its outbound XCiteDB call.** With the JS SDK, instantiate a per-request admin client carrying the test-session token — don't reuse a long-lived admin client, since the same BFF process serves both real and test traffic:
410
+
411
+ ```typescript
412
+ app.post('/api/app/grant-editor', async (req, res) => {
413
+ const testSession = req.header('X-Test-Session') || undefined;
414
+ const admin = new XCiteDBClient({
415
+ baseUrl: process.env.XCITEDB_URL!,
416
+ apiKey: process.env.XCITEDB_BFF_KEY!, // == BFF_ADMIN_KEY
417
+ testSessionToken: testSession, // undefined ⇒ hits production as today
418
+ });
419
+ await admin.updateAppUserGroups(req.body.user_id, [
420
+ XCiteDBClient.buildProjectGroup(process.env.XCITEDB_PROJECT_ID!, 'editor'),
421
+ ]);
422
+ res.status(204).end();
423
+ });
424
+ ```
425
+
426
+ The same shape works in any HTTP framework — the only requirement is propagating `X-Test-Session` from inbound to outbound. Production traffic doesn't carry the header, so `testSessionToken` falls back to `undefined` and the admin client behaves as before.
427
+
428
+ **Verification.** With this wired up, an end-to-end test that registers an app user, calls the BFF endpoint, then reads the user's groups through the SPA's overlay client should see the freshly-granted group on the next read. Without the propagation, the read returns the user's groups as they were at session creation (typically empty) and admin-gated routes return `403`.
429
+
430
+ The behavior is bolted to two server pieces: `additional_keys` snapshotting in `TestController::create` (`src/controllers/TestController.cpp`) and the test-session header dispatch in `AuthFilterShared` (`src/filters/AuthFilterShared.cpp`) — there's no separate "attach admin client to existing session" API; the snapshot + header pair is sufficient.
431
+
380
432
  ---
381
433
 
382
434
  # Health, version & discovery
@@ -1105,18 +1157,55 @@ Definitions are stored as JSON under **`/_xcitedb/triggers`**. After a matching
1105
1157
  | Field | Required | Description |
1106
1158
  |--------|----------|-------------|
1107
1159
  | `enabled` | No (default true) | If false, trigger is skipped. |
1160
+ | `phase` | No (default `"after"`) | `"after"` runs post-write actions; `"before"` is predicate-only and rejects the write with HTTP 422 when matched. BEFORE triggers must not have `action` or `actions`. |
1108
1161
  | `event` | Yes | `meta_changed`, `document_written`, or `document_deleted`. |
1109
- | `match` | Yes | Must include **`identifiers`**: non-empty array of identifier patterns (`exact`, `match_start`, `match_end`, `contains`, `regex`). Optional **`match_meta_path`**: exact path or prefix ending with `*`. Optional **`match_operation`**: `set`, `append`, or `delete` (meta / delete ops). |
1162
+ | `match` | Yes | Must include **`identifiers`**: non-empty array of identifier patterns (`exact`, `match_start`, `match_end`, `contains`, `regex`). Optional **`match_meta_path`** (single exact path or `prefix*`) **or** **`match_meta_paths`** (array of patterns, anyOf — useful for one trigger gating multiple fields; mutually exclusive with `match_meta_path`). Optional **`match_operation`**: `set`, `append`, or `delete`. |
1110
1163
  | `conditions` | No | Optional **`branches`** (same as policies) and **`expression`** (see below). |
1111
- | `action` | Yes | **`query`** (`XCiteQuery`), **`unquery`** (Unquery DSL template), **`target_identifier`** (literal or `"$trigger_identifier"`), **`meta_path`**, optional **`mode`**: `set` (default), `append` (strict — unquery output must be a JSON array, stored target must be an array; trigger logs and skips on misuse), or `merge_append` (deep — recurses into objects to extend nested arrays). |
1164
+ | `action` | AFTER only | **`query`** (`XCiteQuery`), **`unquery`** (Unquery DSL template), **`target_identifier`** (literal or `"$trigger_identifier"`), **`meta_path`**, optional **`mode`**: `set` (default), `append` (strict — unquery output must be a JSON array, stored target must be an array; trigger logs and skips on misuse), or `merge_append` (deep — recurses into objects to extend nested arrays). Mutually exclusive with `actions`. |
1165
+ | `actions` | AFTER only | Array of action objects for multi-op fan-out. Each `kind` is one of `WriteMeta`, `AppendMeta`, `WriteJson`, `DeleteIdentifier`, `AddIdentifier`, `ClearMetaPath`. Mutually exclusive with `action`. |
1166
+ | `reject_reason` | BEFORE only | Message returned to clients on HTTP 422 when the trigger matches. |
1112
1167
 
1113
- ### Expression context for **triggers** (`conditions.expression`)
1168
+ ### BEFORE triggers (predicate-only)
1169
+
1170
+ `phase: "before"` triggers run *before* the write applies and reject it with **HTTP 422** when the match and conditions both hold. They are predicate-only — no `action` / `actions`. Fired only on writes routed through **`/api/v1/txn`** (regular `/api/v1/meta` and `/api/v1/json-documents` writes do not invoke BEFORE triggers).
1171
+
1172
+ The expression sees **`prior`** alongside `value`:
1114
1173
 
1115
- Not the same as policies: there is **no `subject`**. Context object:
1174
+ - **`prior`** (BEFORE + `meta_changed` only) — value at the same `meta_path` before the write. Bound only when at least one BEFORE trigger's expression references `prior`. JSON `null` when the path is currently absent.
1175
+
1176
+ Common patterns (the expression grammar uses `=` / `!=` / `<` / `>`, and single `&` / `|` for boolean):
1177
+
1178
+ - **Immutability after first set**: `prior is_literal & value != prior` — rejects only when a literal value already exists and differs.
1179
+ - **Monotonic non-decreasing**: `prior is_literal & value < prior`.
1180
+ - **Role-gated field**: `subject.role != 'system'` (no `prior` needed).
1181
+
1182
+ Example (`song.id` immutable once set):
1183
+
1184
+ ```json
1185
+ {
1186
+ "trigger_id": "song_id_immutable",
1187
+ "trigger": {
1188
+ "phase": "before",
1189
+ "event": "meta_changed",
1190
+ "match": {
1191
+ "identifiers": [{ "match_start": "/spaces/" }],
1192
+ "match_meta_path": "id"
1193
+ },
1194
+ "conditions": {
1195
+ "expression": "prior is_literal & value != prior"
1196
+ },
1197
+ "reject_reason": "song id is immutable once set"
1198
+ }
1199
+ }
1200
+ ```
1201
+
1202
+ ### Expression context for **triggers** (`conditions.expression`)
1116
1203
 
1117
1204
  - **`trigger`**: `{ "event", "meta_path", "operation" }` (`operation`: `set` / `append` / `delete`)
1118
1205
  - **`value`**: JSON written at `meta_path` for `meta_changed`, or null
1206
+ - **`prior`** (BEFORE + `meta_changed` only): value at `meta_path` before the write, or `null` if absent
1119
1207
  - **`resource`**: `{ "identifier", "path" }` for the firing identifier
1208
+ - **`subject`**: `{ "id", "user_id", "email", "type", "role", "groups", "attr" }` (BEFORE only — AFTER triggers historically have no subject)
1120
1209
  - **`env`**: `{ "branch" }`
1121
1210
 
1122
1211
  ### Unquery variables injected for triggers
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xcitedbs/client",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "description": "XCiteDB BaaS client SDK",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -85,7 +85,7 @@ A path selects a value from the current context.
85
85
  | `[n]` | Array index (0-based) | `"Dependants[0].FirstName"` |
86
86
  | `[expr]` | Computed index | `"Field1[$index+1]"` |
87
87
  | `[]` | Whole array (projection over all elements) | `"Array1[].Field1"` |
88
- | `[a:b]` | Array **slice** in a context modifier (half-open; negative counts from end) | `"scores:[0:3]"`, `"scores:[-2:]"` |
88
+ | `[a:b]` | Array **slice** in a context modifier or path expression (half-open; negative counts from end) | `"scores:[0:3]"`, `"scores[2:5]"`, `"$size(scores[:3])"` |
89
89
  | `/Field` | Absolute from document root | `"/employees.$(.)"` |
90
90
  | `../Field` | Up one path level (skips array indices) | `"../DBInstanceIdentifier"` |
91
91
  | `<<Field` | Read same field in *previous* document context | `"<<Field1"` after a `->$file(...)` |
@@ -710,6 +710,34 @@ Allowed only against literals (see §0.8 and §4.8):
710
710
 
711
711
  Each recipe shows a small input-shape sketch, the Unquery template, and (where useful) the equivalent jq one-liner for cross-reference.
712
712
 
713
+ ### 10.0 Materializing many rows from a prefix walk — pick a multi-row shape
714
+
715
+ Common gotcha for prefix-walk queries (`{ match_start: '/index/users/' }` and similar) feeding a search dropdown or candidate list: if the template root is a **single object**, every document iteration *overwrites* the same fields and you keep only the **last** match's values.
716
+
717
+ `{ "name": "name", "email": "email" }` — *wrong* for multi-row output. Returns one document's fields, not a list. (See §1's "evaluate once vs. per pass".)
718
+
719
+ Three correct shapes for "give me one row per matching document":
720
+
721
+ **Array of objects** (most common — preserves order, easy to iterate client-side):
722
+
723
+ ```json
724
+ [{ "id": "$identifier", "name": "name", "email": "email" }]
725
+ ```
726
+
727
+ **Map keyed by identifier** (constant-time lookup by id, no duplicates):
728
+
729
+ ```json
730
+ { "$($identifier)": { "name": "name", "email": "email" } }
731
+ ```
732
+
733
+ **Array of identifiers only** (just need the keys, e.g. for a count or follow-up reads):
734
+
735
+ ```json
736
+ ["$identifier"]
737
+ ```
738
+
739
+ The bare-object form is correct *only* when the prefix narrows to exactly one document or you genuinely want "last match wins" (e.g. picking a single config row). For dropdowns, search results, member lists, or any "show me everything matching" use case, default to the array-of-objects shape.
740
+
713
741
  ### 10.1 Pick one field per document → array
714
742
 
715
743
  ```json
@@ -128,7 +128,7 @@ Climbing parser levels:
128
128
  |--------|-----------|------------|
129
129
  | 0 | `+`, `-` | Left via loop: each new rhs parsed with `expression(1)`. |
130
130
  | 1 | `*`, `/`, `mod` | Left; rhs parsed with `expression(2)`. |
131
- | 3 | Postfix `.token` and `[ expr ]` | Postfix chain on current `res`. |
131
+ | 3 | Postfix `.token`, `[ expr ]`, `[ slice ]` | Postfix chain on current `res`. |
132
132
 
133
133
  Parentheses: `(` `expression(0)` `)` as primary.
134
134
 
@@ -138,12 +138,13 @@ Parentheses: `(` `expression(0)` `)` as primary.
138
138
  expression ::= '(' expression ')'
139
139
  | baseExpression ( ( '+' | '-' ) expression(1)
140
140
  | ( '*' | '/' | 'mod' ) expression(2) )*
141
- | baseExpression ( '.' fieldSegment | '[' expression ']' )*
141
+ | baseExpression ( '.' fieldSegment | '[' expression ']' | '[' slice ']' )*
142
142
 
143
+ slice ::= INT? ':' INT? (* INT may be `-` INT for negative-from-end *)
143
144
  fieldSegment ::= IDENT | BACKTICK_STR | '$' IDENT ... (* lexer token after '.'; special case: if token starts with '$', subfield parse backs up — see pathWithBrackets *)
144
145
  ```
145
146
 
146
- **Postfix** (prec ≤ 3): after `consume()` of `.` or `[`, if `[` then parse `expression(0)` unless next is `]`; require `]`; build `TExprSubfield(res, expr_or_empty, is_index)`.
147
+ **Postfix** (prec ≤ 3): after `consume()` of `.` or `[`, if `[` then either (a) parse a slice `INT? ':' INT?` followed by `]` → `TExprSlice(res, …)`, or (b) parse `expression(0)` followed by `]` `TExprSubfield(res, expr_or_empty, is_index)`. Empty `[]` builds `TExprSubfield` with no expression. Slice detection uses backtracking: a single integer with no `:` falls through to the expression form.
147
148
 
148
149
  ### 4.3 `baseExpression` — alternatives (first token disambiguation)
149
150
 
@@ -151,7 +152,8 @@ Roughly in parse order:
151
152
 
152
153
  | Prefix / form | AST | Notes |
153
154
  |-----------------|-----|--------|
154
- | `[` optional `]` | `TExprSubfield(TExprField("."), expr?, is_index)` | `[]` = whole array on current `.`; `[e]` = index. (Slice `[a:b]` form is currently parsed only on context modifiers, not in path expressions.) |
155
+ | `[` optional `]` | `TExprSubfield(TExprField("."), expr?, is_index)` | `[]` = whole array on current `.`; `[e]` = index. |
156
+ | `[` (INT)? `:` (INT)? `]` | `TExprSlice(TExprField("."), …)` | Half-open array slice on `.`. Negative ints count from end. Bounds must be integer literals (with optional `-`). Yields a JSON array. |
155
157
  | `$` `(` `expression` `)` | `TExprField(expr)` | Evaluate / path from dynamic name (`$()`). |
156
158
  | `$if` `(` `condition` `,` `expression` `,` `expression` `)` | `TExprITE` | |
157
159
  | `$call` `(` IDENT `)` | `TExprCall(name)` | Zero-arg user function by name. |