@ekodb/ekodb-client 0.22.0 → 0.23.0

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
@@ -508,16 +508,30 @@ export declare class EkoDBClient {
508
508
  batchInsert(collection: string, records: Record[], options?: BatchInsertOptions): Promise<BatchOperationResult>;
509
509
  /**
510
510
  * Batch update multiple documents
511
+ * @param collection - Collection name
512
+ * @param updates - Array of updates ({ id, data, bypassRipple? })
513
+ * @param options - Optional parameters (bypassRipple, transactionId). When
514
+ * `transactionId` is set, the batch is staged into that MVCC transaction
515
+ * (sent as the `transaction_id` query param) instead of committed
516
+ * immediately — mirrors the single-record update.
511
517
  */
512
518
  batchUpdate(collection: string, updates: Array<{
513
519
  id: string;
514
520
  data: Record;
515
521
  bypassRipple?: boolean;
516
- }>): Promise<BatchOperationResult>;
522
+ }>, options?: BatchUpdateOptions): Promise<BatchOperationResult>;
517
523
  /**
518
524
  * Batch delete multiple documents
519
- */
520
- batchDelete(collection: string, ids: string[], bypassRipple?: boolean): Promise<BatchOperationResult>;
525
+ * @param collection - Collection name
526
+ * @param ids - Document IDs to delete
527
+ * @param bypassRipple - Optional flag to bypass ripple propagation (legacy
528
+ * positional form, kept for back-compat)
529
+ * @param options - Optional parameters (bypassRipple, transactionId). When
530
+ * `transactionId` is set, the batch is staged into that MVCC transaction
531
+ * (sent as the `transaction_id` query param) instead of committed
532
+ * immediately — mirrors the single-record delete.
533
+ */
534
+ batchDelete(collection: string, ids: string[], bypassRipple?: boolean, options?: BatchDeleteOptions): Promise<BatchOperationResult>;
521
535
  /**
522
536
  * Set a key-value pair with optional TTL
523
537
  * @param key - The key to set
package/dist/client.js CHANGED
@@ -599,24 +599,61 @@ class EkoDBClient {
599
599
  }
600
600
  /**
601
601
  * Batch update multiple documents
602
+ * @param collection - Collection name
603
+ * @param updates - Array of updates ({ id, data, bypassRipple? })
604
+ * @param options - Optional parameters (bypassRipple, transactionId). When
605
+ * `transactionId` is set, the batch is staged into that MVCC transaction
606
+ * (sent as the `transaction_id` query param) instead of committed
607
+ * immediately — mirrors the single-record update.
602
608
  */
