@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.
@@ -1 +1 @@
1
- {"version":3,"file":"serialize.js","sourceRoot":"","sources":["../src/serialize.ts"],"names":[],"mappings":"AAAA,qEAAqE;AACrE,+DAA+D;AAC/D,EAAE;AACF,SAAS;AACT,0DAA0D;AAC1D,uBAAuB;AACvB,4EAA4E;AAC5E,0EAA0E;AAE1E,uEAAuE;AACvE,sEAAsE;AACtE,+CAA+C;AAC/C,MAAM,cAAc,GAAG,MAAM,CAAC;AAC9B,0EAA0E;AAC1E,MAAM,YAAY,GAAG,SAAS,CAAC;AAE/B,2EAA2E;AAC3E,kFAAkF;AAClF,kEAAkE;AAClE,MAAM,UAAU,OAAO,CAAC,IAAY,EAAE,IAAY;IAChD,OAAO,GAAG,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,IAAI,MAAM,CAAC,IAAI,CAAC,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,EAAE,CAAC;AAC3E,CAAC;AAED,MAAM,UAAU,cAAc,CAAC,CAAU;IACvC,IAAI,CAAC,KAAK,IAAI,IAAI,CAAC,KAAK,SAAS;QAAE,OAAO,MAAM,CAAC;IACjD,IAAI,CAAC,YAAY,IAAI;QAAE,OAAO,CAAC,CAAC,WAAW,EAAE,CAAC;IAC9C,IAAI,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC;QAAE,OAAO,IAAI,CAAC,CAAC,GAAG,CAAC,iBAAiB,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC;IACvE,IAAI,OAAO,CAAC,KAAK,SAAS;QAAE,OAAO,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,OAAO,CAAC;IACxD,IAAI,OAAO,CAAC,KAAK,QAAQ,IAAI,OAAO,CAAC,KAAK,QAAQ;QAAE,OAAO,MAAM,CAAC,CAAC,CAAC,CAAC;IACrE,OAAO,cAAc,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,cAAc,CAAC,CAAC;AACnD,CAAC;AAED,SAAS,iBAAiB,CAAC,CAAU;IACnC,IAAI,CAAC,KAAK,IAAI,IAAI,CAAC,KAAK,SAAS;QAAE,OAAO,MAAM,CAAC;IACjD,IAAI,CAAC,YAAY,IAAI;QAAE,OAAO,CAAC,CAAC,WAAW,EAAE,CAAC;IAC9C,IAAI,OAAO,CAAC,KAAK,SAAS;QAAE,OAAO,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,OAAO,CAAC;IACxD,IAAI,OAAO,CAAC,KAAK,QAAQ,IAAI,OAAO,CAAC,KAAK,QAAQ;QAAE,OAAO,MAAM,CAAC,CAAC,CAAC,CAAC;IACrE,OAAO,cAAc,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,YAAY,CAAC,CAAC;AACjD,CAAC;AAED,SAAS,cAAc,CAAC,CAAS,EAAE,OAAe;IAChD,IAAI,CAAC,KAAK,EAAE;QAAE,OAAO,IAAI,CAAC;IAC1B,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC;QAAE,OAAO,CAAC,CAAC;IAC/B,OAAO,IAAI,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,KAAK,CAAC,GAAG,CAAC;AAC9D,CAAC"}
1
+ {"version":3,"file":"serialize.js","sourceRoot":"","sources":["../src/serialize.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ikeboy003/cloudrest-client",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Typed query builder for cloudrest. PostgREST-grammar compatible.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -17,13 +17,15 @@
17
17
  "README.md"
18
18
  ],
19
19
  "scripts": {
20
- "build": "tsc -p tsconfig.json",
21
- "dev": "tsc -p tsconfig.json --watch"
20
+ "build": "tsc -p tsconfig.json && tsc-alias -p tsconfig.json",
21
+ "dev": "tsc -p tsconfig.json --watch",
22
+ "test": "node --test test/*.test.mjs"
22
23
  },
23
24
  "publishConfig": {
24
25
  "access": "public"
25
26
  },
26
27
  "devDependencies": {
28
+ "tsc-alias": "^1.8.17",
27
29
  "typescript": "^5.5.0"
28
30
  }
29
31
  }
