@ichibase/client 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.js ADDED
@@ -0,0 +1,1066 @@
1
+ // src/core.ts
2
+ function urlJoin(base, path) {
3
+ return base.replace(/\/$/, "") + (path.startsWith("/") ? path : "/" + path);
4
+ }
5
+ async function parseBody(res) {
6
+ const text = await res.text();
7
+ if (!text) return null;
8
+ try {
9
+ return JSON.parse(text);
10
+ } catch {
11
+ return text;
12
+ }
13
+ }
14
+ async function asResult(res) {
15
+ if (res.ok) {
16
+ return { data: await parseBody(res), error: null };
17
+ }
18
+ const body = await parseBody(res);
19
+ return {
20
+ data: null,
21
+ error: {
22
+ code: body?.code ?? `http_${res.status}`,
23
+ detail: body?.detail ?? body?.message ?? `HTTP ${res.status}`,
24
+ status: res.status
25
+ }
26
+ };
27
+ }
28
+
29
+ // src/postgrest.ts
30
+ var QueryBuilder = class {
31
+ // deno-lint-ignore no-explicit-any
32
+ constructor(state) {
33
+ this.state = state;
34
+ }
35
+ // ── Eq family ─────────────────────────────────────────────────────
36
+ eq(col, val) {
37
+ return this.appendFilter(col, "eq", val);
38
+ }
39
+ neq(col, val) {
40
+ return this.appendFilter(col, "neq", val);
41
+ }
42
+ gt(col, val) {
43
+ return this.appendFilter(col, "gt", val);
44
+ }
45
+ gte(col, val) {
46
+ return this.appendFilter(col, "gte", val);
47
+ }
48
+ lt(col, val) {
49
+ return this.appendFilter(col, "lt", val);
50
+ }
51
+ lte(col, val) {
52
+ return this.appendFilter(col, "lte", val);
53
+ }
54
+ // ── Pattern / text search ─────────────────────────────────────────
55
+ like(col, pattern) {
56
+ return this.appendFilter(col, "like", pattern);
57
+ }
58
+ ilike(col, pattern) {
59
+ return this.appendFilter(col, "ilike", pattern);
60
+ }
61
+ /** Full-text search (PostgreSQL @@). `mode` picks the parser. */
62
+ fts(col, query, opts = {}) {
63
+ const op = opts.type === "phrase" ? "phfts" : opts.type === "websearch" ? "wfts" : "plfts";
64
+ const cfg = opts.config ? `(${opts.config})` : "";
65
+ this.state.filters.push(`${col}=${op}${cfg}.${encodeURIComponent(query)}`);
66
+ return this;
67
+ }
68
+ // ── Membership / null / boolean ───────────────────────────────────
69
+ in(col, values) {
70
+ this.state.filters.push(
71
+ `${col}=in.(${values.map((v) => encodeURIComponent(String(v))).join(",")})`
72
+ );
73
+ return this;
74
+ }
75
+ /** is.null, is.true, is.false, is.unknown */
76
+ is(col, val) {
77
+ const v = val === null ? "null" : val === "unknown" ? "unknown" : String(val);
78
+ this.state.filters.push(`${col}=is.${v}`);
79
+ return this;
80
+ }
81
+ // ── Array / range operators ───────────────────────────────────────
82
+ /** array/jsonb `@>` — col contains the given values. */
83
+ contains(col, val) {
84
+ return this.appendFilter(col, "cs", this.formatArrayOrJson(val));
85
+ }
86
+ /** `<@` — col is contained by the given values. */
87
+ containedBy(col, val) {
88
+ return this.appendFilter(col, "cd", this.formatArrayOrJson(val));
89
+ }
90
+ /** `&&` — arrays/ranges overlap. */
91
+ overlaps(col, val) {
92
+ return this.appendFilter(col, "ov", this.formatArrayOrJson(val));
93
+ }
94
+ /** Range strictly left of (`<<`). */
95
+ rangeLt(col, val) {
96
+ return this.appendFilter(col, "sl", val);
97
+ }
98
+ /** Range strictly right of (`>>`). */
99
+ rangeGt(col, val) {
100
+ return this.appendFilter(col, "sr", val);
101
+ }
102
+ /** Range does not extend to the right of (`&<`). */
103
+ rangeLte(col, val) {
104
+ return this.appendFilter(col, "nxr", val);
105
+ }
106
+ /** Range does not extend to the left of (`&>`). */
107
+ rangeGte(col, val) {
108
+ return this.appendFilter(col, "nxl", val);
109
+ }
110
+ /** Adjacent ranges (`-|-`). */
111
+ rangeAdjacent(col, val) {
112
+ return this.appendFilter(col, "adj", val);
113
+ }
114
+ // ── Logical / negation ────────────────────────────────────────────
115
+ /** Negate a filter: `not(col, 'gt', 18)` → `col=not.gt.18`. */
116
+ not(col, op, val) {
117
+ this.state.filters.push(`${col}=not.${op}.${encodeURIComponent(String(val))}`);
118
+ return this;
119
+ }
120
+ /**
121
+ * Logical OR. Pass a comma-separated filter string in PostgREST syntax:
122
+ * .or('age.gt.18,status.eq.active')
123
+ * .or('and(status.eq.paid,total.gt.100),user_id.eq.42')
124
+ */
125
+ or(filters) {
126
+ this.state.filters.push(`or=(${encodeURIComponent(filters)})`);
127
+ return this;
128
+ }
129
+ /** Logical AND group (rarely needed — multiple chained filters are already ANDed). */
130
+ and(filters) {
131
+ this.state.filters.push(`and=(${encodeURIComponent(filters)})`);
132
+ return this;
133
+ }
134
+ /** Multiple eq() in one call: `.match({ status: 'paid', user_id: 42 })`. */
135
+ match(query) {
136
+ for (const [k, v] of Object.entries(query)) this.eq(k, v);
137
+ return this;
138
+ }
139
+ /** Escape hatch — any column/op/value combo PostgREST supports. */
140
+ filter(col, op, val) {
141
+ return this.appendFilter(col, op, val);
142
+ }
143
+ // ── Ordering & paging ─────────────────────────────────────────────
144
+ order(col, opts = {}) {
145
+ const dir = opts.ascending === false ? "desc" : "asc";
146
+ const nulls = opts.nullsFirst === true ? ".nullsfirst" : opts.nullsFirst === false ? ".nullslast" : "";
147
+ this.state.filters.push(`order=${encodeURIComponent(col + "." + dir + nulls)}`);
148
+ return this;
149
+ }
150
+ limit(n) {
151
+ this.state.filters.push(`limit=${n}`);
152
+ return this;
153
+ }
154
+ offset(n) {
155
+ this.state.filters.push(`offset=${n}`);
156
+ return this;
157
+ }
158
+ /**
159
+ * HTTP Range pagination — `from` and `to` are inclusive 0-based.
160
+ * Server returns 206 + Content-Range: from-to/total.
161
+ * Pairs naturally with `.count()`.
162
+ */
163
+ range(from, to) {
164
+ this.state.rangeFrom = from;
165
+ this.state.rangeTo = to;
166
+ return this;
167
+ }
168
+ // ── Column projection & embeds ────────────────────────────────────
169
+ /**
170
+ * Pick columns and embed related resources:
171
+ * .select('id, total, customer:profiles(name, email)')
172
+ * Defaults to '*'.
173
+ */
174
+ select(cols = "*") {
175
+ this.state.method = this.state.method ?? "GET";
176
+ this.state.filters.push(`select=${encodeURIComponent(cols)}`);
177
+ return this;
178
+ }
179
+ // ── Schema (for non-public) ───────────────────────────────────────
180
+ /** Target a non-public schema for this call. */
181
+ schema(name) {
182
+ this.state.schemaName = name;
183
+ return this;
184
+ }
185
+ // ── Write ops ─────────────────────────────────────────────────────
186
+ insert(rows) {
187
+ this.state.method = "POST";
188
+ this.state.body = Array.isArray(rows) ? rows : [rows];
189
+ return this;
190
+ }
191
+ /**
192
+ * INSERT ... ON CONFLICT ... DO UPDATE / DO NOTHING.
193
+ * .upsert([row1, row2], { onConflict: 'email' })
194
+ * .upsert(row, { onConflict: 'id', ignoreDuplicates: true })
195
+ */
196
+ upsert(rows, opts = {}) {
197
+ this.state.method = "POST";
198
+ this.state.body = Array.isArray(rows) ? rows : [rows];
199
+ this.state.upsertResolution = opts.ignoreDuplicates ? "ignore-duplicates" : "merge-duplicates";
200
+ if (opts.onConflict) this.state.onConflict = opts.onConflict;
201
+ return this;
202
+ }
203
+ update(values) {
204
+ this.state.method = "PATCH";
205
+ this.state.body = values;
206
+ return this;
207
+ }
208
+ delete() {
209
+ this.state.method = "DELETE";
210
+ return this;
211
+ }
212
+ /** Mark write op to return the affected row(s). */
213
+ returning() {
214
+ this.state.returnRepresentation = true;
215
+ return this;
216
+ }
217
+ // ── Shape modifiers (re-type the resolved value) ──────────────────
218
+ /**
219
+ * Expect exactly one row — server returns 406 otherwise.
220
+ * After this, the result data is T (not T[]).
221
+ */
222
+ single() {
223
+ this.state.acceptSingle = true;
224
+ return this;
225
+ }
226
+ /**
227
+ * Expect zero or one row — never errors on shape.
228
+ * After this, the result data is T | null.
229
+ */
230
+ maybeSingle() {
231
+ this.state.acceptMaybeSingle = true;
232
+ this.state.filters.push("limit=1");
233
+ return this;
234
+ }
235
+ /** Return CSV text instead of JSON rows. */
236
+ csv() {
237
+ this.state.acceptCsv = true;
238
+ return this;
239
+ }
240
+ /**
241
+ * Include the total row count in the result. The resolved value
242
+ * becomes `{ rows: T[]; count: number }` — count is the size of
243
+ * the FULL result set ignoring limit/offset/range.
244
+ * `mode='exact'` is accurate (slow on big tables);
245
+ * `'planned'` uses the planner estimate;
246
+ * `'estimated'` uses planner then exact-counts only if small.
247
+ */
248
+ count(mode = "exact") {
249
+ this.state.countMode = mode;
250
+ return this;
251
+ }
252
+ // ── Misc ──────────────────────────────────────────────────────────
253
+ /** Add an arbitrary header to the request (e.g. `Prefer: missing=null`). */
254
+ setHeader(name, value) {
255
+ this.state.extraHeaders = { ...this.state.extraHeaders ?? {}, [name]: value };
256
+ return this;
257
+ }
258
+ /** HEAD request — no body returned. Pair with `.count()` to fetch just a total. */
259
+ head() {
260
+ this.state.method = "HEAD";
261
+ return this;
262
+ }
263
+ // ── Thenable ──────────────────────────────────────────────────────
264
+ then(onFulfilled, onRejected) {
265
+ return this.execute().then(onFulfilled, onRejected);
266
+ }
267
+ // ── Internals ─────────────────────────────────────────────────────
268
+ appendFilter(col, op, val) {
269
+ this.state.filters.push(`${col}=${op}.${encodeURIComponent(String(val))}`);
270
+ return this;
271
+ }
272
+ formatArrayOrJson(val) {
273
+ if (Array.isArray(val)) return `{${val.map((v) => String(v)).join(",")}}`;
274
+ if (typeof val === "object" && val !== null) return JSON.stringify(val);
275
+ return String(val);
276
+ }
277
+ buildHeaders() {
278
+ const h = { "Authorization": `Bearer ${this.state.key}` };
279
+ if (this.state.body !== void 0) h["Content-Type"] = "application/json";
280
+ if (this.state.acceptSingle) h["Accept"] = "application/vnd.pgrst.object+json";
281
+ else if (this.state.acceptCsv) h["Accept"] = "text/csv";
282
+ const prefer = [];
283
+ if (this.state.returnRepresentation) prefer.push("return=representation");
284
+ if (this.state.countMode) prefer.push(`count=${this.state.countMode}`);
285
+ if (this.state.upsertResolution) prefer.push(`resolution=${this.state.upsertResolution}`);
286
+ if (prefer.length) h["Prefer"] = prefer.join(",");
287
+ if (this.state.rangeFrom !== void 0 && this.state.rangeTo !== void 0) {
288
+ h["Range"] = `${this.state.rangeFrom}-${this.state.rangeTo}`;
289
+ h["Range-Unit"] = "items";
290
+ }
291
+ if (this.state.schemaName) {
292
+ const m = this.state.method ?? "GET";
293
+ if (m === "GET" || m === "HEAD") h["Accept-Profile"] = this.state.schemaName;
294
+ else h["Content-Profile"] = this.state.schemaName;
295
+ }
296
+ if (this.state.extraHeaders) Object.assign(h, this.state.extraHeaders);
297
+ return h;
298
+ }
299
+ async execute() {
300
+ const method = this.state.method ?? "GET";
301
+ const filters = [...this.state.filters];
302
+ if (this.state.onConflict) filters.push(`on_conflict=${encodeURIComponent(this.state.onConflict)}`);
303
+ const qs = filters.length ? "?" + filters.join("&") : "";
304
+ const url = urlJoin(this.state.base, `/postgres/${this.state.table}${qs}`);
305
+ const headers = this.buildHeaders();
306
+ const res = await this.state.fetchFn(url, {
307
+ method,
308
+ headers,
309
+ body: this.state.body !== void 0 ? JSON.stringify(this.state.body) : void 0
310
+ });
311
+ if (this.state.acceptCsv) {
312
+ if (!res.ok) return await asResult(res);
313
+ return { data: await res.text(), error: null };
314
+ }
315
+ if (this.state.countMode) {
316
+ const contentRange = res.headers.get("Content-Range");
317
+ const total = contentRange?.split("/")[1];
318
+ const count = total && total !== "*" ? parseInt(total, 10) : 0;
319
+ if (method === "HEAD") {
320
+ if (!res.ok) return await asResult(res);
321
+ return { data: { rows: [], count }, error: null };
322
+ }
323
+ const inner = await asResult(res);
324
+ if (inner.error) return inner;
325
+ return { data: { rows: inner.data, count }, error: null };
326
+ }
327
+ if (this.state.acceptMaybeSingle) {
328
+ if (!res.ok) return await asResult(res);
329
+ const body = await parseBody(res);
330
+ if (Array.isArray(body)) {
331
+ return { data: body[0] ?? null, error: null };
332
+ }
333
+ return { data: body ?? null, error: null };
334
+ }
335
+ return await asResult(res);
336
+ }
337
+ };
338
+ var Postgrest = class _Postgrest {
339
+ constructor(url, key, fetchFn) {
340
+ this.url = url;
341
+ this.key = key;
342
+ this.fetchFn = fetchFn;
343
+ }
344
+ /** Start a query against a table or view. */
345
+ from(table) {
346
+ return new QueryBuilder({
347
+ base: this.url,
348
+ key: this.key,
349
+ table,
350
+ fetchFn: this.fetchFn,
351
+ filters: []
352
+ });
353
+ }
354
+ /**
355
+ * Call a stored procedure (PostgREST RPC). Returns whatever the
356
+ * function returns — set the generic to type it. Pass `count:'exact'`
357
+ * if you want a total in the same envelope.
358
+ */
359
+ async rpc(fnName, args = {}, opts = {}) {
360
+ const url = urlJoin(this.url, `/postgres/rpc/${fnName}`);
361
+ const headers = {
362
+ "Authorization": `Bearer ${this.key}`,
363
+ "Content-Type": "application/json"
364
+ };
365
+ const prefer = [];
366
+ if (opts.count) prefer.push(`count=${opts.count}`);
367
+ if (prefer.length) headers["Prefer"] = prefer.join(",");
368
+ if (opts.schema) headers["Content-Profile"] = opts.schema;
369
+ const res = await this.fetchFn(url, {
370
+ method: opts.head ? "HEAD" : "POST",
371
+ headers,
372
+ body: opts.head ? void 0 : JSON.stringify(args)
373
+ });
374
+ return await asResult(res);
375
+ }
376
+ /** Return a new Postgrest authenticated as a specific end-user (RLS applies). */
377
+ asUser(accessToken) {
378
+ return new _Postgrest(this.url, accessToken, this.fetchFn);
379
+ }
380
+ };
381
+
382
+ // src/mongo.ts
383
+ var MongoCollection = class {
384
+ constructor(base, key, collection, fetchFn, userToken) {
385
+ this.base = base;
386
+ this.key = key;
387
+ this.collection = collection;
388
+ this.fetchFn = fetchFn;
389
+ this.userToken = userToken;
390
+ }
391
+ async op(op, body, query) {
392
+ let url = urlJoin(this.base, `/mongo/v1/${op}/${this.collection}`);
393
+ if (query) {
394
+ const qs = new URLSearchParams(query).toString();
395
+ if (qs) url += (url.includes("?") ? "&" : "?") + qs;
396
+ }
397
+ const headers = {
398
+ "apikey": this.key,
399
+ "Content-Type": "application/json"
400
+ };
401
+ if (this.userToken) headers["Authorization"] = `Bearer ${this.userToken}`;
402
+ const res = await this.fetchFn(url, {
403
+ method: "POST",
404
+ headers,
405
+ body: JSON.stringify(body)
406
+ });
407
+ return asResult(res);
408
+ }
409
+ // Build the realtime query suffix for a write. `realtime: false` tells the
410
+ // gateway to skip the realtime emit for THIS write (honoured only for
411
+ // service_role / admin keys server-side). undefined/true emits as normal.
412
+ rt(realtime) {
413
+ return realtime === false ? { realtime: "false" } : void 0;
414
+ }
415
+ find(filter = {}, opts = {}) {
416
+ return this.op("find", { filter, ...opts });
417
+ }
418
+ findOne(filter = {}) {
419
+ return this.op("findOne", { filter });
420
+ }
421
+ insertOne(doc, opts = {}) {
422
+ return this.op("insertOne", { doc }, this.rt(opts.realtime));
423
+ }
424
+ insertMany(docs, opts = {}) {
425
+ return this.op("insertMany", { docs }, this.rt(opts.realtime));
426
+ }
427
+ updateOne(filter, update, opts = {}) {
428
+ const { realtime, ...rest } = opts;
429
+ return this.op(
430
+ "updateOne",
431
+ { filter, update, ...rest },
432
+ this.rt(realtime)
433
+ );
434
+ }
435
+ updateMany(filter, update, opts = {}) {
436
+ return this.op(
437
+ "updateMany",
438
+ { filter, update },
439
+ this.rt(opts.realtime)
440
+ );
441
+ }
442
+ deleteOne(filter, opts = {}) {
443
+ return this.op("deleteOne", { filter }, this.rt(opts.realtime));
444
+ }
445
+ deleteMany(filter, opts = {}) {
446
+ return this.op("deleteMany", { filter }, this.rt(opts.realtime));
447
+ }
448
+ count(filter = {}) {
449
+ return this.op("count", { filter });
450
+ }
451
+ aggregate(pipeline) {
452
+ return this.op("aggregate", { pipeline });
453
+ }
454
+ // ── Phase 2 (v0.3.x) operations ────────────────────────────────
455
+ /**
456
+ * Atomic find-and-update. Returns the matched document (post-update
457
+ * by default; pass `returnDocument: 'before'` for the pre-update
458
+ * snapshot). Honours upsert; on upsert with no match the returned
459
+ * doc is null.
460
+ *
461
+ * await users.findOneAndUpdate(
462
+ * { _id: 1 },
463
+ * { $inc: { visits: 1 } },
464
+ * { returnDocument: 'after' },
465
+ * );
466
+ */
467
+ findOneAndUpdate(filter, update, opts = {}) {
468
+ return this.op("findOneAndUpdate", {
469
+ filter,
470
+ update,
471
+ ...opts.projection ? { projection: opts.projection } : {},
472
+ ...opts.sort ? { sort: opts.sort } : {},
473
+ ...opts.upsert !== void 0 ? { upsert: opts.upsert } : {},
474
+ ...opts.returnDocument ? { return_document: opts.returnDocument } : {}
475
+ }, this.rt(opts.realtime));
476
+ }
477
+ /** Atomic find-and-delete. Returns the deleted document or null. */
478
+ findOneAndDelete(filter, opts = {}) {
479
+ return this.op("findOneAndDelete", {
480
+ filter,
481
+ ...opts.projection ? { projection: opts.projection } : {},
482
+ ...opts.sort ? { sort: opts.sort } : {}
483
+ }, this.rt(opts.realtime));
484
+ }
485
+ /**
486
+ * Replace a single matched document with a full replacement.
487
+ * Unlike updateOne, the body is the new document — no $set / $inc.
488
+ * Keys starting with `$` are rejected.
489
+ */
490
+ replaceOne(filter, replacement, opts = {}) {
491
+ return this.op(
492
+ "replaceOne",
493
+ { filter, replacement, ...opts.upsert !== void 0 ? { upsert: opts.upsert } : {} },
494
+ this.rt(opts.realtime)
495
+ );
496
+ }
497
+ /**
498
+ * Batched write — multiple ops in one HTTP round trip. Each op is
499
+ * one of insertOne / updateOne / updateMany / replaceOne /
500
+ * deleteOne / deleteMany. The whole batch shares one policy check.
501
+ *
502
+ * await users.bulkWrite([
503
+ * { op: 'insertOne', doc: { name: 'a' } },
504
+ * { op: 'updateOne', filter: { _id: 1 }, update: { $set: { name: 'b' } } },
505
+ * { op: 'deleteOne', filter: { _id: 2 } },
506
+ * ]);
507
+ *
508
+ * Set `ordered: true` to stop on first error; default is unordered
509
+ * (continue past errors). Subject to your plan's mongo_max_docs cap.
510
+ */
511
+ bulkWrite(ops, opts = {}) {
512
+ return this.op("bulkWrite", {
513
+ ops,
514
+ ...opts.ordered !== void 0 ? { ordered: opts.ordered } : {}
515
+ }, this.rt(opts.realtime));
516
+ }
517
+ /**
518
+ * Distinct values of a field across docs matching filter.
519
+ *
520
+ * await events.distinct('category', { tenant_id: 'acme' });
521
+ * // → { data: { values: ['ui', 'api', 'cron'], truncated: false } }
522
+ */
523
+ distinct(field, filter = {}) {
524
+ return this.op("distinct", { field, filter });
525
+ }
526
+ };
527
+ var Mongo = class _Mongo {
528
+ constructor(base, key, fetchFn, userToken) {
529
+ this.base = base;
530
+ this.key = key;
531
+ this.fetchFn = fetchFn;
532
+ this.userToken = userToken;
533
+ }
534
+ /**
535
+ * Return a Mongo client that acts AS the signed-in end user: their JWT is
536
+ * sent as `Authorization: Bearer`, so your `_mongo_policy` and realtime rules
537
+ * see the real `$auth.uid` / role. The project key still gates anon vs
538
+ * service_role. Pass the access token you got from ichibase auth.
539
+ */
540
+ asUser(token) {
541
+ return new _Mongo(this.base, this.key, this.fetchFn, token);
542
+ }
543
+ collection(name) {
544
+ return new MongoCollection(this.base, this.key, name, this.fetchFn, this.userToken);
545
+ }
546
+ };
547
+
548
+ // src/functions.ts
549
+ var Functions = class _Functions {
550
+ constructor(base, key, fetchFn, userToken) {
551
+ this.base = base;
552
+ this.key = key;
553
+ this.fetchFn = fetchFn;
554
+ this.userToken = userToken;
555
+ }
556
+ /** Return a Functions client that calls AS a specific end user. */
557
+ asUser(accessToken) {
558
+ return new _Functions(this.base, this.key, this.fetchFn, accessToken);
559
+ }
560
+ /**
561
+ * Invoke an Edge Function by name.
562
+ *
563
+ * const { data, error } = await ichi.functions.invoke('hello', { body: { name: 'world' } });
564
+ */
565
+ async invoke(name, opts = {}) {
566
+ const url = urlJoin(this.base, `/functions/${name}${opts.path ?? ""}`);
567
+ const headers = { apikey: this.key, ...opts.headers };
568
+ if (this.userToken) headers["Authorization"] = `Bearer ${this.userToken}`;
569
+ let body;
570
+ const b = opts.body;
571
+ if (b !== void 0) {
572
+ const isRaw = typeof b === "string" || b instanceof ArrayBuffer || ArrayBuffer.isView(b) || typeof Blob !== "undefined" && b instanceof Blob || typeof FormData !== "undefined" && b instanceof FormData;
573
+ if (isRaw) {
574
+ body = b;
575
+ } else {
576
+ body = JSON.stringify(b);
577
+ if (!("Content-Type" in headers)) headers["Content-Type"] = "application/json";
578
+ }
579
+ }
580
+ const res = await this.fetchFn(url, {
581
+ method: opts.method ?? "POST",
582
+ headers,
583
+ body
584
+ });
585
+ return asResult(res);
586
+ }
587
+ };
588
+
589
+ // src/auth.ts
590
+ var Auth = class {
591
+ constructor(base, key, fetchFn) {
592
+ this.base = base;
593
+ this.key = key;
594
+ this.fetchFn = fetchFn;
595
+ }
596
+ call(path, opts = {}) {
597
+ const headers = {
598
+ "Authorization": `Bearer ${opts.auth ?? this.key}`
599
+ };
600
+ if (opts.body !== void 0) headers["Content-Type"] = "application/json";
601
+ return this.fetchFn(urlJoin(this.base, `/auth${path}`), {
602
+ method: opts.method ?? "POST",
603
+ headers,
604
+ body: opts.body !== void 0 ? JSON.stringify(opts.body) : void 0
605
+ }).then((res) => asResult(res));
606
+ }
607
+ signup(input) {
608
+ return this.call("/signup", { body: input });
609
+ }
610
+ login(input) {
611
+ return this.call("/login", { body: input });
612
+ }
613
+ refresh(refresh_token) {
614
+ return this.call("/refresh", { body: { refresh_token } });
615
+ }
616
+ /** Get the user identified by the given access token (or the SDK's key if none given). */
617
+ getUser(accessToken) {
618
+ return this.call("/me", { method: "GET", auth: accessToken });
619
+ }
620
+ logout(refresh_token, accessToken) {
621
+ return this.call("/logout", { body: { refresh_token }, auth: accessToken });
622
+ }
623
+ logoutAll(accessToken) {
624
+ return this.call("/logout-all", { auth: accessToken });
625
+ }
626
+ requestPasswordReset(email) {
627
+ return this.call("/password-reset/request", { body: { email } });
628
+ }
629
+ confirmPasswordReset(token, new_password) {
630
+ return this.call("/password-reset/confirm", {
631
+ body: { token, new_password }
632
+ });
633
+ }
634
+ verifyEmail(token) {
635
+ return this.call("/verify-email", { body: { token } });
636
+ }
637
+ verifyEmailOtp(email, code) {
638
+ return this.call("/verify-email/otp", { body: { email, code } });
639
+ }
640
+ resendVerification(email) {
641
+ return this.call("/verify-email/resend", { body: { email } });
642
+ }
643
+ // ── Phase 2 (v0.3.x) — bearer-authed profile + session mgmt ──────
644
+ /**
645
+ * Update the user's metadata JSONB. **Full replacement** — to merge,
646
+ * read `getUser()` first and pass the merged object.
647
+ *
648
+ * await auth.updateUser(accessToken, { metadata: { theme: 'dark', tz: 'UTC' } });
649
+ *
650
+ * Server enforces an 8 KB cap on the encoded metadata.
651
+ */
652
+ updateUser(accessToken, patch) {
653
+ return this.call("/me", {
654
+ method: "PATCH",
655
+ body: { metadata: patch.metadata },
656
+ auth: accessToken
657
+ });
658
+ }
659
+ /**
660
+ * Change the user's password. Requires the current password — does
661
+ * NOT use a reset token (that's `confirmPasswordReset`). On success,
662
+ * existing access tokens KEEP working (short TTL); call `refresh()`
663
+ * after to mint a new pair if you want a fresh access token.
664
+ *
665
+ * Returns `{ changed: true, hint: '...' }`.
666
+ */
667
+ changePassword(accessToken, currentPassword, newPassword) {
668
+ return this.call("/change-password", {
669
+ body: { current_password: currentPassword, new_password: newPassword },
670
+ auth: accessToken
671
+ });
672
+ }
673
+ /**
674
+ * List the user's active refresh-token sessions (one row per
675
+ * device / login). Returns up to 100, ordered by most-recently-used.
676
+ * Each row includes the user-agent + IP captured at login so the
677
+ * user can identify devices.
678
+ */
679
+ listSessions(accessToken) {
680
+ return this.call("/sessions", {
681
+ method: "GET",
682
+ auth: accessToken
683
+ });
684
+ }
685
+ /**
686
+ * Revoke a single session by its id. Idempotent — revoking a
687
+ * session that's already revoked returns `{ revoked: true }`.
688
+ * The session must belong to the bearer user (404 otherwise).
689
+ */
690
+ revokeSession(accessToken, sessionId) {
691
+ return this.call(`/sessions/${encodeURIComponent(sessionId)}/revoke`, {
692
+ auth: accessToken
693
+ });
694
+ }
695
+ };
696
+
697
+ // src/realtime.ts
698
+ var HEARTBEAT_MS = 25e3;
699
+ var RECONNECT_BASE_MS = 1e3;
700
+ var RECONNECT_MAX_MS = 15e3;
701
+ var RealtimeClient = class {
702
+ constructor(opts) {
703
+ this.ws = null;
704
+ this.subs = /* @__PURE__ */ new Map();
705
+ this.refSeq = 0;
706
+ this.connecting = false;
707
+ this.closedByUser = false;
708
+ this.reconnectAttempts = 0;
709
+ this.heartbeat = null;
710
+ this.outbox = [];
711
+ this.url = opts.url.replace(/\/$/, "");
712
+ this.getToken = opts.getToken;
713
+ const impl = opts.WebSocketImpl ?? globalThis.WebSocket;
714
+ if (!impl) {
715
+ throw new Error("ichibase realtime: no global WebSocket \u2014 pass opts.WebSocketImpl");
716
+ }
717
+ this.WS = impl;
718
+ }
719
+ /** Subscribe to postgres/mongo changes or a broadcast channel. */
720
+ subscribe(opts, handler) {
721
+ const ref = `s${++this.refSeq}`;
722
+ this.subs.set(ref, { ref, opts, handler });
723
+ this.ensureConnected();
724
+ if (this.isOpen()) this.sendSubscribe(ref);
725
+ return {
726
+ unsubscribe: () => {
727
+ this.subs.delete(ref);
728
+ if (this.isOpen()) this.send({ type: "unsubscribe", ref });
729
+ if (this.subs.size === 0) this.disconnect();
730
+ },
731
+ send: (event, payload) => {
732
+ if (opts.kind !== "broadcast") throw new Error("send() is broadcast-only");
733
+ this.send({ type: "broadcast", channel: opts.channel, event, payload });
734
+ },
735
+ track: (state) => {
736
+ if (opts.kind !== "broadcast") throw new Error("track() is broadcast-only");
737
+ this.send({ type: "presence", channel: opts.channel, state });
738
+ }
739
+ };
740
+ }
741
+ /** Close the socket and drop all subscriptions. */
742
+ disconnect() {
743
+ this.closedByUser = true;
744
+ this.stopHeartbeat();
745
+ this.ws?.close();
746
+ this.ws = null;
747
+ }
748
+ // ── internals ──────────────────────────────────────────────────────
749
+ isOpen() {
750
+ return this.ws?.readyState === this.WS.OPEN;
751
+ }
752
+ ensureConnected() {
753
+ if (this.ws || this.connecting) return;
754
+ this.closedByUser = false;
755
+ this.connecting = true;
756
+ const token = this.getToken();
757
+ const wsUrl = this.url.replace(/^http/, "ws") + "/realtime" + (token ? `?token=${encodeURIComponent(token)}` : "");
758
+ const ws = new this.WS(wsUrl);
759
+ this.ws = ws;
760
+ ws.onopen = () => {
761
+ this.connecting = false;
762
+ this.reconnectAttempts = 0;
763
+ for (const ref of this.subs.keys()) this.sendSubscribe(ref);
764
+ for (const raw of this.outbox.splice(0)) ws.send(raw);
765
+ this.startHeartbeat();
766
+ };
767
+ ws.onmessage = (ev) => this.onFrame(ev.data);
768
+ ws.onclose = () => {
769
+ this.connecting = false;
770
+ this.stopHeartbeat();
771
+ this.ws = null;
772
+ if (!this.closedByUser && this.subs.size > 0) this.scheduleReconnect();
773
+ };
774
+ ws.onerror = () => {
775
+ };
776
+ }
777
+ scheduleReconnect() {
778
+ const delay = Math.min(RECONNECT_BASE_MS * 2 ** this.reconnectAttempts, RECONNECT_MAX_MS);
779
+ this.reconnectAttempts += 1;
780
+ setTimeout(() => {
781
+ if (!this.closedByUser && this.subs.size > 0) this.ensureConnected();
782
+ }, delay);
783
+ }
784
+ startHeartbeat() {
785
+ this.stopHeartbeat();
786
+ this.heartbeat = setInterval(() => this.send({ type: "ping" }), HEARTBEAT_MS);
787
+ }
788
+ stopHeartbeat() {
789
+ if (this.heartbeat) clearInterval(this.heartbeat);
790
+ this.heartbeat = null;
791
+ }
792
+ sendSubscribe(ref) {
793
+ const s = this.subs.get(ref);
794
+ if (!s) return;
795
+ const o = s.opts;
796
+ const msg = { type: "subscribe", ref, kind: o.kind };
797
+ if (o.kind === "postgres") {
798
+ msg.table = o.table;
799
+ if (o.events) msg.events = o.events;
800
+ if (o.filter !== void 0) msg.filter = o.filter;
801
+ } else if (o.kind === "mongo") {
802
+ msg.collection = o.collection;
803
+ if (o.events) msg.events = o.events;
804
+ if (o.filter !== void 0) msg.filter = o.filter;
805
+ } else {
806
+ msg.channel = o.channel;
807
+ if (o.presence) msg.presence = true;
808
+ if (o.state) msg.state = o.state;
809
+ }
810
+ this.send(msg);
811
+ }
812
+ send(msg) {
813
+ const raw = JSON.stringify(msg);
814
+ if (this.isOpen()) this.ws.send(raw);
815
+ else this.outbox.push(raw);
816
+ }
817
+ onFrame(data) {
818
+ if (typeof data !== "string") return;
819
+ let m;
820
+ try {
821
+ m = JSON.parse(data);
822
+ } catch {
823
+ return;
824
+ }
825
+ const type = m.type;
826
+ if (type === "pong" || type === "subscribed" || type === "unsubscribed" || type === "token_refreshed") {
827
+ return;
828
+ }
829
+ if (type === "change") {
830
+ for (const s of this.subs.values()) {
831
+ if (s.opts.kind === "postgres" && m.table === qualify(s.opts.table) || s.opts.kind === "mongo" && m.collection === s.opts.collection) {
832
+ s.handler(m);
833
+ }
834
+ }
835
+ } else if (type === "broadcast") {
836
+ for (const s of this.subs.values()) {
837
+ if (s.opts.kind === "broadcast" && m.channel === s.opts.channel) {
838
+ s.handler(m);
839
+ }
840
+ }
841
+ } else if (type === "presence_state" || type === "presence_diff") {
842
+ for (const s of this.subs.values()) {
843
+ if (s.opts.kind === "broadcast" && (m.channel === s.opts.channel || m.channel === void 0)) {
844
+ s.handler(m);
845
+ }
846
+ }
847
+ }
848
+ }
849
+ };
850
+ function qualify(table) {
851
+ return table.includes(".") ? table : `public.${table}`;
852
+ }
853
+
854
+ // src/storage-adapter.ts
855
+ var MemoryStorage = class {
856
+ constructor() {
857
+ this.m = /* @__PURE__ */ new Map();
858
+ }
859
+ getItem(key) {
860
+ return this.m.has(key) ? this.m.get(key) : null;
861
+ }
862
+ setItem(key, value) {
863
+ this.m.set(key, value);
864
+ }
865
+ removeItem(key) {
866
+ this.m.delete(key);
867
+ }
868
+ };
869
+
870
+ // src/client.ts
871
+ var DEFAULT_STORAGE_KEY = "ichibase.session";
872
+ var SessionAuth = class {
873
+ constructor(inner, client) {
874
+ this.inner = inner;
875
+ this.client = client;
876
+ }
877
+ async signup(input) {
878
+ return this.inner.signup(input);
879
+ }
880
+ async login(input) {
881
+ const res = await this.inner.login(input);
882
+ if (res.data) {
883
+ await this.client._setSession(
884
+ {
885
+ access_token: res.data.access_token,
886
+ refresh_token: res.data.refresh_token,
887
+ expires_at: expiresAt(res.data.expires_in),
888
+ user: res.data.user
889
+ },
890
+ "SIGNED_IN"
891
+ );
892
+ }
893
+ return res;
894
+ }
895
+ async refresh() {
896
+ const s = this.client.getSession();
897
+ if (!s) return { data: null, error: { code: "no_session", detail: "not logged in", status: 401 } };
898
+ const res = await this.inner.refresh(s.refresh_token);
899
+ if (res.data) {
900
+ await this.client._setSession(
901
+ {
902
+ access_token: res.data.access_token,
903
+ refresh_token: res.data.refresh_token,
904
+ expires_at: expiresAt(res.data.expires_in),
905
+ user: s.user
906
+ },
907
+ "TOKEN_REFRESHED"
908
+ );
909
+ }
910
+ return res;
911
+ }
912
+ /** Current signed-in user (from the live access token), or null. */
913
+ async getUser() {
914
+ const s = this.client.getSession();
915
+ if (!s) return null;
916
+ const res = await this.inner.getUser(s.access_token);
917
+ return res.data;
918
+ }
919
+ async logout() {
920
+ const s = this.client.getSession();
921
+ if (s) await this.inner.logout(s.refresh_token, s.access_token).catch(() => {
922
+ });
923
+ await this.client._setSession(null, "SIGNED_OUT");
924
+ }
925
+ requestPasswordReset(email) {
926
+ return this.inner.requestPasswordReset(email);
927
+ }
928
+ confirmPasswordReset(token, newPassword) {
929
+ return this.inner.confirmPasswordReset(token, newPassword);
930
+ }
931
+ verifyEmail(token) {
932
+ return this.inner.verifyEmail(token);
933
+ }
934
+ verifyEmailOtp(email, code) {
935
+ return this.inner.verifyEmailOtp(email, code);
936
+ }
937
+ resendVerification(email) {
938
+ return this.inner.resendVerification(email);
939
+ }
940
+ /** Hydrate the session from the storage adapter (call once at startup for async adapters). */
941
+ async loadSession() {
942
+ return this.client._loadSession();
943
+ }
944
+ /** Set the session directly (e.g. from your own SSR cookie). */
945
+ async setSession(session) {
946
+ await this.client._setSession(session, session ? "SIGNED_IN" : "SIGNED_OUT");
947
+ }
948
+ };
949
+ var IchibaseClient = class {
950
+ constructor(url, anonKey, opts = {}) {
951
+ this.session = null;
952
+ this.listeners = /* @__PURE__ */ new Set();
953
+ this._realtime = null;
954
+ if (!url) throw new Error("ichibase: url is required");
955
+ if (!anonKey) throw new Error("ichibase: anon key is required");
956
+ if (anonKey.startsWith("ich_admin_")) {
957
+ throw new Error(
958
+ "ichibase: @ichibase/client is anon-key only. ich_admin_ (service) keys bypass RLS \u2014 never ship them to a client. Use them server-side via the JSR SDKs."
959
+ );
960
+ }
961
+ this.url = url.replace(/\/$/, "");
962
+ this.anonKey = anonKey;
963
+ this.fetchFn = opts.fetch ?? globalThis.fetch.bind(globalThis);
964
+ this.sessionStore = opts.storage ?? new MemoryStorage();
965
+ this.storageKey = opts.storageKey ?? DEFAULT_STORAGE_KEY;
966
+ this.wsImpl = opts.WebSocketImpl;
967
+ this.auth = new SessionAuth(new Auth(this.url, this.anonKey, this.fetchFn), this);
968
+ try {
969
+ const raw = this.sessionStore.getItem(this.storageKey);
970
+ if (typeof raw === "string") this.session = parseSession(raw);
971
+ } catch {
972
+ }
973
+ }
974
+ /** The bearer to send on data-plane calls: the user token if signed in, else the anon key. */
975
+ bearer() {
976
+ return this.session?.access_token ?? this.anonKey;
977
+ }
978
+ cfg(key) {
979
+ return { url: this.url, key, fetch: this.fetchFn };
980
+ }
981
+ // ── PostgREST ──────────────────────────────────────────────────────
982
+ /** Start a PostgREST query against a table or view. */
983
+ from(table) {
984
+ return new Postgrest(this.url, this.bearer(), this.fetchFn).from(table);
985
+ }
986
+ /** Call a Postgres stored procedure (RPC). */
987
+ rpc(fn, args, opts) {
988
+ return new Postgrest(this.url, this.bearer(), this.fetchFn).rpc(fn, args, opts);
989
+ }
990
+ // ── Storage ────────────────────────────────────────────────────────
991
+ // Intentionally NOT exposed on the client. Storage tokens / presigned
992
+ // upload URLs are minted server-side by the project owner (Edge Function +
993
+ // service key) and handed to users — never minted from a client. See the
994
+ // Storage docs. Public files are read directly from cdn.ichibase.net.
995
+ // ── Mongo ──────────────────────────────────────────────────────────
996
+ /** Mongo data client (apikey = anon; user token attached when signed in). */
997
+ get mongo() {
998
+ const m = new Mongo(this.url, this.anonKey, this.fetchFn);
999
+ return this.session ? m.asUser(this.session.access_token) : m;
1000
+ }
1001
+ // ── Edge Functions ─────────────────────────────────────────────────
1002
+ /** Invoke your deployed Edge Functions: `ichi.functions.invoke('name', { body })`. */
1003
+ get functions() {
1004
+ const f = new Functions(this.url, this.anonKey, this.fetchFn);
1005
+ return this.session ? f.asUser(this.session.access_token) : f;
1006
+ }
1007
+ // ── Realtime ───────────────────────────────────────────────────────
1008
+ get realtime() {
1009
+ if (!this._realtime) {
1010
+ this._realtime = new RealtimeClient({
1011
+ url: this.url,
1012
+ getToken: () => this.bearer(),
1013
+ WebSocketImpl: this.wsImpl
1014
+ });
1015
+ }
1016
+ return this._realtime;
1017
+ }
1018
+ // ── Session ────────────────────────────────────────────────────────
1019
+ getSession() {
1020
+ return this.session;
1021
+ }
1022
+ /** Subscribe to auth state changes. Returns an unsubscribe fn. */
1023
+ onAuthStateChange(cb) {
1024
+ this.listeners.add(cb);
1025
+ return () => this.listeners.delete(cb);
1026
+ }
1027
+ /** @internal */
1028
+ async _setSession(session, event) {
1029
+ this.session = session;
1030
+ try {
1031
+ if (session) await this.sessionStore.setItem(this.storageKey, JSON.stringify(session));
1032
+ else await this.sessionStore.removeItem(this.storageKey);
1033
+ } catch {
1034
+ }
1035
+ for (const l of this.listeners) l(event, session);
1036
+ }
1037
+ /** @internal */
1038
+ async _loadSession() {
1039
+ try {
1040
+ const raw = await this.sessionStore.getItem(this.storageKey);
1041
+ this.session = typeof raw === "string" ? parseSession(raw) : null;
1042
+ } catch {
1043
+ this.session = null;
1044
+ }
1045
+ return this.session;
1046
+ }
1047
+ };
1048
+ function createClient(url, anonKey, opts) {
1049
+ return new IchibaseClient(url, anonKey, opts);
1050
+ }
1051
+ function expiresAt(expiresIn) {
1052
+ if (!expiresIn) return void 0;
1053
+ return Math.floor(Date.now() / 1e3) + expiresIn;
1054
+ }
1055
+ function parseSession(raw) {
1056
+ try {
1057
+ const s = JSON.parse(raw);
1058
+ return s && typeof s.access_token === "string" ? s : null;
1059
+ } catch {
1060
+ return null;
1061
+ }
1062
+ }
1063
+
1064
+ export { Auth, Functions, IchibaseClient, MemoryStorage, Mongo, MongoCollection, Postgrest, QueryBuilder, RealtimeClient, createClient };
1065
+ //# sourceMappingURL=index.js.map
1066
+ //# sourceMappingURL=index.js.map