@ekodb/ekodb-client 0.7.1 → 0.8.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/src/client.ts CHANGED
@@ -78,6 +78,62 @@ export interface BatchOperationResult {
78
78
  failed: Array<{ id?: string; error: string }>;
79
79
  }
80
80
 
81
+ // ========== Operation Options Interfaces ==========
82
+
83
+ export interface InsertOptions {
84
+ ttl?: string;
85
+ bypassRipple?: boolean;
86
+ transactionId?: string;
87
+ bypassCache?: boolean;
88
+ }
89
+
90
+ export interface UpdateOptions {
91
+ bypassRipple?: boolean;
92
+ transactionId?: string;
93
+ bypassCache?: boolean;
94
+ selectFields?: string[];
95
+ excludeFields?: string[];
96
+ }
97
+
98
+ export interface DeleteOptions {
99
+ bypassRipple?: boolean;
100
+ transactionId?: string;
101
+ }
102
+
103
+ export interface UpsertOptions {
104
+ ttl?: string;
105
+ bypassRipple?: boolean;
106
+ transactionId?: string;
107
+ bypassCache?: boolean;
108
+ }
109
+
110
+ export interface FindOptions {
111
+ filter?: any;
112
+ sort?: any;
113
+ limit?: number;
114
+ skip?: number;
115
+ join?: any;
116
+ bypassRipple?: boolean;
117
+ bypassCache?: boolean;
118
+ selectFields?: string[];
119
+ excludeFields?: string[];
120
+ }
121
+
122
+ export interface BatchInsertOptions {
123
+ bypassRipple?: boolean;
124
+ transactionId?: string;
125
+ }
126
+
127
+ export interface BatchUpdateOptions {
128
+ bypassRipple?: boolean;
129
+ transactionId?: string;
130
+ }
131
+
132
+ export interface BatchDeleteOptions {
133
+ bypassRipple?: boolean;
134
+ transactionId?: string;
135
+ }
136
+
81
137
  // ========== Chat Interfaces ==========
82
138
 
83
139
  export interface CollectionConfig {
@@ -254,7 +310,16 @@ export class EkoDBClient {
254
310
  });
255
311
 
256
312
  if (!response.ok) {
257
- throw new Error(`Auth failed with status: ${response.status}`);
313
+ let errorMsg = `Auth failed with status: ${response.status}`;
314
+ try {
315
+ const errorBody = (await response.json()) as { error?: string };
316
+ if (errorBody.error) {
317
+ errorMsg = errorBody.error;
318
+ }
319
+ } catch {
320
+ // Ignore JSON parse errors, use default message
321
+ }
322
+ throw new Error(errorMsg);
258
323
  }
259
324
 
260
325
  const result = (await response.json()) as { token: string };