package/src/client.ts CHANGED
@@ -1,7 +1,16 @@
1
- import type { AnyPaths, ClientOptions, RowOf, TableName } from "./types.js";
2
- import { QueryBuilder, type ExecContext } from "./query.js";
3
- import { InsertBuilder, UpdateBuilder, DeleteBuilder } from "./mutation.js";
4
- import { RpcCaller } from "./rpc.js";
1
+ import type { AnyPaths, ClientOptions, RowOf, TableName } from "@/types.js";
2
+ import { QueryBuilder, type ExecContext } from "@/query.js";
3
+ import { InsertBuilder, UpdateBuilder, DeleteBuilder } from "@/mutation.js";
4
+ import { RpcCaller } from "@/rpc.js";
5
+
6
+ export interface CopyOptions {
7
+ /** Target columns, in body order. Required (maps to `?columns=`). */
8
+ columns: string[];
9
+ /** Schema the table lives in (default "public"). */
10
+ schema?: string;
11
+ /** Wire format of `body` (default "csv"). NDJSON is transcoded server-side. */
12
+ format?: "csv" | "ndjson";
13
+ }
5
14
 
6
15
  export class CloudRestClient<P extends AnyPaths> {
7
16
  private readonly ctx: ExecContext;
@@ -50,6 +59,42 @@ export class CloudRestClient<P extends AnyPaths> {
50
59
  ): RpcCaller<Result> {
51
60
  return new RpcCaller<Result>(this.ctx, fn, args);
52
61
  }
62
+
63
+ /**
64
+ * Bulk-ingest via the server's COPY endpoint
65
+ * (`POST /_copy/{schema}/{table}?columns=`). `body` is CSV (with a header row)
66
+ * or NDJSON. Streams straight into `COPY` — far faster than row-by-row
67
+ * inserts for large loads. Resolves to `{ inserted: N }`.
68
+ */
69
+ async copy(
70
+ table: TableName<P>,
71
+ body: string | Uint8Array,
72
+ opts: CopyOptions,
73
+ ): Promise<{ inserted: number }> {
74
+ const schema = opts.schema ?? "public";
75
+ const cols = opts.columns.map(encodeURIComponent).join(",");
76
+ const url = `${this.ctx.baseUrl}/_copy/${schema}/${String(table)}?columns=${cols}`;
77
+ const headers: Record<string, string> = {
78
+ Accept: "application/json",
79
+ "Content-Type":
80
+ opts.format === "ndjson" ? "application/x-ndjson" : "text/csv",
81
+ ...this.ctx.headers,
82
+ };
83
+ const token = await this.ctx.getToken?.();
84
+ if (token) headers["Authorization"] = `Bearer ${token}`;
85
+
86
+ const res = await this.ctx.fetch(url, {
87
+ method: "POST",
88
+ headers,
89
+ body: body as BodyInit,
90
+ });
91
+ if (!res.ok) {
92
+ const errBody = await res.json().catch(() => null);
93
+ const { CloudRestError } = await import("@/errors.js");
94
+ throw new CloudRestError(res.status, res.statusText, errBody);
95
+ }
96
+ return (await res.json()) as { inserted: number };
97
+ }
53
98
  }
54
99
 
