@bizcore/js 0.1.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/index.d.ts +129 -0
- package/dist/index.js +261 -0
- package/package.json +50 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The BizCore client for JavaScript (M21).
|
|
3
|
+
*
|
|
4
|
+
* ONE DEPENDENCY-FREE FILE OVER `fetch`. A client library for an HTTP API is
|
|
5
|
+
* mostly a typing exercise plus good errors, and every dependency it takes is
|
|
6
|
+
* one a customer inherits into their bundle. There are none.
|
|
7
|
+
*
|
|
8
|
+
* WHAT THIS CLIENT WILL NOT DO, and it matters more than what it will:
|
|
9
|
+
*
|
|
10
|
+
* * It never holds a personal access token. A PAT authenticates a PERSON
|
|
11
|
+
* across every organization they belong to; a browser bundle is the last
|
|
12
|
+
* place that belongs. This client takes a PROJECT API KEY, whose blast
|
|
13
|
+
* radius is one project — and warns if you hand it a PAT by mistake.
|
|
14
|
+
*
|
|
15
|
+
* * It does not paper over row-level security. If you are acting on behalf
|
|
16
|
+
* of one of your own end users, you pass their token and PostgreSQL
|
|
17
|
+
* decides what they see. There is no client-side filtering here that could
|
|
18
|
+
* be mistaken for a security boundary.
|
|
19
|
+
*
|
|
20
|
+
* A KEY IN A BROWSER IS STILL A KEY. Even scoped to one project, an API key
|
|
21
|
+
* shipped in front-end JavaScript is readable by anyone who opens devtools.
|
|
22
|
+
* Use `rest:read`-scoped keys for anything public-facing, enable row-level
|
|
23
|
+
* security, and keep write-capable keys on your server. `createClient` says so
|
|
24
|
+
* once, out loud, when it sees a write-capable key in a browser.
|
|
25
|
+
*/
|
|
26
|
+
export interface ClientOptions {
|
|
27
|
+
/** e.g. "https://api.bizcorehq.com". Trailing slash optional. */
|
|
28
|
+
url?: string;
|
|
29
|
+
/** A PROJECT API KEY — `bzc_live_…`. Never a personal access token. */
|
|
30
|
+
key: string;
|
|
31
|
+
/** Your project's id. */
|
|
32
|
+
projectId: string;
|
|
33
|
+
/**
|
|
34
|
+
* An end user's token, when acting on their behalf. Their claims are bound
|
|
35
|
+
* to the transaction server-side and your RLS policies apply.
|
|
36
|
+
*/
|
|
37
|
+
endUserToken?: string;
|
|
38
|
+
fetch?: typeof globalThis.fetch;
|
|
39
|
+
}
|
|
40
|
+
export declare class BizCoreError extends Error {
|
|
41
|
+
readonly status: number;
|
|
42
|
+
readonly detail?: unknown | undefined;
|
|
43
|
+
constructor(message: string, status: number, detail?: unknown | undefined);
|
|
44
|
+
}
|
|
45
|
+
/** Comparison operators, exactly the ones the server implements. */
|
|
46
|
+
export type Operator = "eq" | "neq" | "gt" | "gte" | "lt" | "lte" | "like" | "ilike" | "is" | "in";
|
|
47
|
+
export interface SelectResult<T> {
|
|
48
|
+
rows: T[];
|
|
49
|
+
count: number;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* A query against one table.
|
|
53
|
+
*
|
|
54
|
+
* Chainable and immutable-ish: each call mutates and returns `this`, which is
|
|
55
|
+
* the shape people expect from this kind of client. It is only sent when you
|
|
56
|
+
* await it.
|
|
57
|
+
*/
|
|
58
|
+
declare class Query<T> implements PromiseLike<SelectResult<T>> {
|
|
59
|
+
private readonly client;
|
|
60
|
+
private readonly table;
|
|
61
|
+
private filters;
|
|
62
|
+
private columns?;
|
|
63
|
+
private ordering?;
|
|
64
|
+
private rowLimit?;
|
|
65
|
+
private rowOffset?;
|
|
66
|
+
constructor(client: BizCoreClient, table: string);
|
|
67
|
+
select(columns: string): this;
|
|
68
|
+
filter(column: string, operator: Operator, value: unknown): this;
|
|
69
|
+
eq(column: string, value: unknown): this;
|
|
70
|
+
neq(column: string, value: unknown): this;
|
|
71
|
+
gt(column: string, value: unknown): this;
|
|
72
|
+
gte(column: string, value: unknown): this;
|
|
73
|
+
lt(column: string, value: unknown): this;
|
|
74
|
+
lte(column: string, value: unknown): this;
|
|
75
|
+
like(column: string, value: string): this;
|
|
76
|
+
isNull(column: string): this;
|
|
77
|
+
in(column: string, values: readonly unknown[]): this;
|
|
78
|
+
order(column: string, direction?: "asc" | "desc"): this;
|
|
79
|
+
limit(count: number): this;
|
|
80
|
+
offset(count: number): this;
|
|
81
|
+
private queryString;
|
|
82
|
+
/** Makes the query awaitable without a `.execute()` nobody remembers. */
|
|
83
|
+
then<R1 = SelectResult<T>, R2 = never>(onfulfilled?: ((value: SelectResult<T>) => R1 | PromiseLike<R1>) | null, onrejected?: ((reason: unknown) => R2 | PromiseLike<R2>) | null): PromiseLike<R1 | R2>;
|
|
84
|
+
/** The first row, or null. */
|
|
85
|
+
single(): Promise<T | null>;
|
|
86
|
+
insert(values: Record<string, unknown> | Record<string, unknown>[]): Promise<SelectResult<T>>;
|
|
87
|
+
update(values: Record<string, unknown>): Promise<SelectResult<T>>;
|
|
88
|
+
delete(): Promise<SelectResult<T>>;
|
|
89
|
+
/**
|
|
90
|
+
* Refused HERE as well as on the server.
|
|
91
|
+
*
|
|
92
|
+
* The server already rejects an unfiltered UPDATE or DELETE, and this is not
|
|
93
|
+
* a substitute for that — it is a better error, thrown before a round trip,
|
|
94
|
+
* naming the method that is missing a filter. A forgotten `.eq()` on a
|
|
95
|
+
* `.delete()` is one keystroke from an empty table.
|
|
96
|
+
*/
|
|
97
|
+
private requireFilter;
|
|
98
|
+
}
|
|
99
|
+
export declare class BizCoreClient {
|
|
100
|
+
private readonly url;
|
|
101
|
+
private readonly key;
|
|
102
|
+
private readonly projectId;
|
|
103
|
+
private readonly doFetch;
|
|
104
|
+
private endUserToken?;
|
|
105
|
+
constructor(options: ClientOptions);
|
|
106
|
+
/** Act on behalf of one of your end users. Their RLS policies apply. */
|
|
107
|
+
asUser(token: string): BizCoreClient;
|
|
108
|
+
from<T = Record<string, unknown>>(table: string): Query<T>;
|
|
109
|
+
/** Queue a job — an invocation of one of this project's functions. */
|
|
110
|
+
enqueue(functionName: string, payload?: Record<string, unknown>): Promise<{
|
|
111
|
+
id: string;
|
|
112
|
+
status: string;
|
|
113
|
+
}>;
|
|
114
|
+
/** Call one of this project's functions and get its response. */
|
|
115
|
+
invoke<T = unknown>(functionName: string, payload?: Record<string, unknown>): Promise<T>;
|
|
116
|
+
private headers;
|
|
117
|
+
/** @internal */
|
|
118
|
+
request<T>(method: string, path: string, body?: unknown): Promise<T>;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Create a client.
|
|
122
|
+
*
|
|
123
|
+
* Warns once — not throws — if a write-capable key appears in a browser. It
|
|
124
|
+
* cannot know your intent (a public read-only app is a legitimate thing), but
|
|
125
|
+
* a key in front-end JavaScript is readable by anyone who opens devtools, and
|
|
126
|
+
* somebody should be told that at least once.
|
|
127
|
+
*/
|
|
128
|
+
export declare function createClient(options: ClientOptions): BizCoreClient;
|
|
129
|
+
export { Query };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The BizCore client for JavaScript (M21).
|
|
3
|
+
*
|
|
4
|
+
* ONE DEPENDENCY-FREE FILE OVER `fetch`. A client library for an HTTP API is
|
|
5
|
+
* mostly a typing exercise plus good errors, and every dependency it takes is
|
|
6
|
+
* one a customer inherits into their bundle. There are none.
|
|
7
|
+
*
|
|
8
|
+
* WHAT THIS CLIENT WILL NOT DO, and it matters more than what it will:
|
|
9
|
+
*
|
|
10
|
+
* * It never holds a personal access token. A PAT authenticates a PERSON
|
|
11
|
+
* across every organization they belong to; a browser bundle is the last
|
|
12
|
+
* place that belongs. This client takes a PROJECT API KEY, whose blast
|
|
13
|
+
* radius is one project — and warns if you hand it a PAT by mistake.
|
|
14
|
+
*
|
|
15
|
+
* * It does not paper over row-level security. If you are acting on behalf
|
|
16
|
+
* of one of your own end users, you pass their token and PostgreSQL
|
|
17
|
+
* decides what they see. There is no client-side filtering here that could
|
|
18
|
+
* be mistaken for a security boundary.
|
|
19
|
+
*
|
|
20
|
+
* A KEY IN A BROWSER IS STILL A KEY. Even scoped to one project, an API key
|
|
21
|
+
* shipped in front-end JavaScript is readable by anyone who opens devtools.
|
|
22
|
+
* Use `rest:read`-scoped keys for anything public-facing, enable row-level
|
|
23
|
+
* security, and keep write-capable keys on your server. `createClient` says so
|
|
24
|
+
* once, out loud, when it sees a write-capable key in a browser.
|
|
25
|
+
*/
|
|
26
|
+
export class BizCoreError extends Error {
|
|
27
|
+
status;
|
|
28
|
+
detail;
|
|
29
|
+
constructor(message, status, detail) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.status = status;
|
|
32
|
+
this.detail = detail;
|
|
33
|
+
this.name = "BizCoreError";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const DEFAULT_URL = "https://api.bizcorehq.com";
|
|
37
|
+
function inBrowser() {
|
|
38
|
+
return typeof window !== "undefined" && typeof window.document !== "undefined";
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* A query against one table.
|
|
42
|
+
*
|
|
43
|
+
* Chainable and immutable-ish: each call mutates and returns `this`, which is
|
|
44
|
+
* the shape people expect from this kind of client. It is only sent when you
|
|
45
|
+
* await it.
|
|
46
|
+
*/
|
|
47
|
+
class Query {
|
|
48
|
+
client;
|
|
49
|
+
table;
|
|
50
|
+
filters = [];
|
|
51
|
+
columns;
|
|
52
|
+
ordering;
|
|
53
|
+
rowLimit;
|
|
54
|
+
rowOffset;
|
|
55
|
+
constructor(client, table) {
|
|
56
|
+
this.client = client;
|
|
57
|
+
this.table = table;
|
|
58
|
+
}
|
|
59
|
+
select(columns) {
|
|
60
|
+
this.columns = columns;
|
|
61
|
+
return this;
|
|
62
|
+
}
|
|
63
|
+
filter(column, operator, value) {
|
|
64
|
+
this.filters.push({ column, operator, value: String(value) });
|
|
65
|
+
return this;
|
|
66
|
+
}
|
|
67
|
+
eq(column, value) {
|
|
68
|
+
return this.filter(column, "eq", value);
|
|
69
|
+
}
|
|
70
|
+
neq(column, value) {
|
|
71
|
+
return this.filter(column, "neq", value);
|
|
72
|
+
}
|
|
73
|
+
gt(column, value) {
|
|
74
|
+
return this.filter(column, "gt", value);
|
|
75
|
+
}
|
|
76
|
+
gte(column, value) {
|
|
77
|
+
return this.filter(column, "gte", value);
|
|
78
|
+
}
|
|
79
|
+
lt(column, value) {
|
|
80
|
+
return this.filter(column, "lt", value);
|
|
81
|
+
}
|
|
82
|
+
lte(column, value) {
|
|
83
|
+
return this.filter(column, "lte", value);
|
|
84
|
+
}
|
|
85
|
+
like(column, value) {
|
|
86
|
+
return this.filter(column, "like", value);
|
|
87
|
+
}
|
|
88
|
+
isNull(column) {
|
|
89
|
+
return this.filter(column, "is", "null");
|
|
90
|
+
}
|
|
91
|
+
in(column, values) {
|
|
92
|
+
return this.filter(column, "in", `(${values.map(String).join(",")})`);
|
|
93
|
+
}
|
|
94
|
+
order(column, direction = "asc") {
|
|
95
|
+
this.ordering = `${column}.${direction}`;
|
|
96
|
+
return this;
|
|
97
|
+
}
|
|
98
|
+
limit(count) {
|
|
99
|
+
this.rowLimit = count;
|
|
100
|
+
return this;
|
|
101
|
+
}
|
|
102
|
+
offset(count) {
|
|
103
|
+
this.rowOffset = count;
|
|
104
|
+
return this;
|
|
105
|
+
}
|
|
106
|
+
queryString() {
|
|
107
|
+
const params = new URLSearchParams();
|
|
108
|
+
for (const filter of this.filters) {
|
|
109
|
+
params.append(filter.column, `${filter.operator}.${filter.value}`);
|
|
110
|
+
}
|
|
111
|
+
if (this.columns)
|
|
112
|
+
params.set("select", this.columns);
|
|
113
|
+
if (this.ordering)
|
|
114
|
+
params.set("order", this.ordering);
|
|
115
|
+
if (this.rowLimit !== undefined)
|
|
116
|
+
params.set("limit", String(this.rowLimit));
|
|
117
|
+
if (this.rowOffset !== undefined)
|
|
118
|
+
params.set("offset", String(this.rowOffset));
|
|
119
|
+
const text = params.toString();
|
|
120
|
+
return text ? `?${text}` : "";
|
|
121
|
+
}
|
|
122
|
+
/** Makes the query awaitable without a `.execute()` nobody remembers. */
|
|
123
|
+
then(onfulfilled, onrejected) {
|
|
124
|
+
return this.client
|
|
125
|
+
.request("GET", `/rest/${this.table}${this.queryString()}`)
|
|
126
|
+
.then(onfulfilled, onrejected);
|
|
127
|
+
}
|
|
128
|
+
/** The first row, or null. */
|
|
129
|
+
async single() {
|
|
130
|
+
const result = await this.limit(1);
|
|
131
|
+
return result.rows[0] ?? null;
|
|
132
|
+
}
|
|
133
|
+
async insert(values) {
|
|
134
|
+
return this.client.request("POST", `/rest/${this.table}`, values);
|
|
135
|
+
}
|
|
136
|
+
async update(values) {
|
|
137
|
+
this.requireFilter("update");
|
|
138
|
+
return this.client.request("PATCH", `/rest/${this.table}${this.queryString()}`, values);
|
|
139
|
+
}
|
|
140
|
+
async delete() {
|
|
141
|
+
this.requireFilter("delete");
|
|
142
|
+
return this.client.request("DELETE", `/rest/${this.table}${this.queryString()}`);
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Refused HERE as well as on the server.
|
|
146
|
+
*
|
|
147
|
+
* The server already rejects an unfiltered UPDATE or DELETE, and this is not
|
|
148
|
+
* a substitute for that — it is a better error, thrown before a round trip,
|
|
149
|
+
* naming the method that is missing a filter. A forgotten `.eq()` on a
|
|
150
|
+
* `.delete()` is one keystroke from an empty table.
|
|
151
|
+
*/
|
|
152
|
+
requireFilter(operation) {
|
|
153
|
+
if (this.filters.length === 0) {
|
|
154
|
+
throw new BizCoreError(`Refusing to ${operation} every row in "${this.table}". Add a filter, ` +
|
|
155
|
+
`e.g. .eq("id", 1). The server refuses this too.`, 400);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
export class BizCoreClient {
|
|
160
|
+
url;
|
|
161
|
+
key;
|
|
162
|
+
projectId;
|
|
163
|
+
doFetch;
|
|
164
|
+
endUserToken;
|
|
165
|
+
constructor(options) {
|
|
166
|
+
this.url = (options.url ?? DEFAULT_URL).replace(/\/$/, "");
|
|
167
|
+
this.key = options.key;
|
|
168
|
+
this.projectId = options.projectId;
|
|
169
|
+
this.endUserToken = options.endUserToken;
|
|
170
|
+
this.doFetch = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
171
|
+
if (this.key.startsWith("bzp_")) {
|
|
172
|
+
// A PAT reaches everything its owner reaches, in every organization.
|
|
173
|
+
// Refused rather than warned about: this is not a preference.
|
|
174
|
+
throw new BizCoreError("That is a personal access token, not a project API key. A personal token " +
|
|
175
|
+
"authenticates you across every organization you belong to and must never be " +
|
|
176
|
+
"given to a client library. Use a project key (bzc_live_…).", 400);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/** Act on behalf of one of your end users. Their RLS policies apply. */
|
|
180
|
+
asUser(token) {
|
|
181
|
+
return new BizCoreClient({
|
|
182
|
+
url: this.url,
|
|
183
|
+
key: this.key,
|
|
184
|
+
projectId: this.projectId,
|
|
185
|
+
endUserToken: token,
|
|
186
|
+
fetch: this.doFetch,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
from(table) {
|
|
190
|
+
return new Query(this, table);
|
|
191
|
+
}
|
|
192
|
+
/** Queue a job — an invocation of one of this project's functions. */
|
|
193
|
+
async enqueue(functionName, payload = {}) {
|
|
194
|
+
return this.request("POST", "/jobs", { function_name: functionName, payload });
|
|
195
|
+
}
|
|
196
|
+
/** Call one of this project's functions and get its response. */
|
|
197
|
+
async invoke(functionName, payload = {}) {
|
|
198
|
+
const response = await this.doFetch(`${this.url}/fn/${this.projectId}/${functionName}`, {
|
|
199
|
+
method: "POST",
|
|
200
|
+
headers: this.headers(),
|
|
201
|
+
body: JSON.stringify(payload),
|
|
202
|
+
});
|
|
203
|
+
if (!response.ok) {
|
|
204
|
+
throw new BizCoreError(`The function returned ${response.status}.`, response.status);
|
|
205
|
+
}
|
|
206
|
+
return (await response.json());
|
|
207
|
+
}
|
|
208
|
+
headers() {
|
|
209
|
+
const headers = {
|
|
210
|
+
"X-API-Key": this.key,
|
|
211
|
+
"Content-Type": "application/json",
|
|
212
|
+
};
|
|
213
|
+
if (this.endUserToken)
|
|
214
|
+
headers["Authorization"] = `Bearer ${this.endUserToken}`;
|
|
215
|
+
return headers;
|
|
216
|
+
}
|
|
217
|
+
/** @internal */
|
|
218
|
+
async request(method, path, body) {
|
|
219
|
+
const response = await this.doFetch(`${this.url}/v1/projects/${this.projectId}${path}`, {
|
|
220
|
+
method,
|
|
221
|
+
headers: this.headers(),
|
|
222
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
223
|
+
});
|
|
224
|
+
if (!response.ok) {
|
|
225
|
+
let detail;
|
|
226
|
+
try {
|
|
227
|
+
detail = await response.json();
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
detail = undefined;
|
|
231
|
+
}
|
|
232
|
+
const message = detail && typeof detail === "object" && "detail" in detail
|
|
233
|
+
? String(detail.detail)
|
|
234
|
+
: `The API returned ${response.status}.`;
|
|
235
|
+
throw new BizCoreError(message, response.status, detail);
|
|
236
|
+
}
|
|
237
|
+
if (response.status === 204)
|
|
238
|
+
return undefined;
|
|
239
|
+
return (await response.json());
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Create a client.
|
|
244
|
+
*
|
|
245
|
+
* Warns once — not throws — if a write-capable key appears in a browser. It
|
|
246
|
+
* cannot know your intent (a public read-only app is a legitimate thing), but
|
|
247
|
+
* a key in front-end JavaScript is readable by anyone who opens devtools, and
|
|
248
|
+
* somebody should be told that at least once.
|
|
249
|
+
*/
|
|
250
|
+
export function createClient(options) {
|
|
251
|
+
const client = new BizCoreClient(options);
|
|
252
|
+
if (inBrowser() && !warned) {
|
|
253
|
+
warned = true;
|
|
254
|
+
console.warn("[bizcore] This API key is visible to anyone who opens devtools. For anything " +
|
|
255
|
+
"public-facing, use a key scoped to rest:read and enable row-level security. " +
|
|
256
|
+
"Keep write-capable keys on your server.");
|
|
257
|
+
}
|
|
258
|
+
return client;
|
|
259
|
+
}
|
|
260
|
+
let warned = false;
|
|
261
|
+
export { Query };
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bizcore/js",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The BizCore client for JavaScript and TypeScript.",
|
|
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
|
+
"scripts": {
|
|
15
|
+
"build": "tsc -p tsconfig.build.json",
|
|
16
|
+
"typecheck": "tsc --noEmit",
|
|
17
|
+
"test": "vitest run",
|
|
18
|
+
"prepublishOnly": "npm run build"
|
|
19
|
+
},
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=20"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/node": "^22.10.2",
|
|
25
|
+
"typescript": "^5.7.2",
|
|
26
|
+
"vitest": "^2.1.8"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"dist",
|
|
30
|
+
"README.md"
|
|
31
|
+
],
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/BizCoreHQ/bizcore.git",
|
|
36
|
+
"directory": "packages/js"
|
|
37
|
+
},
|
|
38
|
+
"homepage": "https://docs.bizcorehq.com",
|
|
39
|
+
"keywords": [
|
|
40
|
+
"bizcore",
|
|
41
|
+
"postgres",
|
|
42
|
+
"postgresql",
|
|
43
|
+
"database",
|
|
44
|
+
"backend",
|
|
45
|
+
"client"
|
|
46
|
+
],
|
|
47
|
+
"publishConfig": {
|
|
48
|
+
"access": "public"
|
|
49
|
+
}
|
|
50
|
+
}
|