@ekodb/ekodb-client 0.26.0 → 0.26.2

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
@@ -1246,9 +1246,21 @@ export declare class EkoDBClient {
1246
1246
  agentsByDeployment(deploymentId: string): Promise<Record>;
1247
1247
  /** Get documents linked to a KV key */
1248
1248
  kvGetLinks(key: string): Promise<Record>;
1249
- /** Link a document to a KV key */
1250
- kvLink(key: string, collection: string, documentId: string): Promise<Record>;
1251
- /** Unlink a document from a KV key */
1249
+ /**
1250
+ * Link a document to a KV key.
1251
+ *
1252
+ * The identifying triple goes in the path; the body carries the optional
1253
+ * link payload (`keys`, `field_path`, `metadata`), and an empty object means
1254
+ * "no extra link data".
1255
+ */
1256
+ kvLink(key: string, collection: string, documentId: string, linkData?: {
1257
+ keys?: string[];
1258
+ field_path?: string;
1259
+ metadata?: {
1260
+ [key: string]: string;
1261
+ };
1262
+ }): Promise<Record>;
1263
+ /** Unlink a document from a KV key. DELETE, with the triple in the path. */
1252
1264
  kvUnlink(key: string, collection: string, documentId: string): Promise<Record>;
1253
1265
  /** Create a new schedule */
1254
1266
  createSchedule(data: Record): Promise<Record>;
@@ -1260,10 +1272,25 @@ export declare class EkoDBClient {
1260
1272
  updateSchedule(id: string, data: Record): Promise<Record>;
1261
1273
  /** Delete a schedule */
1262
1274
  deleteSchedule(id: string): Promise<void>;
1263
- /** Pause a schedule */
1275
+ /**
1276
+ * Pause a schedule.
1277
+ *
1278
+ * There is no `/pause` endpoint — pausing is a partial update of the
1279
+ * schedule's `enabled` flag. This previously POSTed to
1280
+ * `/api/schedules/{id}/pause`, which has never existed and always 404'd.
1281
+ */
1264
1282
  pauseSchedule(id: string): Promise<Record>;
1265
- /** Resume a schedule */
1283
+ /**
1284
+ * Resume a paused schedule. See {@link pauseSchedule} for why this is an
1285
+ * update rather than its own endpoint.
1286
+ */
1266
1287
  resumeSchedule(id: string): Promise<Record>;
1288
+ /**
1289
+ * Shared implementation for pause/resume: a partial update carrying only
1290
+ * `enabled`. The server recomputes the next execution time when `enabled`
1291
+ * changes, so nothing else needs sending.
1292
+ */
1293
+ private setScheduleEnabled;
1267
1294
  /**
1268
1295
  * Check if a collection exists
1269
1296
  * @param collection - Collection name to check
package/dist/client.js CHANGED
@@ -327,11 +327,11 @@ class EkoDBClient {
327
327
  // ONLY these operations support MessagePack
328
328
  const msgpackPaths = [
329
329
  "/api/insert/",
330
- "/api/batch_insert/",
330
+ "/api/batch/insert/",
331
331
  "/api/update/",
332
- "/api/batch_update/",
332
+ "/api/batch/update/",
333
333
  "/api/delete/",
334
- "/api/batch_delete/",
334
+ "/api/batch/delete/",
335
335
  ];
336
336
  // Check if path starts with any MessagePack-supported operation
337
337
  for (const prefix of msgpackPaths) {
@@ -1910,15 +1910,21 @@ class EkoDBClient {
1910
1910
  // ========================================================================
1911
1911
  /** Get documents linked to a KV key */
1912
1912
  async kvGetLinks(key) {
1913
- return this.makeRequest("GET", `/api/kv/links/${encodeURIComponent(key)}`, undefined, 0, true);
1913
+ return this.makeRequest("GET", `/api/kv/${encodeURIComponent(key)}/links`, undefined, 0, true);
1914
1914
  }
1915
- /** Link a document to a KV key */
1916
- async kvLink(key, collection, documentId) {
1917
- return this.makeRequest("POST", `/api/kv/link`, { key, collection, document_id: documentId }, 0, true);
1915
+ /**
1916
+ * Link a document to a KV key.
1917
+ *
1918
+ * The identifying triple goes in the path; the body carries the optional
1919
+ * link payload (`keys`, `field_path`, `metadata`), and an empty object means
1920
+ * "no extra link data".
1921
+ */
1922
+ async kvLink(key, collection, documentId, linkData = {}) {
1923
+ return this.makeRequest("POST", `/api/kv/${encodeURIComponent(key)}/links/${encodeURIComponent(collection)}/${encodeURIComponent(documentId)}`, linkData, 0, true);
1918
1924
  }
1919
- /** Unlink a document from a KV key */
1925
+ /** Unlink a document from a KV key. DELETE, with the triple in the path. */
1920
1926
  async kvUnlink(key, collection, documentId) {
1921
- return this.makeRequest("POST", `/api/kv/unlink`, { key, collection, document_id: documentId }, 0, true);
1927
+ return this.makeRequest("DELETE", `/api/kv/${encodeURIComponent(key)}/links/${encodeURIComponent(collection)}/${encodeURIComponent(documentId)}`, undefined, 0, true);
1922
1928
  }
1923
1929
  // ========================================================================
1924
1930
  // SCHEDULE MANAGEMENT
@@ -1943,13 +1949,30 @@ class EkoDBClient {
1943
1949
  async deleteSchedule(id) {
1944
1950
  await this.makeRequest("DELETE", `/api/schedules/${encodeURIComponent(id)}`, undefined, 0, true);
1945
1951
  }
1946
- /** Pause a schedule */
1952
+ /**
1953
+ * Pause a schedule.
1954
+ *
1955
+ * There is no `/pause` endpoint — pausing is a partial update of the
1956
+ * schedule's `enabled` flag. This previously POSTed to
1957
+ * `/api/schedules/{id}/pause`, which has never existed and always 404'd.
1958
+ */
1947
1959
  async pauseSchedule(id) {
1948
- return this.makeRequest("POST", `/api/schedules/${encodeURIComponent(id)}/pause`, undefined, 0, true);
1960
+ return this.setScheduleEnabled(id, false);
1949
1961
  }
1950
- /** Resume a schedule */
1962
+ /**
1963
+ * Resume a paused schedule. See {@link pauseSchedule} for why this is an
1964
+ * update rather than its own endpoint.
1965
+ */
1951
1966
  async resumeSchedule(id) {
1952
- return this.makeRequest("POST", `/api/schedules/${encodeURIComponent(id)}/resume`, undefined, 0, true);
1967
+ return this.setScheduleEnabled(id, true);
1968
+ }
1969
+ /**
1970
+ * Shared implementation for pause/resume: a partial update carrying only
1971
+ * `enabled`. The server recomputes the next execution time when `enabled`
1972
+ * changes, so nothing else needs sending.
1973
+ */
1974
+ async setScheduleEnabled(id, enabled) {
1975
+ return this.makeRequest("PUT", `/api/schedules/${encodeURIComponent(id)}`, { enabled }, 0, true);
1953
1976
  }
1954
1977
  // ========================================================================
1955
1978
  // COLLECTION UTILITIES
@@ -2277,6 +2277,15 @@ function mockErrorResponse(status, message) {
2277
2277
  mockJsonResponse({ id: "sched_1", status: "paused" });
2278
2278
  const result = await client.pauseSchedule("sched_1");
2279
2279
  (0, vitest_1.expect)(result).toHaveProperty("status", "paused");
2280
+ // Assert the REQUEST. There is no /pause route; pausing is a partial
2281
+ // update of `enabled`. A response-only assertion passed for as long as
2282
+ // this method POSTed to a route that does not exist.
2283
+ const calls = global.fetch.mock.calls;
2284
+ const dataCall = calls[1]; // calls[0] is the token exchange
2285
+ (0, vitest_1.expect)(dataCall[0]).toContain("/api/schedules/sched_1");
2286
+ (0, vitest_1.expect)(dataCall[0]).not.toContain("/pause");
2287
+ (0, vitest_1.expect)(dataCall[1]?.method).toBe("PUT");
2288
+ (0, vitest_1.expect)(JSON.parse(dataCall[1]?.body)).toEqual({ enabled: false });
2280
2289
  });
2281
2290
  (0, vitest_1.it)("resumes a schedule", async () => {
2282
2291
  const client = createTestClient();
@@ -2284,6 +2293,12 @@ function mockErrorResponse(status, message) {
2284
2293
  mockJsonResponse({ id: "sched_1", status: "active" });
2285
2294
  const result = await client.resumeSchedule("sched_1");
2286
2295
  (0, vitest_1.expect)(result).toHaveProperty("status", "active");
2296
+ const calls = global.fetch.mock.calls;
2297
+ const dataCall = calls[1];
2298
+ (0, vitest_1.expect)(dataCall[0]).toContain("/api/schedules/sched_1");
2299
+ (0, vitest_1.expect)(dataCall[0]).not.toContain("/resume");
2300
+ (0, vitest_1.expect)(dataCall[1]?.method).toBe("PUT");
2301
+ (0, vitest_1.expect)(JSON.parse(dataCall[1]?.body)).toEqual({ enabled: true });
2287
2302
  });
2288
2303
  });
2289
2304
  // ============================================================================
@@ -2301,6 +2316,14 @@ function mockErrorResponse(status, message) {
2301
2316
  });
2302
2317
  const result = await client.kvGetLinks("session:user123");
2303
2318
  (0, vitest_1.expect)(result).toHaveProperty("links");
2319
+ // Assert the REQUEST, not just the mocked response. These three methods
2320
+ // shipped pointing at routes that do not exist, and every one of these
2321
+ // tests passed the whole time, because a mocked response says nothing
2322
+ // about the URL the client actually asked for.
2323
+ const calls = global.fetch.mock.calls;
2324
+ const dataCall = calls[1]; // calls[0] is the token exchange
2325
+ (0, vitest_1.expect)(dataCall[0]).toContain("/api/kv/session%3Auser123/links");
2326
+ (0, vitest_1.expect)(dataCall[1]?.method).toBe("GET");
2304
2327
  });