@@ -443,18 +508,31 @@ export class EkoDBClient {
443
508
  * Insert a document into a collection
444
509
  * @param collection - Collection name
445
510
  * @param record - Document to insert
446
- * @param ttl - Optional TTL: duration string ("1h", "30m"), seconds ("3600"), or ISO8601 timestamp
511
+ * @param options - Optional parameters (ttl, bypassRipple, transactionId, bypassCache)
447
512
  */
448
513
  async insert(
449
514
  collection: string,
450
515
  record: Record,
451
- ttl?: string,
516
+ options?: InsertOptions,
452
517
  ): Promise<Record> {
453
518
  const data = { ...record };
454
- if (ttl) {
455
- data.ttl = ttl;
519
+ if (options?.ttl) {
520
+ data.ttl = options.ttl;
456
521
  }
457
- return this.makeRequest<Record>("POST", `/api/insert/${collection}`, data);
522
+
523
+ const params = new URLSearchParams();
524
+ if (options?.bypassRipple !== undefined) {
525
+ params.append("bypass_ripple", String(options.bypassRipple));
526
+ }
527
+ if (options?.transactionId) {
528
+ params.append("transaction_id", options.transactionId);
529
+ }
530
+
531
+ const url = params.toString()
532
+ ? `/api/insert/${collection}?${params.toString()}`
533
+ : `/api/insert/${collection}`;
534
+
535
+ return this.makeRequest<Record>("POST", url, data);
458
536
  }
459
537
 
460
538
  /**
@@ -500,43 +578,83 @@ export class EkoDBClient {
500
578
 
501
579
  /**
502
580
  * Update a document
581
+ * @param collection - Collection name
582
+ * @param id - Document ID
583
+ * @param record - Update data
584
+ * @param options - Optional parameters (bypassRipple, transactionId, bypassCache, selectFields, excludeFields)
503
585
  */
504
586
  async update(
505
587
  collection: string,
506
588
  id: string,
507
589
  record: Record,
590
+ options?: UpdateOptions,
508
591
  ): Promise<Record> {
509
- return this.makeRequest<Record>(
510
- "PUT",
511
- `/api/update/${collection}/${id}`,
512
- record,
513
- );
592
+ const params = new URLSearchParams();
593
+ if (options?.bypassRipple !== undefined) {
594
+ params.append("bypass_ripple", String(options.bypassRipple));
595
+ }
596
+ if (options?.transactionId) {
597
+ params.append("transaction_id", options.transactionId);
598
+ }
599
+
600
+ const url = params.toString()
601
+ ? `/api/update/${collection}/${id}?${params.toString()}`
602
+ : `/api/update/${collection}/${id}`;
603
+
604
+ return this.makeRequest<Record>("PUT", url, record);
514
605
  }
515
606
 
516
607
  /**
517
608
  * Delete a document
609
+ * @param collection - Collection name
610
+ * @param id - Document ID
611
+ * @param options - Optional parameters (bypassRipple, transactionId)
518
612
  */
519
- async delete(collection: string, id: string): Promise<void> {
520
- await this.makeRequest<void>("DELETE", `/api/delete/${collection}/${id}`);
613
+ async delete(
614
+ collection: string,
615
+ id: string,
616
+ options?: DeleteOptions,
617
+ ): Promise<void> {
618
+ const params = new URLSearchParams();
619
+ if (options?.bypassRipple !== undefined) {
620
+ params.append("bypass_ripple", String(options.bypassRipple));
621
+ }
622
+ if (options?.transactionId) {
623
+ params.append("transaction_id", options.transactionId);
624
+ }
625
+
626
+ const url = params.toString()
627
+ ? `/api/delete/${collection}/${id}?${params.toString()}`
628
+ : `/api/delete/${collection}/${id}`;
629
+
630
+ await this.makeRequest<void>("DELETE", url);
521
631
  }
522
632
 
523
633
  /**
524
634
  * Batch insert multiple documents
635
+ * @param collection - Collection name
636
+ * @param records - Array of documents to insert
637
+ * @param options - Optional parameters (bypassRipple, transactionId)
525
638
  */
526
639
  async batchInsert(
527
640
  collection: string,
528
641
  records: Record[],
529
- bypassRipple?: boolean,
642
+ options?: BatchInsertOptions,
530
643
  ): Promise<BatchOperationResult> {
531
- const inserts = records.map((data) => ({
532
- data,
533
- bypass_ripple: bypassRipple,
534
- }));
535
- return this.makeRequest<BatchOperationResult>(
536
- "POST",
537
- `/api/batch/insert/${collection}`,
538
- { inserts },
539
- );
644
+ const params = new URLSearchParams();
645
+ if (options?.bypassRipple !== undefined) {
646
+ params.append("bypass_ripple", String(options.bypassRipple));
647
+ }
648
+ if (options?.transactionId) {
649
+ params.append("transaction_id", options.transactionId);
650
+ }
651
+
652
+ const inserts = records.map((data) => ({ data }));
653
+ const url = params.toString()
654
+ ? `/api/batch/insert/${collection}?${params.toString()}`
655
+ : `/api/batch/insert/${collection}`;
656
+
657
+ return this.makeRequest<BatchOperationResult>("POST", url, { inserts });
540
658
  }
541
659
 
542
660
  /**
@@ -734,6 +852,159 @@ export class EkoDBClient {
734
852
  );
735
853
  }
736
854
 
855
+ // ============================================================================
856
+ // Convenience Methods
857
+ // ============================================================================
858
+
859
+ /**
860
+ * Insert or update a record (upsert operation)
861
+ *
862
+ * Attempts to update the record first. If the record doesn't exist (404 error),
863
+ * it will be inserted instead. This provides atomic insert-or-update semantics.
864
+ *
865
+ * @param collection - Collection name
866
+ * @param id - Record ID
867
+ * @param record - Record data to insert or update
868
+ * @param bypassRipple - Optional flag to bypass ripple effects
869
+ * @returns The inserted or updated record
870
+ *
871
+ * @example
872
+ * ```typescript
873
+ * const record = { name: "John Doe", email: "john@example.com" };
874
+ * // Will update if exists, insert if not
875
+ * const result = await client.upsert("users", "user123", record);
876
+ * ```
877
+ */
878
+ /**
879
+ * Upsert a document (insert or update)
880
+ * @param collection - Collection name
881
+ * @param id - Document ID
882
+ * @param record - Document data
883
+ * @param options - Optional parameters (ttl, bypassRipple, transactionId, bypassCache)
884
+ */
885
+ async upsert(
886
+ collection: string,
887
+ id: string,
888
+ record: Record,
889
+ options?: UpsertOptions,
890
+ ): Promise<Record> {
891
+ try {
892
+ // Try update first
893
+ return await this.update(collection, id, record, {
894
+ bypassRipple: options?.bypassRipple,
895
+ transactionId: options?.transactionId,
896
+ bypassCache: options?.bypassCache,
897
+ });
898
+ } catch (error: any) {
899
+ // If not found, insert instead
900
+ if (
901
+ error.message?.includes("404") ||
902
+ error.message?.includes("Not found")
903
+ ) {
904
+ return await this.insert(collection, record, {
905
+ ttl: options?.ttl,
906
+ bypassRipple: options?.bypassRipple,
907
+ transactionId: options?.transactionId,
908
+ bypassCache: options?.bypassCache,
909
+ });
910
+ }
911
+ throw error;
912
+ }
913
+ }
914
+
915
+ /**
916
+ * Find a single record by field value
917
+ *
918
+ * Convenience method for finding one record matching a specific field value.
919
+ * Returns null if no record matches, or the first matching record.
920
+ *
921
+ * @param collection - Collection name
922
+ * @param field - Field name to search
923
+ * @param value - Value to match
924
+ * @returns The matching record or null if not found
925
+ *
926
+ * @example
927
+ * ```typescript
928
+ * // Find user by email
929
+ * const user = await client.findOne("users", "email", "john@example.com");
930
+ * if (user) {
931
+ * console.log("Found user:", user);
932
+ * }
933
+ * ```
934
+ */
935
+ async findOne(
936
+ collection: string,
937
+ field: string,
938
+ value: any,
939
+ ): Promise<Record | null> {
940
+ const query = new QueryBuilder().eq(field, value).limit(1).build();
941
+ const results = await this.find(collection, query);
942
+ return results.length > 0 ? results[0] : null;
943
+ }
944
+
945
+ /**
946
+ * Check if a record exists by ID
947
+ *
948
+ * This is more efficient than fetching the record when you only need to check existence.
949
+ *
950
+ * @param collection - Collection name
951
+ * @param id - Record ID to check
952
+ * @returns true if the record exists, false if it doesn't
953
+ *
954
+ * @example
955
+ * ```typescript
956
+ * if (await client.exists("users", "user123")) {
957
+ * console.log("User exists");
958
+ * } else {
959
+ * console.log("User not found");
960
+ * }
961
+ * ```
962
+ */
963
+ async exists(collection: string, id: string): Promise<boolean> {
964
+ try {
965
+ await this.findById(collection, id);
966
+ return true;
967
+ } catch (error: any) {
968
+ if (
969
+ error.message?.includes("404") ||
970
+ error.message?.includes("Not found")
971
+ ) {
972
+ return false;
973
+ }
974
+ throw error;
975
+ }
976
+ }
977
+
978
+ /**
979
+ * Paginate through records
980
+ *
981
+ * Convenience method for pagination with page numbers (1-indexed).
982
+ *
983
+ * @param collection - Collection name
984
+ * @param page - Page number (1-indexed, i.e., first page is 1)
985
+ * @param pageSize - Number of records per page
986
+ * @returns Array of records for the requested page
987
+ *
988
+ * @example
989
+ * ```typescript
990
+ * // Get page 2 with 10 records per page
991
+ * const records = await client.paginate("users", 2, 10);
992
+ * ```
993
+ */
994
+ async paginate(
995
+ collection: string,
996
+ page: number,
997
+ pageSize: number,
998
+ ): Promise<Record[]> {
999
+ // Page 1 = skip 0, Page 2 = skip pageSize, etc.
1000
+ const skip = page > 0 ? (page - 1) * pageSize : 0;
1001
+ const query: QueryBuilderQuery = {
1002
+ limit: pageSize,
1003
+ skip: skip,
1004
+ };
1005
+ return this.find(collection, query);
1006
+ }
1007
+
737
1008
  /**
738
1009
  * List all collections
739
1010
  */
@@ -761,6 +1032,43 @@ export class EkoDBClient {
761
1032
  );
762
1033
  }
763
1034
 
1035
+ /**
1036
+ * Restore a deleted record from trash
1037
+ * Records remain in trash for 30 days before permanent deletion
1038
+ *
1039
+ * @param collection - Collection name
1040
+ * @param id - Record ID to restore
1041
+ * @returns true if restored successfully
1042
+ */
1043
+ async restoreRecord(collection: string, id: string): Promise<boolean> {
1044
+ const result = await this.makeRequest<{ status: string }>(
1045
+ "POST",
1046
+ `/api/trash/${collection}/${id}`,
1047
+ undefined,
1048
+ 0,
1049
+ true,
1050
+ );
1051
+ return result.status === "restored";
1052
+ }
1053
+
1054
+ /**
1055
+ * Restore all deleted records in a collection from trash
1056
+ * Records remain in trash for 30 days before permanent deletion
1057
+ *
1058
+ * @param collection - Collection name
1059
+ * @returns Number of records restored
1060
+ */
1061
+ async restoreCollection(
1062
+ collection: string,
1063
+ ): Promise<{ recordsRestored: number }> {
1064
+ const result = await this.makeRequest<{
1065
+ status: string;
1066
+ collection: string;
1067
+ records_restored: number;
1068
+ }>("POST", `/api/trash/${collection}`, undefined, 0, true);
1069
+ return { recordsRestored: result.records_restored };
1070
+ }
1071
+
764
1072
  /**
765
1073
  * Create a collection with schema
766
1074
  *
@@ -853,21 +1161,36 @@ export class EkoDBClient {
853
1161
  */
854
1162
  async search(
855
1163
  collection: string,
856
- searchQuery: SearchQuery | SearchQueryBuilder,
1164
+ query: SearchQuery,
857
1165
  ): Promise<SearchResponse> {
858
- const queryObj =
859
- searchQuery instanceof SearchQueryBuilder
860
- ? searchQuery.build()
861
- : searchQuery;
1166
+ // Ensure all parameters from SearchQuery are sent to server
862
1167
  return this.makeRequest<SearchResponse>(
863
1168
  "POST",
864
1169
  `/api/search/${collection}`,
865
- queryObj,
1170
+ query,
866
1171
  0,
867
1172
  true, // Force JSON for search operations
868
1173
  );
869
1174
  }
870
1175
 
1176
+ /**
1177
+ * Health check - verify the ekoDB server is responding
1178
+ */
1179
+ async health(): Promise<boolean> {
1180
+ try {
1181
+ const result = await this.makeRequest<{ status: string }>(
1182
+ "GET",
1183
+ "/api/health",
1184
+ undefined,
1185
+ 0,
1186
+ true,
1187
+ );
1188
+ return result.status === "ok";
1189
+ } catch {
1190
+ return false;
1191
+ }
1192
+ }
1193
+
871
1194
  // ========== Chat Methods ==========
872
1195
 
873
1196
  /**
@@ -1233,31 +1556,33 @@ export class EkoDBClient {
1233
1556
  * Simplified text search with full-text matching, fuzzy search, and stemming.
1234
1557
  *
1235
1558
  * @param collection - Collection name to search
1236
- * @param queryText - Search query text
1237
- * @param limit - Maximum number of results to return
1238
- * @returns Array of matching records
1559
+ * @param query - Search query text
1560
+ * @param options - Additional search options
1561
+ * @returns Search response with results and metadata
1239
1562
  *
1240
1563
  * @example
1241
1564
  * ```typescript
1242
1565
  * const results = await client.textSearch(
1243
1566
  * "documents",
1244
1567
  * "ownership system",
1245
- * 10
1568
+ * {
1569
+ * limit: 10,
1570
+ * select_fields: ["title", "content"],
1571
+ * exclude_fields: ["author"]
1572
+ * }
1246
1573
  * );
1247
1574
  * ```
1248
1575
  */
1249
1576
  async textSearch(
1250
1577
  collection: string,
1251
- queryText: string,
1252
- limit: number,
1253
- ): Promise<Record[]> {
1578
+ query: string,
1579
+ options?: Partial<SearchQuery>,
1580
+ ): Promise<SearchResponse> {
1254
1581
  const searchQuery: SearchQuery = {
1255
- query: queryText,
1256
- limit,
1582
+ query,
1583
+ ...options,
1257
1584
  };
1258
-
1259
- const response = await this.search(collection, searchQuery);
1260
- return response.results.map((r) => r.record);
1585
+ return this.search(collection, searchQuery);
1261
1586
  }
1262
1587
 
1263
1588
  /**
package/src/search.ts CHANGED
@@ -53,6 +53,12 @@ export interface SearchQuery {
53
53
  text_weight?: number;
54
54
  /** Weight for vector search (0.0-1.0) */
55
55
  vector_weight?: number;
56
+
57
+ // Field projection parameters
58
+ /** Only return these fields (plus 'id') */
59
+ select_fields?: string[];
60
+ /** Exclude these fields from results */
61
+ exclude_fields?: string[];
56
62
  }
57
63
 
58
64
  /**
@@ -253,6 +259,22 @@ export class SearchQueryBuilder {
253
259
  return this;
254
260
  }
255
261
 
262
+ /**
263
+ * Select specific fields to return
264
+ */
265
+ selectFields(fields: string[]): this {
266
+ this.query.select_fields = fields;
267
+ return this;
268
+ }
269
+
270
+ /**
271
+ * Exclude specific fields from results
272
+ */
273
+ excludeFields(fields: string[]): this {
274
+ this.query.exclude_fields = fields;
275
+ return this;
276
+ }
277
+
256
278
  /**
257
279
  * Build the final SearchQuery object
258
280
  */