cresco-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/README.md +77 -0
- package/index.d.ts +141 -0
- package/index.js +333 -0
- package/package.json +31 -0
package/README.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# cresco-js
|
|
2
|
+
|
|
3
|
+
The official JavaScript / TypeScript client for [CrescoDB](https://crescodb.com) — auth + CRUD over your project's auto-generated REST API. **Zero dependencies** (uses `fetch`), works in the browser and Node 18+.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install cresco-js
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Quick start
|
|
10
|
+
|
|
11
|
+
```js
|
|
12
|
+
import { createClient } from 'cresco-js';
|
|
13
|
+
|
|
14
|
+
const cresco = createClient('http://localhost:3000');
|
|
15
|
+
|
|
16
|
+
// Auth (when your project has `cresco add auth`)
|
|
17
|
+
await cresco.auth.register('me@example.com', 'secret1');
|
|
18
|
+
// or: await cresco.auth.login('me@example.com', 'secret1');
|
|
19
|
+
|
|
20
|
+
// Create
|
|
21
|
+
const post = await cresco.from('posts').create({ title: 'Hello', published: true });
|
|
22
|
+
|
|
23
|
+
// Read — list (thenable: await the builder, or call .list())
|
|
24
|
+
const published = await cresco.from('posts').eq('published', true).limit(10).offset(0);
|
|
25
|
+
const one = await cresco.from('posts').get(post.id);
|
|
26
|
+
|
|
27
|
+
// Update / replace / delete
|
|
28
|
+
await cresco.from('posts').update(post.id, { title: 'Edited' }); // PATCH
|
|
29
|
+
await cresco.from('posts').replace(post.id, { title: 'New', published: false }); // PUT
|
|
30
|
+
await cresco.from('posts').remove(post.id);
|
|
31
|
+
|
|
32
|
+
// Bulk insert (transactional)
|
|
33
|
+
await cresco.from('posts').bulk([{ title: 'a' }, { title: 'b' }]);
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Auth
|
|
37
|
+
|
|
38
|
+
```js
|
|
39
|
+
const { token, user } = await cresco.auth.login('me@example.com', 'secret1');
|
|
40
|
+
cresco.auth.user(); // last-known user, or null
|
|
41
|
+
await cresco.auth.me(); // refetch the current user (GET /auth/me)
|
|
42
|
+
cresco.auth.logout(); // clears the token
|
|
43
|
+
|
|
44
|
+
// React to sign-in/out (e.g. to update UI)
|
|
45
|
+
const off = cresco.onAuthChange(({ token, user }) => { /* ... */ });
|
|
46
|
+
off(); // unsubscribe
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The token is kept in memory and, in the browser, persisted to `localStorage` (key `cresco-token`) so a session survives a reload. In Node it stays in memory unless you pass your own `storage`.
|
|
50
|
+
|
|
51
|
+
## Options
|
|
52
|
+
|
|
53
|
+
```js
|
|
54
|
+
createClient('https://api.example.com', {
|
|
55
|
+
token, // start signed-in with an existing token
|
|
56
|
+
apiKey, // project API key (x-cresco-key) — trusted server-to-server, full access
|
|
57
|
+
fetch, // custom fetch (e.g. older Node)
|
|
58
|
+
storage, // custom token store, or null to disable persistence
|
|
59
|
+
});
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Row-level security
|
|
63
|
+
|
|
64
|
+
If a table is configured with a row owner (`access.rowOwner`), the API automatically scopes rows to the signed-in user — `list()` returns only their rows, and reads/writes to others' rows return a `404`. Just sign in and use the table normally.
|
|
65
|
+
|
|
66
|
+
## Errors
|
|
67
|
+
|
|
68
|
+
Non-2xx responses throw a `CrescoError` with `.status` and `.body`:
|
|
69
|
+
|
|
70
|
+
```js
|
|
71
|
+
import { CrescoError } from 'cresco-js';
|
|
72
|
+
try {
|
|
73
|
+
await cresco.from('posts').get('missing-id');
|
|
74
|
+
} catch (e) {
|
|
75
|
+
if (e instanceof CrescoError && e.status === 404) { /* not found */ }
|
|
76
|
+
}
|
|
77
|
+
```
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Type definitions for cresco-js — the CrescoDB JavaScript client.
|
|
2
|
+
|
|
3
|
+
export interface CrescoUser {
|
|
4
|
+
id: string;
|
|
5
|
+
email: string;
|
|
6
|
+
role: string;
|
|
7
|
+
[key: string]: unknown;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface AuthResult {
|
|
11
|
+
token: string;
|
|
12
|
+
user: CrescoUser;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface AuthState {
|
|
16
|
+
token: string | null;
|
|
17
|
+
user: CrescoUser | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A minimal localStorage-like interface for token persistence. */
|
|
21
|
+
export interface CrescoStorage {
|
|
22
|
+
getItem(key: string): string | null;
|
|
23
|
+
setItem(key: string, value: string): void;
|
|
24
|
+
removeItem(key: string): void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface CrescoClientOptions {
|
|
28
|
+
/** Start signed-in with an existing token. */
|
|
29
|
+
token?: string | null;
|
|
30
|
+
/** Project API key (x-cresco-key) for trusted server-to-server access (full access). */
|
|
31
|
+
apiKey?: string | null;
|
|
32
|
+
/** Custom fetch implementation (e.g. for older Node). Defaults to global fetch. */
|
|
33
|
+
fetch?: typeof fetch;
|
|
34
|
+
/** Token persistence. Defaults to localStorage in the browser, in-memory in Node. Pass null to disable. */
|
|
35
|
+
storage?: CrescoStorage | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class CrescoError extends Error {
|
|
39
|
+
name: 'CrescoError';
|
|
40
|
+
/** HTTP status (0 on a network error). */
|
|
41
|
+
status: number;
|
|
42
|
+
/** Parsed response body, when present. */
|
|
43
|
+
body: unknown;
|
|
44
|
+
constructor(message: string, status: number, body: unknown);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface CrescoAuth {
|
|
48
|
+
register(email: string, password: string, extra?: Record<string, unknown>): Promise<AuthResult>;
|
|
49
|
+
login(email: string, password: string): Promise<AuthResult>;
|
|
50
|
+
/** GET /auth/me — the current user (throws if not signed in). */
|
|
51
|
+
me(): Promise<CrescoUser>;
|
|
52
|
+
logout(): void;
|
|
53
|
+
/** The last-known user (from login/register/me), or null. */
|
|
54
|
+
user(): CrescoUser | null;
|
|
55
|
+
/** The current bearer token, or null. */
|
|
56
|
+
token(): string | null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Fluent query + CRUD for one table. Thenable: awaiting it runs list(). */
|
|
60
|
+
export interface CrescoTable<T = any> extends PromiseLike<T[]> {
|
|
61
|
+
eq(column: string, value: unknown): CrescoTable<T>;
|
|
62
|
+
where(column: string, value: unknown): CrescoTable<T>;
|
|
63
|
+
limit(n: number): CrescoTable<T>;
|
|
64
|
+
offset(n: number): CrescoTable<T>;
|
|
65
|
+
list(): Promise<T[]>;
|
|
66
|
+
get(id: string | number): Promise<T>;
|
|
67
|
+
create(values: Partial<T>): Promise<T>;
|
|
68
|
+
bulk(rows: Partial<T>[]): Promise<{ count: number; rows: T[] }>;
|
|
69
|
+
update(id: string | number, patch: Partial<T>): Promise<T>;
|
|
70
|
+
replace(id: string | number, values: Partial<T>): Promise<T>;
|
|
71
|
+
remove(id: string | number): Promise<void>;
|
|
72
|
+
schema(): Promise<{ schema: { model: string; table: string; fields: unknown[] } }>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface FileMeta {
|
|
76
|
+
bucket: string;
|
|
77
|
+
key: string;
|
|
78
|
+
name: string;
|
|
79
|
+
size: number;
|
|
80
|
+
content_type: string;
|
|
81
|
+
created_at: string;
|
|
82
|
+
url?: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Scoped file storage for one bucket. */
|
|
86
|
+
export interface CrescoStorage {
|
|
87
|
+
list(): Promise<{ bucket: string; public: boolean; objects: FileMeta[] }>;
|
|
88
|
+
upload(key: string, data: Blob | ArrayBuffer | Uint8Array | string, opts?: { contentType?: string; name?: string }): Promise<FileMeta>;
|
|
89
|
+
/** Returns the raw Response — call .blob() / .arrayBuffer() / .text(). */
|
|
90
|
+
download(key: string): Promise<Response>;
|
|
91
|
+
remove(key: string): Promise<void>;
|
|
92
|
+
/** Direct object URL (useful for public buckets / <img src>). */
|
|
93
|
+
url(key: string): string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The row-change verb a realtime event carries. */
|
|
97
|
+
export type ChangeEvent = 'created' | 'updated' | 'deleted';
|
|
98
|
+
|
|
99
|
+
/** A realtime change pushed over the subscription. */
|
|
100
|
+
export interface ChangePayload<T = any> {
|
|
101
|
+
type: 'change';
|
|
102
|
+
event: ChangeEvent;
|
|
103
|
+
table: string;
|
|
104
|
+
data: T;
|
|
105
|
+
at: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface ChannelOptions {
|
|
109
|
+
tables?: string | string[] | null;
|
|
110
|
+
events?: ChangeEvent | ChangeEvent[] | null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** A live subscription to row changes (Server-Sent Events; auto-reconnects). */
|
|
114
|
+
export interface CrescoChannel {
|
|
115
|
+
/** Register a handler for a verb, or '*' for every change. Chainable. */
|
|
116
|
+
on(verb: ChangeEvent | '*', cb: (payload: ChangePayload) => void): CrescoChannel;
|
|
117
|
+
onError(cb: (err: CrescoError) => void): CrescoChannel;
|
|
118
|
+
onReady(cb: (info: { tables: string[] | 'all'; events: string[] | 'all' }) => void): CrescoChannel;
|
|
119
|
+
/** Open the stream. Keep the returned handle to unsubscribe later. */
|
|
120
|
+
subscribe(): CrescoChannel;
|
|
121
|
+
/** Close the stream and stop reconnecting. */
|
|
122
|
+
unsubscribe(): CrescoChannel;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface CrescoClient {
|
|
126
|
+
readonly baseUrl: string;
|
|
127
|
+
readonly auth: CrescoAuth;
|
|
128
|
+
from<T = any>(table: string): CrescoTable<T>;
|
|
129
|
+
storage(bucket: string): CrescoStorage;
|
|
130
|
+
/** Subscribe to live row changes. Pass a table name/array, or { tables, events }. */
|
|
131
|
+
channel(tables?: string | string[] | ChannelOptions | null, events?: ChangeEvent | ChangeEvent[] | null): CrescoChannel;
|
|
132
|
+
setToken(token: string | null): this;
|
|
133
|
+
getToken(): string | null;
|
|
134
|
+
onAuthChange(cb: (state: AuthState) => void): () => void;
|
|
135
|
+
request(method: string, path: string, opts?: { body?: unknown; query?: Record<string, unknown>; auth?: boolean; headers?: Record<string, string> }): Promise<any>;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function createClient(baseUrl: string, options?: CrescoClientOptions): CrescoClient;
|
|
139
|
+
|
|
140
|
+
declare const _default: { createClient: typeof createClient; CrescoError: typeof CrescoError };
|
|
141
|
+
export default _default;
|
package/index.js
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// cresco-js — the official JavaScript client for a CrescoDB project's HTTP API.
|
|
2
|
+
// Zero dependencies; works in the browser and in Node 18+ (global fetch). It wraps
|
|
3
|
+
// the auto-generated REST API (/:table CRUD) and the built-in auth (/auth/*).
|
|
4
|
+
//
|
|
5
|
+
// import { createClient } from 'cresco-js';
|
|
6
|
+
// const cresco = createClient('http://localhost:3000');
|
|
7
|
+
// await cresco.auth.login('me@x.dev', 'secret1');
|
|
8
|
+
// const posts = await cresco.from('posts').eq('published', true).limit(10);
|
|
9
|
+
// const post = await cresco.from('posts').create({ title: 'Hello' });
|
|
10
|
+
//
|
|
11
|
+
// Auth tokens are kept in memory and (in the browser) persisted to localStorage,
|
|
12
|
+
// so a signed-in session survives a page reload. Pass `apiKey` for trusted
|
|
13
|
+
// server-to-server access (sends x-cresco-key, full access).
|
|
14
|
+
|
|
15
|
+
export class CrescoError extends Error {
|
|
16
|
+
constructor(message, status, body) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = 'CrescoError';
|
|
19
|
+
this.status = status; // HTTP status (0 on a network error)
|
|
20
|
+
this.body = body; // parsed response body, if any
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// localStorage when available (browser), else null → token lives only in memory.
|
|
25
|
+
function defaultStorage() {
|
|
26
|
+
try { if (typeof globalThis !== 'undefined' && globalThis.localStorage) return globalThis.localStorage; } catch { /* access denied */ }
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function queryString(params) {
|
|
31
|
+
if (!params) return '';
|
|
32
|
+
const parts = [];
|
|
33
|
+
for (const [k, v] of Object.entries(params)) {
|
|
34
|
+
if (v === undefined || v === null) continue;
|
|
35
|
+
parts.push(encodeURIComponent(k) + '=' + encodeURIComponent(String(v)));
|
|
36
|
+
}
|
|
37
|
+
return parts.length ? '?' + parts.join('&') : '';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const STORAGE_KEY = 'cresco-token';
|
|
41
|
+
|
|
42
|
+
class CrescoClient {
|
|
43
|
+
constructor(baseUrl, { token = null, apiKey = null, fetch: customFetch, storage } = {}) {
|
|
44
|
+
if (!baseUrl) throw new Error('createClient(baseUrl): baseUrl is required');
|
|
45
|
+
this.baseUrl = String(baseUrl).replace(/\/+$/, '');
|
|
46
|
+
this._apiKey = apiKey || null;
|
|
47
|
+
this._fetch = customFetch || (typeof globalThis !== 'undefined' ? globalThis.fetch : undefined);
|
|
48
|
+
if (typeof this._fetch !== 'function') throw new Error('No fetch available — pass options.fetch (e.g. node-fetch) on this runtime');
|
|
49
|
+
this._fetch = this._fetch.bind(globalThis);
|
|
50
|
+
this._storage = storage !== undefined ? storage : defaultStorage();
|
|
51
|
+
this._token = token || (this._storage && this._storage.getItem(STORAGE_KEY)) || null;
|
|
52
|
+
this._user = null;
|
|
53
|
+
this._listeners = new Set();
|
|
54
|
+
this.auth = new CrescoAuth(this);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// A scoped query/CRUD builder for one table (resource), e.g. cresco.from('posts').
|
|
58
|
+
from(table) { return new CrescoTable(this, table); }
|
|
59
|
+
|
|
60
|
+
// A scoped file-storage client for one bucket, e.g. cresco.storage('avatars').
|
|
61
|
+
storage(bucket) { return new CrescoStorage(this, bucket); }
|
|
62
|
+
|
|
63
|
+
// A realtime change-feed subscription (step 9). Accepts either an options object
|
|
64
|
+
// ({ tables, events }) or a tables arg + events arg:
|
|
65
|
+
// cresco.channel('posts').on('created', r => …).subscribe()
|
|
66
|
+
// cresco.channel({ tables: ['posts','comments'] }).on('*', p => …).subscribe()
|
|
67
|
+
channel(tablesOrOpts, events) {
|
|
68
|
+
if (tablesOrOpts && typeof tablesOrOpts === 'object' && !Array.isArray(tablesOrOpts)) return new CrescoChannel(this, tablesOrOpts);
|
|
69
|
+
return new CrescoChannel(this, { tables: tablesOrOpts || null, events: events || null });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Auth headers for non-JSON requests (file upload/download).
|
|
73
|
+
_authHeaders() {
|
|
74
|
+
const h = {};
|
|
75
|
+
if (this._apiKey) h['x-cresco-key'] = this._apiKey;
|
|
76
|
+
else if (this._token) h['Authorization'] = 'Bearer ' + this._token;
|
|
77
|
+
return h;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Token management. setToken(null) signs out. Persisted via storage when present.
|
|
81
|
+
setToken(token) {
|
|
82
|
+
this._token = token || null;
|
|
83
|
+
if (this._storage) {
|
|
84
|
+
try { if (this._token) this._storage.setItem(STORAGE_KEY, this._token); else this._storage.removeItem(STORAGE_KEY); } catch { /* quota / denied */ }
|
|
85
|
+
}
|
|
86
|
+
return this;
|
|
87
|
+
}
|
|
88
|
+
getToken() { return this._token; }
|
|
89
|
+
|
|
90
|
+
// Subscribe to sign-in/sign-out. Returns an unsubscribe function.
|
|
91
|
+
onAuthChange(cb) { this._listeners.add(cb); return () => this._listeners.delete(cb); }
|
|
92
|
+
_emit() { for (const cb of this._listeners) { try { cb({ token: this._token, user: this._user }); } catch { /* listener error */ } } }
|
|
93
|
+
|
|
94
|
+
// The single request primitive every method goes through.
|
|
95
|
+
async request(method, path, { body, query, auth = true, headers = {} } = {}) {
|
|
96
|
+
const url = this.baseUrl + path + queryString(query);
|
|
97
|
+
const h = { ...headers };
|
|
98
|
+
if (body !== undefined) h['Content-Type'] = 'application/json';
|
|
99
|
+
if (this._apiKey) h['x-cresco-key'] = this._apiKey; // server-to-server: full access
|
|
100
|
+
else if (auth && this._token) h['Authorization'] = 'Bearer ' + this._token;
|
|
101
|
+
|
|
102
|
+
let res;
|
|
103
|
+
try {
|
|
104
|
+
res = await this._fetch(url, { method, headers: h, body: body !== undefined ? JSON.stringify(body) : undefined });
|
|
105
|
+
} catch (e) {
|
|
106
|
+
throw new CrescoError(e.message || 'Network request failed', 0, null);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let data = null;
|
|
110
|
+
const text = await res.text();
|
|
111
|
+
if (text) { try { data = JSON.parse(text); } catch { data = text; } }
|
|
112
|
+
|
|
113
|
+
if (!res.ok) {
|
|
114
|
+
const msg = (data && data.error) || res.statusText || `HTTP ${res.status}`;
|
|
115
|
+
throw new CrescoError(msg, res.status, data);
|
|
116
|
+
}
|
|
117
|
+
return data;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
class CrescoAuth {
|
|
122
|
+
constructor(client) { this._c = client; }
|
|
123
|
+
|
|
124
|
+
async register(email, password, extra = {}) {
|
|
125
|
+
const d = await this._c.request('POST', '/auth/register', { body: { email, password, ...extra }, auth: false });
|
|
126
|
+
this._c.setToken(d && d.token); this._c._user = (d && d.user) || null; this._c._emit();
|
|
127
|
+
return d;
|
|
128
|
+
}
|
|
129
|
+
async login(email, password) {
|
|
130
|
+
const d = await this._c.request('POST', '/auth/login', { body: { email, password }, auth: false });
|
|
131
|
+
this._c.setToken(d && d.token); this._c._user = (d && d.user) || null; this._c._emit();
|
|
132
|
+
return d;
|
|
133
|
+
}
|
|
134
|
+
// Load the current user from the token (GET /auth/me). Throws if not signed in.
|
|
135
|
+
async me() {
|
|
136
|
+
const d = await this._c.request('GET', '/auth/me');
|
|
137
|
+
this._c._user = (d && d.user) || null;
|
|
138
|
+
return this._c._user;
|
|
139
|
+
}
|
|
140
|
+
logout() { this._c._user = null; this._c.setToken(null); this._c._emit(); }
|
|
141
|
+
|
|
142
|
+
user() { return this._c._user; }
|
|
143
|
+
token() { return this._c.getToken(); }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Fluent query + CRUD for one resource. It's thenable, so awaiting the builder
|
|
147
|
+
// runs a list() — `await cresco.from('posts').eq('published', true).limit(10)`.
|
|
148
|
+
class CrescoTable {
|
|
149
|
+
constructor(client, table) {
|
|
150
|
+
this._c = client;
|
|
151
|
+
this._path = '/' + encodeURIComponent(table);
|
|
152
|
+
this._query = {};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── query options (chainable) ──
|
|
156
|
+
// CrescoDB's list endpoint supports one equality filter (?where=col:value).
|
|
157
|
+
eq(column, value) { this._query.where = `${column}:${value}`; return this; }
|
|
158
|
+
where(column, value) { return this.eq(column, value); }
|
|
159
|
+
limit(n) { this._query.limit = n; return this; }
|
|
160
|
+
offset(n) { this._query.offset = n; return this; }
|
|
161
|
+
|
|
162
|
+
// ── terminal operations ──
|
|
163
|
+
list() { return this._c.request('GET', this._path, { query: this._query }); }
|
|
164
|
+
get(id) { return this._c.request('GET', `${this._path}/${encodeURIComponent(id)}`); }
|
|
165
|
+
create(values) { return this._c.request('POST', this._path, { body: values }); }
|
|
166
|
+
bulk(rows) { return this._c.request('POST', `${this._path}/bulk`, { body: rows }); }
|
|
167
|
+
update(id, patch) { return this._c.request('PATCH', `${this._path}/${encodeURIComponent(id)}`, { body: patch }); }
|
|
168
|
+
replace(id, values) { return this._c.request('PUT', `${this._path}/${encodeURIComponent(id)}`, { body: values }); }
|
|
169
|
+
remove(id) { return this._c.request('DELETE', `${this._path}/${encodeURIComponent(id)}`); }
|
|
170
|
+
schema() { return this._c.request('GET', `${this._path}/schema`); }
|
|
171
|
+
|
|
172
|
+
// thenable → `await cresco.from('posts')...` resolves to the list.
|
|
173
|
+
then(onFulfilled, onRejected) { return this.list().then(onFulfilled, onRejected); }
|
|
174
|
+
catch(onRejected) { return this.list().catch(onRejected); }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Scoped file storage for one bucket. Uploads raw bytes (Content-Type preserved);
|
|
178
|
+
// downloads return the Response so you can choose .blob()/.arrayBuffer()/.text().
|
|
179
|
+
class CrescoStorage {
|
|
180
|
+
constructor(client, bucket) {
|
|
181
|
+
this._c = client;
|
|
182
|
+
this._bucket = bucket;
|
|
183
|
+
this._base = '/storage/' + encodeURIComponent(bucket);
|
|
184
|
+
}
|
|
185
|
+
_keyPath(key) { return String(key).split('/').map(encodeURIComponent).join('/'); }
|
|
186
|
+
|
|
187
|
+
list() { return this._c.request('GET', this._base); }
|
|
188
|
+
|
|
189
|
+
// upload(key, data, { contentType, name }) — data: Blob | ArrayBuffer | Buffer | string.
|
|
190
|
+
async upload(key, data, { contentType, name } = {}) {
|
|
191
|
+
const url = this._c.baseUrl + this._base + '/' + this._keyPath(key) + (name ? '?name=' + encodeURIComponent(name) : '');
|
|
192
|
+
const headers = { ...this._c._authHeaders() };
|
|
193
|
+
headers['Content-Type'] = contentType || (data && data.type) || 'application/octet-stream';
|
|
194
|
+
let res;
|
|
195
|
+
try { res = await this._c._fetch(url, { method: 'PUT', headers, body: data }); }
|
|
196
|
+
catch (e) { throw new CrescoError(e.message || 'Upload failed', 0, null); }
|
|
197
|
+
const text = await res.text();
|
|
198
|
+
let body = null; if (text) { try { body = JSON.parse(text); } catch { body = text; } }
|
|
199
|
+
if (!res.ok) throw new CrescoError((body && body.error) || res.statusText, res.status, body);
|
|
200
|
+
return body;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// download(key) → the raw Response (caller picks .blob()/.arrayBuffer()/.text()).
|
|
204
|
+
async download(key) {
|
|
205
|
+
const url = this._c.baseUrl + this._base + '/' + this._keyPath(key);
|
|
206
|
+
let res;
|
|
207
|
+
try { res = await this._c._fetch(url, { headers: this._c._authHeaders() }); }
|
|
208
|
+
catch (e) { throw new CrescoError(e.message || 'Download failed', 0, null); }
|
|
209
|
+
if (!res.ok) {
|
|
210
|
+
let body = null; const t = await res.text(); if (t) { try { body = JSON.parse(t); } catch { body = t; } }
|
|
211
|
+
throw new CrescoError((body && body.error) || res.statusText, res.status, body);
|
|
212
|
+
}
|
|
213
|
+
return res;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
remove(key) { return this._c.request('DELETE', this._base + '/' + this._keyPath(key)); }
|
|
217
|
+
|
|
218
|
+
// Direct URL to an object (useful for public buckets / <img src>).
|
|
219
|
+
url(key) { return this._c.baseUrl + this._base + '/' + this._keyPath(key); }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// A realtime change-feed subscription over Server-Sent Events. Uses a streaming
|
|
223
|
+
// fetch (not EventSource) so it can send the auth header / API key and run in both
|
|
224
|
+
// the browser and Node 18+. Auto-reconnects with backoff until unsubscribe().
|
|
225
|
+
//
|
|
226
|
+
// const sub = cresco.channel('posts')
|
|
227
|
+
// .on('created', (p) => console.log('new post', p.data))
|
|
228
|
+
// .on('*', (p) => console.log(p.event, p.table, p.data))
|
|
229
|
+
// .subscribe();
|
|
230
|
+
// // later: sub.unsubscribe();
|
|
231
|
+
class CrescoChannel {
|
|
232
|
+
constructor(client, { tables = null, events = null } = {}) {
|
|
233
|
+
this._c = client;
|
|
234
|
+
this._tables = tables ? (Array.isArray(tables) ? tables : [tables]) : null;
|
|
235
|
+
this._events = events ? (Array.isArray(events) ? events : [events]) : null;
|
|
236
|
+
this._handlers = { '*': new Set() }; // verb → Set<cb>; '*' = every change
|
|
237
|
+
this._abort = null;
|
|
238
|
+
this._closed = false;
|
|
239
|
+
this._onError = null;
|
|
240
|
+
this._onReady = null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Register a handler. verb is 'created' | 'updated' | 'deleted' | '*'.
|
|
244
|
+
on(verb, cb) { (this._handlers[verb] = this._handlers[verb] || new Set()).add(cb); return this; }
|
|
245
|
+
onError(cb) { this._onError = cb; return this; }
|
|
246
|
+
onReady(cb) { this._onReady = cb; return this; } // fired once the server confirms the subscription
|
|
247
|
+
|
|
248
|
+
_emit(payload) {
|
|
249
|
+
const set = this._handlers[payload.event];
|
|
250
|
+
if (set) for (const cb of set) { try { cb(payload); } catch { /* handler error */ } }
|
|
251
|
+
for (const cb of this._handlers['*']) { try { cb(payload); } catch { /* handler error */ } }
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Open the stream. Returns `this` so you can keep the handle for unsubscribe().
|
|
255
|
+
subscribe() { this._closed = false; this._run(); return this; }
|
|
256
|
+
|
|
257
|
+
// Close the stream and stop reconnecting.
|
|
258
|
+
unsubscribe() { this._closed = true; if (this._abort) { try { this._abort.abort(); } catch { /* ignore */ } } return this; }
|
|
259
|
+
|
|
260
|
+
async _run() {
|
|
261
|
+
let backoff = 500;
|
|
262
|
+
while (!this._closed) {
|
|
263
|
+
try {
|
|
264
|
+
await this._stream(); // resolves when the server closes the stream
|
|
265
|
+
backoff = 500; // clean end → reset backoff before reconnecting
|
|
266
|
+
} catch (e) {
|
|
267
|
+
if (this._closed) break;
|
|
268
|
+
if (this._onError) { try { this._onError(e); } catch { /* ignore */ } }
|
|
269
|
+
}
|
|
270
|
+
if (this._closed) break;
|
|
271
|
+
await new Promise((r) => setTimeout(r, backoff));
|
|
272
|
+
backoff = Math.min(backoff * 2, 10000);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async _stream() {
|
|
277
|
+
const q = {};
|
|
278
|
+
if (this._tables) q.tables = this._tables.join(',');
|
|
279
|
+
if (this._events) q.events = this._events.join(',');
|
|
280
|
+
const url = this._c.baseUrl + '/realtime' + queryString(q);
|
|
281
|
+
const headers = { Accept: 'text/event-stream', ...this._c._authHeaders() };
|
|
282
|
+
|
|
283
|
+
this._abort = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
|
284
|
+
const res = await this._c._fetch(url, { headers, signal: this._abort ? this._abort.signal : undefined });
|
|
285
|
+
if (!res.ok) {
|
|
286
|
+
let body = null; try { const t = await res.text(); if (t) body = JSON.parse(t); } catch { /* non-JSON */ }
|
|
287
|
+
throw new CrescoError((body && body.error) || res.statusText || `HTTP ${res.status}`, res.status, body);
|
|
288
|
+
}
|
|
289
|
+
if (!res.body || typeof res.body.getReader !== 'function') {
|
|
290
|
+
throw new CrescoError('Realtime needs a streaming fetch (res.body.getReader) — not available on this runtime', 0, null);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const reader = res.body.getReader();
|
|
294
|
+
const decoder = new TextDecoder();
|
|
295
|
+
let buf = '';
|
|
296
|
+
try {
|
|
297
|
+
while (!this._closed) {
|
|
298
|
+
const { value, done } = await reader.read();
|
|
299
|
+
if (done) break;
|
|
300
|
+
buf += decoder.decode(value, { stream: true });
|
|
301
|
+
let i;
|
|
302
|
+
while ((i = buf.indexOf('\n\n')) !== -1) { // one full SSE frame
|
|
303
|
+
this._frame(buf.slice(0, i));
|
|
304
|
+
buf = buf.slice(i + 2);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
} finally {
|
|
308
|
+
// cancel() rejects when the stream was aborted — swallow it (sync + async).
|
|
309
|
+
try { const c = reader.cancel(); if (c && c.catch) c.catch(() => {}); } catch { /* ignore */ }
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
_frame(frame) {
|
|
314
|
+
let event = 'message';
|
|
315
|
+
const data = [];
|
|
316
|
+
for (const line of frame.split('\n')) {
|
|
317
|
+
if (!line || line[0] === ':') continue; // blank or comment (heartbeat)
|
|
318
|
+
if (line.startsWith('event:')) event = line.slice(6).trim();
|
|
319
|
+
else if (line.startsWith('data:')) data.push(line.slice(5).replace(/^ /, ''));
|
|
320
|
+
}
|
|
321
|
+
if (!data.length) return;
|
|
322
|
+
let parsed; try { parsed = JSON.parse(data.join('\n')); } catch { parsed = data.join('\n'); }
|
|
323
|
+
if (event === 'ready') { if (this._onReady) { try { this._onReady(parsed); } catch { /* ignore */ } } return; }
|
|
324
|
+
if (event === 'change') this._emit(parsed);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Create a CrescoDB client for a project's API base URL.
|
|
329
|
+
export function createClient(baseUrl, options = {}) {
|
|
330
|
+
return new CrescoClient(baseUrl, options);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export default { createClient, CrescoError };
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cresco-js",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Official JavaScript/TypeScript client for CrescoDB — auth + CRUD over the auto-generated REST API. Zero dependencies (uses fetch).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"module": "index.js",
|
|
8
|
+
"types": "index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./index.d.ts",
|
|
12
|
+
"import": "./index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"index.js",
|
|
17
|
+
"index.d.ts",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"keywords": [
|
|
21
|
+
"crescodb",
|
|
22
|
+
"client",
|
|
23
|
+
"sdk",
|
|
24
|
+
"rest",
|
|
25
|
+
"database"
|
|
26
|
+
],
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=18.0.0"
|
|
30
|
+
}
|
|
31
|
+
}
|