@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/dist/client.d.ts +17 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +30 -0
- package/dist/client.js.map +1 -1
- package/dist/filter.d.ts +51 -10
- package/dist/filter.d.ts.map +1 -1
- package/dist/filter.js +71 -36
- package/dist/filter.js.map +1 -1
- package/dist/index.d.ts +36 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -1
- package/dist/mutation.d.ts +82 -20
- package/dist/mutation.d.ts.map +1 -1
- package/dist/mutation.js +160 -49
- package/dist/mutation.js.map +1 -1
- package/dist/query.d.ts +42 -1
- package/dist/query.d.ts.map +1 -1
- package/dist/query.js +84 -17
- package/dist/query.js.map +1 -1
- package/dist/rpc.d.ts.map +1 -1
- package/dist/rpc.js +2 -1
- package/dist/rpc.js.map +1 -1
- package/dist/serialize.d.ts +4 -0
- package/dist/serialize.d.ts.map +1 -1
- package/dist/serialize.js +21 -1
- package/dist/serialize.js.map +1 -1
- package/dist/types.d.ts +1 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +10 -4
- package/src/client.ts +49 -4
- package/src/filter.ts +86 -36
- package/src/index.ts +26 -8
- package/src/mutation.ts +163 -55
- package/src/query.ts +95 -24
- package/src/rpc.ts +4 -3
- package/src/serialize.ts +24 -1
- package/src/types.ts +3 -1
package/src/mutation.ts
CHANGED
|
@@ -1,28 +1,96 @@
|
|
|
1
|
-
// Mutations: insert / update / delete
|
|
2
|
-
//
|
|
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
|
|
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 "
|
|
8
|
-
import
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
} from "
|
|
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
|
-
|
|
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(
|
|
17
|
-
neq(
|
|
18
|
-
gt(
|
|
19
|
-
gte(
|
|
20
|
-
lt(
|
|
21
|
-
lte(
|
|
22
|
-
like(
|
|
23
|
-
ilike(
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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("
|
|
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
|
|
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
|
-
/**
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|
83
|
-
|
|
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
|
|
110
|
-
|
|
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.
|
|
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
|
|
145
|
-
|
|
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.
|
|
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 "
|
|
8
|
-
import
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
} from "
|
|
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
|
-
|
|
59
|
-
|
|
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
|
|
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
|
|
142
|
+
return `${joinUrl(this.ctx.baseUrl, String(this.table))}${qs ? `?${qs}` : ""}`;
|
|
97
143
|
}
|
|
98
144
|
|
|
99
|
-
private
|
|
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("
|
|
170
|
+
const { CloudRestError } = await import("@/errors.js");
|
|
116
171
|
throw new CloudRestError(res.status, res.statusText, body);
|
|
117
172
|
}
|
|
118
|
-
|
|
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("
|
|
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 "
|
|
11
|
-
import { CloudRestError } from "
|
|
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 =
|
|
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
|
-
|
|
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>;
|