55
100
  export function createClient<P extends AnyPaths>(opts: ClientOptions): CloudRestClient<P> {
package/src/filter.ts CHANGED
@@ -1,57 +1,100 @@
1
- // Filter ops — equality, comparison, set membership, null checks, pattern match,
2
- // plus the `or` composite. (`and`, `fts`, `jsonpath` still to come.)
1
+ // Filter ops — the full PostgREST operator set + nestable and/or/not logic.
3
2
  //
4
- // Each filter contributes one entry to URLSearchParams as `column=op.value`.
3
+ // Each filter contributes one entry. A leaf renders as `column=op.value`; a
4
+ // logic group renders as `and=(child,child)` / `or=(...)`. As a CHILD inside a
5
+ // group, a leaf re-spells to `column.op.value` and a group to `and(...)`/`or(...)`,
6
+ // which is how PostgREST nests them.
5
7
 
6
- import { serializeValue } from "./serialize.js";
8
+ import { serializeValue, serializeArray, serializeRange } from "@/serialize.js";
7
9
 
8
10
  export type IsValue = "null" | "true" | "false" | "unknown";
9
11
 
10
12
  export interface FilterEntry {
13
+ /** Leaf: the column. Group: "and" | "or". */
11
14
  column: string;
15
+ /** Leaf: "op.value". Group: "(child,child,...)". */
12
16
  expr: string;
17
+ /** True for a logic group (and/or), so child-rendering omits the dot. */
18
+ group?: boolean;
13
19
  }
14
20
 
15
- export function fEq(column: string, value: unknown): FilterEntry {
16
- return { column, expr: `eq.${serializeValue(value)}` };
17
- }
18
- export function fNeq(column: string, value: unknown): FilterEntry {
19
- return { column, expr: `neq.${serializeValue(value)}` };
20
- }
21
- export function fGt(column: string, value: unknown): FilterEntry {
22
- return { column, expr: `gt.${serializeValue(value)}` };
23
- }
24
- export function fGte(column: string, value: unknown): FilterEntry {
25
- return { column, expr: `gte.${serializeValue(value)}` };
26
- }
27
- export function fLt(column: string, value: unknown): FilterEntry {
28
- return { column, expr: `lt.${serializeValue(value)}` };
29
- }
30
- export function fLte(column: string, value: unknown): FilterEntry {
31
- return { column, expr: `lte.${serializeValue(value)}` };
32
- }
33
- export function fLike(column: string, pattern: string): FilterEntry {
34
- return { column, expr: `like.${serializeValue(pattern)}` };
35
- }
36
- export function fIlike(column: string, pattern: string): FilterEntry {
37
- return { column, expr: `ilike.${serializeValue(pattern)}` };
38
- }
39
- export function fIn(column: string, values: readonly unknown[]): FilterEntry {
40
- return { column, expr: `in.${serializeValue(values)}` };
41
- }
42
- // `is` is the correct way to check NULL / boolean `eq.null` is a no-op in SQL.
43
- export function fIs(column: string, value: IsValue): FilterEntry {
44
- return { column, expr: `is.${value}` };
45
- }
46
- // Negate any single-op filter: PostgREST prefixes the operator with `not.`
47
- // e.g. `column=not.is.null`, `column=not.eq.5`.
21
+ const leaf = (column: string, expr: string): FilterEntry => ({ column, expr });
22
+
23
+ // ── comparison / equality ───────────────────────────────────────────────────
24
+ export const fEq = (c: string, v: unknown): FilterEntry => leaf(c, `eq.${serializeValue(v)}`);
25
+ export const fNeq = (c: string, v: unknown): FilterEntry => leaf(c, `neq.${serializeValue(v)}`);
26
+ export const fGt = (c: string, v: unknown): FilterEntry => leaf(c, `gt.${serializeValue(v)}`);
27
+ export const fGte = (c: string, v: unknown): FilterEntry => leaf(c, `gte.${serializeValue(v)}`);
28
+ export const fLt = (c: string, v: unknown): FilterEntry => leaf(c, `lt.${serializeValue(v)}`);
29
+ export const fLte = (c: string, v: unknown): FilterEntry => leaf(c, `lte.${serializeValue(v)}`);
30
+
31
+ // ── pattern match ───────────────────────────────────────────────────────────
32
+ export const fLike = (c: string, p: string): FilterEntry => leaf(c, `like.${serializeValue(p)}`);
33
+ export const fIlike = (c: string, p: string): FilterEntry => leaf(c, `ilike.${serializeValue(p)}`);
34
+ /** POSIX regex (`~`). */
35
+ export const fMatch = (c: string, p: string): FilterEntry => leaf(c, `match.${serializeValue(p)}`);
36
+ /** Case-insensitive POSIX regex (`~*`). */
37
+ export const fImatch = (c: string, p: string): FilterEntry => leaf(c, `imatch.${serializeValue(p)}`);
38
+
39
+ // ── set membership / null / distinct ────────────────────────────────────────
40
+ export const fIn = (c: string, vals: readonly unknown[]): FilterEntry => leaf(c, `in.${serializeValue(vals)}`);
41
+ // `is` is the correct NULL / boolean check — `eq.null` is a SQL no-op.
42
+ export const fIs = (c: string, v: IsValue): FilterEntry => leaf(c, `is.${v}`);
43
+ /** `IS DISTINCT FROM` — null-safe inequality. */
44
+ export const fIsDistinct = (c: string, v: unknown): FilterEntry => leaf(c, `isdistinct.${serializeValue(v)}`);
45
+
46
+ // ── full-text search ────────────────────────────────────────────────────────
47
+ // Optional text-search config (e.g. "english") rides in parens: `fts(english).query`.
48
+ const ftsOp = (op: string) => (c: string, query: string, config?: string): FilterEntry =>
49
+ leaf(c, config ? `${op}(${config}).${serializeValue(query)}` : `${op}.${serializeValue(query)}`);
50
+ /** `to_tsquery` full-text search. */
51
+ export const fFts = ftsOp("fts");
52
+ /** `plainto_tsquery`. */
53
+ export const fPlfts = ftsOp("plfts");
54
+ /** `phraseto_tsquery`. */
55
+ export const fPhfts = ftsOp("phfts");
56
+ /** `websearch_to_tsquery`. */
57
+ export const fWfts = ftsOp("wfts");
58
+
59
+ // ── array / range containment + adjacency ───────────────────────────────────
60
+ /** contains `@>` (array/range/jsonb). */
61
+ export const fCs = (c: string, v: readonly unknown[]): FilterEntry => leaf(c, `cs.${serializeArray(v)}`);
62
+ /** contained-in `<@`. */
63
+ export const fCd = (c: string, v: readonly unknown[]): FilterEntry => leaf(c, `cd.${serializeArray(v)}`);
64
+ /** overlap `&&` (array or range). */
65
+ export const fOv = (c: string, v: readonly unknown[] | string): FilterEntry =>
66
+ leaf(c, `ov.${Array.isArray(v) ? serializeArray(v) : serializeRange(v)}`);
67
+ /** strictly-left-of `<<`. */
68
+ export const fSl = (c: string, range: unknown): FilterEntry => leaf(c, `sl.${serializeRange(range)}`);
69
+ /** strictly-right-of `>>`. */
70
+ export const fSr = (c: string, range: unknown): FilterEntry => leaf(c, `sr.${serializeRange(range)}`);
71
+ /** does-not-extend-to-the-right-of `&<`. */
72
+ export const fNxr = (c: string, range: unknown): FilterEntry => leaf(c, `nxr.${serializeRange(range)}`);
73
+ /** does-not-extend-to-the-left-of `&>`. */
74
+ export const fNxl = (c: string, range: unknown): FilterEntry => leaf(c, `nxl.${serializeRange(range)}`);
75
+ /** adjacent `-|-`. */
76
+ export const fAdj = (c: string, range: unknown): FilterEntry => leaf(c, `adj.${serializeRange(range)}`);
77
+
78
+ // ── generic escape hatch ────────────────────────────────────────────────────
79
+ /** Any operator + already-serialized value, e.g. `fFilter("data->>k", "eq", "x")`. */
80
+ export const fFilter = (c: string, op: string, value: string): FilterEntry => leaf(c, `${op}.${value}`);
81
+
82
+ // ── negation + logic groups (nestable) ──────────────────────────────────────
83
+ /** Negate any single-op filter: `column=not.is.null`, `column=not.eq.5`. */
48
84
  export function fNot(entry: FilterEntry): FilterEntry {
49
85
  return { column: entry.column, expr: `not.${entry.expr}` };
50
86
  }
51
- // Composite OR: `or=(title.ilike.*x*,author.ilike.*x*)`. Each child is a normal
52
- // FilterEntry (`column` + `op.value`) re-spelled in the dotted inner grammar
53
- // `column.op.value`. Compose children from the same f* helpers above.
87
+
88
+ /** Render an entry as a CHILD inside a logic group. */
89
+ function renderChild(e: FilterEntry): string {
90
+ return e.group ? `${e.column}${e.expr}` : `${e.column}.${e.expr}`;
91
+ }
92
+
93
+ /** Composite AND — `and=(a.eq.1,b.gt.2)`. Children may be leaves or groups (nests). */
94
+ export function fAnd(entries: readonly FilterEntry[]): FilterEntry {
95
+ return { column: "and", expr: `(${entries.map(renderChild).join(",")})`, group: true };
96
+ }
97
+ /** Composite OR — `or=(a.eq.1,b.gt.2)`. Children may be leaves or groups (nests). */
54
98
  export function fOr(entries: readonly FilterEntry[]): FilterEntry {
55
- const inner = entries.map((e) => `${e.column}.${e.expr}`).join(",");
56
- return { column: "or", expr: `(${inner})` };
99
+ return { column: "or", expr: `(${entries.map(renderChild).join(",")})`, group: true };
57
100
  }
package/src/index.ts CHANGED
@@ -1,24 +1,32 @@
1
- export { createClient, CloudRestClient } from "./client.js";
2
- export { CloudRestError } from "./errors.js";
3
- export { QueryBuilder } from "./query.js";
4
- export { InsertBuilder, UpdateBuilder, DeleteBuilder } from "./mutation.js";
5
- export { RpcCaller } from "./rpc.js";
1
+ export { createClient, CloudRestClient } from "@/client.js";
2
+ export type { CopyOptions } from "@/client.js";
3
+ export { CloudRestError } from "@/errors.js";
4
+ export { QueryBuilder } from "@/query.js";
5
+ export type { OrderOptions, CountStrategy, SingleQuery, PagedQuery } from "@/query.js";
6
+ export { InsertBuilder, UpdateBuilder, DeleteBuilder } from "@/mutation.js";
7
+ export { RpcCaller } from "@/rpc.js";
6
8
  export type {
7
9
  AnyPaths,
8
10
  ClientOptions,
9
11
  RowOf,
10
12
  TableName,
11
13
  TokenGetter,
12
- } from "./types.js";
13
- export type { OrderOptions, SingleQuery, PagedQuery } from "./query.js";
14
- export type { IsValue, FilterEntry } from "./filter.js";
14
+ } from "@/types.js";
15
+ export type { IsValue, FilterEntry } from "@/filter.js";
15
16
 