2305
2328
  (0, vitest_1.it)("links a document to a KV key", async () => {
2306
2329
  const client = createTestClient();
@@ -2308,6 +2331,26 @@ function mockErrorResponse(status, message) {
2308
2331
  mockJsonResponse({ status: "linked" });
2309
2332
  const result = await client.kvLink("session:user123", "users", "user_1");
2310
2333
  (0, vitest_1.expect)(result).toHaveProperty("status", "linked");
2334
+ const calls = global.fetch.mock.calls;
2335
+ const dataCall = calls[1];
2336
+ // The identifying triple belongs in the PATH, not the body.
2337
+ (0, vitest_1.expect)(dataCall[0]).toContain("/api/kv/session%3Auser123/links/users/user_1");
2338
+ (0, vitest_1.expect)(dataCall[1]?.method).toBe("POST");
2339
+ });
2340
+ (0, vitest_1.it)("passes optional link data in the body", async () => {
2341
+ const client = createTestClient();
2342
+ mockTokenResponse();
2343
+ mockJsonResponse({ status: "linked" });
2344
+ await client.kvLink("session:user123", "users", "user_1", {
2345
+ field_path: "profile.avatar",
2346
+ metadata: { source: "signup" },
2347
+ });
2348
+ const calls = global.fetch.mock.calls;
2349
+ const body = JSON.parse(calls[1][1]?.body);
2350
+ (0, vitest_1.expect)(body).toEqual({
2351
+ field_path: "profile.avatar",
2352
+ metadata: { source: "signup" },
2353
+ });
2311
2354
  });
2312
2355
  (0, vitest_1.it)("unlinks a document from a KV key", async () => {
2313
2356
  const client = createTestClient();
@@ -2315,6 +2358,11 @@ function mockErrorResponse(status, message) {
2315
2358
  mockJsonResponse({ status: "unlinked" });
2316
2359
  const result = await client.kvUnlink("session:user123", "users", "user_1");
2317
2360
  (0, vitest_1.expect)(result).toHaveProperty("status", "unlinked");
2361
+ const calls = global.fetch.mock.calls;
2362
+ const dataCall = calls[1];
2363
+ (0, vitest_1.expect)(dataCall[0]).toContain("/api/kv/session%3Auser123/links/users/user_1");
2364
+ // DELETE, not POST — the previous implementation used POST and 404'd.
2365
+ (0, vitest_1.expect)(dataCall[1]?.method).toBe("DELETE");
2318
2366
  });
2319
2367
  });
