@ikeboy003/cloudrest-client 0.0.1 → 0.2.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/mutation.ts CHANGED
@@ -1,28 +1,96 @@
1
- // Mutations: insert / update / delete. PostgREST conventions:
2
- // - POST /table with array body → insert
1
+ // Mutations: insert / update / delete (+ upsert, bulk media, write tuning).
2
+ // PostgREST conventions:
3
+ // - POST /table array/object body → insert (`?on_conflict=` → upsert)
3
4
  // - PATCH /table?filters → update
4
5
  // - DELETE /table?filters → delete
5
- // - Prefer: return=representation header makes the server return rows.
6
+ // - Prefer: return=representation|minimal, resolution=*, missing=*, tx=rollback,
7
+ // max-affected/min-affected, timezone, response-buffered
6
8
 
7
- import type { AnyPaths, RowOf, TableName } from "./types.js";
8
- import {
9
- fEq, fNeq, fGt, fGte, fLt, fLte, fLike, fIlike, fIn, fIs,
10
- type FilterEntry, type IsValue,
11
- } from "./filter.js";
12
- import type { ExecContext } from "./query.js";
9
+ import type { AnyPaths, RowOf, TableName } from "@/types.js";
10
+ import * as F from "@/filter.js";
11
+ import type { FilterEntry, IsValue } from "@/filter.js";
12
+ import type { ExecContext } from "@/query.js";
13
+ import { joinUrl } from "@/serialize.js";
13
14
 