16
- // Bare condition builders for composing `.or(...)`. Each returns a FilterEntry:
17
- // db.from("catalog_public").or(cond.ilike("title","*x*"), cond.ilike("author","*x*"))
18
- import {
19
- fEq, fNeq, fGt, fGte, fLt, fLte, fLike, fIlike, fIn, fIs, fNot,
20
- } from "./filter.js";
17
+ // Bare condition builders for composing `.and(...)` / `.or(...)` (nestable).
18
+ // db.from("books").or(cond.ilike("title","*x*"), cond.ilike("author","*x*"))
19
+ // db.from("books").and(cond.gte("year",2000), cond.or(cond.eq("a",1), cond.eq("b",2)))
20
+ import * as F from "@/filter.js";
21
+ import type { FilterEntry } from "@/filter.js";
21
22
  export const cond = {
22
- eq: fEq, neq: fNeq, gt: fGt, gte: fGte, lt: fLt, lte: fLte,
23
- like: fLike, ilike: fIlike, in: fIn, is: fIs, not: fNot,
23
+ eq: F.fEq, neq: F.fNeq, gt: F.fGt, gte: F.fGte, lt: F.fLt, lte: F.fLte,
24
+ like: F.fLike, ilike: F.fIlike, match: F.fMatch, imatch: F.fImatch,
25
+ in: F.fIn, is: F.fIs, isDistinct: F.fIsDistinct,
26
+ fts: F.fFts, plfts: F.fPlfts, phfts: F.fPhfts, wfts: F.fWfts,
27
+ cs: F.fCs, cd: F.fCd, ov: F.fOv, sl: F.fSl, sr: F.fSr, nxr: F.fNxr, nxl: F.fNxl, adj: F.fAdj,
28
+ filter: F.fFilter, not: F.fNot,
29
+ // Variadic so they compose ergonomically + nest: cond.and(cond.eq(..), cond.or(..)).
30
+ and: (...e: FilterEntry[]): FilterEntry => F.fAnd(e),
31
+ or: (...e: FilterEntry[]): FilterEntry => F.fOr(e),
24
32
  } as const;
