@taruvi/sdk 1.4.9 → 1.5.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,19 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(tree:*)",
5
+ "Bash(npm run build:*)",
6
+ "Bash(npm ls:*)",
7
+ "Bash(npm link:*)",
8
+ "Bash(npx --yes markdownlint-cli:*)",
9
+ "Bash(cat:*)",
10
+ "Bash(test:*)",
11
+ "Bash(ls:*)",
12
+ "WebFetch(domain:www.markdownpaste.com)",
13
+ "WebSearch",
14
+ "WebFetch(domain:supabase.com)"
15
+ ],
16
+ "deny": [],
17
+ "ask": []
18
+ }
19
+ }
package/README.md CHANGED
@@ -17,7 +17,7 @@ Recent updates to the SDK:
17
17
  - **App Service**: New service to retrieve app roles with `roles()` method.
18
18
  - **User Types**: Added `UserCreateRequest`, `UserResponse`, and `UserDataResponse` types for user management.
19
19
  - **Auth Service**: Web UI Flow with `login()`, `signup()`, `logout()` methods that redirect to backend pages. Token refresh with rotation support, `getCurrentUser()` for JWT decoding.
20
- - **Database Service**: Added `create()` method for creating records. Added `populate()` method for eager loading related records. Comprehensive filter support with Django-style operators (`__gte`, `__lte`, `__icontains`, etc.).
20
+ - **Database Service**: Added `create()` method for creating records. Added `populate()` method for eager loading related records. Comprehensive filter support with Django-style operators (`__gte`, `__lte`, `__icontains`, etc.). Use **`orderBy()`** for sorting: one field (`orderBy('created_at', 'desc')`), multiple fields (`orderBy([{ field, order }])`), or a raw `ordering` string (`orderBy('-salary,hire_date')`). Hierarchy traversal uses **`.include('descendants' | 'ancestors' | 'both')`** — not `FilterOperator`. Import **`PgRangeValue`** for typed PG range columns in row data.
21
21
  - **Storage Service**: Added `download()` method. Enhanced filter support with size, date, MIME type, visibility filters. `delete()` now accepts array of paths for bulk deletion.
22
22
  - **Client**: Automatic token extraction from URL hash after OAuth callback - no manual token handling needed.
23
23
  - **Types**: Comprehensive `StorageFilters` and `DatabaseFilters` interfaces with full operator support.
@@ -363,43 +363,52 @@ const db = new Database(taruviClient)
363
363
  await db.from("accounts").delete("record-id").execute()
364
364
  ```
365
365
 
366
- ### Filter Records
366
+ ### Filter records
367
+
368
+ `Database` supports **DRF-style flat filters** (`filters(field, operator, value)`) and a **JSON filter tree** sent as the `filters` query param (`filters(tree)`).
367
369
 
368
370
  ```typescript
369
371
  const db = new Database(taruviClient)
370
372
 
371
- // Simple field filters
373
+ // Flat: one condition per call (chain for AND)
372
374
  const filtered = await db
373
375
  .from("accounts")
374
- .filter({
375
- status: "active",
376
- country: "USA"
377
- })
376
+ .filters("status", "eq", "active")
377
+ .filters("country", "eq", "USA")
378
378
  .execute()
379
379
 
380
- // Advanced filters with operators
380
+ // Flat: operators map to `field__suffix` query keys
381
381
  const advanced = await db
382
382
  .from("accounts")
383
- .filter({
384
- age__gte: 18, // age >= 18
385
- age__lt: 65, // age < 65
386
- name__icontains: "john", // case-insensitive contains
387
- created_at__gte: "2024-01-01",
388
- ordering: "-created_at" // Sort by created_at descending
389
- })
383
+ .filters("age", "gte", 18)
384
+ .filters("age", "lt", 65)
385
+ .filters("name", "icontains", "john")
386
+ .filters("created_at", "gte", "2024-01-01")
387
+ .orderBy("created_at", "desc")
390
388
  .execute()
391
389
 
392
- // Pagination
390
+ // Pagination (separate methods)
393
391
  const paginated = await db
394
392
  .from("accounts")
395
- .filter({
396
- page: 1,
397
- pageSize: 20
398
- })
393
+ .page(1)
394
+ .pageSize(20)
399
395
  .execute()
396
+
397
+ // JSON tree → `?filters=<url-encoded JSON>` (e.g. from Refine / your UI)
398
+ import type { BackendFilterTreeRoot } from "@taruvi/sdk"
399
+ const tree: BackendFilterTreeRoot = [
400
+ {
401
+ operator: "and",
402
+ value: [
403
+ { field: "is_active", operator: "eq", value: true },
404
+ { field: "hire_date", operator: "lt", value: "2021-01-01" },
405
+ ],
406
+ },
407
+ ]
408
+ await db.from("employees").filters(tree).execute()
400
409
  ```
401
410
 
402
- ### Populate Related Records
411
+ ### Populate related records
403
412
 
404
413
  Use `populate()` to eager load related records (foreign key relationships):
405
414
 
@@ -422,21 +431,20 @@ const invoices = await db
422
431
  .populate(["customer", "created_by", "items"])
423
432
  .execute()
424
433
 
425
- // Combine with filters
434
+ // Combine with flat filters + populate
426
435
  const recentOrders = await db
427
436
  .from("orders")
428
- .filter({
429
- status: "completed",
430
- created_at__gte: "2024-01-01",
431
- ordering: "-created_at"
432
- })
437
+ .filters("status", "eq", "completed")
438
+ .filters("created_at", "gte", "2024-01-01")
439
+ .orderBy("created_at", "desc")
433
440
  .populate(["customer", "product"])
434
441
  .execute()
435
442
 
436
- // Combine with pagination
443
+ // Combine with pagination + populate
437
444
  const paginatedOrders = await db
438
445
  .from("orders")
439
- .filter({ page: 1, pageSize: 10 })
446
+ .page(1)
447
+ .pageSize(10)
440
448
  .populate(["customer"])
441
449
  .execute()
442
450
  ```
