@ikeboy003/cloudrest-client 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client.d.ts +20 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +40 -0
- package/dist/client.js.map +1 -0
- package/dist/errors.d.ts +7 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +13 -0
- package/dist/errors.js.map +1 -0
- package/dist/filter.d.ts +17 -0
- package/dist/filter.d.ts.map +1 -0
- package/dist/filter.js +42 -0
- package/dist/filter.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/mutation.d.ts +47 -0
- package/dist/mutation.d.ts.map +1 -0
- package/dist/mutation.js +121 -0
- package/dist/mutation.js.map +1 -0
- package/dist/query.d.ts +56 -0
- package/dist/query.d.ts.map +1 -0
- package/dist/query.js +120 -0
- package/dist/query.js.map +1 -0
- package/dist/rpc.d.ts +10 -0
- package/dist/rpc.d.ts.map +1 -0
- package/dist/rpc.js +50 -0
- package/dist/rpc.js.map +1 -0
- package/dist/serialize.d.ts +2 -0
- package/dist/serialize.d.ts.map +1 -0
- package/dist/serialize.js +46 -0
- package/dist/serialize.js.map +1 -0
- package/dist/types.d.ts +21 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -0
- package/package.json +25 -0
- package/src/client.ts +57 -0
- package/src/errors.ts +10 -0
- package/src/filter.ts +50 -0
- package/src/index.ts +14 -0
- package/src/mutation.ts +158 -0
- package/src/query.ts +152 -0
- package/src/rpc.ts +54 -0
- package/src/serialize.ts +38 -0
- package/src/types.ts +31 -0
package/dist/rpc.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// RPC caller. Posts JSON args to `/rpc/<name>` and returns the parsed body.
|
|
2
|
+
// Awaitable directly (PromiseLike), matching the QueryBuilder ergonomics:
|
|
3
|
+
//
|
|
4
|
+
// const result = await db.rpc("update_mastery", { p_concept_id, p_correct: true });
|
|
5
|
+
//
|
|
6
|
+
// PostgREST RPC convention:
|
|
7
|
+
// - POST /rpc/<fn> body = { argName: value, ... }
|
|
8
|
+
// - Response is the function return — scalar, row, or set-of rows.
|
|
9
|
+
import { CloudRestError } from "./errors.js";
|
|
10
|
+
export class RpcCaller {
|
|
11
|
+
ctx;
|
|
12
|
+
fn;
|
|
13
|
+
args;
|
|
14
|
+
constructor(ctx, fn, args = {}) {
|
|
15
|
+
this.ctx = ctx;
|
|
16
|
+
this.fn = fn;
|
|
17
|
+
this.args = args;
|
|
18
|
+
}
|
|
19
|
+
async exec() {
|
|
20
|
+
const url = `${this.ctx.baseUrl}/rpc/${this.fn}`;
|
|
21
|
+
const headers = {
|
|
22
|
+
Accept: "application/json",
|
|
23
|
+
"Content-Type": "application/json",
|
|
24
|
+
...this.ctx.headers,
|
|
25
|
+
};
|
|
26
|
+
const token = await this.ctx.getToken?.();
|
|
27
|
+
if (token)
|
|
28
|
+
headers["Authorization"] = `Bearer ${token}`;
|
|
29
|
+
const res = await this.ctx.fetch(url, {
|
|
30
|
+
method: "POST",
|
|
31
|
+
headers,
|
|
32
|
+
body: JSON.stringify(this.args),
|
|
33
|
+
});
|
|
34
|
+
if (!res.ok) {
|
|
35
|
+
const body = await res.text().catch(() => "");
|
|
36
|
+
throw new CloudRestError(res.status, res.statusText, body);
|
|
37
|
+
}
|
|
38
|
+
// Functions returning void produce 204; everything else returns JSON.
|
|
39
|
+
if (res.status === 204)
|
|
40
|
+
return undefined;
|
|
41
|
+
const text = await res.text();
|
|
42
|
+
if (!text)
|
|
43
|
+
return undefined;
|
|
44
|
+
return JSON.parse(text);
|
|
45
|
+
}
|
|
46
|
+
then(onfulfilled, onrejected) {
|
|
47
|
+
return this.exec().then(onfulfilled, onrejected);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
//# sourceMappingURL=rpc.js.map
|
package/dist/rpc.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rpc.js","sourceRoot":"","sources":["../src/rpc.ts"],"names":[],"mappings":"AAAA,4EAA4E;AAC5E,0EAA0E;AAC1E,EAAE;AACF,sFAAsF;AACtF,EAAE;AACF,4BAA4B;AAC5B,qDAAqD;AACrD,qEAAqE;AAGrE,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAE7C,MAAM,OAAO,SAAS;IAED;IACA;IACA;IAHnB,YACmB,GAAgB,EAChB,EAAU,EACV,OAAgC,EAAE;QAFlC,QAAG,GAAH,GAAG,CAAa;QAChB,OAAE,GAAF,EAAE,CAAQ;QACV,SAAI,GAAJ,IAAI,CAA8B;IAClD,CAAC;IAEI,KAAK,CAAC,IAAI;QAChB,MAAM,GAAG,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,OAAO,QAAQ,IAAI,CAAC,EAAE,EAAE,CAAC;QACjD,MAAM,OAAO,GAA2B;YACtC,MAAM,EAAE,kBAAkB;YAC1B,cAAc,EAAE,kBAAkB;YAClC,GAAG,IAAI,CAAC,GAAG,CAAC,OAAO;SACpB,CAAC;QACF,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,GAAG,CAAC,QAAQ,EAAE,EAAE,CAAC;QAC1C,IAAI,KAAK;YAAE,OAAO,CAAC,eAAe,CAAC,GAAG,UAAU,KAAK,EAAE,CAAC;QAExD,MAAM,GAAG,GAAG,MAAM,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,GAAG,EAAE;YACpC,MAAM,EAAE,MAAM;YACd,OAAO;YACP,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC;SAChC,CAAC,CAAC;QAEH,IAAI,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC;YACZ,MAAM,IAAI,GAAG,MAAM,GAAG,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,EAAE,CAAC,CAAC;YAC9C,MAAM,IAAI,cAAc,CAAC,GAAG,CAAC,MAAM,EAAE,GAAG,CAAC,UAAU,EAAE,IAAI,CAAC,CAAC;QAC7D,CAAC;QAED,sEAAsE;QACtE,IAAI,GAAG,CAAC,MAAM,KAAK,GAAG;YAAE,OAAO,SAAmB,CAAC;QACnD,MAAM,IAAI,GAAG,MAAM,GAAG,CAAC,IAAI,EAAE,CAAC;QAC9B,IAAI,CAAC,IAAI;YAAE,OAAO,SAAmB,CAAC;QACtC,OAAO,IAAI,CAAC,KAAK,CAAC,IAAI,CAAW,CAAC;IACpC,CAAC;IAED,IAAI,CACF,WAA8D,EAC9D,UAA+D;QAE/D,OAAO,IAAI,CAAC,IAAI,EAAE,CAAC,IAAI,CAAC,WAAW,EAAE,UAAmB,CAAC,CAAC;IAC5D,CAAC;CACF"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"serialize.d.ts","sourceRoot":"","sources":["../src/serialize.ts"],"names":[],"mappings":"AAgBA,wBAAgB,cAAc,CAAC,CAAC,EAAE,OAAO,GAAG,MAAM,CAOjD"}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// PostgREST-grammar value serialization. Mirrors the server's parser
|
|
2
|
+
// expectations: see cloudrest-workerd/src/parser/operators.ts.
|
|
3
|
+
//
|
|
4
|
+
// Rules:
|
|
5
|
+
// - null → "null" (use with `is.null`, never `eq.null`)
|
|
6
|
+
// - Date → ISO string
|
|
7
|
+
// - array → "(a,b,c)" with each element quoted if it contains special chars
|
|
8
|
+
// - string with special chars → wrapped in double quotes, internal " → \"
|
|
9
|
+
// Scalar values: only `,` and `"` need quoting. Parens / dots / spaces
|
|
10
|
+
// are fine bare — wrapping them in quotes makes the server search for
|
|
11
|
+
// the literal quoted string and return 0 rows.
|
|
12
|
+
const SCALAR_SPECIAL = /[,"]/;
|
|
13
|
+
// List items are inside `(a,b,c)`, so `(`, `)`, and `,` all need quoting.
|
|
14
|
+
const LIST_SPECIAL = /[(),."]/;
|
|
15
|
+
export function serializeValue(v) {
|
|
16
|
+
if (v === null || v === undefined)
|
|
17
|
+
return "null";
|
|
18
|
+
if (v instanceof Date)
|
|
19
|
+
return v.toISOString();
|
|
20
|
+
if (Array.isArray(v))
|
|
21
|
+
return `(${v.map(serializeListItem).join(",")})`;
|
|
22
|
+
if (typeof v === "boolean")
|
|
23
|
+
return v ? "true" : "false";
|
|
24
|
+
if (typeof v === "number" || typeof v === "bigint")
|
|
25
|
+
return String(v);
|
|
26
|
+
return escapeIfNeeded(String(v), SCALAR_SPECIAL);
|
|
27
|
+
}
|
|
28
|
+
function serializeListItem(v) {
|
|
29
|
+
if (v === null || v === undefined)
|
|
30
|
+
return "null";
|
|
31
|
+
if (v instanceof Date)
|
|
32
|
+
return v.toISOString();
|
|
33
|
+
if (typeof v === "boolean")
|
|
34
|
+
return v ? "true" : "false";
|
|
35
|
+
if (typeof v === "number" || typeof v === "bigint")
|
|
36
|
+
return String(v);
|
|
37
|
+
return escapeIfNeeded(String(v), LIST_SPECIAL);
|
|
38
|
+
}
|
|
39
|
+
function escapeIfNeeded(s, special) {
|
|
40
|
+
if (s === "")
|
|
41
|
+
return '""';
|
|
42
|
+
if (!special.test(s))
|
|
43
|
+
return s;
|
|
44
|
+
return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=serialize.js.map
|
|
@@ -0,0 +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,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"}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type AnyPaths = Record<string, any>;
|
|
2
|
+
export type RowOf<P extends AnyPaths, T extends keyof P> = P[T] extends {
|
|
3
|
+
get: {
|
|
4
|
+
responses: {
|
|
5
|
+
200: {
|
|
6
|
+
content: {
|
|
7
|
+
"application/json": infer Body;
|
|
8
|
+
};
|
|
9
|
+
};
|
|
10
|
+
};
|
|
11
|
+
};
|
|
12
|
+
} ? Body extends ReadonlyArray<infer Item> ? Item : Body : never;
|
|
13
|
+
export type TableName<P extends AnyPaths> = Extract<keyof P, string>;
|
|
14
|
+
export type TokenGetter = () => string | null | Promise<string | null>;
|
|
15
|
+
export interface ClientOptions {
|
|
16
|
+
baseUrl: string;
|
|
17
|
+
getToken?: TokenGetter;
|
|
18
|
+
headers?: Record<string, string>;
|
|
19
|
+
fetch?: typeof fetch;
|
|
20
|
+
}
|
|
21
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAOA,MAAM,MAAM,QAAQ,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;AAK3C,MAAM,MAAM,KAAK,CAAC,CAAC,SAAS,QAAQ,EAAE,CAAC,SAAS,MAAM,CAAC,IACrD,CAAC,CAAC,CAAC,CAAC,SAAS;IAAE,GAAG,EAAE;QAAE,SAAS,EAAE;YAAE,GAAG,EAAE;gBAAE,OAAO,EAAE;oBAAE,kBAAkB,EAAE,MAAM,IAAI,CAAA;iBAAE,CAAA;aAAE,CAAA;SAAE,CAAA;KAAE,CAAA;CAAE,GACzF,IAAI,SAAS,aAAa,CAAC,MAAM,IAAI,CAAC,GACpC,IAAI,GACJ,IAAI,GACN,KAAK,CAAC;AAGZ,MAAM,MAAM,SAAS,CAAC,CAAC,SAAS,QAAQ,IAAI,OAAO,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC,CAAC;AAGrE,MAAM,MAAM,WAAW,GAAG,MAAM,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;AAEvE,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,WAAW,CAAC;IACvB,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,KAAK,CAAC,EAAE,OAAO,KAAK,CAAC;CACtB"}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Generic helpers that let callers parameterize the client with their
|
|
2
|
+
// openapi-typescript-generated `paths` interface.
|
|
3
|
+
//
|
|
4
|
+
// Usage:
|
|
5
|
+
// import type { paths } from "./schema";
|
|
6
|
+
// const db = createClient<paths>({ baseUrl, getToken });
|
|
7
|
+
export {};
|
|
8
|
+
//# sourceMappingURL=types.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,sEAAsE;AACtE,kDAAkD;AAClD,EAAE;AACF,SAAS;AACT,2CAA2C;AAC3C,2DAA2D"}
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ikeboy003/cloudrest-client",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Typed query builder for cloudrest. PostgREST-grammar compatible.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": ["dist", "src", "README.md"],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsc -p tsconfig.json",
|
|
17
|
+
"dev": "tsc -p tsconfig.json --watch"
|
|
18
|
+
},
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"typescript": "^5.5.0"
|
|
24
|
+
}
|
|
25
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
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 class CloudRestClient<P extends AnyPaths> {
|
|
7
|
+
private readonly ctx: ExecContext;
|
|
8
|
+
|
|
9
|
+
constructor(opts: ClientOptions) {
|
|
10
|
+
// Trim trailing slash so we can always use `${baseUrl}/table` cleanly.
|
|
11
|
+
const baseUrl = opts.baseUrl.replace(/\/+$/, "");
|
|
12
|
+
this.ctx = {
|
|
13
|
+
baseUrl,
|
|
14
|
+
getToken: opts.getToken,
|
|
15
|
+
fetch: opts.fetch ?? globalThis.fetch.bind(globalThis),
|
|
16
|
+
headers: opts.headers ?? {},
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Start a typed read query against `table`. */
|
|
21
|
+
from<T extends TableName<P>>(table: T): QueryBuilder<P, T> {
|
|
22
|
+
return new QueryBuilder<P, T>(this.ctx, table);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Insert one row or many rows into `table`. Add `.returning()` to get them back. */
|
|
26
|
+
insert<T extends TableName<P>>(
|
|
27
|
+
table: T,
|
|
28
|
+
rows: Partial<RowOf<P, T>> | Partial<RowOf<P, T>>[],
|
|
29
|
+
): InsertBuilder<P, T> {
|
|
30
|
+
return new InsertBuilder<P, T>(this.ctx, table, rows);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Update rows in `table` matching subsequent filters. */
|
|
34
|
+
update<T extends TableName<P>>(
|
|
35
|
+
table: T,
|
|
36
|
+
patch: Partial<RowOf<P, T>>,
|
|
37
|
+
): UpdateBuilder<P, T> {
|
|
38
|
+
return new UpdateBuilder<P, T>(this.ctx, table, patch);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Delete rows in `table` matching subsequent filters. */
|
|
42
|
+
delete<T extends TableName<P>>(table: T): DeleteBuilder<P, T> {
|
|
43
|
+
return new DeleteBuilder<P, T>(this.ctx, table);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Call a Postgres function via `POST /rpc/<fn>`. Awaitable. */
|
|
47
|
+
rpc<Result = unknown>(
|
|
48
|
+
fn: string,
|
|
49
|
+
args: Record<string, unknown> = {},
|
|
50
|
+
): RpcCaller<Result> {
|
|
51
|
+
return new RpcCaller<Result>(this.ctx, fn, args);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function createClient<P extends AnyPaths>(opts: ClientOptions): CloudRestClient<P> {
|
|
56
|
+
return new CloudRestClient<P>(opts);
|
|
57
|
+
}
|
package/src/errors.ts
ADDED
package/src/filter.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Filter ops — v0 covers the 80% of queries: equality, comparison, set membership,
|
|
2
|
+
// null checks, pattern match. Composite ops (or, and, fts, jsonpath) come later.
|
|
3
|
+
//
|
|
4
|
+
// Each filter contributes one entry to URLSearchParams as `column=op.value`.
|
|
5
|
+
|
|
6
|
+
import { serializeValue } from "./serialize.js";
|
|
7
|
+
|
|
8
|
+
export type IsValue = "null" | "true" | "false" | "unknown";
|
|
9
|
+
|
|
10
|
+
export interface FilterEntry {
|
|
11
|
+
column: string;
|
|
12
|
+
expr: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
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`.
|
|
48
|
+
export function fNot(entry: FilterEntry): FilterEntry {
|
|
49
|
+
return { column: entry.column, expr: `not.${entry.expr}` };
|
|
50
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
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";
|
|
6
|
+
export type {
|
|
7
|
+
AnyPaths,
|
|
8
|
+
ClientOptions,
|
|
9
|
+
RowOf,
|
|
10
|
+
TableName,
|
|
11
|
+
TokenGetter,
|
|
12
|
+
} from "./types.js";
|
|
13
|
+
export type { OrderOptions } from "./query.js";
|
|
14
|
+
export type { IsValue } from "./filter.js";
|
package/src/mutation.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Mutations: insert / update / delete. PostgREST conventions:
|
|
2
|
+
// - POST /table with array body → insert
|
|
3
|
+
// - PATCH /table?filters → update
|
|
4
|
+
// - DELETE /table?filters → delete
|
|
5
|
+
// - Prefer: return=representation header makes the server return rows.
|
|
6
|
+
|
|
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
|
+
|
|
14
|
+
class FilterableMutation<Row> {
|
|
15
|
+
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; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function send(
|
|
29
|
+
ctx: ExecContext,
|
|
30
|
+
method: "POST" | "PATCH" | "DELETE",
|
|
31
|
+
url: string,
|
|
32
|
+
body: unknown | undefined,
|
|
33
|
+
returnRepresentation: boolean,
|
|
34
|
+
): Promise<unknown> {
|
|
35
|
+
const headers: Record<string, string> = {
|
|
36
|
+
Accept: "application/json",
|
|
37
|
+
"Content-Type": "application/json",
|
|
38
|
+
...ctx.headers,
|
|
39
|
+
};
|
|
40
|
+
if (returnRepresentation) headers["Prefer"] = "return=representation";
|
|
41
|
+
const token = await ctx.getToken?.();
|
|
42
|
+
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
43
|
+
|
|
44
|
+
const res = await ctx.fetch(url, {
|
|
45
|
+
method,
|
|
46
|
+
headers,
|
|
47
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
48
|
+
});
|
|
49
|
+
if (!res.ok) {
|
|
50
|
+
const errBody = await res.json().catch(() => null);
|
|
51
|
+
const { CloudRestError } = await import("./errors.js");
|
|
52
|
+
throw new CloudRestError(res.status, res.statusText, errBody);
|
|
53
|
+
}
|
|
54
|
+
if (res.status === 204) return [];
|
|
55
|
+
return res.json();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export class InsertBuilder<P extends AnyPaths, T extends TableName<P>, Row = RowOf<P, T>>
|
|
59
|
+
implements PromiseLike<Row[]>
|
|
60
|
+
{
|
|
61
|
+
private rows: Partial<Row>[];
|
|
62
|
+
private returnRows = false;
|
|
63
|
+
|
|
64
|
+
constructor(
|
|
65
|
+
private readonly ctx: ExecContext,
|
|
66
|
+
private readonly table: T,
|
|
67
|
+
rows: Partial<Row> | Partial<Row>[],
|
|
68
|
+
) {
|
|
69
|
+
this.rows = Array.isArray(rows) ? rows : [rows];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Return inserted rows (adds Prefer: return=representation). */
|
|
73
|
+
returning(): this {
|
|
74
|
+
this.returnRows = true;
|
|
75
|
+
return this;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
then<R1 = Row[], R2 = never>(
|
|
79
|
+
onfulfilled?: ((value: Row[]) => R1 | PromiseLike<R1>) | null,
|
|
80
|
+
onrejected?: ((reason: unknown) => R2 | PromiseLike<R2>) | null,
|
|
81
|
+
): PromiseLike<R1 | R2> {
|
|
82
|
+
const url = `${this.ctx.baseUrl}${String(this.table)}`;
|
|
83
|
+
return send(this.ctx, "POST", url, this.rows, this.returnRows)
|
|
84
|
+
.then((v) => v as Row[])
|
|
85
|
+
.then(onfulfilled, onrejected as never);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export class UpdateBuilder<P extends AnyPaths, T extends TableName<P>, Row = RowOf<P, T>>
|
|
90
|
+
extends FilterableMutation<Row>
|
|
91
|
+
implements PromiseLike<Row[]>
|
|
92
|
+
{
|
|
93
|
+
private returnRows = false;
|
|
94
|
+
|
|
95
|
+
constructor(
|
|
96
|
+
private readonly ctx: ExecContext,
|
|
97
|
+
private readonly table: T,
|
|
98
|
+
private readonly patch: Partial<Row>,
|
|
99
|
+
) {
|
|
100
|
+
super();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
returning(): this {
|
|
104
|
+
this.returnRows = true;
|
|
105
|
+
return this;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
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}` : ""}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
then<R1 = Row[], R2 = never>(
|
|
116
|
+
onfulfilled?: ((value: Row[]) => R1 | PromiseLike<R1>) | null,
|
|
117
|
+
onrejected?: ((reason: unknown) => R2 | PromiseLike<R2>) | null,
|
|
118
|
+
): PromiseLike<R1 | R2> {
|
|
119
|
+
return send(this.ctx, "PATCH", this.url(), this.patch, this.returnRows)
|
|
120
|
+
.then((v) => v as Row[])
|
|
121
|
+
.then(onfulfilled, onrejected as never);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export class DeleteBuilder<P extends AnyPaths, T extends TableName<P>, Row = RowOf<P, T>>
|
|
126
|
+
extends FilterableMutation<Row>
|
|
127
|
+
implements PromiseLike<Row[]>
|
|
128
|
+
{
|
|
129
|
+
private returnRows = false;
|
|
130
|
+
|
|
131
|
+
constructor(
|
|
132
|
+
private readonly ctx: ExecContext,
|
|
133
|
+
private readonly table: T,
|
|
134
|
+
) {
|
|
135
|
+
super();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
returning(): this {
|
|
139
|
+
this.returnRows = true;
|
|
140
|
+
return this;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
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}` : ""}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
then<R1 = Row[], R2 = never>(
|
|
151
|
+
onfulfilled?: ((value: Row[]) => R1 | PromiseLike<R1>) | null,
|
|
152
|
+
onrejected?: ((reason: unknown) => R2 | PromiseLike<R2>) | null,
|
|
153
|
+
): PromiseLike<R1 | R2> {
|
|
154
|
+
return send(this.ctx, "DELETE", this.url(), undefined, this.returnRows)
|
|
155
|
+
.then((v) => v as Row[])
|
|
156
|
+
.then(onfulfilled, onrejected as never);
|
|
157
|
+
}
|
|
158
|
+
}
|
package/src/query.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Read query builder. Chainable, immutable-ish: each call returns `this` after
|
|
2
|
+
// pushing to internal state. Terminal `then` (PromiseLike) makes the builder
|
|
3
|
+
// awaitable directly:
|
|
4
|
+
//
|
|
5
|
+
// const rows = await db.from("x").select("a,b").eq("a", 1);
|
|
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";
|
|
13
|
+
|
|
14
|
+
export interface ExecContext {
|
|
15
|
+
baseUrl: string;
|
|
16
|
+
getToken?: () => string | null | Promise<string | null>;
|
|
17
|
+
fetch: typeof fetch;
|
|
18
|
+
headers: Record<string, string>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface OrderOptions {
|
|
22
|
+
ascending?: boolean;
|
|
23
|
+
nullsFirst?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class QueryBuilder<P extends AnyPaths, T extends TableName<P>, Row = RowOf<P, T>>
|
|
27
|
+
implements PromiseLike<Row[]>
|
|
28
|
+
{
|
|
29
|
+
private filters: FilterEntry[] = [];
|
|
30
|
+
private selectExpr?: string;
|
|
31
|
+
private orderExpr?: string;
|
|
32
|
+
private limitN?: number;
|
|
33
|
+
private offsetN?: number;
|
|
34
|
+
private rangeFrom?: number;
|
|
35
|
+
private rangeTo?: number;
|
|
36
|
+
private singleMode = false;
|
|
37
|
+
|
|
38
|
+
constructor(
|
|
39
|
+
private readonly ctx: ExecContext,
|
|
40
|
+
private readonly table: T,
|
|
41
|
+
) {}
|
|
42
|
+
|
|
43
|
+
// ── projection ──────────────────────────────────────────────────────────
|
|
44
|
+
select(columns: string): this {
|
|
45
|
+
this.selectExpr = columns;
|
|
46
|
+
return this;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── 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; }
|
|
60
|
+
// 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; }
|
|
64
|
+
|
|
65
|
+
// ── ordering / paging ───────────────────────────────────────────────────
|
|
66
|
+
order(column: string, opts: OrderOptions = {}): this {
|
|
67
|
+
const { ascending = true, nullsFirst } = opts;
|
|
68
|
+
const parts = [column, ascending ? "asc" : "desc"];
|
|
69
|
+
if (nullsFirst !== undefined) parts.push(nullsFirst ? "nullsfirst" : "nullslast");
|
|
70
|
+
this.orderExpr = (this.orderExpr ? `${this.orderExpr},` : "") + parts.join(".");
|
|
71
|
+
return this;
|
|
72
|
+
}
|
|
73
|
+
limit(n: number): this { this.limitN = n; return this; }
|
|
74
|
+
offset(n: number): this { this.offsetN = n; return this; }
|
|
75
|
+
range(from: number, to: number): this { this.rangeFrom = from; this.rangeTo = to; return this; }
|
|
76
|
+
|
|
77
|
+
// ── result shape ────────────────────────────────────────────────────────
|
|
78
|
+
/**
|
|
79
|
+
* Expect exactly one row. Resolves to `Row` (not `Row[]`).
|
|
80
|
+
* Throws if 0 or >1 rows match.
|
|
81
|
+
*/
|
|
82
|
+
single(): SingleQuery<Row> {
|
|
83
|
+
this.singleMode = true;
|
|
84
|
+
return this as unknown as SingleQuery<Row>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── build + execute ─────────────────────────────────────────────────────
|
|
88
|
+
private buildUrl(): string {
|
|
89
|
+
const params = new URLSearchParams();
|
|
90
|
+
if (this.selectExpr) params.set("select", this.selectExpr);
|
|
91
|
+
for (const f of this.filters) params.append(f.column, f.expr);
|
|
92
|
+
if (this.orderExpr) params.set("order", this.orderExpr);
|
|
93
|
+
if (this.limitN !== undefined) params.set("limit", String(this.limitN));
|
|
94
|
+
if (this.offsetN !== undefined) params.set("offset", String(this.offsetN));
|
|
95
|
+
const qs = params.toString();
|
|
96
|
+
return `${this.ctx.baseUrl}${String(this.table)}${qs ? `?${qs}` : ""}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private async exec(): Promise<Row[]> {
|
|
100
|
+
const url = this.buildUrl();
|
|
101
|
+
const headers: Record<string, string> = {
|
|
102
|
+
Accept: "application/json",
|
|
103
|
+
...this.ctx.headers,
|
|
104
|
+
};
|
|
105
|
+
if (this.rangeFrom !== undefined && this.rangeTo !== undefined) {
|
|
106
|
+
headers["Range-Unit"] = "items";
|
|
107
|
+
headers["Range"] = `${this.rangeFrom}-${this.rangeTo}`;
|
|
108
|
+
}
|
|
109
|
+
const token = await this.ctx.getToken?.();
|
|
110
|
+
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
111
|
+
|
|
112
|
+
const res = await this.ctx.fetch(url, { method: "GET", headers });
|
|
113
|
+
if (!res.ok) {
|
|
114
|
+
const body = await res.json().catch(() => null);
|
|
115
|
+
const { CloudRestError } = await import("./errors.js");
|
|
116
|
+
throw new CloudRestError(res.status, res.statusText, body);
|
|
117
|
+
}
|
|
118
|
+
const rows = (await res.json()) as Row[];
|
|
119
|
+
if (this.singleMode) {
|
|
120
|
+
if (rows.length !== 1) {
|
|
121
|
+
const { CloudRestError } = await import("./errors.js");
|
|
122
|
+
throw new CloudRestError(
|
|
123
|
+
rows.length === 0 ? 404 : 409,
|
|
124
|
+
rows.length === 0 ? "Not Found" : "Conflict",
|
|
125
|
+
{ expected: 1, got: rows.length },
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
return rows[0] as unknown as Row[];
|
|
129
|
+
}
|
|
130
|
+
return rows;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// PromiseLike — `await db.from(...).select(...)` works without `.exec()`.
|
|
134
|
+
then<TResult1 = Row[], TResult2 = never>(
|
|
135
|
+
onfulfilled?: ((value: Row[]) => TResult1 | PromiseLike<TResult1>) | null,
|
|
136
|
+
onrejected?: ((reason: CloudRestError) => TResult2 | PromiseLike<TResult2>) | null,
|
|
137
|
+
): PromiseLike<TResult1 | TResult2> {
|
|
138
|
+
return this.exec().then(onfulfilled, onrejected as never);
|
|
139
|
+
}
|
|
140
|
+
// Match Promise surface so `.catch()` / `.finally()` work directly on builders.
|
|
141
|
+
catch<TResult = never>(
|
|
142
|
+
onrejected?: ((reason: CloudRestError) => TResult | PromiseLike<TResult>) | null,
|
|
143
|
+
): Promise<Row[] | TResult> {
|
|
144
|
+
return this.exec().catch(onrejected as never);
|
|
145
|
+
}
|
|
146
|
+
finally(onfinally?: (() => void) | null): Promise<Row[]> {
|
|
147
|
+
return this.exec().finally(onfinally);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Phantom type alias for `.single()` — narrows the awaited type from Row[] to Row.
|
|
152
|
+
export interface SingleQuery<Row> extends PromiseLike<Row> {}
|