package/src/mutation.ts CHANGED
@@ -1,29 +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";
13
- import { joinUrl } from "./serialize.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";
14
14
 
15
- 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> {
16
57
  protected filters: FilterEntry[] = [];
17
- eq(col: string, v: unknown): this { this.filters.push(fEq(col, v)); return this; }
18
- neq(col: string, v: unknown): this { this.filters.push(fNeq(col, v)); return this; }
19
- gt(col: string, v: unknown): this { this.filters.push(fGt(col, v)); return this; }
20
- gte(col: string, v: unknown): this { this.filters.push(fGte(col, v)); return this; }
21
- lt(col: string, v: unknown): this { this.filters.push(fLt(col, v)); return this; }
22
- lte(col: string, v: unknown): this { this.filters.push(fLte(col, v)); return this; }
23
- like(col: string, pat: string): this { this.filters.push(fLike(col, pat)); return this; }
24
- ilike(col: string, pat: string): this { this.filters.push(fIlike(col, pat)); return this; }
25
- in(col: string, vals: readonly unknown[]): this { this.filters.push(fIn(col, vals)); return this; }
26
- 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
+ }
27
94
  }
28
95
 
29
96
  async function send(
@@ -31,7 +98,7 @@ async function send(
31
98
  method: "POST" | "PATCH" | "DELETE",
32
99
  url: string,
33
100
  body: unknown | undefined,
34
- returnRepresentation: boolean,
101
+ prefer: string[],
35
102
  ): Promise<unknown> {
36
103
  // A FormData body is a multipart media write — let the runtime set the
37
104
  // multipart Content-Type (with boundary); don't JSON-encode or force a type.
@@ -41,7 +108,7 @@ async function send(
41
108
  ...(isForm ? {} : { "Content-Type": "application/json" }),
42
109
  ...ctx.headers,
43
110
  };
44
- if (returnRepresentation) headers["Prefer"] = "return=representation";
111
+ if (prefer.length) headers["Prefer"] = prefer.join(",");
45
112
  const token = await ctx.getToken?.();
46
113
  if (token) headers["Authorization"] = `Bearer ${token}`;
47
114
 
@@ -54,7 +121,7 @@ async function send(
54
121
  });
55
122
  if (!res.ok) {
56
123
  const errBody = await res.json().catch(() => null);
57
- const { CloudRestError } = await import("./errors.js");
124
+ const { CloudRestError } = await import("@/errors.js");
58
125
  throw new CloudRestError(res.status, res.statusText, errBody);
59
126
  }
60
127
  if (res.status === 204) return [];
@@ -62,39 +129,62 @@ async function send(
62
129
  }
63
130
 
64
131
  export class InsertBuilder<P extends AnyPaths, T extends TableName<P>, Row = RowOf<P, T>>
132
+ extends WriteTuning<Row>
65
133
  implements PromiseLike<Row[]>
66
134
  {
67
135
  private rows: Partial<Row>[];
68
- private returnRows = false;
69
136
  private files: Record<string, Blob> = {};
137
+ private onConflictCols?: string;
138
+ private columnsList?: string;
70
139
 
71
140
  constructor(
72
141
  private readonly ctx: ExecContext,
73
142
  private readonly table: T,
74
143
  rows: Partial<Row> | Partial<Row>[],
75
144
  ) {
145
+ super();
76
146
  this.rows = Array.isArray(rows) ? rows : [rows];
77
147
  }
78
148
 
79
- /** Return inserted rows (adds Prefer: return=representation). */
80
- returning(): this {
81
- 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";
156
+ return this;
157
+ }
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;
82
168
  return this;
83
169
  }
84
170
 
85
171
  /**
86
- * Attach a file for a media column. Switches the insert to a multipart
87
- * write: the row's scalar columns ride as a `payload` JSON part and each
88
- * file as a part named after its column. The server's media stage stores
89
- * the bytes and puts a reference in the column. Single-row inserts only.
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.
90
176
  */
91
177
  attach(column: string, file: Blob): this {
92
178
  this.files[column] = file;
93
179
  return this;
94
180
  }
95
181
 
96
- private hasFiles(): boolean {
97
- return Object.keys(this.files).length > 0;
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}` : ""}`;
98
188
  }