@@ -1285,8 +1293,8 @@ All query-building services use method chaining:
1285
1293
  // Database
1286
1294
  const db = new Database(taruviClient)
1287
1295
  await db.from("table").get("id").update(data).execute()
1288
- await db.from("table").filter({ status: "active" }).execute()
1289
- await db.from("table").filter({ page: 1 }).populate(["related_field"]).execute()
1296
+ await db.from("table").filters("status", "eq", "active").execute()
1297
+ await db.from("table").page(1).populate(["related_field"]).execute()
1290
1298
  await db.from("table").create({ name: "New" }).execute()
1291
1299
 
1292
1300
  // Storage
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taruvi/sdk",
3
- "version": "1.4.9",
3
+ "version": "1.5.0-beta.1",
4
4
  "description": "Taruvi SDK",
5
5
  "main": "src/index.ts",
6
6
  "type": "module",
package/src/client.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { HttpClient } from "./lib-internal/http/HttpClient.js";
2
- import { TokenClient, type AuthTokens } from "./lib-internal/token/TokenClient.js";
2
+ import { TokenClient } from "./lib-internal/token/TokenClient.js";
3
3
  import type { TaruviConfig } from "./types.js";
4
4
  import packageJson from "../package.json" with { type: "json" };
5
5
 
package/src/index.ts CHANGED
@@ -35,7 +35,8 @@ export type { RoleData, AppSettingsData, RoleResponse, RolesListResponse, AppSet
35
35
  export type { FunctionRequest, FunctionResponse, FunctionInvocation } from "./lib/functions/types.js"
36
36
 
37
37
  // Database types
38
- export type { DatabaseRequest, DatabaseResponse, DatabaseSingleResponse, FilterOperator, SortOrder, GraphInclude, GraphFormat, EdgeRequest, EdgeResponse, EdgeDeleteRequest } from "./lib/database/types.js"
38
+ export type { DatabaseRequest, DatabaseResponse, DatabaseSingleResponse, FilterOperator, SortOrder, GraphInclude, GraphFormat, EdgeRequest, EdgeResponse, EdgeDeleteRequest, PgRangeValue, BackendFilterLeafNode, BackendFilterLogicalNode, BackendFilterNode, BackendFilterTreeRoot } from "./lib/database/types.js"
39
+ export { isBackendFilterTreeRoot } from "./lib/database/types.js"
39
40
 
40
41
  // Storage types
41
42
  export type { StorageRequest, StorageUpdateRequest, StorageObject, StorageResponse, StorageListResponse, StorageUploadBatchResponse, StorageDeleteBatchResponse } from "./lib/storage/types.js"
@@ -1,7 +1,6 @@
1
1
  import type { Client } from "../../client.js";
2
2
  import type { TaruviResponse } from "../../types.js";
3
3
  import type { UserData } from "../users/types.js";
4
- import { AuthRoutes } from "../../lib-internal/routes/AuthRoutes.js";
5
4
  import { UserRoutes } from "../../lib-internal/routes/UserRoutes.js";
6
5
 
7
6
  /**
@@ -95,33 +94,12 @@ export class Auth {
95
94
  }
96
95
 
97
96
  /**
98
- * Check if a session token exists locally (does not validate with server)
97
+ * Check if user is authenticated (has session token)
99
98
  */
100
- hasToken(): boolean {
99
+ isUserAuthenticated(): boolean {
101
100
  return this.client.tokenClient.isAuthenticated()
102
101
  }
103
102
 
104
- /**
105
- * Check if user is authenticated by validating session with the server
106
- */
107
- async isUserAuthenticated(): Promise<boolean> {
108
- if (!this.hasToken()) return false
109
- try {
110
- await this.validateSession()
111
- return true
112
- } catch {
113
- return false
114
- }
115
- }
116
-
117
- /**
118
- * Validate current session token with auth session endpoint.
119
- * HttpClient injects X-Session-Token automatically.
120
- */
121
- async validateSession(): Promise<void> {
122
- await this.client.httpClient.get(AuthRoutes.session())
123
- }
124
-
125
103
  /**
126
104
  * Get the current session token
127
105
  */
@@ -134,7 +112,7 @@ export class Auth {
134
112
  * @returns Promise with user data or null if not authenticated
135
113
  */
136
114
  async getCurrentUser(): Promise<TaruviResponse<UserData> | null> {
137
- if (!this.hasToken()) {
115
+ if (!this.isUserAuthenticated()) {
138
116
  return null
139
117
  }
140
118
 
@@ -2,7 +2,8 @@ import type { Client } from "../../client.js";
2
2
  import { DatabaseRoutes } from "../../lib-internal/routes/DatabaseRoutes.js";
3
3
  import { HttpMethod } from "../../lib-internal/http/types.js";
4
4
  import type { TaruviConfig, DatabaseFilters, TaruviResponse } from "../../types.js";
5
- import type { UrlParams, FilterOperator, SortOrder, GraphInclude, GraphFormat, EdgeRequest, EdgeDeleteRequest } from "./types.js";
5
+ import type { UrlParams, FilterOperator, SortOrder, GraphInclude, GraphFormat, EdgeRequest, EdgeDeleteRequest, BackendFilterTreeRoot } from "./types.js";
6
+ import { isBackendFilterTreeRoot } from "./types.js";
6
7
  import { buildQueryString } from "../../utils/utils.js";
7
8
 
8
9
  interface GraphQueryParams {
@@ -62,17 +63,65 @@ export class Database<T = Record<string, unknown>> {
62
63
  }
63
64
 
64
65
  // Filter & query methods
65
- filter(field: string, operator: FilterOperator, value: string | number | boolean | (string | number)[]): Database<T> {
66
- const filterKey = operator === 'eq' ? field : `${field}__${operator}`
67
- const filterValue = Array.isArray(value) ? value.join(',') : value
68
- return new Database<T>(this.client, { ...this.urlParams }, undefined, undefined, {
69
- ...this.queryParams,
70
- [filterKey]: filterValue
71
- }, { ...this.graphParams }, this.isEdges)
66
+ /**
67
+ * JSON filter tree for the `filters` query param. Must match the platform contract:
68
+ * root is an array of logical nodes `{ operator: "and" | "or", value: [...] }`; leaves are
69
+ * `{ field, operator, value }` where `operator` is a **backend** token (`contains` = case-insensitive
70
+ * substring, `containss` = case-sensitive, `eq`, `in`, …). The SDK only JSON-stringifies this value.
71
+ */
72
+ filters(tree: BackendFilterTreeRoot): Database<T>
73
+ /**
74
+ * DRF-style flat filter: `field` or `field__operator` query keys.
75
+ */
76
+ filters(field: string, operator: FilterOperator, value: string | number | boolean | (string | number | boolean)[]): Database<T>
77
+ filters(
78
+ arg0: string | BackendFilterTreeRoot,
79
+ arg1?: FilterOperator,
80
+ arg2?: string | number | boolean | (string | number | boolean)[]
81
+ ): Database<T> {
82
+ if (typeof arg0 === 'string' && arg1 !== undefined && arg2 !== undefined) {
83
+ const field = arg0
84
+ const operator = arg1
85
+ const value = arg2
86
+ const filterKey = operator === 'eq' ? field : `${field}__${operator}`
87
+ const filterValue = Array.isArray(value) ? value.join(',') : value
88
+ return new Database<T>(this.client, { ...this.urlParams }, undefined, undefined, {
89
+ ...this.queryParams,
90
+ [filterKey]: filterValue
91
+ }, { ...this.graphParams }, this.isEdges)
92
+ }
93
+
94
+ if (arg1 === undefined && arg2 === undefined && isBackendFilterTreeRoot(arg0)) {
95
+ return new Database<T>(this.client, { ...this.urlParams }, undefined, undefined, {
96
+ ...this.queryParams,
97
+ filters: JSON.stringify(arg0)
98
+ }, { ...this.graphParams }, this.isEdges)
99
+ }
100
+
101
+ throw new TypeError(
102
+ 'Database.filters: use filters(tree) with a root array of { operator: "and"|"or", value: [...] }, or filters(field, operator, value).'
103
+ )
72
104
  }
73
105
 
74
- sort(field: string, order: SortOrder = 'asc'): Database<T> {
75
- const ordering = order === 'desc' ? `-${field}` : field
106
+ /**
107
+ * Sets the `ordering` query param (DRF-style: `-field` for desc, comma-separated for multiple).
108
+ * - `orderBy('created_at', 'desc')` — one column (optional second arg defaults to `'asc'`)
109
+ * - `orderBy([{ field: 'salary', order: 'desc' }, { field: 'hire_date' }])` — multiple columns
110
+ * - `orderBy('-salary,hire_date')` — raw string (e.g. from `convertRefineSorters`); omit the second arg
111
+ */
112
+ orderBy(
113
+ fieldOrFields: string | Array<{ field: string; order?: SortOrder }>,
114
+ order?: SortOrder
115
+ ): Database<T> {
116
+ let ordering: string
117
+ if (typeof fieldOrFields === 'string') {
118
+ const o = order ?? 'asc'
119
+ ordering = o === 'desc' ? `-${fieldOrFields}` : fieldOrFields
120
+ } else {
121
+ ordering = fieldOrFields
122
+ .map(({ field, order: o = 'asc' }) => (o === 'desc' ? `-${field}` : field))
123
+ .join(',')
124
+ }
76
125
  return new Database<T>(this.client, { ...this.urlParams }, undefined, undefined, {
77
126
  ...this.queryParams,
78
127
  ordering
@@ -100,6 +149,11 @@ export class Database<T = Record<string, unknown>> {
100
149
  }, { ...this.graphParams }, this.isEdges)
101
150
  }
102
151
 
152
+ /** Populate all first-level relations (`?populate=*`). */
153
+ populateAll(): Database<T> {
154
+ return this.populate(['*'])
155
+ }
156
+
103
157
  search(query: string): Database<T> {
104
158
  return new Database<T>(this.client, { ...this.urlParams }, undefined, undefined, {
105
159
  ...this.queryParams,
@@ -107,6 +161,14 @@ export class Database<T = Record<string, unknown>> {
107
161
  }, { ...this.graphParams }, this.isEdges)
108
162
  }
109
163
 
164
+ /** Restrict SELECT to named columns (`?fields=a,b,c`). */
165
+ fields(columns: string): Database<T> {
166
+ return new Database<T>(this.client, { ...this.urlParams }, undefined, undefined, {
167
+ ...this.queryParams,
168
+ fields: columns
169
+ }, { ...this.graphParams }, this.isEdges)
170
+ }
171
+
110
172
  allowedActions(actions: string[]): Database<T> {
111
173
  return new Database<T>(this.client, { ...this.urlParams }, undefined, undefined, {
112
174
  ...this.queryParams,
@@ -4,7 +4,7 @@ import type { TaruviResponse } from "../../types.js"
4
4
 
5
5
  export type DatabaseOperation = HttpMethod
6
6
 
7
- // Filter operators matching backend FilterParams.OPERATORS (32 operators)
7
+ // Filter operators matching backend FilterParams.OPERATORS
8
8
  export type FilterOperator =
9
9
  // Comparison
10
10
  | 'eq' // Equal
@@ -18,29 +18,49 @@ export type FilterOperator =
18
18
  | 'nin' // Not in array
19
19
  | 'ina' // In array (case-insensitive)
20
20
  | 'nina' // Not in array (case-insensitive)
21
- // String contains
22
- | 'contains' // Contains (case-sensitive)
23
- | 'ncontains' // Not contains (case-sensitive)
24
- | 'containss' // Contains (case-sensitive, strict)
25
- | 'ncontainss' // Not contains (case-sensitive, strict)
26
- | 'icontains' // Contains (case-insensitive)
27
- | 'nicontains' // Not contains (case-insensitive)
21
+ // String contains (backend: plain contains uses ILIKE; *s suffix = case-sensitive LIKE)
22
+ | 'contains' // Contains (case-insensitive, ILIKE)
23
+ | 'ncontains' // Not contains (case-insensitive)
24
+ | 'containss' // Contains (case-sensitive LIKE)
25
+ | 'ncontainss' // Not contains (case-sensitive LIKE)
26
+ | 'icontains' // Alias → contains (case-insensitive)
27
+ | 'nicontains' // Alias ncontains
28
28
  // Starts with
29
- | 'startswith' // Starts with (case-sensitive)
30
- | 'nstartswith' // Not starts with (case-sensitive)
31
- | 'startswiths' // Starts with (case-sensitive, strict)
32
- | 'nstartswiths' // Not starts with (case-sensitive, strict)
29
+ | 'startswith' // Starts with (case-insensitive)
30
+ | 'nstartswith' // Not starts with (case-insensitive)
31
+ | 'startswiths' // Starts with (case-sensitive LIKE)
32
+ | 'nstartswiths' // Not starts with (case-sensitive LIKE)
33
33
  // Ends with
34
- | 'endswith' // Ends with (case-sensitive)
35
- | 'nendswith' // Not ends with (case-sensitive)
36
- | 'endswiths' // Ends with (case-sensitive, strict)
37
- | 'nendswiths' // Not ends with (case-sensitive, strict)
34
+ | 'endswith' // Ends with (case-insensitive)
35
+ | 'nendswith' // Not ends with (case-insensitive)
36
+ | 'endswiths' // Ends with (case-sensitive LIKE)
37
+ | 'nendswiths' // Not ends with (case-sensitive LIKE)
38
38
  // Range
39
39
  | 'between' // Between two values
40
40
  | 'nbetween' // Not between two values
41
41
  // Null checks
42
42
  | 'null' // Is null
43
43
  | 'nnull' // Is not null
44
+ // Search / pattern
45
+ | 'search' // Full-text search
46
+ | 'like' // SQL LIKE pattern
47
+ | 'ilike' // Case-insensitive LIKE
48
+ // Array containment (PostgreSQL)
49
+ | 'acontains' // Array contains all items (column @> ARRAY[values])
50
+ | 'nacontains' // NOT array contains
51
+ | 'acontainedby' // Array contained by (column <@ ARRAY[values])
52
+ | 'nacontainedby' // NOT array contained by
53
+ | 'aoverlap' // Arrays have overlap (column && ARRAY[values])
54
+ | 'naoverlap' // No overlap
55
+ | 'aelement' // Value exists in array (value = ANY(column))
56
+ | 'naelement' // Value not in array (value != ALL(column))
57
+ // PostgreSQL range type operators
58
+ | 'rcontains' // Range column @> value or [lower, upper]
59
+ | 'rcontainedby' // Range column <@ [lower, upper]
60
+ | 'roverlaps' // Range column && [lower, upper]
61
+ | 'radjacent' // Range column -|- [lower, upper]
62
+ | 'rstrictleft' // Range column << [lower, upper]
63
+ | 'rstrictright' // Range column >> [lower, upper]
44
64
 
45
65
  export type SortOrder = 'asc' | 'desc'
46
66
 
@@ -87,4 +107,50 @@ export interface EdgeResponse {
87
107
 
88
108
  export interface EdgeDeleteRequest {
89
109
  edge_ids: number[]
110
+ }
111
+
112
+ export interface PgRangeValue {
113
+ lower: string | number | null
114
+ upper: string | number | null
115
+ bounds: '()' | '(]' | '[)' | '[]'
116
+ empty: boolean
117
+ }
118
+
119
+ /**
120
+ * Leaf node for the backend `filters` JSON query param.
121
+ * Leaf `operator` strings are platform tokens (see taruvi-platform `FIELD_OPERATORS` /
122
+ * `filter_translator`). `contains` = case-insensitive substring; `containss` = case-sensitive.
123
+ */
124
+ export interface BackendFilterLeafNode {
125
+ field: string
126
+ operator: string
127
+ value: unknown
128
+ }
129
+
130
+ /** Logical `and` / `or` group for the backend `filters` JSON query param. */
131
+ export interface BackendFilterLogicalNode {
132
+ operator: 'and' | 'or'
133
+ value: BackendFilterNode[]
134
+ }
135
+
136
+ export type BackendFilterNode = BackendFilterLogicalNode | BackendFilterLeafNode
137
+
138
+ /** Top level of the `filters` JSON payload: an array of logical nodes. */
139
+ export type BackendFilterTreeRoot = BackendFilterLogicalNode[]
140
+
141
+ function isPlainObject(x: unknown): x is Record<string, unknown> {
142
+ return typeof x === 'object' && x !== null && !Array.isArray(x)
143
+ }
144
+
145
+ function isBackendFilterLogicalNode(x: unknown): x is BackendFilterLogicalNode {
146
+ if (!isPlainObject(x)) return false
147
+ if (x.operator !== 'and' && x.operator !== 'or') return false
148
+ if (!Array.isArray(x.value)) return false
149
+ if ('field' in x) return false
150
+ return true
151
+ }
152
+
153
+ /** Runtime check for the single-arg `filters(tree)` overload. */
154
+ export function isBackendFilterTreeRoot(x: unknown): x is BackendFilterTreeRoot {
155
+ return Array.isArray(x) && x.every(isBackendFilterLogicalNode)
90
156
  }
@@ -12,12 +12,10 @@ import type { ErrorResponseBody } from "../errors/index.js";
12
12
  * @internal
13
13
  */
14
14
  export class HttpClient {
15
- private config: TaruviConfig
16
15
  private tokenClient: TokenClient
17
16
  private axiosInstance: AxiosInstance
18
17
 
19
18
  constructor(config: TaruviConfig, tokenClient: TokenClient) {
20
- this.config = config
21
19
  this.tokenClient = tokenClient
22
20
  this.axiosInstance = axios.create({ baseURL: config.apiUrl, withCredentials: true })
23
21
  this.setupInterceptors()
@@ -25,6 +23,7 @@ export class HttpClient {
25
23
 
26
24
  private setupInterceptors(): void {
27
25
  // Request interceptor: attach session token
26
+ console.log("test")
28
27
  this.axiosInstance.interceptors.request.use((config: InternalAxiosRequestConfig) => {
29
28
  const isFormData = config.data instanceof FormData
30
29
  if (!isFormData) {
@@ -1,3 +0,0 @@
1
- export const AuthRoutes = {
2
- session: () => "_allauth/app/v1/auth/session"
3
- } as const
package/src/types.ts CHANGED
@@ -93,6 +93,12 @@ export interface DatabaseFilters {
93
93
  _group_by?: string
94
94
  _having?: string
95
95
 
96
+ /**
97
+ * JSON filter tree for the `filters` query param (set via `Database.filters(tree)`).
98
+ * Do not use the flat `filters(field, …)` triple overload with `field === 'filters'`.
99
+ */
100
+ filters?: string
101
+
96
102
  // Dynamic filters - allows any field with operators
97
103
  [key: string]: string | number | boolean | undefined
98
104
  }
@@ -18,6 +18,7 @@ export const getRuntimeEnvironment = (): string => {
18
18
  return 'Server'
19
19
  }
20
20
 
21
+ /** Builds a query string. Keys may include pre-flattened bracket names (e.g. CrudFilters `filters[0][field]=...`). */
21
22
  export function buildQueryString(queryParams: Record<string, unknown> | undefined): string {
22
23
  if (!queryParams || Object.keys(queryParams).length === 0) {
23
24
  return ''
@@ -1,7 +1,7 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest'
2
2
  import { Database } from '../../../src/lib/database/DatabaseClient.js'
3
3
  import { Client } from '../../../src/client.js'
4
- import type { DatabaseResponse, DatabaseSingleResponse, EdgeResponse } from '../../../src/lib/database/types.js'
4
+ import type { BackendFilterTreeRoot, DatabaseResponse, DatabaseSingleResponse, EdgeResponse } from '../../../src/lib/database/types.js'
5
5
  import type { TaruviResponse } from '../../../src/types.js'
6
6
 
7
7
  // Mock the Client
@@ -30,61 +30,61 @@ describe('Database', () => {
30
30
  })
31
31
  })
32
32
 
33
- describe('filter()', () => {
33
+ describe('filters() flat (triple-arg)', () => {
34
34
  it('eq operator uses field name without suffix', async () => {
35
35
  mockHttpClient.get.mockResolvedValue([])
36
- await new Database(mockClient).from('accounts').filter('status', 'eq', 'active').execute()
36
+ await new Database(mockClient).from('accounts').filters('status', 'eq', 'active').execute()
37
37
  expect(mockHttpClient.get).toHaveBeenCalledWith(expect.stringContaining('status=active'))
38
38
  })
39
39
 
40
40
  it('gt operator appends __gt suffix', async () => {
41
41
  mockHttpClient.get.mockResolvedValue([])
42
- await new Database(mockClient).from('accounts').filter('age', 'gt', 18).execute()
42
+ await new Database(mockClient).from('accounts').filters('age', 'gt', 18).execute()
43
43
  expect(mockHttpClient.get).toHaveBeenCalledWith(expect.stringContaining('age__gt=18'))
44
44
  })
45
45
 
46
46
  it('gte operator appends __gte suffix', async () => {
47
47
  mockHttpClient.get.mockResolvedValue([])
48
- await new Database(mockClient).from('accounts').filter('age', 'gte', 18).execute()
48
+ await new Database(mockClient).from('accounts').filters('age', 'gte', 18).execute()
49
49
  expect(mockHttpClient.get).toHaveBeenCalledWith(expect.stringContaining('age__gte=18'))
50
50
  })
51
51
 
52
52
  it('lt operator appends __lt suffix', async () => {
53
53
  mockHttpClient.get.mockResolvedValue([])
54
- await new Database(mockClient).from('accounts').filter('age', 'lt', 65).execute()
54
+ await new Database(mockClient).from('accounts').filters('age', 'lt', 65).execute()
55
55
  expect(mockHttpClient.get).toHaveBeenCalledWith(expect.stringContaining('age__lt=65'))
56
56
  })
57
57
 
58
58
  it('lte operator appends __lte suffix', async () => {
59
59
  mockHttpClient.get.mockResolvedValue([])
60
- await new Database(mockClient).from('accounts').filter('age', 'lte', 65).execute()
60
+ await new Database(mockClient).from('accounts').filters('age', 'lte', 65).execute()
61
61
  expect(mockHttpClient.get).toHaveBeenCalledWith(expect.stringContaining('age__lte=65'))
62
62
  })
63
63
 
64
64
  it('icontains operator appends __icontains suffix', async () => {
65
65
  mockHttpClient.get.mockResolvedValue([])
66
- await new Database(mockClient).from('accounts').filter('name', 'icontains', 'john').execute()
66
+ await new Database(mockClient).from('accounts').filters('name', 'icontains', 'john').execute()
67
67
  expect(mockHttpClient.get).toHaveBeenCalledWith(expect.stringContaining('name__icontains=john'))
68
68
  })
69
69
 
70
70
  it('in operator joins array values with comma', async () => {
71
71
  mockHttpClient.get.mockResolvedValue([])
72
- await new Database(mockClient).from('accounts').filter('status', 'in', ['active', 'pending']).execute()
72
+ await new Database(mockClient).from('accounts').filters('status', 'in', ['active', 'pending']).execute()
73
73
  expect(mockHttpClient.get).toHaveBeenCalledWith(expect.stringContaining('status__in=active%2Cpending'))
74
74
  })
75
75
 
76
- it('isnull operator appends __isnull suffix', async () => {
76
+ it('null operator appends __null suffix', async () => {
77
77
  mockHttpClient.get.mockResolvedValue([])
78
- await new Database(mockClient).from('accounts').filter('deleted_at', 'isnull', true).execute()
79
- expect(mockHttpClient.get).toHaveBeenCalledWith(expect.stringContaining('deleted_at__isnull=true'))
78
+ await new Database(mockClient).from('accounts').filters('deleted_at', 'null', true).execute()
79
+ expect(mockHttpClient.get).toHaveBeenCalledWith(expect.stringContaining('deleted_at__null=true'))
80
80
  })
81
81
 
82
82
  it('multiple filters chain correctly', async () => {
83
83
  mockHttpClient.get.mockResolvedValue([])
84
84
  await new Database(mockClient)
85
85
  .from('accounts')
86
- .filter('status', 'eq', 'active')
87
- .filter('age', 'gte', 18)
86
+ .filters('status', 'eq', 'active')
87
+ .filters('age', 'gte', 18)
88
88
  .execute()
89
89
  const url = mockHttpClient.get.mock.calls[0][0]
90
90
  expect(url).toContain('status=active')
@@ -92,24 +92,105 @@ describe('Database', () => {
92
92
  })
93
93
  })
94
94
 
95
- describe('sort()', () => {
95
+ describe('filters() JSON tree', () => {
96
+ const normativeTree: BackendFilterTreeRoot = [
97
+ {
98
+ operator: 'and',
99
+ value: [
100
+ { field: 'is_active', operator: 'eq', value: true },
101
+ { field: 'hire_date', operator: 'lt', value: '2021-01-01' },
102
+ {
103
+ operator: 'or',
104
+ value: [
105
+ { field: 'salary', operator: 'gte', value: 200000 },
106
+ {
107
+ operator: 'and',
108
+ value: [
109
+ { field: 'title', operator: 'containss', value: 'VP' },
110
+ { field: 'bonus', operator: 'gte', value: 50000 },
111
+ ],
112
+ },
113
+ ],
114
+ },
115
+ ],
116
+ },
117
+ ]
118
+
119
+ it('serializes tree into filters query param (round-trip)', async () => {
120
+ mockHttpClient.get.mockResolvedValue([])
121
+ await new Database(mockClient).from('accounts').filters(normativeTree).execute()
122
+ const url = mockHttpClient.get.mock.calls[0][0] as string
123
+ const q = url.includes('?') ? url.split('?')[1] : ''
124
+ const params = new URLSearchParams(q)
125
+ const raw = params.get('filters')
126
+ expect(raw).toBeTruthy()
127
+ expect(JSON.parse(decodeURIComponent(raw!))).toEqual(normativeTree)
128
+ })
129
+
130
+ it('chains JSON filters with populate', async () => {
131
+ mockHttpClient.get.mockResolvedValue([])
132
+ await new Database(mockClient)
133
+ .from('accounts')
134
+ .filters(normativeTree)
135
+ .populate(['customer'])
136
+ .execute()
137
+ const url = mockHttpClient.get.mock.calls[0][0] as string
138
+ expect(url).toContain('populate=customer')
139
+ const q = url.split('?')[1]
140
+ expect(new URLSearchParams(q).get('filters')).toBeTruthy()
141
+ })
142
+ })
143
+
144
+ describe('orderBy()', () => {
96
145
  it('asc order uses field name without prefix', async () => {
97
146
  mockHttpClient.get.mockResolvedValue([])
98
- await new Database(mockClient).from('accounts').sort('created_at', 'asc').execute()
147
+ await new Database(mockClient).from('accounts').orderBy('created_at', 'asc').execute()
99
148
  expect(mockHttpClient.get).toHaveBeenCalledWith(expect.stringContaining('ordering=created_at'))
100
149
  })
101
150
 
102
151
  it('desc order adds - prefix', async () => {
103
152
  mockHttpClient.get.mockResolvedValue([])
104
- await new Database(mockClient).from('accounts').sort('created_at', 'desc').execute()
153
+ await new Database(mockClient).from('accounts').orderBy('created_at', 'desc').execute()
105
154
  expect(mockHttpClient.get).toHaveBeenCalledWith(expect.stringContaining('ordering=-created_at'))
106
155
  })
107
156
 
108
157
  it('defaults to asc when order not specified', async () => {
109
158
  mockHttpClient.get.mockResolvedValue([])
110
- await new Database(mockClient).from('accounts').sort('name').execute()
159
+ await new Database(mockClient).from('accounts').orderBy('name').execute()
111
160
  expect(mockHttpClient.get).toHaveBeenCalledWith(expect.stringMatching(/ordering=name(?!-)/))
112
161
  })
162
+
163
+ it('comma-joins multiple fields from array (mixed asc/desc)', async () => {
164
+ mockHttpClient.get.mockResolvedValue([])
165
+ await new Database(mockClient)
166
+ .from('accounts')
167
+ .orderBy([
168
+ { field: 'salary', order: 'desc' },
169
+ { field: 'hire_date', order: 'asc' },
170
+ ])
171
+ .execute()
172
+ expect(mockHttpClient.get).toHaveBeenCalledWith(
173
+ expect.stringContaining('ordering=-salary%2Chire_date')
174
+ )
175
+ })
176
+
177
+ it('accepts pre-built ordering string', async () => {
178
+ mockHttpClient.get.mockResolvedValue([])
179
+ await new Database(mockClient).from('accounts').orderBy('-a,b').execute()
180
+ expect(mockHttpClient.get).toHaveBeenCalledWith(expect.stringContaining('ordering=-a%2Cb'))
181
+ })
182
+
183
+ it('chains with filters without dropping ordering', async () => {
184
+ mockHttpClient.get.mockResolvedValue([])
185
+ await new Database(mockClient)
186
+ .from('accounts')
187
+ .filters('status', 'eq', 'active')
188
+ .orderBy([{ field: 'name', order: 'asc' }])
189
+ .execute()
190
+ const url = mockHttpClient.get.mock.calls[0][0] as string
191
+ expect(url).toContain('status=active')
192
+ expect(url).toContain('ordering=name')
193
+ })
113
194
  })
114
195
 
115
196
  describe('pagination', () => {
@@ -132,6 +213,12 @@ describe('Database', () => {
132
213
  await new Database(mockClient).from('orders').populate(['customer', 'items']).execute()
133
214
  expect(mockHttpClient.get).toHaveBeenCalledWith(expect.stringContaining('populate=customer%2Citems'))
134
215
  })
216
+
217
+ it('populateAll sets wildcard populate', async () => {
218
+ mockHttpClient.get.mockResolvedValue([])
219
+ await new Database(mockClient).from('orders').populateAll().execute()
220
+ expect(mockHttpClient.get).toHaveBeenCalledWith(expect.stringContaining('populate=*'))
221
+ })
135
222
  })
136
223
 
137
224
  describe('CRUD operations', () => {
@@ -232,7 +319,7 @@ describe('Database', () => {
232
319
  mockHttpClient.get.mockResolvedValue([])
233
320
  await new Database(mockClient)
234
321
  .from('accounts')
235
- .filter('status', 'eq', 'active')
322
+ .filters('status', 'eq', 'active')
236
323
  .page(1)
237
324
  .pageSize(10)
238
325
  .execute()
@@ -486,7 +573,7 @@ describe('Database', () => {
486
573
  describe('deleteFiltered()', () => {
487
574
  it('calls httpClient.delete with filter params in query string', async () => {
488
575
  mockHttpClient.delete.mockResolvedValue({ status: 'success' })
489
- await new Database(mockClient).from('accounts').filter('status', 'eq', 'inactive').deleteFiltered().execute()
576
+ await new Database(mockClient).from('accounts').filters('status', 'eq', 'inactive').deleteFiltered().execute()
490
577
  const url = mockHttpClient.delete.mock.calls[0][0]
491
578
  expect(url).toContain('status=inactive')
492
579
  expect(url).not.toContain('/undefined/')
@@ -494,7 +581,7 @@ describe('Database', () => {
494
581
 
495
582
  it('hits collection endpoint without recordId', async () => {
496
583
  mockHttpClient.delete.mockResolvedValue({ status: 'success' })
497
- await new Database(mockClient).from('accounts').filter('age', 'lt', 18).deleteFiltered().execute()
584
+ await new Database(mockClient).from('accounts').filters('age', 'lt', 18).deleteFiltered().execute()
498
585
  expect(mockHttpClient.delete).toHaveBeenCalledWith(
499
586
  expect.stringContaining('api/apps/test-app/datatables/accounts/data/?')
500
587
  )
@@ -100,26 +100,26 @@ describe('Encoding edge cases', () => {
100
100
 
101
101
  it('encodes filter value with spaces', async () => {
102
102
  mockHttpClient.get.mockResolvedValue([])
103
- await new Database(mockClient).from('accounts').filter('name', 'eq', 'John Doe').execute()
103
+ await new Database(mockClient).from('accounts').filters('name', 'eq', 'John Doe').execute()
104
104
  expect(mockHttpClient.get).toHaveBeenCalledWith(expect.stringContaining('name=John+Doe'))
105
105
  })
106
106
 
107
107
  it('encodes filter value with special characters', async () => {
108
108
  mockHttpClient.get.mockResolvedValue([])
109
- await new Database(mockClient).from('accounts').filter('email', 'eq', 'user@example.com').execute()
109
+ await new Database(mockClient).from('accounts').filters('email', 'eq', 'user@example.com').execute()
110
110
  expect(mockHttpClient.get).toHaveBeenCalledWith(expect.stringContaining('email=user%40example.com'))
111
111
  })
112
112
 
113
113
  it('encodes filter value with ampersand', async () => {
114
114
  mockHttpClient.get.mockResolvedValue([])
115
- await new Database(mockClient).from('accounts').filter('company', 'eq', 'A&B Corp').execute()
115
+ await new Database(mockClient).from('accounts').filters('company', 'eq', 'A&B Corp').execute()
116
116
  const url = mockHttpClient.get.mock.calls[0][0]
117
117
  expect(url).toContain('company=A%26B')
118
118
  })
119
119
 
120
120
  it('handles unicode in filter values', async () => {
121
121
  mockHttpClient.get.mockResolvedValue([])
122
- await new Database(mockClient).from('accounts').filter('name', 'icontains', '日本語').execute()
122
+ await new Database(mockClient).from('accounts').filters('name', 'icontains', '日本語').execute()
123
123
  expect(mockHttpClient.get).toHaveBeenCalledWith(expect.stringContaining('name__icontains='))
124
124
  })
125
125
 
@@ -137,7 +137,7 @@ describe('Encoding edge cases', () => {
137
137
 
138
138
  it('handles empty string filter value', async () => {
139
139
  mockHttpClient.get.mockResolvedValue([])
140
- await new Database(mockClient).from('accounts').filter('status', 'eq', '').execute()
140
+ await new Database(mockClient).from('accounts').filters('status', 'eq', '').execute()
141
141
  const url = mockHttpClient.get.mock.calls[0][0]
142
142
  expect(url).toContain('status=')
143
143
  })
@@ -149,8 +149,8 @@ describe('Builder immutability', () => {
149
149
  it('Database: chaining does not mutate original instance', async () => {
150
150
  mockHttpClient.get.mockResolvedValue([])
151
151
  const base = new Database(mockClient).from('accounts')
152
- const filtered = base.filter('status', 'eq', 'active')
153
- const sorted = base.sort('name', 'asc')
152
+ const filtered = base.filters('status', 'eq', 'active')
153
+ const sorted = base.orderBy('name', 'asc')
154
154
 
155
155
  await filtered.execute()
156
156
  const filteredUrl = mockHttpClient.get.mock.calls[0][0]