2320
2368
  // ============================================================================
@@ -2338,7 +2386,7 @@ function mockErrorResponse(status, message) {
2338
2386
  },
2339
2387
  ],
2340
2388
  total: 2,
2341
- took_ms: 12,
2389
+ execution_time_ms: 12,
2342
2390
  });
2343
2391
  const result = await client.textSearch("documents", "ownership", {
2344
2392
  limit: 10,
@@ -2360,7 +2408,7 @@ function mockErrorResponse(status, message) {
2360
2408
  },
2361
2409
  ],
2362
2410
  total: 1,
2363
- took_ms: 25,
2411
+ execution_time_ms: 25,
2364
2412
  });
2365
2413
  const queryVector = [0.1, 0.2, 0.3, 0.4, 0.5];
2366
2414
  const result = await client.hybridSearch("documents", "machine learning", queryVector, 5);
@@ -56,18 +56,6 @@ export type FunctionStageConfig = {
56
56
  } | {
57
57
  type: "Count";
58
58
  output_field: string;
59
- } | {
60
- type: "Filter";
61
- filter: Record<string, any>;
62
- } | {
63
- type: "Sort";
64
- sort: SortFieldConfig[];
65
- } | {
66
- type: "Limit";
67
- limit: number;
68
- } | {
69
- type: "Skip";
70
- skip: number;
71
59
  } | {
72
60
  type: "Insert";
73
61
  collection: string;
@@ -581,10 +569,25 @@ export declare const Stage: {
581
569
  deleteById: (collection: string, record_id: string, bypassRipple?: boolean) => FunctionStageConfig;
582
570
  batchInsert: (collection: string, records: Record<string, any>[], bypassRipple?: boolean) => FunctionStageConfig;
583
571
  batchDelete: (collection: string, record_ids: string[], bypassRipple?: boolean) => FunctionStageConfig;
584
- filter: (filter: Record<string, any>) => FunctionStageConfig;
585
- sort: (sort: SortFieldConfig[]) => FunctionStageConfig;
586
- limit: (limit: number) => FunctionStageConfig;
587
- skip: (skip: number) => FunctionStageConfig;
572
+ /**
573
+ * Filter a collection.
574
+ *
575
+ * Shorthand for a `Query` stage carrying only `filter`. There is no separate
576
+ * `Filter` stage server-side — filtering, sorting, limiting and skipping are
577
+ * all fields on `Query`. This previously emitted `{ type: "Filter" }`, which
578
+ * the server has no variant for; because a function's stage array
579
+ * deserializes as a unit, one such stage rejected the ENTIRE function.
580
+ *
581
+ * Use {@link Stage.query} when you need more than one of these at once — it
582
+ * takes them together and produces a single stage.
583
+ */
584
+ filter: (collection: string, filter: Record<string, any>) => FunctionStageConfig;
585
+ /** Sort a collection. Shorthand for a `Query` carrying only `sort`. */
586
+ sort: (collection: string, sort: SortFieldConfig[]) => FunctionStageConfig;
587
+ /** Limit a collection read. Shorthand for a `Query` carrying only `limit`. */
588
+ limit: (collection: string, limit: number) => FunctionStageConfig;
589
+ /** Skip rows of a collection read. Shorthand for a `Query` with only `skip`. */
590
+ skip: (collection: string, skip: number) => FunctionStageConfig;
588
591
  httpRequest: (url: string, method?: string, headers?: Record<string, string>, body?: any) => FunctionStageConfig;
589
592
  vectorSearch: (collection: string, query_vector: number[], limit?: number, threshold?: number) => FunctionStageConfig;
590
593
  textSearch: (collection: string, query_text: string, options?: {
package/dist/functions.js CHANGED
@@ -103,20 +103,39 @@ exports.Stage = {
103
103
  record_ids,
104
104
  bypass_ripple: bypassRipple,
105
105
  }),
106
- filter: (filter) => ({
107
- type: "Filter",
106
+ /**
107
+ * Filter a collection.
108
+ *
109
+ * Shorthand for a `Query` stage carrying only `filter`. There is no separate
110
+ * `Filter` stage server-side — filtering, sorting, limiting and skipping are
111
+ * all fields on `Query`. This previously emitted `{ type: "Filter" }`, which
112
+ * the server has no variant for; because a function's stage array
113
+ * deserializes as a unit, one such stage rejected the ENTIRE function.
114
+ *
115
+ * Use {@link Stage.query} when you need more than one of these at once — it
116
+ * takes them together and produces a single stage.
117
+ */
118
+ filter: (collection, filter) => ({
119
+ type: "Query",
120
+ collection,
108
121
  filter,
109
122
  }),
110
- sort: (sort) => ({
111
- type: "Sort",
123
+ /** Sort a collection. Shorthand for a `Query` carrying only `sort`. */
124
+ sort: (collection, sort) => ({
125
+ type: "Query",
126
+ collection,
112
127
  sort,
113
128
  }),
114
- limit: (limit) => ({
115
- type: "Limit",
129
+ /** Limit a collection read. Shorthand for a `Query` carrying only `limit`. */
130
+ limit: (collection, limit) => ({
131
+ type: "Query",
132
+ collection,
116
133
  limit,
117
134
  }),
118
- skip: (skip) => ({
119
- type: "Skip",
135
+ /** Skip rows of a collection read. Shorthand for a `Query` with only `skip`. */
136
+ skip: (collection, skip) => ({
137
+ type: "Query",
138
+ collection,
120
139
  skip,
121
140
  }),
122
141
  httpRequest: (url, method = "GET", headers, body) => ({
@@ -538,4 +538,46 @@ const functions_1 = require("./functions");
538
538
  (0, vitest_1.expect)(wire.type).toBe(s.type);
539
539
  }
540
540
  });
541
+ (0, vitest_1.describe)("filter/sort/limit/skip emit Query stages", () => {
542
+ // These four used to emit { type: "Filter" | "Sort" | "Limit" | "Skip" },
543
+ // none of which the server has a variant for. Because a function's stage
544
+ // array deserializes as a unit, a single one of them rejected the ENTIRE
545
+ // function. They are shorthands for a Query carrying that one field.
546
+ (0, vitest_1.it)("filter emits a Query with only the filter set", () => {
547
+ const wire = JSON.parse(JSON.stringify(functions_1.Stage.filter("users", { status: "active" })));
548
+ (0, vitest_1.expect)(wire.type).toBe("Query");
549
+ (0, vitest_1.expect)(wire.collection).toBe("users");
550
+ (0, vitest_1.expect)(wire.filter).toEqual({ status: "active" });
551
+ });
552
+ (0, vitest_1.it)("sort emits a Query with only the sort set", () => {
553
+ const wire = JSON.parse(JSON.stringify(functions_1.Stage.sort("users", [{ field: "created_at", ascending: false }])));
554
+ (0, vitest_1.expect)(wire.type).toBe("Query");
555
+ (0, vitest_1.expect)(wire.collection).toBe("users");
556
+ (0, vitest_1.expect)(wire.sort).toEqual([{ field: "created_at", ascending: false }]);
557
+ });
558
+ (0, vitest_1.it)("limit emits a Query with only the limit set", () => {
559
+ const wire = JSON.parse(JSON.stringify(functions_1.Stage.limit("users", 10)));
560
+ (0, vitest_1.expect)(wire.type).toBe("Query");
561
+ (0, vitest_1.expect)(wire.collection).toBe("users");
562
+ (0, vitest_1.expect)(wire.limit).toBe(10);
563
+ });
564
+ (0, vitest_1.it)("skip emits a Query with only the skip set", () => {
565
+ const wire = JSON.parse(JSON.stringify(functions_1.Stage.skip("users", 5)));
566
+ (0, vitest_1.expect)(wire.type).toBe("Query");
567
+ (0, vitest_1.expect)(wire.collection).toBe("users");
568
+ (0, vitest_1.expect)(wire.skip).toBe(5);
569
+ });
570
+ (0, vitest_1.it)("never emits a stage type the server has no variant for", () => {
571
+ const wire = [
572
+ functions_1.Stage.filter("users", {}),
573
+ functions_1.Stage.sort("users", []),
574
+ functions_1.Stage.limit("users", 1),
575
+ functions_1.Stage.skip("users", 1),
576
+ ].map((s) => JSON.parse(JSON.stringify(s)).type);
577
+ (0, vitest_1.expect)(wire).not.toContain("Filter");
578
+ (0, vitest_1.expect)(wire).not.toContain("Sort");
579
+ (0, vitest_1.expect)(wire).not.toContain("Limit");
580
+ (0, vitest_1.expect)(wire).not.toContain("Skip");
581
+ });
582
+ });
541
583
  });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const node_fs_1 = require("node:fs");
4
+ const node_path_1 = require("node:path");
5
+ const vitest_1 = require("vitest");
6
+ const search_1 = require("./search");
7
+ const query_builder_1 = require("./query-builder");
8
+ const golden = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.resolve)(__dirname, "../../test-fixtures/search-requests.json"), "utf8"));
9
+ const vector = [0.1, 0.2, 0.3];
10
+ const base = (query = "") => new search_1.SearchQueryBuilder(query)
11
+ .bypassRipple(false)
12
+ .bypassCache(false)
13
+ .limit(10);
14
+ (0, vitest_1.describe)("cross-client search wire cases", () => {
15
+ const cases = {
16
+ filtered_vector: base()
17
+ .vector(vector)
18
+ .vectorMetric("cosine")
19
+ .vectorK(10)
20
+ .vectorThreshold(0.25)
21
+ .filters(new query_builder_1.QueryBuilder().eq("category", "ml").build().filter)
22
+ .build(),
23
+ hybrid_custom_weights: base("machine learning")
24
+ .vector(vector)
25
+ .textWeight(0.7)
26
+ .vectorWeight(0.3)
27
+ .build(),
28
+ named_vector_field: base()
29
+ .vector(vector)
30
+ .vectorField("title_embedding")
31
+ .vectorMetric("dotproduct")
32
+ .vectorK(10)
33
+ .build(),
34
+ full_text: base("enginer")
35
+ .fields(["title", "body"])
36
+ .weights({ title: 2.5, body: 1.5 })
37
+ .fuzzy(true)
38
+ .maxEditDistance(2)
39
+ .build(),
40
+ };
41
+ for (const [name, query] of Object.entries(cases)) {
42
+ (0, vitest_1.it)(name, () => (0, vitest_1.expect)(JSON.parse(JSON.stringify(query))).toEqual(golden[name]));
43
+ }
44
+ (0, vitest_1.it)("omits unset options", () => {
45
+ (0, vitest_1.expect)(JSON.parse(JSON.stringify(new search_1.SearchQueryBuilder("").build()))).toEqual({ query: "" });
46
+ });
47
+ });
package/dist/search.d.ts CHANGED
@@ -79,7 +79,7 @@ export interface SearchResponse {
79
79
  /** Total number of results found */
80
80
  total: number;
81
81
  /** Query execution time in milliseconds */
82
- took_ms?: number;
82
+ execution_time_ms?: number;
83
83
  }
84
84
  /**
85
85
  * Builder for constructing search queries with fluent API
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ekodb/ekodb-client",
3
- "version": "0.26.0",
3
+ "version": "0.26.2",
4
4
  "description": "Official TypeScript/JavaScript client for ekoDB",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -20,7 +20,7 @@
20
20
  "author": "ekoDB",
21
21
  "license": "MIT",
22
22
  "devDependencies": {
23
- "@types/node": "^24.13.2",
23
+ "@types/node": "^26.4.0",
24
24
  "@types/ws": "^8.18.1",
25
25
  "typescript": "^6.0.3",
26
26
  "vitest": "^4.0.18"
@@ -3021,6 +3021,16 @@ describe("EkoDBClient schedules", () => {
3021
3021
 
3022
3022
  const result = await client.pauseSchedule("sched_1");
3023
3023
  expect(result).toHaveProperty("status", "paused");
3024
+
3025
+ // Assert the REQUEST. There is no /pause route; pausing is a partial
3026
+ // update of `enabled`. A response-only assertion passed for as long as
3027
+ // this method POSTed to a route that does not exist.
3028
+ const calls = (global.fetch as ReturnType<typeof vi.fn>).mock.calls;
3029
+ const dataCall = calls[1]; // calls[0] is the token exchange
3030
+ expect(dataCall[0]).toContain("/api/schedules/sched_1");
3031
+ expect(dataCall[0]).not.toContain("/pause");
3032
+ expect(dataCall[1]?.method).toBe("PUT");
3033
+ expect(JSON.parse(dataCall[1]?.body as string)).toEqual({ enabled: false });
3024
3034
  });
3025
3035
 
3026
3036
  it("resumes a schedule", async () => {
@@ -3030,6 +3040,13 @@ describe("EkoDBClient schedules", () => {
3030
3040
 
3031
3041
  const result = await client.resumeSchedule("sched_1");
3032
3042
  expect(result).toHaveProperty("status", "active");
3043
+
3044
+ const calls = (global.fetch as ReturnType<typeof vi.fn>).mock.calls;
3045
+ const dataCall = calls[1];
3046
+ expect(dataCall[0]).toContain("/api/schedules/sched_1");
3047
+ expect(dataCall[0]).not.toContain("/resume");
3048
+ expect(dataCall[1]?.method).toBe("PUT");
3049
+ expect(JSON.parse(dataCall[1]?.body as string)).toEqual({ enabled: true });
3033
3050
  });
3034
3051
  });
3035
3052
 
@@ -3050,6 +3067,15 @@ describe("EkoDBClient kv links", () => {
3050
3067
 
3051
3068
  const result = await client.kvGetLinks("session:user123");
3052
3069
  expect(result).toHaveProperty("links");
3070
+
3071
+ // Assert the REQUEST, not just the mocked response. These three methods
3072
+ // shipped pointing at routes that do not exist, and every one of these
3073
+ // tests passed the whole time, because a mocked response says nothing
3074
+ // about the URL the client actually asked for.
3075
+ const calls = (global.fetch as ReturnType<typeof vi.fn>).mock.calls;
3076
+ const dataCall = calls[1]; // calls[0] is the token exchange
3077
+ expect(dataCall[0]).toContain("/api/kv/session%3Auser123/links");
3078
+ expect(dataCall[1]?.method).toBe("GET");
3053
3079
  });
3054
3080
 
3055
3081
  it("links a document to a KV key", async () => {
@@ -3059,6 +3085,32 @@ describe("EkoDBClient kv links", () => {
3059
3085
 
3060
3086
  const result = await client.kvLink("session:user123", "users", "user_1");
3061
3087
  expect(result).toHaveProperty("status", "linked");
3088
+
3089
+ const calls = (global.fetch as ReturnType<typeof vi.fn>).mock.calls;
3090
+ const dataCall = calls[1];
3091
+ // The identifying triple belongs in the PATH, not the body.
3092
+ expect(dataCall[0]).toContain(
3093
+ "/api/kv/session%3Auser123/links/users/user_1",
3094
+ );
3095
+ expect(dataCall[1]?.method).toBe("POST");
3096
+ });
3097
+
3098
+ it("passes optional link data in the body", async () => {
3099
+ const client = createTestClient();
3100
+ mockTokenResponse();
3101
+ mockJsonResponse({ status: "linked" });
3102
+
3103
+ await client.kvLink("session:user123", "users", "user_1", {
3104
+ field_path: "profile.avatar",
3105
+ metadata: { source: "signup" },
3106
+ });
3107
+
3108
+ const calls = (global.fetch as ReturnType<typeof vi.fn>).mock.calls;
3109
+ const body = JSON.parse(calls[1][1]?.body as string);
3110
+ expect(body).toEqual({
3111
+ field_path: "profile.avatar",
3112
+ metadata: { source: "signup" },
3113
+ });
3062
3114
  });
3063
3115
 
3064
3116
  it("unlinks a document from a KV key", async () => {
@@ -3068,6 +3120,14 @@ describe("EkoDBClient kv links", () => {
3068
3120
 
3069
3121
  const result = await client.kvUnlink("session:user123", "users", "user_1");
3070
3122
  expect(result).toHaveProperty("status", "unlinked");
3123
+
3124
+ const calls = (global.fetch as ReturnType<typeof vi.fn>).mock.calls;
3125
+ const dataCall = calls[1];
3126
+ expect(dataCall[0]).toContain(
3127
+ "/api/kv/session%3Auser123/links/users/user_1",
3128
+ );
3129
+ // DELETE, not POST — the previous implementation used POST and 404'd.
3130
+ expect(dataCall[1]?.method).toBe("DELETE");
3071
3131
  });
3072
3132
  });
3073
3133
 
@@ -3093,7 +3153,7 @@ describe("EkoDBClient text and hybrid search", () => {
3093
3153
  },
3094
3154
  ],
3095
3155
  total: 2,
3096
- took_ms: 12,
3156
+ execution_time_ms: 12,
3097
3157
  });
3098
3158
 
3099
3159
  const result = await client.textSearch("documents", "ownership", {
@@ -3118,7 +3178,7 @@ describe("EkoDBClient text and hybrid search", () => {
3118
3178
  },
3119
3179
  ],
3120
3180
  total: 1,
3121
- took_ms: 25,
3181
+ execution_time_ms: 25,
3122
3182
  });
3123
3183
 
3124
3184
  const queryVector = [0.1, 0.2, 0.3, 0.4, 0.5];
package/src/client.ts CHANGED
@@ -782,11 +782,11 @@ export class EkoDBClient {
782
782
  // ONLY these operations support MessagePack
783
783
  const msgpackPaths = [
784
784
  "/api/insert/",
785
- "/api/batch_insert/",
785
+ "/api/batch/insert/",
786
786
  "/api/update/",
787
- "/api/batch_update/",
787
+ "/api/batch/update/",
788
788
  "/api/delete/",
789
- "/api/batch_delete/",
789
+ "/api/batch/delete/",
790
790
  ];
791
791
 
792
792
  // Check if path starts with any MessagePack-supported operation
@@ -3231,38 +3231,49 @@ export class EkoDBClient {
3231
3231
  async kvGetLinks(key: string): Promise<Record> {
3232
3232
  return this.makeRequest<Record>(
3233
3233
  "GET",
3234
- `/api/kv/links/${encodeURIComponent(key)}`,
3234
+ `/api/kv/${encodeURIComponent(key)}/links`,
3235
3235
  undefined,
3236
3236
  0,
3237
3237
  true,
3238
3238
  );
3239
3239
  }
3240
3240
 
3241
- /** Link a document to a KV key */
3241
+ /**
3242
+ * Link a document to a KV key.
3243
+ *
3244
+ * The identifying triple goes in the path; the body carries the optional
3245
+ * link payload (`keys`, `field_path`, `metadata`), and an empty object means
3246
+ * "no extra link data".
3247
+ */
3242
3248
  async kvLink(
3243
3249
  key: string,
3244
3250
  collection: string,
3245
3251
  documentId: string,
3252
+ linkData: {
3253
+ keys?: string[];
3254
+ field_path?: string;
3255
+ metadata?: { [key: string]: string };
3256
+ } = {},
3246
3257
  ): Promise<Record> {
3247
3258
  return this.makeRequest<Record>(
3248
3259
  "POST",
3249
- `/api/kv/link`,
3250
- { key, collection, document_id: documentId },
3260
+ `/api/kv/${encodeURIComponent(key)}/links/${encodeURIComponent(collection)}/${encodeURIComponent(documentId)}`,
3261
+ linkData,
3251
3262
  0,
3252
3263
  true,
3253
3264
  );
3254
3265
  }
3255
3266
 
3256
- /** Unlink a document from a KV key */
3267
+ /** Unlink a document from a KV key. DELETE, with the triple in the path. */
3257
3268
  async kvUnlink(
3258
3269
  key: string,
3259
3270
  collection: string,
3260
3271
  documentId: string,
3261
3272
  ): Promise<Record> {
3262
3273
  return this.makeRequest<Record>(
3263
- "POST",
3264
- `/api/kv/unlink`,
3265
- { key, collection, document_id: documentId },
3274
+ "DELETE",
3275
+ `/api/kv/${encodeURIComponent(key)}/links/${encodeURIComponent(collection)}/${encodeURIComponent(documentId)}`,
3276
+ undefined,
3266
3277
  0,
3267
3278
  true,
3268
3279
  );
@@ -3321,23 +3332,38 @@ export class EkoDBClient {
3321
3332
  );
3322
3333
  }
3323
3334
 
3324
- /** Pause a schedule */
3335
+ /**
3336
+ * Pause a schedule.
3337
+ *
3338
+ * There is no `/pause` endpoint — pausing is a partial update of the
3339
+ * schedule's `enabled` flag. This previously POSTed to
3340
+ * `/api/schedules/{id}/pause`, which has never existed and always 404'd.
3341
+ */
3325
3342
  async pauseSchedule(id: string): Promise<Record> {
3326
- return this.makeRequest<Record>(
3327
- "POST",
3328
- `/api/schedules/${encodeURIComponent(id)}/pause`,
3329
- undefined,
3330
- 0,
3331
- true,
3332
- );
3343
+ return this.setScheduleEnabled(id, false);
3333
3344
  }
3334
3345
 
3335
- /** Resume a schedule */
3346
+ /**
3347
+ * Resume a paused schedule. See {@link pauseSchedule} for why this is an
3348
+ * update rather than its own endpoint.
3349
+ */
3336
3350
  async resumeSchedule(id: string): Promise<Record> {
3351
+ return this.setScheduleEnabled(id, true);
3352
+ }
3353
+
3354
+ /**
3355
+ * Shared implementation for pause/resume: a partial update carrying only
3356
+ * `enabled`. The server recomputes the next execution time when `enabled`
3357
+ * changes, so nothing else needs sending.
3358
+ */
3359
+ private async setScheduleEnabled(
3360
+ id: string,
3361
+ enabled: boolean,
3362
+ ): Promise<Record> {
3337
3363
  return this.makeRequest<Record>(
3338
- "POST",
3339
- `/api/schedules/${encodeURIComponent(id)}/resume`,
3340
- undefined,
3364
+ "PUT",
3365
+ `/api/schedules/${encodeURIComponent(id)}`,
3366
+ { enabled },
3341
3367
  0,
3342
3368
  true,
3343
3369
  );
@@ -722,4 +722,58 @@ describe("Crypto and concurrency stages", () => {
722
722
  expect(wire.type).toBe(s.type);
723
723
  }
724
724
  });
725
+
726
+ describe("filter/sort/limit/skip emit Query stages", () => {
727
+ // These four used to emit { type: "Filter" | "Sort" | "Limit" | "Skip" },
728
+ // none of which the server has a variant for. Because a function's stage
729
+ // array deserializes as a unit, a single one of them rejected the ENTIRE
730
+ // function. They are shorthands for a Query carrying that one field.
731
+
732
+ it("filter emits a Query with only the filter set", () => {
733
+ const wire = JSON.parse(
734
+ JSON.stringify(Stage.filter("users", { status: "active" })),
735
+ );
736
+ expect(wire.type).toBe("Query");
737
+ expect(wire.collection).toBe("users");
738
+ expect(wire.filter).toEqual({ status: "active" });
739
+ });
740
+
741
+ it("sort emits a Query with only the sort set", () => {
742
+ const wire = JSON.parse(
743
+ JSON.stringify(
744
+ Stage.sort("users", [{ field: "created_at", ascending: false }]),
745
+ ),
746
+ );
747
+ expect(wire.type).toBe("Query");
748
+ expect(wire.collection).toBe("users");
749
+ expect(wire.sort).toEqual([{ field: "created_at", ascending: false }]);
750
+ });
751
+
752
+ it("limit emits a Query with only the limit set", () => {
753
+ const wire = JSON.parse(JSON.stringify(Stage.limit("users", 10)));
754
+ expect(wire.type).toBe("Query");
755
+ expect(wire.collection).toBe("users");
756
+ expect(wire.limit).toBe(10);
757
+ });
758
+
759
+ it("skip emits a Query with only the skip set", () => {
760
+ const wire = JSON.parse(JSON.stringify(Stage.skip("users", 5)));
761
+ expect(wire.type).toBe("Query");
762
+ expect(wire.collection).toBe("users");
763
+ expect(wire.skip).toBe(5);
764
+ });
765
+
766
+ it("never emits a stage type the server has no variant for", () => {
767
+ const wire = [
768
+ Stage.filter("users", {}),
769
+ Stage.sort("users", []),
770
+ Stage.limit("users", 1),
771
+ Stage.skip("users", 1),
772
+ ].map((s) => JSON.parse(JSON.stringify(s)).type);
773
+ expect(wire).not.toContain("Filter");
774
+ expect(wire).not.toContain("Sort");
775
+ expect(wire).not.toContain("Limit");
776
+ expect(wire).not.toContain("Skip");
777
+ });
778
+ });
725
779
  });
package/src/functions.ts CHANGED
@@ -55,10 +55,6 @@ export type FunctionStageConfig =
55
55
  functions: GroupFunctionConfig[];
56
56
  }
57
57
  | { type: "Count"; output_field: string }
58
- | { type: "Filter"; filter: Record<string, any> }
59
- | { type: "Sort"; sort: SortFieldConfig[] }
60
- | { type: "Limit"; limit: number }
61
- | { type: "Skip"; skip: number }
62
58
  | {
63
59
  type: "Insert";
64
60
  collection: string;
@@ -742,23 +738,45 @@ export const Stage = {
742
738
  bypass_ripple: bypassRipple,
743
739
  }),
744
740
 
745
- filter: (filter: Record<string, any>): FunctionStageConfig => ({
746
- type: "Filter",
741
+ /**
742
+ * Filter a collection.
743
+ *
744
+ * Shorthand for a `Query` stage carrying only `filter`. There is no separate
745
+ * `Filter` stage server-side — filtering, sorting, limiting and skipping are
746
+ * all fields on `Query`. This previously emitted `{ type: "Filter" }`, which
747
+ * the server has no variant for; because a function's stage array
748
+ * deserializes as a unit, one such stage rejected the ENTIRE function.
749
+ *
750
+ * Use {@link Stage.query} when you need more than one of these at once — it
751
+ * takes them together and produces a single stage.
752
+ */
753
+ filter: (
754
+ collection: string,
755
+ filter: Record<string, any>,
756
+ ): FunctionStageConfig => ({
757
+ type: "Query",
758
+ collection,
747
759
  filter,
748
760
  }),
749
761
 
750
- sort: (sort: SortFieldConfig[]): FunctionStageConfig => ({
751
- type: "Sort",
762
+ /** Sort a collection. Shorthand for a `Query` carrying only `sort`. */
763
+ sort: (collection: string, sort: SortFieldConfig[]): FunctionStageConfig => ({
764
+ type: "Query",
765
+ collection,
752
766
  sort,
753
767
  }),
754
768
 
755
- limit: (limit: number): FunctionStageConfig => ({
756
- type: "Limit",
769
+ /** Limit a collection read. Shorthand for a `Query` carrying only `limit`. */
770
+ limit: (collection: string, limit: number): FunctionStageConfig => ({
771
+ type: "Query",
772
+ collection,
757
773
  limit,
758
774
  }),
759
775
 
760
- skip: (skip: number): FunctionStageConfig => ({
761
- type: "Skip",
776
+ /** Skip rows of a collection read. Shorthand for a `Query` with only `skip`. */
777
+ skip: (collection: string, skip: number): FunctionStageConfig => ({
778
+ type: "Query",
779
+ collection,
762
780
  skip,
763
781
  }),
764
782
 
@@ -0,0 +1,57 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { describe, expect, it } from "vitest";
4
+ import { SearchQueryBuilder } from "./search";
5
+ import { QueryBuilder } from "./query-builder";
6
+
7
+ const golden = JSON.parse(
8
+ readFileSync(
9
+ resolve(__dirname, "../../test-fixtures/search-requests.json"),
10
+ "utf8",
11
+ ),
12
+ );
13
+ const vector = [0.1, 0.2, 0.3];
14
+ const base = (query = "") =>
15
+ new SearchQueryBuilder(query)
16
+ .bypassRipple(false)
17
+ .bypassCache(false)
18
+ .limit(10);
19
+
20
+ describe("cross-client search wire cases", () => {
21
+ const cases = {
22
+ filtered_vector: base()
23
+ .vector(vector)
24
+ .vectorMetric("cosine")
25
+ .vectorK(10)
26
+ .vectorThreshold(0.25)
27
+ .filters(new QueryBuilder().eq("category", "ml").build().filter)
28
+ .build(),
29
+ hybrid_custom_weights: base("machine learning")
30
+ .vector(vector)
31
+ .textWeight(0.7)
32
+ .vectorWeight(0.3)
33
+ .build(),
34
+ named_vector_field: base()
35
+ .vector(vector)
36
+ .vectorField("title_embedding")
37
+ .vectorMetric("dotproduct")
38
+ .vectorK(10)
39
+ .build(),
40
+ full_text: base("enginer")
41
+ .fields(["title", "body"])
42
+ .weights({ title: 2.5, body: 1.5 })
43
+ .fuzzy(true)
44
+ .maxEditDistance(2)
45
+ .build(),
46
+ };
47
+ for (const [name, query] of Object.entries(cases)) {
48
+ it(name, () =>
49
+ expect(JSON.parse(JSON.stringify(query))).toEqual(golden[name]),
50
+ );
51
+ }
52
+ it("omits unset options", () => {
53
+ expect(
54
+ JSON.parse(JSON.stringify(new SearchQueryBuilder("").build())),
55
+ ).toEqual({ query: "" });
56
+ });
57
+ });
package/src/search.ts CHANGED
@@ -90,7 +90,7 @@ export interface SearchResponse {
90
90
  /** Total number of results found */
91
91
  total: number;
92
92
  /** Query execution time in milliseconds */
93
- took_ms?: number;
93
+ execution_time_ms?: number;
94
94
  }
95
95
 
96
96
  /**