99
189
 
100
190
  private formBody(): FormData {
@@ -106,9 +196,7 @@ export class InsertBuilder<P extends AnyPaths, T extends TableName<P>, Row = Row
106
196
  "payload",
107
197
  new Blob([JSON.stringify(this.rows[0])], { type: "application/json" }),
108
198
  );
109
- for (const [column, file] of Object.entries(this.files)) {
110
- fd.append(column, file);
111
- }
199
+ for (const [column, file] of Object.entries(this.files)) fd.append(column, file);
112
200
  return fd;
113
201
  }
114
202
 
@@ -116,9 +204,9 @@ export class InsertBuilder<P extends AnyPaths, T extends TableName<P>, Row = Row
116
204
  onfulfilled?: ((value: Row[]) => R1 | PromiseLike<R1>) | null,
117
205
  onrejected?: ((reason: unknown) => R2 | PromiseLike<R2>) | null,
118
206
  ): PromiseLike<R1 | R2> {
119
- const url = `${joinUrl(this.ctx.baseUrl, String(this.table))}`;
120
- const body = this.hasFiles() ? this.formBody() : this.rows;
121
- return send(this.ctx, "POST", url, body, 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())
122
210
  .then((v) => v as Row[])
123
211
  .then(onfulfilled, onrejected as never);
124
212
  }
