@ikeboy003/cloudrest-client 0.1.0 → 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/query.ts CHANGED
@@ -4,13 +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, fOr,
10
- type FilterEntry, type IsValue,
11
- } from "./filter.js";
12
- import type { CloudRestError } from "./errors.js";
13
- import { joinUrl } from "./serialize.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";
14
12
 
15
13
  export interface ExecContext {
16
14
  baseUrl: string;
@@ -24,6 +22,8 @@ export interface OrderOptions {
24
22
  nullsFirst?: boolean;
25
23
  }
26
24
 
25
+ export type CountStrategy = "exact" | "planned" | "estimated";
26
+
27
27
  export class QueryBuilder<P extends AnyPaths, T extends TableName<P>, Row = RowOf<P, T>>
28
28
  implements PromiseLike<Row[]>
29
29
  {
@@ -35,7 +35,8 @@ export class QueryBuilder<P extends AnyPaths, T extends TableName<P>, Row = RowO
35
35
  private rangeFrom?: number;
36
36
  private rangeTo?: number;
37
37
  private singleMode = false;
38
- private wantCount = false;
38
+ private countStrategy?: CountStrategy;
39
+ private preferExtra: string[] = [];
39
40
 
40
41
  constructor(
41
42
  private readonly ctx: ExecContext,
@@ -43,29 +44,51 @@ export class QueryBuilder<P extends AnyPaths, T extends TableName<P>, Row = RowO
43
44
  ) {}
44
45
 
45
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`).
46
50
  select(columns: string): this {
47
51
  this.selectExpr = columns;
48
52
  return this;
49
53
  }
50
54
 
51
55
  // ── filters ─────────────────────────────────────────────────────────────
52
- eq(col: string, v: unknown): this { this.filters.push(fEq(col, v)); return this; }
53
- neq(col: string, v: unknown): this { this.filters.push(fNeq(col, v)); return this; }
54
- gt(col: string, v: unknown): this { this.filters.push(fGt(col, v)); return this; }
55
- gte(col: string, v: unknown): this { this.filters.push(fGte(col, v)); return this; }
56
- lt(col: string, v: unknown): this { this.filters.push(fLt(col, v)); return this; }
57
- lte(col: string, v: unknown): this { this.filters.push(fLte(col, v)); return this; }
58
- like(col: string, pat: string): this { this.filters.push(fLike(col, pat)); return this; }
59
- ilike(col: string, pat: string): this { this.filters.push(fIlike(col, pat)); return this; }
60
- in(col: string, vals: readonly unknown[]): this { this.filters.push(fIn(col, vals)); return this; }
61
- 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; }
62
83
  // Negated `is` — e.g. `notIs("col", "null")` → `col=not.is.null`.
63
- notIs(col: string, v: IsValue): this { this.filters.push(fNot(fIs(col, v))); return this; }
64
- // Generic negation: pass the column + a non-negated filter helper output.
65
- not(entry: FilterEntry): this { this.filters.push(fNot(entry)); return this; }
66
- // Composite OR pass bare condition helpers (e.g. `cond.ilike("title","*x*")`).
67
- // Emits `or=(title.ilike.*x*,author.ilike.*x*)`.
68
- or(...entries: FilterEntry[]): this { this.filters.push(fOr(entries)); 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; }
69
92
 
70
93
  // ── ordering / paging ───────────────────────────────────────────────────
71
94
  order(column: string, opts: OrderOptions = {}): this {
@@ -79,6 +102,14 @@ export class QueryBuilder<P extends AnyPaths, T extends TableName<P>, Row = RowO
79
102
  offset(n: number): this { this.offsetN = n; return this; }
80
103
  range(from: number, to: number): this { this.rangeFrom = from; this.rangeTo = to; return this; }
81
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
+
82
113
  // ── result shape ────────────────────────────────────────────────────────
83
114
  /**
84
115
  * Expect exactly one row. Resolves to `Row` (not `Row[]`).
@@ -90,12 +121,12 @@ export class QueryBuilder<P extends AnyPaths, T extends TableName<P>, Row = RowO
90
121
  }
91
122
 
92
123
  /**
93
- * Page-aware terminal: returns the rows AND the exact total row count via
94
- * `Prefer: count=exact` + the `Content-Range` response header. Use with
95
- * `.range(from,to)` for paginated lists that need a total/page count.
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.
96
127
  */
97
128
  withCount(): PagedQuery<Row> {
98
- this.wantCount = true;
129
+ if (!this.countStrategy) this.countStrategy = "exact";
99
130
  return { then: (f, r) => this.execPaged().then(f as never, r as never) };
100
131
  }
101
132
 
@@ -111,6 +142,12 @@ export class QueryBuilder<P extends AnyPaths, T extends TableName<P>, Row = RowO
111
142
  return `${joinUrl(this.ctx.baseUrl, String(this.table))}${qs ? `?${qs}` : ""}`;
112
143
  }
113
144
 
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
+
114
151
  // Shared request path for every terminal (then / single / withCount).
115
152
  private async request(): Promise<{ res: Response; rows: Row[] }> {
116
153
  const url = this.buildUrl();
@@ -122,14 +159,15 @@ export class QueryBuilder<P extends AnyPaths, T extends TableName<P>, Row = RowO
122
159
  headers["Range-Unit"] = "items";
123
160
  headers["Range"] = `${this.rangeFrom}-${this.rangeTo}`;
124
161
  }
125
- if (this.wantCount) headers["Prefer"] = "count=exact";
162
+ const prefer = this.preferHeader();
163
+ if (prefer) headers["Prefer"] = prefer;
126
164
  const token = await this.ctx.getToken?.();
127
165
  if (token) headers["Authorization"] = `Bearer ${token}`;
128
166
 
129
167
  const res = await this.ctx.fetch(url, { method: "GET", headers });
130
168
  if (!res.ok) {
131
169
  const body = await res.json().catch(() => null);
132
- const { CloudRestError } = await import("./errors.js");
170
+ const { CloudRestError } = await import("@/errors.js");
133
171
  throw new CloudRestError(res.status, res.statusText, body);
134
172
  }
135
173
  return { res, rows: (await res.json()) as Row[] };
@@ -149,7 +187,7 @@ export class QueryBuilder<P extends AnyPaths, T extends TableName<P>, Row = RowO
149
187
  const { rows } = await this.request();
150
188
  if (this.singleMode) {
151
189
  if (rows.length !== 1) {
152
- const { CloudRestError } = await import("./errors.js");
190
+ const { CloudRestError } = await import("@/errors.js");
153
191
  throw new CloudRestError(
154
192
  rows.length === 0 ? 404 : 409,
155
193
  rows.length === 0 ? "Not Found" : "Conflict",
package/src/rpc.ts CHANGED
@@ -7,9 +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";
12
- import { joinUrl } from "./serialize.js";
10
+ import type { ExecContext } from "@/query.js";
11
+ import { CloudRestError } from "@/errors.js";
12
+ import { joinUrl } from "@/serialize.js";
13
13
 
14
14
  export class RpcCaller<Result = unknown> implements PromiseLike<Result> {
15
15
  constructor(
package/src/serialize.ts CHANGED
@@ -30,7 +30,7 @@ export function serializeValue(v: unknown): string {
30
30
  return escapeIfNeeded(String(v), SCALAR_SPECIAL);
31
31
  }
32
32
 
33
- function serializeListItem(v: unknown): string {
33
+ export function serializeListItem(v: unknown): string {
34
34
  if (v === null || v === undefined) return "null";
35
35
  if (v instanceof Date) return v.toISOString();
36
36
  if (typeof v === "boolean") return v ? "true" : "false";
@@ -38,6 +38,22 @@ function serializeListItem(v: unknown): string {
38
38
  return escapeIfNeeded(String(v), LIST_SPECIAL);
39
39
  }
40
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
+
41
57
  function escapeIfNeeded(s: string, special: RegExp): string {
42
58
  if (s === "") return '""';
43
59
  if (!special.test(s)) return s;