14
- class FilterableMutation<Row> {
15
+ // Shared write-tuning state + Prefer assembly (insert/update/delete).
16
+ abstract class WriteTuning<Row> {
17
+ protected returnMode?: "representation" | "minimal";
18
+ protected txMode?: "rollback" | "commit";
19
+ protected maxAff?: number;
20
+ protected minAff?: number;
21
+ protected tz?: string;
22
+ protected bufferedFlag = false;
23
+ protected missingMode?: "default" | "null";
24
+ protected resolution?: "merge-duplicates" | "ignore-duplicates";
25
+
26
+ /** Return affected rows (adds Prefer: return=representation). */
27
+ returning(): this { this.returnMode = "representation"; return this; }
28
+ /** No body back (Prefer: return=minimal) — the default if unset. */
29
+ minimal(): this { this.returnMode = "minimal"; return this; }
30
+ /** Dry-run: run + roll back (Prefer: tx=rollback). */
31
+ dryRun(): this { this.txMode = "rollback"; return this; }
32
+ /** Fail (PGRST) if more than N rows would be affected. */
33
+ maxAffected(n: number): this { this.maxAff = n; return this; }
34
+ /** Fail if fewer than N rows are affected. */
35
+ minAffected(n: number): this { this.minAff = n; return this; }
36
+ /** Render timestamptz output in this zone (Prefer: timezone=<tz>). */
37
+ timezone(tz: string): this { this.tz = tz; return this; }
38
+ /** Buffer the response + set Content-Length (Prefer: response-buffered). */
39
+ buffered(): this { this.bufferedFlag = true; return this; }
40
+
41
+ protected prefer(): string[] {
42
+ const p: string[] = [];
43
+ if (this.returnMode) p.push(`return=${this.returnMode}`);
44
+ if (this.resolution) p.push(`resolution=${this.resolution}`);
45
+ if (this.missingMode) p.push(`missing=${this.missingMode}`);
46
+ if (this.txMode) p.push(`tx=${this.txMode}`);
47
+ if (this.maxAff !== undefined) p.push(`max-affected=${this.maxAff}`);
48
+ if (this.minAff !== undefined) p.push(`min-affected=${this.minAff}`);
49
+ if (this.tz) p.push(`timezone=${this.tz}`);
50
+ if (this.bufferedFlag) p.push("response-buffered");
51
+ return p;
52
+ }
53
+ }
54
+
55
+ // Filter methods shared by update/delete (mirrors QueryBuilder's set).
56
+ abstract class FilterableMutation<Row> extends WriteTuning<Row> {
15
57
  protected filters: FilterEntry[] = [];
16
- eq(col: string, v: unknown): this { this.filters.push(fEq(col, v)); return this; }
17
- neq(col: string, v: unknown): this { this.filters.push(fNeq(col, v)); return this; }
18
- gt(col: string, v: unknown): this { this.filters.push(fGt(col, v)); return this; }
19
- gte(col: string, v: unknown): this { this.filters.push(fGte(col, v)); return this; }
20
- lt(col: string, v: unknown): this { this.filters.push(fLt(col, v)); return this; }
21
- lte(col: string, v: unknown): this { this.filters.push(fLte(col, v)); return this; }
22
- like(col: string, pat: string): this { this.filters.push(fLike(col, pat)); return this; }
23
- ilike(col: string, pat: string): this { this.filters.push(fIlike(col, pat)); return this; }
24
- in(col: string, vals: readonly unknown[]): this { this.filters.push(fIn(col, vals)); return this; }
25
- is(col: string, v: IsValue): this { this.filters.push(fIs(col, v)); return this; }
58
+ eq(c: string, v: unknown): this { this.filters.push(F.fEq(c, v)); return this; }
59
+ neq(c: string, v: unknown): this { this.filters.push(F.fNeq(c, v)); return this; }
60
+ gt(c: string, v: unknown): this { this.filters.push(F.fGt(c, v)); return this; }
61
+ gte(c: string, v: unknown): this { this.filters.push(F.fGte(c, v)); return this; }
62
+ lt(c: string, v: unknown): this { this.filters.push(F.fLt(c, v)); return this; }
63
+ lte(c: string, v: unknown): this { this.filters.push(F.fLte(c, v)); return this; }
64
+ like(c: string, p: string): this { this.filters.push(F.fLike(c, p)); return this; }
65
+ ilike(c: string, p: string): this { this.filters.push(F.fIlike(c, p)); return this; }
66
+ match(c: string, p: string): this { this.filters.push(F.fMatch(c, p)); return this; }
67
+ imatch(c: string, p: string): this { this.filters.push(F.fImatch(c, p)); return this; }
68
+ in(c: string, vals: readonly unknown[]): this { this.filters.push(F.fIn(c, vals)); return this; }
69
+ is(c: string, v: IsValue): this { this.filters.push(F.fIs(c, v)); return this; }
70
+ isDistinct(c: string, v: unknown): this { this.filters.push(F.fIsDistinct(c, v)); return this; }
71
+ fts(c: string, q: string, cfg?: string): this { this.filters.push(F.fFts(c, q, cfg)); return this; }
72
+ plfts(c: string, q: string, cfg?: string): this { this.filters.push(F.fPlfts(c, q, cfg)); return this; }
73
+ phfts(c: string, q: string, cfg?: string): this { this.filters.push(F.fPhfts(c, q, cfg)); return this; }
74
+ wfts(c: string, q: string, cfg?: string): this { this.filters.push(F.fWfts(c, q, cfg)); return this; }
75
+ cs(c: string, vals: readonly unknown[]): this { this.filters.push(F.fCs(c, vals)); return this; }
76
+ cd(c: string, vals: readonly unknown[]): this { this.filters.push(F.fCd(c, vals)); return this; }
77
+ ov(c: string, v: readonly unknown[] | string): this { this.filters.push(F.fOv(c, v)); return this; }
78
+ sl(c: string, r: unknown): this { this.filters.push(F.fSl(c, r)); return this; }
79
+ sr(c: string, r: unknown): this { this.filters.push(F.fSr(c, r)); return this; }
80
+ nxr(c: string, r: unknown): this { this.filters.push(F.fNxr(c, r)); return this; }
81
+ nxl(c: string, r: unknown): this { this.filters.push(F.fNxl(c, r)); return this; }
82
+ adj(c: string, r: unknown): this { this.filters.push(F.fAdj(c, r)); return this; }
83
+ not(entry: FilterEntry): this { this.filters.push(F.fNot(entry)); return this; }
84
+ notIs(c: string, v: IsValue): this { this.filters.push(F.fNot(F.fIs(c, v))); return this; }
85
+ filter(c: string, op: string, value: string): this { this.filters.push(F.fFilter(c, op, value)); return this; }
86
+ and(...entries: FilterEntry[]): this { this.filters.push(F.fAnd(entries)); return this; }
87
+ or(...entries: FilterEntry[]): this { this.filters.push(F.fOr(entries)); return this; }
88
+
89
+ protected filterQs(): string {
90
+ const params = new URLSearchParams();
91
+ for (const f of this.filters) params.append(f.column, f.expr);
92
+ return params.toString();
93
+ }
26
94
  }
27
95
 
28
96
  async function send(
@@ -30,25 +98,30 @@ async function send(
30
98
  method: "POST" | "PATCH" | "DELETE",
31
99
  url: string,
32
100
  body: unknown | undefined,
33
- returnRepresentation: boolean,
101
+ prefer: string[],
34
102
  ): Promise<unknown> {
103
+ // A FormData body is a multipart media write — let the runtime set the
104
+ // multipart Content-Type (with boundary); don't JSON-encode or force a type.
105
+ const isForm = typeof FormData !== "undefined" && body instanceof FormData;
35
106
  const headers: Record<string, string> = {
36
107
  Accept: "application/json",
37
- "Content-Type": "application/json",
108
+ ...(isForm ? {} : { "Content-Type": "application/json" }),
38
109
  ...ctx.headers,
39
110
  };
40
- if (returnRepresentation) headers["Prefer"] = "return=representation";
111
+ if (prefer.length) headers["Prefer"] = prefer.join(",");
41
112
  const token = await ctx.getToken?.();
42
113
  if (token) headers["Authorization"] = `Bearer ${token}`;
43
114
 
44
115
  const res = await ctx.fetch(url, {
45
116
  method,
46
117
  headers,
47
- ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
118
+ ...(body !== undefined
119
+ ? { body: isForm ? (body as FormData) : JSON.stringify(body) }
120
+ : {}),
48
121
  });
49
122
  if (!res.ok) {
50
123
  const errBody = await res.json().catch(() => null);
51
- const { CloudRestError } = await import("./errors.js");
124
+ const { CloudRestError } = await import("@/errors.js");
52
125
  throw new CloudRestError(res.status, res.statusText, errBody);
53
126
  }
54
127
  if (res.status === 204) return [];
@@ -56,31 +129,84 @@ async function send(
56
129
  }
57
130
 
58
131
  export class InsertBuilder<P extends AnyPaths, T extends TableName<P>, Row = RowOf<P, T>>
132
+ extends WriteTuning<Row>
59
133
  implements PromiseLike<Row[]>
60
134
  {
61
135
  private rows: Partial<Row>[];
62
- private returnRows = false;
136
+ private files: Record<string, Blob> = {};
137
+ private onConflictCols?: string;
138
+ private columnsList?: string;
63
139
 
64
140
  constructor(
65
141
  private readonly ctx: ExecContext,
66
142
  private readonly table: T,
67
143
  rows: Partial<Row> | Partial<Row>[],
68
144
  ) {
145
+ super();
69
146
  this.rows = Array.isArray(rows) ? rows : [rows];
70
147
  }
71
148
 
72
- /** Return inserted rows (adds Prefer: return=representation). */
73
- returning(): this {
74
- this.returnRows = true;
149
+ /**
150
+ * Upsert on conflict of `columns` (a PK/unique set). Default resolution
151
+ * merges (UPDATE); pass `{ ignoreDuplicates: true }` for DO NOTHING.
152
+ */
153
+ onConflict(columns: string | string[], opts: { ignoreDuplicates?: boolean } = {}): this {
154
+ this.onConflictCols = Array.isArray(columns) ? columns.join(",") : columns;
155
+ this.resolution = opts.ignoreDuplicates ? "ignore-duplicates" : "merge-duplicates";
75
156
  return this;
76
157
  }
77
158
 
159
+ /** Restrict the insert to these columns (`?columns=`) — consistent bulk keys. */
160
+ columns(cols: string[]): this {
161
+ this.columnsList = cols.join(",");
162
+ return this;
163
+ }
164
+
165
+ /** Treat keys absent from a row as `NULL` rather than the column default. */
166
+ missing(mode: "default" | "null"): this {
167
+ this.missingMode = mode;
168
+ return this;
169
+ }
170
+
171
+ /**
172
+ * Attach a file for a media column — switches to a multipart write (scalar
173
+ * columns as a JSON `payload` part, each file as a part named after its
174
+ * column). The server's media stage stores bytes → reference into the column.
175
+ * Single-row inserts only.
176
+ */
177
+ attach(column: string, file: Blob): this {
178
+ this.files[column] = file;
179
+ return this;
180
+ }
181
+
182
+ private buildUrl(): string {
183
+ const params = new URLSearchParams();
184
+ if (this.onConflictCols) params.set("on_conflict", this.onConflictCols);
185
+ if (this.columnsList) params.set("columns", this.columnsList);
186
+ const qs = params.toString();
187
+ return `${joinUrl(this.ctx.baseUrl, String(this.table))}${qs ? `?${qs}` : ""}`;
188
+ }
189
+
190
+ private formBody(): FormData {
191
+ if (this.rows.length !== 1) {
192
+ throw new Error("cloudrest: multipart insert (attach) supports a single row");
193
+ }
194
+ const fd = new FormData();
195
+ fd.append(
196
+ "payload",
197
+ new Blob([JSON.stringify(this.rows[0])], { type: "application/json" }),
198
+ );
199
+ for (const [column, file] of Object.entries(this.files)) fd.append(column, file);
200
+ return fd;
201
+ }
202
+
78
203
  then<R1 = Row[], R2 = never>(
79
204
  onfulfilled?: ((value: Row[]) => R1 | PromiseLike<R1>) | null,
80
205
  onrejected?: ((reason: unknown) => R2 | PromiseLike<R2>) | null,
81
206
  ): PromiseLike<R1 | R2> {
82
- const url = `${this.ctx.baseUrl}${String(this.table)}`;
83
- return send(this.ctx, "POST", url, this.rows, this.returnRows)
207
+ const hasFiles = Object.keys(this.files).length > 0;
208
+ const body = hasFiles ? this.formBody() : this.rows;
209
+ return send(this.ctx, "POST", this.buildUrl(), body, this.prefer())
84
210
  .then((v) => v as Row[])
85
211
  .then(onfulfilled, onrejected as never);
86
212
  }
@@ -90,8 +216,6 @@ export class UpdateBuilder<P extends AnyPaths, T extends TableName<P>, Row = Row
90
216
  extends FilterableMutation<Row>
91
217
  implements PromiseLike<Row[]>
92
218
  {
93
- private returnRows = false;
94
-
95
219
  constructor(
96
220
  private readonly ctx: ExecContext,
97
221
  private readonly table: T,
@@ -100,23 +224,16 @@ export class UpdateBuilder<P extends AnyPaths, T extends TableName<P>, Row = Row
100
224
  super();
101
225
  }
102
226
 
103
- returning(): this {
104
- this.returnRows = true;
105
- return this;
106
- }
107
-
108
227
  private url(): string {
109
- const params = new URLSearchParams();
110
- for (const f of this.filters) params.append(f.column, f.expr);
111
- const qs = params.toString();
112
- return `${this.ctx.baseUrl}${String(this.table)}${qs ? `?${qs}` : ""}`;
228
+ const qs = this.filterQs();
229
+ return `${joinUrl(this.ctx.baseUrl, String(this.table))}${qs ? `?${qs}` : ""}`;
113
230
  }
114
231
 
115
232
  then<R1 = Row[], R2 = never>(
116
233
  onfulfilled?: ((value: Row[]) => R1 | PromiseLike<R1>) | null,
117
234
  onrejected?: ((reason: unknown) => R2 | PromiseLike<R2>) | null,
118
235
  ): PromiseLike<R1 | R2> {
119
- return send(this.ctx, "PATCH", this.url(), this.patch, this.returnRows)
236
+ return send(this.ctx, "PATCH", this.url(), this.patch, this.prefer())
120
237
  .then((v) => v as Row[])
121
238
  .then(onfulfilled, onrejected as never);
122
239
  }
@@ -126,8 +243,6 @@ export class DeleteBuilder<P extends AnyPaths, T extends TableName<P>, Row = Row
126
243
  extends FilterableMutation<Row>
127
244
  implements PromiseLike<Row[]>
128
245
  {
129
- private returnRows = false;
130
-
131
246
  constructor(
132
247
  private readonly ctx: ExecContext,
133
248
  private readonly table: T,
@@ -135,23 +250,16 @@ export class DeleteBuilder<P extends AnyPaths, T extends TableName<P>, Row = Row
135
250
  super();
136
251
  }
137
252
 
138
- returning(): this {
139
- this.returnRows = true;
140
- return this;
141
- }
142
-
143
253
  private url(): string {
144
- const params = new URLSearchParams();
145
- for (const f of this.filters) params.append(f.column, f.expr);
146
- const qs = params.toString();
147
- return `${this.ctx.baseUrl}${String(this.table)}${qs ? `?${qs}` : ""}`;
254
+ const qs = this.filterQs();
255
+ return `${joinUrl(this.ctx.baseUrl, String(this.table))}${qs ? `?${qs}` : ""}`;
148
256
  }
149
257
 
150
258
  then<R1 = Row[], R2 = never>(
151
259
  onfulfilled?: ((value: Row[]) => R1 | PromiseLike<R1>) | null,
152
260
  onrejected?: ((reason: unknown) => R2 | PromiseLike<R2>) | null,
153
261
  ): PromiseLike<R1 | R2> {
154
- return send(this.ctx, "DELETE", this.url(), undefined, this.returnRows)
262
+ return send(this.ctx, "DELETE", this.url(), undefined, this.prefer())
155
263
  .then((v) => v as Row[])
156
264
  .then(onfulfilled, onrejected as never);
157
265
  }
package/src/query.ts CHANGED
@@ -4,12 +4,11 @@
4
4
  //
5
5
  // const rows = await db.from("x").select("a,b").eq("a", 1);
6
6
 
7
- import type { AnyPaths, RowOf, TableName } from "./types.js";
8
- import {
9
- fEq, fNeq, fGt, fGte, fLt, fLte, fLike, fIlike, fIn, fIs, fNot,
10
- type FilterEntry, type IsValue,
11
- } from "./filter.js";
12
- import type { CloudRestError } from "./errors.js";
7
+ import type { AnyPaths, RowOf, TableName } from "@/types.js";
8
+ import * as F from "@/filter.js";
9
+ import type { FilterEntry, IsValue } from "@/filter.js";
10
+ import type { CloudRestError } from "@/errors.js";
11
+ import { joinUrl } from "@/serialize.js";
13
12
 
14
13
  export interface ExecContext {
15
14
  baseUrl: string;
@@ -23,6 +22,8 @@ export interface OrderOptions {
23
22
  nullsFirst?: boolean;
24
23
  }
25
24
 
25
+ export type CountStrategy = "exact" | "planned" | "estimated";
26
+
26
27
  export class QueryBuilder<P extends AnyPaths, T extends TableName<P>, Row = RowOf<P, T>>
27
28
  implements PromiseLike<Row[]>
28
29
  {
@@ -34,6 +35,8 @@ export class QueryBuilder<P extends AnyPaths, T extends TableName<P>, Row = RowO
34
35
  private rangeFrom?: number;
35
36
  private rangeTo?: number;
36
37
  private singleMode = false;
38
+ private countStrategy?: CountStrategy;
39
+ private preferExtra: string[] = [];
37
40
 
38
41
  constructor(
39
42
  private readonly ctx: ExecContext,
@@ -41,26 +44,51 @@ export class QueryBuilder<P extends AnyPaths, T extends TableName<P>, Row = RowO
41
44
  ) {}
42
45
 
43
46
  // ── projection ──────────────────────────────────────────────────────────
47
+ // Raw PostgREST select string — supports embedding (`*,author(*)`), aggregates
48
+ // (`count(),sum(x)`), JSON paths (`data->>k`), casts (`x::text`), spreads
49
+ // (`...t(*)`), and aliases (`alias:col`).
44
50
  select(columns: string): this {
45
51
  this.selectExpr = columns;
46
52
  return this;
47
53
  }
48
54
 
49
55
  // ── filters ─────────────────────────────────────────────────────────────
50
- eq(col: string, v: unknown): this { this.filters.push(fEq(col, v)); return this; }
51
- neq(col: string, v: unknown): this { this.filters.push(fNeq(col, v)); return this; }
52
- gt(col: string, v: unknown): this { this.filters.push(fGt(col, v)); return this; }
53
- gte(col: string, v: unknown): this { this.filters.push(fGte(col, v)); return this; }
54
- lt(col: string, v: unknown): this { this.filters.push(fLt(col, v)); return this; }
55
- lte(col: string, v: unknown): this { this.filters.push(fLte(col, v)); return this; }
56
- like(col: string, pat: string): this { this.filters.push(fLike(col, pat)); return this; }
57
- ilike(col: string, pat: string): this { this.filters.push(fIlike(col, pat)); return this; }
58
- in(col: string, vals: readonly unknown[]): this { this.filters.push(fIn(col, vals)); return this; }
59
- is(col: string, v: IsValue): this { this.filters.push(fIs(col, v)); return this; }
56
+ eq(col: string, v: unknown): this { this.filters.push(F.fEq(col, v)); return this; }
57
+ neq(col: string, v: unknown): this { this.filters.push(F.fNeq(col, v)); return this; }
58
+ gt(col: string, v: unknown): this { this.filters.push(F.fGt(col, v)); return this; }
59
+ gte(col: string, v: unknown): this { this.filters.push(F.fGte(col, v)); return this; }
60
+ lt(col: string, v: unknown): this { this.filters.push(F.fLt(col, v)); return this; }
61
+ lte(col: string, v: unknown): this { this.filters.push(F.fLte(col, v)); return this; }
62
+ like(col: string, pat: string): this { this.filters.push(F.fLike(col, pat)); return this; }
63
+ ilike(col: string, pat: string): this { this.filters.push(F.fIlike(col, pat)); return this; }
64
+ match(col: string, pat: string): this { this.filters.push(F.fMatch(col, pat)); return this; }
65
+ imatch(col: string, pat: string): this { this.filters.push(F.fImatch(col, pat)); return this; }
66
+ in(col: string, vals: readonly unknown[]): this { this.filters.push(F.fIn(col, vals)); return this; }
67
+ is(col: string, v: IsValue): this { this.filters.push(F.fIs(col, v)); return this; }
68
+ isDistinct(col: string, v: unknown): this { this.filters.push(F.fIsDistinct(col, v)); return this; }
69
+ // full-text search (optional ts config, e.g. "english")
70
+ fts(col: string, q: string, config?: string): this { this.filters.push(F.fFts(col, q, config)); return this; }
71
+ plfts(col: string, q: string, config?: string): this { this.filters.push(F.fPlfts(col, q, config)); return this; }
72
+ phfts(col: string, q: string, config?: string): this { this.filters.push(F.fPhfts(col, q, config)); return this; }
73
+ wfts(col: string, q: string, config?: string): this { this.filters.push(F.fWfts(col, q, config)); return this; }
74
+ // array / range
75
+ cs(col: string, vals: readonly unknown[]): this { this.filters.push(F.fCs(col, vals)); return this; }
76
+ cd(col: string, vals: readonly unknown[]): this { this.filters.push(F.fCd(col, vals)); return this; }
77
+ ov(col: string, v: readonly unknown[] | string): this { this.filters.push(F.fOv(col, v)); return this; }
78
+ sl(col: string, range: unknown): this { this.filters.push(F.fSl(col, range)); return this; }
79
+ sr(col: string, range: unknown): this { this.filters.push(F.fSr(col, range)); return this; }
80
+ nxr(col: string, range: unknown): this { this.filters.push(F.fNxr(col, range)); return this; }
81
+ nxl(col: string, range: unknown): this { this.filters.push(F.fNxl(col, range)); return this; }
82
+ adj(col: string, range: unknown): this { this.filters.push(F.fAdj(col, range)); return this; }
60
83
  // Negated `is` — e.g. `notIs("col", "null")` → `col=not.is.null`.
61
- notIs(col: string, v: IsValue): this { this.filters.push(fNot(fIs(col, v))); return this; }
62
- // Generic negation: pass the column + a non-negated filter helper output.
63
- not(entry: FilterEntry): this { this.filters.push(fNot(entry)); return this; }
84
+ notIs(col: string, v: IsValue): this { this.filters.push(F.fNot(F.fIs(col, v))); return this; }
85
+ // Generic negation: pass a non-negated filter helper output.
86
+ not(entry: FilterEntry): this { this.filters.push(F.fNot(entry)); return this; }
87
+ // Generic escape hatch: any operator + already-serialized value.
88
+ filter(col: string, op: string, value: string): this { this.filters.push(F.fFilter(col, op, value)); return this; }
89
+ // Composite logic — pass bare `cond.*` helpers; nestable (`cond.and(cond.or(...))`).
90
+ and(...entries: FilterEntry[]): this { this.filters.push(F.fAnd(entries)); return this; }
91
+ or(...entries: FilterEntry[]): this { this.filters.push(F.fOr(entries)); return this; }
64
92
 
65
93
  // ── ordering / paging ───────────────────────────────────────────────────
66
94
  order(column: string, opts: OrderOptions = {}): this {
@@ -74,6 +102,14 @@ export class QueryBuilder<P extends AnyPaths, T extends TableName<P>, Row = RowO
74
102
  offset(n: number): this { this.offsetN = n; return this; }
75
103
  range(from: number, to: number): this { this.rangeFrom = from; this.rangeTo = to; return this; }
76
104
 
105
+ // ── prefer tuning ─────────────────────────────────────────────────────────
106
+ /** Set the count strategy (`exact` | `planned` | `estimated`). withCount() implies `exact`. */
107
+ count(strategy: CountStrategy = "exact"): this { this.countStrategy = strategy; return this; }
108
+ /** `Prefer: timezone=<tz>` — render timestamptz output in this zone. */
109
+ timezone(tz: string): this { this.preferExtra.push(`timezone=${tz}`); return this; }
110
+ /** `Prefer: response-buffered` — server buffers + sets Content-Length (CF throttle fix). */
111
+ buffered(): this { this.preferExtra.push("response-buffered"); return this; }
112
+
77
113
  // ── result shape ────────────────────────────────────────────────────────
78
114
  /**
79
115
  * Expect exactly one row. Resolves to `Row` (not `Row[]`).
@@ -84,6 +120,16 @@ export class QueryBuilder<P extends AnyPaths, T extends TableName<P>, Row = RowO
84
120
  return this as unknown as SingleQuery<Row>;
85
121
  }
86
122
 
123
+ /**
124
+ * Page-aware terminal: returns the rows AND the total row count via the
125
+ * `Content-Range` response header. Use with `.range(from,to)` (and optionally
126
+ * `.count("estimated")` for big tables) for paginated lists.
127
+ */
128
+ withCount(): PagedQuery<Row> {
129
+ if (!this.countStrategy) this.countStrategy = "exact";
130
+ return { then: (f, r) => this.execPaged().then(f as never, r as never) };
131
+ }
132
+
87
133
  // ── build + execute ─────────────────────────────────────────────────────
88
134
  private buildUrl(): string {
89
135
  const params = new URLSearchParams();
@@ -93,10 +139,17 @@ export class QueryBuilder<P extends AnyPaths, T extends TableName<P>, Row = RowO
93
139
  if (this.limitN !== undefined) params.set("limit", String(this.limitN));
94
140
  if (this.offsetN !== undefined) params.set("offset", String(this.offsetN));
95
141
  const qs = params.toString();
96
- return `${this.ctx.baseUrl}${String(this.table)}${qs ? `?${qs}` : ""}`;
142
+ return `${joinUrl(this.ctx.baseUrl, String(this.table))}${qs ? `?${qs}` : ""}`;
97
143
  }
98
144
 
99
- private async exec(): Promise<Row[]> {
145
+ private preferHeader(): string | undefined {
146
+ const tokens = [...this.preferExtra];
147
+ if (this.countStrategy) tokens.push(`count=${this.countStrategy}`);
148
+ return tokens.length ? tokens.join(",") : undefined;
149
+ }
150
+
151
+ // Shared request path for every terminal (then / single / withCount).
152
+ private async request(): Promise<{ res: Response; rows: Row[] }> {
100
153
  const url = this.buildUrl();
101
154
  const headers: Record<string, string> = {
102
155
  Accept: "application/json",
@@ -106,19 +159,35 @@ export class QueryBuilder<P extends AnyPaths, T extends TableName<P>, Row = RowO
106
159
  headers["Range-Unit"] = "items";
107
160
  headers["Range"] = `${this.rangeFrom}-${this.rangeTo}`;
108
161
  }
162
+ const prefer = this.preferHeader();
163
+ if (prefer) headers["Prefer"] = prefer;
109
164
  const token = await this.ctx.getToken?.();
110
165
  if (token) headers["Authorization"] = `Bearer ${token}`;
111
166
 
112
167
  const res = await this.ctx.fetch(url, { method: "GET", headers });
113
168
  if (!res.ok) {
114
169
  const body = await res.json().catch(() => null);
115
- const { CloudRestError } = await import("./errors.js");
170
+ const { CloudRestError } = await import("@/errors.js");
116
171
  throw new CloudRestError(res.status, res.statusText, body);
117
172
  }
118
- const rows = (await res.json()) as Row[];
173
+ return { res, rows: (await res.json()) as Row[] };
174
+ }
175
+
176
+ // Total count lives in the `Content-Range` trailer: `0-19/348` → 348.
177
+ // `*` (server declined to count) falls back to the rows already returned.
178
+ private async execPaged(): Promise<{ rows: Row[]; count: number }> {
179
+ const { res, rows } = await this.request();
180
+ const cr = res.headers.get("content-range");
181
+ const m = cr ? /\/(\d+|\*)$/.exec(cr) : null;
182
+ const count = m && m[1] !== "*" ? Number(m[1]) : rows.length;
183
+ return { rows, count: Number.isFinite(count) ? count : rows.length };
184
+ }
185
+
186
+ private async exec(): Promise<Row[]> {
187
+ const { rows } = await this.request();
119
188
  if (this.singleMode) {
120
189
  if (rows.length !== 1) {
121
- const { CloudRestError } = await import("./errors.js");
190
+ const { CloudRestError } = await import("@/errors.js");
122
191
  throw new CloudRestError(
123
192
  rows.length === 0 ? 404 : 409,
124
193
  rows.length === 0 ? "Not Found" : "Conflict",
@@ -150,3 +219,5 @@ export class QueryBuilder<P extends AnyPaths, T extends TableName<P>, Row = RowO
150
219
 
151
220
  // Phantom type alias for `.single()` — narrows the awaited type from Row[] to Row.
152
221
  export interface SingleQuery<Row> extends PromiseLike<Row> {}
222
+ // `.withCount()` awaits to rows + total count instead of a bare Row[].
223
+ export interface PagedQuery<Row> extends PromiseLike<{ rows: Row[]; count: number }> {}
package/src/rpc.ts CHANGED
@@ -7,8 +7,9 @@
7
7
  // - POST /rpc/<fn> body = { argName: value, ... }
8
8
  // - Response is the function return — scalar, row, or set-of rows.
9
9
 
10
- import type { ExecContext } from "./query.js";
11
- import { CloudRestError } from "./errors.js";
10
+ import type { ExecContext } from "@/query.js";
11
+ import { CloudRestError } from "@/errors.js";
12
+ import { joinUrl } from "@/serialize.js";
12
13
 
13
14
  export class RpcCaller<Result = unknown> implements PromiseLike<Result> {
14
15
  constructor(
@@ -18,7 +19,7 @@ export class RpcCaller<Result = unknown> implements PromiseLike<Result> {
18
19
  ) {}
19
20
 
20
21
  private async exec(): Promise<Result> {
21
- const url = `${this.ctx.baseUrl}/rpc/${this.fn}`;
22
+ const url = joinUrl(this.ctx.baseUrl, `rpc/${this.fn}`);
22
23
  const headers: Record<string, string> = {
23
24
  Accept: "application/json",
24
25
  "Content-Type": "application/json",
package/src/serialize.ts CHANGED
@@ -14,6 +14,13 @@ const SCALAR_SPECIAL = /[,"]/;
14
14
  // List items are inside `(a,b,c)`, so `(`, `)`, and `,` all need quoting.
15
15
  const LIST_SPECIAL = /[(),."]/;
16
16
 
17
+ // Join the client baseUrl with a table/route, tolerant of trailing/leading
18
+ // slashes on either side, so `baseUrl="http://h:3005"` + `table="catalog_public"`
19
+ // (or "/catalog_public") both yield exactly one separating slash.
20
+ export function joinUrl(base: string, path: string): string {
21
+ return `${base.replace(/\/+$/, "")}/${String(path).replace(/^\/+/, "")}`;
22
+ }
23
+
17
24
  export function serializeValue(v: unknown): string {
18
25
  if (v === null || v === undefined) return "null";
19
26
  if (v instanceof Date) return v.toISOString();
@@ -23,7 +30,7 @@ export function serializeValue(v: unknown): string {
23
30
  return escapeIfNeeded(String(v), SCALAR_SPECIAL);
24
31
  }
25
32
 
26
- function serializeListItem(v: unknown): string {
33
+ export function serializeListItem(v: unknown): string {
27
34
  if (v === null || v === undefined) return "null";
28
35
  if (v instanceof Date) return v.toISOString();
29
36
  if (typeof v === "boolean") return v ? "true" : "false";
@@ -31,6 +38,22 @@ function serializeListItem(v: unknown): string {
31
38
  return escapeIfNeeded(String(v), LIST_SPECIAL);
32
39
  }
33
40
 
41
+ // PostgreSQL array literal `{a,b,c}` — used by the array operators cs/cd/ov
42
+ // (`tags=cs.{a,b}`). Distinct from the `(a,b)` list form used by `in`.
43
+ export function serializeArray(v: readonly unknown[]): string {
44
+ return `{${v.map(serializeListItem).join(",")}}`;
45
+ }
46
+
47
+ // Range/value argument for the range operators (sl/sr/nxr/nxl/adj). Accepts a
48
+ // ready-made literal (`"(1,10)"`, `"[2020-01-01,2020-12-31]"`) or a `[lo, hi]`
49
+ // tuple → `(lo,hi)`.
50
+ export function serializeRange(v: unknown): string {
51
+ if (Array.isArray(v)) {
52
+ return `(${serializeListItem(v[0])},${serializeListItem(v[1])})`;
53
+ }
54
+ return String(v);
55
+ }
56
+
34
57
  function escapeIfNeeded(s: string, special: RegExp): string {
35
58
  if (s === "") return '""';
36
59
  if (!special.test(s)) return s;
package/src/types.ts CHANGED
@@ -15,7 +15,9 @@ export type RowOf<P extends AnyPaths, T extends keyof P> =
15
15
  ? Body extends ReadonlyArray<infer Item>
16
16
  ? Item
17
17
  : Body
18
- : never;
18
+ // Loosely-typed clients (e.g. `createClient<Record<string, unknown>>`) have no
19
+ // openapi shape per path; fall back to an open row so insert/update stay usable.
20
+ : Record<string, unknown>;
19
21
 
20
22
  // Tables = path keys that look like top-level table routes ("/foo", not "/rpc/...").
21
23
  export type TableName<P extends AnyPaths> = Extract<keyof P, string>;