@@ -128,8 +216,6 @@ export class UpdateBuilder<P extends AnyPaths, T extends TableName<P>, Row = Row
128
216
  extends FilterableMutation<Row>
129
217
  implements PromiseLike<Row[]>
130
218
  {
131
- private returnRows = false;
132
-
133
219
  constructor(
134
220
  private readonly ctx: ExecContext,
135
221
  private readonly table: T,
@@ -138,15 +224,8 @@ export class UpdateBuilder<P extends AnyPaths, T extends TableName<P>, Row = Row
138
224
  super();
139
225
  }
140
226
 
141
- returning(): this {
142
- this.returnRows = true;
143
- return this;
144
- }
145
-
146
227
  private url(): string {
147
- const params = new URLSearchParams();
148
- for (const f of this.filters) params.append(f.column, f.expr);
149
- const qs = params.toString();
228
+ const qs = this.filterQs();
150
229
  return `${joinUrl(this.ctx.baseUrl, String(this.table))}${qs ? `?${qs}` : ""}`;
151
230
  }
152
231
 
@@ -154,7 +233,7 @@ export class UpdateBuilder<P extends AnyPaths, T extends TableName<P>, Row = Row
154
233
  onfulfilled?: ((value: Row[]) => R1 | PromiseLike<R1>) | null,
155
234
  onrejected?: ((reason: unknown) => R2 | PromiseLike<R2>) | null,
156
235
  ): PromiseLike<R1 | R2> {
157
- return send(this.ctx, "PATCH", this.url(), this.patch, this.returnRows)
236
+ return send(this.ctx, "PATCH", this.url(), this.patch, this.prefer())
158
237
  .then((v) => v as Row[])
159
238
  .then(onfulfilled, onrejected as never);
160
239
  }
@@ -164,8 +243,6 @@ export class DeleteBuilder<P extends AnyPaths, T extends TableName<P>, Row = Row
164
243
  extends FilterableMutation<Row>
165
244
  implements PromiseLike<Row[]>
166
245
  {
167
- private returnRows = false;
168
-
169
246
  constructor(
170
247
  private readonly ctx: ExecContext,
171
248
  private readonly table: T,
@@ -173,15 +250,8 @@ export class DeleteBuilder<P extends AnyPaths, T extends TableName<P>, Row = Row
173
250
  super();
174
251
  }
175
252
 
176
- returning(): this {
177
- this.returnRows = true;
178
- return this;
179
- }
180
-
181
253
  private url(): string {
182
- const params = new URLSearchParams();
183
- for (const f of this.filters) params.append(f.column, f.expr);
184
- const qs = params.toString();
254
+ const qs = this.filterQs();
185
255
  return `${joinUrl(this.ctx.baseUrl, String(this.table))}${qs ? `?${qs}` : ""}`;
186
256
  }
187
257
 
@@ -189,7 +259,7 @@ export class DeleteBuilder<P extends AnyPaths, T extends TableName<P>, Row = Row
189
259
  onfulfilled?: ((value: Row[]) => R1 | PromiseLike<R1>) | null,
190
260
  onrejected?: ((reason: unknown) => R2 | PromiseLike<R2>) | null,
191
261
  ): PromiseLike<R1 | R2> {
192
- return send(this.ctx, "DELETE", this.url(), undefined, this.returnRows)
262
+ return send(this.ctx, "DELETE", this.url(), undefined, this.prefer())
193
263
  .then((v) => v as Row[])
194
264
  .then(onfulfilled, onrejected as never);
195
265
  }