603
- async batchUpdate(collection, updates) {
609
+ async batchUpdate(collection, updates, options) {
610
+ const params = new URLSearchParams();
611
+ if (options?.bypassRipple !== undefined) {
612
+ params.append("bypass_ripple", String(options.bypassRipple));
613
+ }
614
+ if (options?.transactionId) {
615
+ params.append("transaction_id", options.transactionId);
616
+ }
604
617
  const formattedUpdates = updates.map((u) => ({
605
618
  id: u.id,
606
619
  data: u.data,
607
620
  bypass_ripple: u.bypassRipple,
608
621
  }));
609
- return this.makeRequest("PUT", `/api/batch/update/${encodeURIComponent(collection)}`, { updates: formattedUpdates });
622
+ const url = params.toString()
623
+ ? `/api/batch/update/${encodeURIComponent(collection)}?${params.toString()}`
624
+ : `/api/batch/update/${encodeURIComponent(collection)}`;
625
+ return this.makeRequest("PUT", url, {
626
+ updates: formattedUpdates,
627
+ });
610
628
  }
611
629
  /**
612
630
  * Batch delete multiple documents
613
- */
614
- async batchDelete(collection, ids, bypassRipple) {
631
+ * @param collection - Collection name
632
+ * @param ids - Document IDs to delete
633
+ * @param bypassRipple - Optional flag to bypass ripple propagation (legacy
634
+ * positional form, kept for back-compat)
635
+ * @param options - Optional parameters (bypassRipple, transactionId). When
636
+ * `transactionId` is set, the batch is staged into that MVCC transaction
637
+ * (sent as the `transaction_id` query param) instead of committed
638
+ * immediately — mirrors the single-record delete.
639
+ */
640
+ async batchDelete(collection, ids, bypassRipple, options) {
641
+ // bypass_ripple is sent per-item in the body (existing behavior). The
642
+ // effective value prefers the explicit positional arg, falling back to the
643
+ // options object so callers can use either form.
644
+ const effectiveBypassRipple = bypassRipple !== undefined ? bypassRipple : options?.bypassRipple;
645
+ const params = new URLSearchParams();
646
+ if (options?.transactionId) {
647
+ params.append("transaction_id", options.transactionId);
648
+ }
615
649
  const deletes = ids.map((id) => ({
616
650
  id: id,
617
- bypass_ripple: bypassRipple,
651
+ bypass_ripple: effectiveBypassRipple,
618
652
  }));
619
- return this.makeRequest("DELETE", `/api/batch/delete/${encodeURIComponent(collection)}`, { deletes });
653
+ const url = params.toString()
654
+ ? `/api/batch/delete/${encodeURIComponent(collection)}?${params.toString()}`
655
+ : `/api/batch/delete/${encodeURIComponent(collection)}`;
656
+ return this.makeRequest("DELETE", url, { deletes });
620
657
  }
621
658
  /**
622
659
  * Set a key-value pair with optional TTL
@@ -1533,7 +1570,11 @@ class EkoDBClient {
1533
1570
  * List all functions, optionally filtered by tags
1534
1571
  */
1535
1572
  async listFunctions(tags) {
1536
- const params = tags ? `?tags=${tags.join(",")}` : "";
1573
+ // URLSearchParams percent-encodes the value (`&`/`=`/`,`), so a tag
1574
+ // containing query-reserved characters can't smuggle extra params.
1575
+ const params = tags
1576
+ ? `?${new URLSearchParams({ tags: tags.join(",") }).toString()}`
1577
+ : "";
1537
1578
  return this.makeRequest("GET", `/api/functions${params}`);
1538
1579
  }
1539
1580
  /**
@@ -1580,7 +1621,11 @@ class EkoDBClient {
1580
1621
  * @returns Array of user functions
1581
1622
  */
1582
1623
  async listUserFunctions(tags) {
1583
- const params = tags ? `?tags=${tags.join(",")}` : "";
1624
+ // URLSearchParams percent-encodes the value (`&`/`=`/`,`), so a tag
1625
+ // containing query-reserved characters can't smuggle extra params.
1626
+ const params = tags
1627
+ ? `?${new URLSearchParams({ tags: tags.join(",") }).toString()}`
1628
+ : "";
1584
1629
  return this.makeRequest("GET", `/api/functions${params}`, undefined, 0, true);
1585
1630
  }
1586
1631
  /**
@@ -227,6 +227,56 @@ function mockErrorResponse(status, message) {
227
227
  const result = await client.batchDelete("users", ["id_1", "id_2"]);
228
228
  (0, vitest_1.expect)(result.successful).toHaveLength(2);
229
229
  });
230
+ (0, vitest_1.it)("batchInsert sends transaction_id as a query param", async () => {
231
+ const client = createTestClient();
232
+ mockTokenResponse();
233
+ mockJsonResponse({ successful: ["id_1"], failed: [] });
234
+ await client.batchInsert("users", [{ name: "A" }], {
235
+ transactionId: "tx_123",
236
+ });
237
+ const [url, init] = mockFetch.mock.calls[1];
238
+ (0, vitest_1.expect)(init.method).toBe("POST");
239
+ const parsed = new URL(url);
240
+ (0, vitest_1.expect)(parsed.pathname).toBe("/api/batch/insert/users");
241
+ (0, vitest_1.expect)(parsed.searchParams.get("transaction_id")).toBe("tx_123");
242
+ });
243
+ (0, vitest_1.it)("batchUpdate sends transaction_id as a query param", async () => {
244
+ const client = createTestClient();
245
+ mockTokenResponse();
246
+ mockJsonResponse({ successful: ["id_1"], failed: [] });
247
+ await client.batchUpdate("users", [{ id: "id_1", data: { name: "B" } }], {
248
+ transactionId: "tx_123",
249
+ });
250
+ const [url, init] = mockFetch.mock.calls[1];
251
+ (0, vitest_1.expect)(init.method).toBe("PUT");
252
+ const parsed = new URL(url);
253
+ (0, vitest_1.expect)(parsed.pathname).toBe("/api/batch/update/users");
254
+ (0, vitest_1.expect)(parsed.searchParams.get("transaction_id")).toBe("tx_123");
255
+ });
256
+ (0, vitest_1.it)("batchDelete sends transaction_id as a query param", async () => {
257
+ const client = createTestClient();
258
+ mockTokenResponse();
259
+ mockJsonResponse({ successful: ["id_1", "id_2"], failed: [] });
260
+ await client.batchDelete("users", ["id_1", "id_2"], undefined, {
261
+ transactionId: "tx_123",
262
+ });
263
+ const [url, init] = mockFetch.mock.calls[1];
264
+ (0, vitest_1.expect)(init.method).toBe("DELETE");
265
+ const parsed = new URL(url);
266
+ (0, vitest_1.expect)(parsed.pathname).toBe("/api/batch/delete/users");
267
+ (0, vitest_1.expect)(parsed.searchParams.get("transaction_id")).toBe("tx_123");
268
+ });
269
+ (0, vitest_1.it)("batch ops without transaction_id send no such query param (additive)", async () => {
270
+ const client = createTestClient();
271
+ mockTokenResponse();
272
+ mockJsonResponse({ successful: ["id_1"], failed: [] });
273
+ // Legacy call sites: no options object at all.
274
+ await client.batchUpdate("users", [{ id: "id_1", data: { name: "B" } }]);
275
+ const [url] = mockFetch.mock.calls[1];
276
+ const parsed = new URL(url);
277
+ (0, vitest_1.expect)(parsed.searchParams.get("transaction_id")).toBeNull();
278
+ (0, vitest_1.expect)(parsed.search).toBe("");
279
+ });
230
280
  });
231
281
  // ============================================================================
232
282
  // KV Store Tests
@@ -2503,6 +2553,19 @@ function mockErrorResponse(status, message) {
2503
2553
  // URLSearchParams, so they are out of scope here.
2504
2554
  // ============================================================================
2505
2555
  (0, vitest_1.describe)("EkoDBClient URL path segment encoding", () => {
2556
+ (0, vitest_1.it)("listUserFunctions percent-encodes reserved chars in the tags query param", async () => {
2557
+ // A tag with query-reserved characters must be percent-encoded, not
2558
+ // concatenated raw into `?tags=...`. Without encoding, `a&injected=1`
2559
+ // splits into tags="a" plus a smuggled `injected=1` query param.
2560
+ const client = createTestClient();
2561
+ mockTokenResponse();
2562
+ mockJsonResponse([]);
2563
+ await client.listUserFunctions(["a&injected=1", "b"]);
2564
+ const [url] = mockFetch.mock.calls[1];
2565
+ const parsed = new URL(url);
2566
+ (0, vitest_1.expect)(parsed.searchParams.get("tags")).toBe("a&injected=1,b");
2567
+ (0, vitest_1.expect)(parsed.searchParams.has("injected")).toBe(false);
2568
+ });
2506
2569
  (0, vitest_1.it)("findById encodes a reserved-char id (a/b -> a%2Fb)", async () => {
2507
2570
  const client = createTestClient();
2508
2571
  mockTokenResponse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ekodb/ekodb-client",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "Official TypeScript/JavaScript client for ekoDB",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -313,6 +313,72 @@ describe("EkoDBClient batch operations", () => {
313
313
 
314
314
  expect(result.successful).toHaveLength(2);
315
315
  });
316
+
317
+ it("batchInsert sends transaction_id as a query param", async () => {
318
+ const client = createTestClient();
319
+
320
+ mockTokenResponse();
321
+ mockJsonResponse({ successful: ["id_1"], failed: [] });
322
+
323
+ await client.batchInsert("users", [{ name: "A" }], {
324
+ transactionId: "tx_123",
325
+ });
326
+
327
+ const [url, init] = mockFetch.mock.calls[1];
328
+ expect(init.method).toBe("POST");
329
+ const parsed = new URL(url as string);
330
+ expect(parsed.pathname).toBe("/api/batch/insert/users");
331
+ expect(parsed.searchParams.get("transaction_id")).toBe("tx_123");
332
+ });
333
+
334
+ it("batchUpdate sends transaction_id as a query param", async () => {
335
+ const client = createTestClient();
336
+
337
+ mockTokenResponse();
338
+ mockJsonResponse({ successful: ["id_1"], failed: [] });
339
+
340
+ await client.batchUpdate("users", [{ id: "id_1", data: { name: "B" } }], {
341
+ transactionId: "tx_123",
342
+ });
343
+
344
+ const [url, init] = mockFetch.mock.calls[1];
345
+ expect(init.method).toBe("PUT");
346
+ const parsed = new URL(url as string);
347
+ expect(parsed.pathname).toBe("/api/batch/update/users");
348
+ expect(parsed.searchParams.get("transaction_id")).toBe("tx_123");
349
+ });
350
+
351
+ it("batchDelete sends transaction_id as a query param", async () => {
352
+ const client = createTestClient();
353
+
354
+ mockTokenResponse();
355
+ mockJsonResponse({ successful: ["id_1", "id_2"], failed: [] });
356
+
357
+ await client.batchDelete("users", ["id_1", "id_2"], undefined, {
358
+ transactionId: "tx_123",
359
+ });
360
+
361
+ const [url, init] = mockFetch.mock.calls[1];
362
+ expect(init.method).toBe("DELETE");
363
+ const parsed = new URL(url as string);
364
+ expect(parsed.pathname).toBe("/api/batch/delete/users");
365
+ expect(parsed.searchParams.get("transaction_id")).toBe("tx_123");
366
+ });
367
+
368
+ it("batch ops without transaction_id send no such query param (additive)", async () => {
369
+ const client = createTestClient();
370
+
371
+ mockTokenResponse();
372
+ mockJsonResponse({ successful: ["id_1"], failed: [] });
373
+
374
+ // Legacy call sites: no options object at all.
375
+ await client.batchUpdate("users", [{ id: "id_1", data: { name: "B" } }]);
376
+
377
+ const [url] = mockFetch.mock.calls[1];
378
+ const parsed = new URL(url as string);
379
+ expect(parsed.searchParams.get("transaction_id")).toBeNull();
380
+ expect(parsed.search).toBe("");
381
+ });
316
382
  });
317
383
 
318
384
  // ============================================================================
@@ -3281,6 +3347,23 @@ describe("extractRecordId", () => {
3281
3347
  // ============================================================================
3282
3348
 
3283
3349
  describe("EkoDBClient URL path segment encoding", () => {
3350
+ it("listUserFunctions percent-encodes reserved chars in the tags query param", async () => {
3351
+ // A tag with query-reserved characters must be percent-encoded, not
3352
+ // concatenated raw into `?tags=...`. Without encoding, `a&injected=1`
3353
+ // splits into tags="a" plus a smuggled `injected=1` query param.
3354
+ const client = createTestClient();
3355
+
3356
+ mockTokenResponse();
3357
+ mockJsonResponse([]);
3358
+
3359
+ await client.listUserFunctions(["a&injected=1", "b"]);
3360
+
3361
+ const [url] = mockFetch.mock.calls[1];
3362
+ const parsed = new URL(url as string);
3363
+ expect(parsed.searchParams.get("tags")).toBe("a&injected=1,b");
3364
+ expect(parsed.searchParams.has("injected")).toBe(false);
3365
+ });
3366
+
3284
3367
  it("findById encodes a reserved-char id (a/b -> a%2Fb)", async () => {
3285
3368
  const client = createTestClient();
3286
3369
 
package/src/client.ts CHANGED
@@ -1051,40 +1051,77 @@ export class EkoDBClient {
1051
1051
 
1052
1052
  /**
1053
1053
  * Batch update multiple documents
1054
+ * @param collection - Collection name
1055
+ * @param updates - Array of updates ({ id, data, bypassRipple? })
1056
+ * @param options - Optional parameters (bypassRipple, transactionId). When
1057
+ * `transactionId` is set, the batch is staged into that MVCC transaction
1058
+ * (sent as the `transaction_id` query param) instead of committed
1059
+ * immediately — mirrors the single-record update.
1054
1060
  */
1055
1061
  async batchUpdate(
1056
1062
  collection: string,
1057
1063
  updates: Array<{ id: string; data: Record; bypassRipple?: boolean }>,
1064
+ options?: BatchUpdateOptions,
1058
1065
  ): Promise<BatchOperationResult> {
1066
+ const params = new URLSearchParams();
1067
+ if (options?.bypassRipple !== undefined) {
1068
+ params.append("bypass_ripple", String(options.bypassRipple));
1069
+ }
1070
+ if (options?.transactionId) {
1071
+ params.append("transaction_id", options.transactionId);
1072
+ }
1073
+
1059
1074
  const formattedUpdates = updates.map((u) => ({
1060
1075
  id: u.id,
1061
1076
  data: u.data,
1062
1077
  bypass_ripple: u.bypassRipple,
1063
1078
  }));
1064
- return this.makeRequest<BatchOperationResult>(
1065
- "PUT",
1066
- `/api/batch/update/${encodeURIComponent(collection)}`,
1067
- { updates: formattedUpdates },
1068
- );
1079
+ const url = params.toString()
1080
+ ? `/api/batch/update/${encodeURIComponent(collection)}?${params.toString()}`
1081
+ : `/api/batch/update/${encodeURIComponent(collection)}`;
1082
+
1083
+ return this.makeRequest<BatchOperationResult>("PUT", url, {
1084
+ updates: formattedUpdates,
1085
+ });
1069
1086
  }
1070
1087
 
1071
1088
  /**
1072
1089
  * Batch delete multiple documents
1090
+ * @param collection - Collection name
1091
+ * @param ids - Document IDs to delete
1092
+ * @param bypassRipple - Optional flag to bypass ripple propagation (legacy
1093
+ * positional form, kept for back-compat)
1094
+ * @param options - Optional parameters (bypassRipple, transactionId). When
1095
+ * `transactionId` is set, the batch is staged into that MVCC transaction
1096
+ * (sent as the `transaction_id` query param) instead of committed
1097
+ * immediately — mirrors the single-record delete.
1073
1098
  */
1074
1099
  async batchDelete(
1075
1100
  collection: string,
1076
1101
  ids: string[],
1077
1102
  bypassRipple?: boolean,
1103
+ options?: BatchDeleteOptions,
1078
1104
  ): Promise<BatchOperationResult> {
1105
+ // bypass_ripple is sent per-item in the body (existing behavior). The
1106
+ // effective value prefers the explicit positional arg, falling back to the
1107
+ // options object so callers can use either form.
1108
+ const effectiveBypassRipple =
1109
+ bypassRipple !== undefined ? bypassRipple : options?.bypassRipple;
1110
+
1111
+ const params = new URLSearchParams();
1112
+ if (options?.transactionId) {
1113
+ params.append("transaction_id", options.transactionId);
1114
+ }
1115
+
1079
1116
  const deletes = ids.map((id) => ({
1080
1117
  id: id,
1081
- bypass_ripple: bypassRipple,
1118
+ bypass_ripple: effectiveBypassRipple,
1082
1119
  }));
1083
- return this.makeRequest<BatchOperationResult>(
1084
- "DELETE",
1085
- `/api/batch/delete/${encodeURIComponent(collection)}`,
1086
- { deletes },
1087
- );
1120
+ const url = params.toString()
1121
+ ? `/api/batch/delete/${encodeURIComponent(collection)}?${params.toString()}`
1122
+ : `/api/batch/delete/${encodeURIComponent(collection)}`;
1123
+
1124
+ return this.makeRequest<BatchOperationResult>("DELETE", url, { deletes });
1088
1125
  }
1089
1126
 
1090
1127
  /**
@@ -2449,7 +2486,11 @@ export class EkoDBClient {
2449
2486
  * List all functions, optionally filtered by tags
2450
2487
  */
2451
2488
  async listFunctions(tags?: string[]): Promise<UserFunction[]> {
2452
- const params = tags ? `?tags=${tags.join(",")}` : "";
2489
+ // URLSearchParams percent-encodes the value (`&`/`=`/`,`), so a tag
2490
+ // containing query-reserved characters can't smuggle extra params.
2491
+ const params = tags
2492
+ ? `?${new URLSearchParams({ tags: tags.join(",") }).toString()}`
2493
+ : "";
2453
2494
  return this.makeRequest<UserFunction[]>("GET", `/api/functions${params}`);
2454
2495
  }
2455
2496
 
@@ -2529,7 +2570,11 @@ export class EkoDBClient {
2529
2570
  * @returns Array of user functions
2530
2571
  */
2531
2572
  async listUserFunctions(tags?: string[]): Promise<UserFunction[]> {
2532
- const params = tags ? `?tags=${tags.join(",")}` : "";
2573
+ // URLSearchParams percent-encodes the value (`&`/`=`/`,`), so a tag
2574
+ // containing query-reserved characters can't smuggle extra params.
2575
+ const params = tags
2576
+ ? `?${new URLSearchParams({ tags: tags.join(",") }).toString()}`
2577
+ : "";
2533
2578
  return this.makeRequest<UserFunction[]>(
2534
2579
  "GET",
2535
2580
  `/api/functions${params}`,