marcidb-client 0.14.1 → 0.16.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 CHANGED
@@ -34,12 +34,15 @@ marcidb generate
34
34
  marcidb generate schema.marci node_modules/.marcidb/client
35
35
  ```
36
36
 
37
- 3. Start your MarciDB server and connect:
37
+ 3. Start your MarciDB server and connect — the database name is the last segment of the URL:
38
38
 
39
39
  ```typescript
40
40
  import { marcidb } from "marcidb-client";
41
41
 
42
- const db = marcidb("http://localhost:3000");
42
+ const db = marcidb("http://localhost:3000/myapp");
43
+
44
+ // a server started with --token (or an embedded app's maintenance port opened with one)
45
+ const secured = marcidb("http://localhost:3000/myapp", { token: process.env.MARCI_TOKEN });
43
46
  ```
44
47
 
45
48
  ## Usage
@@ -47,7 +50,7 @@ const db = marcidb("http://localhost:3000");
47
50
  ```typescript
48
51
  import { marcidb } from "marcidb-client";
49
52
 
50
- const db = marcidb("http://localhost:3000");
53
+ const db = marcidb("http://localhost:3000/myapp");
51
54
 
52
55
  // Find — a chainable query; no select = id + every scalar
53
56
  const users = await db.user.where({ name: { $startsWith: "A" } }).order("id", "desc").limit(20);
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
package/bin.js CHANGED
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { execFileSync } from "node:child_process";
3
+ import { execFileSync, spawnSync } from "node:child_process";
4
4
  import { fileURLToPath } from "node:url";
5
+ import { Readable } from "node:stream";
6
+ import { pipeline } from "node:stream/promises";
5
7
  import path from "node:path";
6
8
  import os from "node:os";
7
9
  import fs from "node:fs";
@@ -26,6 +28,9 @@ function binPath(base) {
26
28
  return path.join(__dirname, "bin", `${base}-${suffix}`);
27
29
  }
28
30
 
31
+ // Flags that never take a value (so `--yes backup.marcibin` keeps the file as a positional argument)
32
+ const BOOLEAN_FLAGS = new Set(["yes", "no-migrations"]);
33
+
29
34
  // Parses positional arguments and flags (--flag value | --flag)
30
35
  function parseArgs(args) {
31
36
  const positional = [];
@@ -35,7 +40,7 @@ function parseArgs(args) {
35
40
  if (a.startsWith("--")) {
36
41
  const key = a.slice(2);
37
42
  const next = args[i + 1];
38
- if (next === undefined || next.startsWith("--")) {
43
+ if (BOOLEAN_FLAGS.has(key) || next === undefined || next.startsWith("--")) {
39
44
  flags[key] = true;
40
45
  } else {
41
46
  flags[key] = next;
@@ -66,14 +71,45 @@ Commands:
66
71
  --url <url> server url (default: http://localhost:3000)
67
72
  --migrations <dir> migrations directory (default: migrations)
68
73
 
74
+ backup <db> [options] Download a consistent backup of a database (.marcibin)
75
+ --url <url> server url (default: http://localhost:3000)
76
+ --out <file|dir> where to write it (default: <db>-<YYYYMMDD-HHMMSS>.marcibin in cwd)
77
+
78
+ restore <db> <file> [options] Upload a backup; replaces the live database <db>
79
+ --url <url> server url (default: http://localhost:3000)
80
+ --into <name> restore under another name instead (created if missing)
81
+ --yes confirm replacing a database that exists
82
+
83
+ inspect <file|dir> [options] Read a backup, or a database directory no process has open
84
+ --tree <name> list one tree's entries (rows decoded through the schema)
85
+ --limit <n> entries to show (default: 20)
86
+ --id <json> one row of a model tree, by id
87
+ --raw hex keys/values; row headers annotated
88
+
89
+ Every command talking to a server sends --token <token> (or $MARCI_TOKEN) as a bearer token.
90
+
69
91
  Examples:
70
92
  marcidb generate
71
93
  marcidb generate schema.marci --name add_users
72
94
  marcidb migrate push myapp --url http://localhost:3000
73
95
  marcidb migrate check myapp
96
+ marcidb backup myapp --out backups/
97
+ marcidb restore myapp backups/myapp-20261012-030000.marcibin --into myapp_check
98
+ marcidb inspect backups/myapp-20261012-030000.marcibin --tree User --limit 5
74
99
  `);
75
100
  }
76
101
 
102
+ // The server url (--url, default local) without a trailing slash
103
+ function serverUrl(flags) {
104
+ return (flags.url ?? "http://localhost:3000").replace(/\/+$/, "");
105
+ }
106
+
107
+ // `Authorization: Bearer` for a server started with --token / MARCI_TOKEN
108
+ function authHeaders(flags) {
109
+ const token = typeof flags.token === "string" ? flags.token : process.env.MARCI_TOKEN;
110
+ return token ? { Authorization: `Bearer ${token}` } : {};
111
+ }
112
+
77
113
  function cmdGenerate(args) {
78
114
  const { positional, flags } = parseArgs(args);
79
115
  const schema = positional[0] ?? "schema.marci";
@@ -112,8 +148,8 @@ async function cmdMigrate(args) {
112
148
  }
113
149
 
114
150
  // The server's current snapshot ("" if the DB doesn't exist yet)
115
- async function fetchServerSnapshot(url, db) {
116
- const res = await fetch(`${url}/${db}/$snapshot`);
151
+ async function fetchServerSnapshot(url, db, flags) {
152
+ const res = await fetch(`${url}/${db}/$snapshot`, { headers: authHeaders(flags) });
117
153
  if (res.ok) return await res.text();
118
154
  if (res.status === 404) return "";
119
155
  console.error(`Cannot read server snapshot [${res.status}]: ${await res.text()}`);
@@ -135,21 +171,21 @@ async function migratePush(rest) {
135
171
  const db = positional[0];
136
172
  if (!db) { console.error("marcidb migrate push: <db> name required"); process.exit(1); }
137
173
 
138
- const url = (flags.url ?? "http://localhost:3000").replace(/\/+$/, "");
174
+ const url = serverUrl(flags);
139
175
  const dir = flags.migrations ?? "migrations";
140
176
  if (!fs.existsSync(dir)) {
141
177
  console.error(`marcidb migrate push: migrations directory "${dir}" not found — run "marcidb generate" first`);
142
178
  process.exit(1);
143
179
  }
144
180
 
145
- const serverSnap = await fetchServerSnapshot(url, db);
181
+ const serverSnap = await fetchServerSnapshot(url, db, flags);
146
182
  const plan = planActions(dir, serverSnap);
147
183
  if (plan.error !== undefined) { console.error(`Migration planning failed: ${plan.error}`); process.exit(1); }
148
184
  if (!plan.out.trim()) { console.log(`'${db}' is up to date — nothing to push`); return; }
149
185
 
150
186
  const res = await fetch(`${url}/${db}/$migrate`, {
151
187
  method: "POST",
152
- headers: { "Content-Type": "text/plain" },
188
+ headers: { "Content-Type": "text/plain", ...authHeaders(flags) },
153
189
  body: plan.out,
154
190
  });
155
191
  if (!res.ok) { console.error(`Migration failed [${res.status}]: ${await res.text()}`); process.exit(1); }
@@ -162,10 +198,10 @@ async function migrateCheck(rest) {
162
198
  const db = positional[0];
163
199
  if (!db) { console.error("marcidb migrate check: <db> name required"); process.exit(1); }
164
200
 
165
- const url = (flags.url ?? "http://localhost:3000").replace(/\/+$/, "");
201
+ const url = serverUrl(flags);
166
202
  const dir = flags.migrations ?? "migrations";
167
203
 
168
- const serverSnap = await fetchServerSnapshot(url, db);
204
+ const serverSnap = await fetchServerSnapshot(url, db, flags);
169
205
  const plan = planActions(dir, serverSnap);
170
206
  if (plan.error !== undefined) {
171
207
  console.error(`'${db}' has DIVERGED — the server schema isn't a point in your migration history.`);
@@ -179,9 +215,89 @@ async function migrateCheck(rest) {
179
215
  }
180
216
  }
181
217
 
218
+ // `20261012-120000` for a local timestamp (the server's file name carries the snapshot time instead)
219
+ function stamp() {
220
+ return new Date().toISOString().replace(/\.\d+Z$/, "").replace(/[-:]/g, "").replace("T", "-");
221
+ }
222
+
223
+ // Backup: GET /:db/$backup streamed to <out>.tmp, renamed when complete. A download that breaks off
224
+ // (the server aborts the response when its copy fails) leaves nothing behind.
225
+ async function cmdBackup(args) {
226
+ const { positional, flags } = parseArgs(args);
227
+ const db = positional[0];
228
+ if (!db) { console.error("marcidb backup: <db> name required"); process.exit(1); }
229
+
230
+ const url = serverUrl(flags);
231
+ const res = await fetch(`${url}/${db}/$backup`, { headers: authHeaders(flags) });
232
+ if (!res.ok) { console.error(`Backup failed [${res.status}]: ${await res.text()}`); process.exit(1); }
233
+
234
+ const offered = /filename="([^"]+)"/.exec(res.headers.get("content-disposition") ?? "")?.[1];
235
+ const name = path.basename(offered ?? `${db}-${stamp()}.marcibin`);
236
+ const outFlag = typeof flags.out === "string" ? flags.out : undefined;
237
+ const out = outFlag === undefined ? name
238
+ : fs.existsSync(outFlag) && fs.statSync(outFlag).isDirectory() ? path.join(outFlag, name)
239
+ : outFlag;
240
+
241
+ const tmp = `${out}.tmp`;
242
+ try {
243
+ await pipeline(Readable.fromWeb(res.body), fs.createWriteStream(tmp));
244
+ fs.renameSync(tmp, out);
245
+ } catch (e) {
246
+ fs.rmSync(tmp, { force: true });
247
+ console.error(`Backup failed: the download broke off (${e.message ?? e}) — nothing was written`);
248
+ process.exit(1);
249
+ }
250
+ console.log(`Backed up '${db}' to ${out} (${fs.statSync(out).size} bytes)`);
251
+ }
252
+
253
+ // Restore: POST the file to /:db/$restore[?into=]. Replacing a database that exists needs --yes.
254
+ async function cmdRestore(args) {
255
+ const { positional, flags } = parseArgs(args);
256
+ const [db, file] = positional;
257
+ if (!db || !file) { console.error("marcidb restore: usage: marcidb restore <db> <file.marcibin> [--into <name>] [--yes]"); process.exit(1); }
258
+ if (!fs.existsSync(file)) { console.error(`marcidb restore: "${file}" not found`); process.exit(1); }
259
+
260
+ const url = serverUrl(flags);
261
+ const into = typeof flags.into === "string" ? flags.into : undefined;
262
+ const target = into ?? db;
263
+ if (!flags.yes) {
264
+ // Without --into the live database is replaced; with it, only when that name already exists.
265
+ const exists = into === undefined || (await fetch(`${url}/${into}/$snapshot`, { headers: authHeaders(flags) })).status !== 404;
266
+ if (exists) {
267
+ console.error(
268
+ `This replaces the database '${target}' on ${url} with the contents of ${file}:\n` +
269
+ `everything written to '${target}' after that backup was taken is lost.\n` +
270
+ `Run again with --yes to proceed${into === undefined ? ", or restore under another name with --into <name>" : ""}.`,
271
+ );
272
+ process.exit(1);
273
+ }
274
+ }
275
+
276
+ const query = into === undefined ? "" : `?into=${encodeURIComponent(into)}`;
277
+ const body = typeof fs.openAsBlob === "function" ? await fs.openAsBlob(file) : fs.readFileSync(file);
278
+ const res = await fetch(`${url}/${db}/$restore${query}`, {
279
+ method: "POST",
280
+ headers: { "Content-Type": "application/x-marcidb-backup", ...authHeaders(flags) },
281
+ body,
282
+ });
283
+ if (!res.ok) { console.error(`Restore failed [${res.status}]: ${await res.text()}`); process.exit(1); }
284
+ const info = await res.json();
285
+ console.log(`Restored ${file} into '${info.restored}' (${info.trees} trees, ${info.entries} entries, schema version ${info.schemaVersion}, taken ${info.createdAt})`);
286
+ }
287
+
288
+ // Inspect runs locally (no server): marci-migrate reads the file or the closed directory.
289
+ function cmdInspect(args) {
290
+ const r = spawnSync(binPath("marci-migrate"), ["inspect", ...args], { stdio: "inherit" });
291
+ if (r.error) { console.error(`marcidb inspect: ${r.error.message}`); process.exit(1); }
292
+ process.exit(r.status ?? 1);
293
+ }
294
+
182
295
  const COMMANDS = {
183
296
  generate: cmdGenerate,
184
297
  migrate: cmdMigrate,
298
+ backup: cmdBackup,
299
+ restore: cmdRestore,
300
+ inspect: cmdInspect,
185
301
  };
186
302
 
187
303
  // --- main ---
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "marcidb-client",
3
3
  "type": "module",
4
- "version": "0.14.1",
4
+ "version": "0.16.0",
5
5
  "license": "MIT",
6
6
  "author": {
7
7
  "name": "den59k"
@@ -207,6 +207,8 @@ type MarciOp = {
207
207
  query?: any;
208
208
  data?: any;
209
209
  id?: any;
210
+ key?: any;
211
+ onInsert?: any;
210
212
  journal?: JournalArgs;
211
213
  };
212
214
  /** The arguments of a journal action (`$journalOpen` / `$journalRead` / `$journalDrop`). `wait` is in seconds. */
@@ -275,9 +277,11 @@ interface Query<
275
277
  readonly [__sel]: Effective<T, Sel>;
276
278
  /** Filter (marcidb `$where`). Repeated calls are ANDed. */
277
279
  where(where: T["where"]): Query<T, Sel>;
278
- /** Sort by one field: `order("age", "desc")` or `order({ age: "desc" })`. */
280
+ /** Sort: `order("age", "desc")` or `order({ age: "desc" })`. A second `order()` adds the next key — ties on
281
+ * the first are broken by it (`order("createdAt", "desc").order("id")`); the keys may also come as an array
282
+ * in one call. Several keys sort in memory. */
279
283
  order(field: keyof T["order"] & string, direction?: "asc" | "desc"): Query<T, Sel>;
280
- order(order: T["order"]): Query<T, Sel>;
284
+ order(order: T["order"] | T["order"][]): Query<T, Sel>;
281
285
  limit(n: number): Query<T, Sel>;
282
286
  skip(n: number): Query<T, Sel>;
283
287
  /** Keyset cursor: rows strictly after this id in the current order (`$cursor`). */
@@ -302,7 +306,26 @@ interface Query<
302
306
  }>;
303
307
  aggregate<A extends T["aggregate"]>(query: A): Op<AggregateResult<T["model"], A>> & Sub<T["name"], NoInfer<A>>;
304
308
  insert(data: T["insert"]): Op<T["id"]>;
309
+ /** The new row by `select`'s shape — its defaults, dates and files as stored: one call, nothing to read back. */
310
+ insert<S extends T["query"]>(data: T["insert"], options: {
311
+ select: S;
312
+ }): Promise<Rows<T, S>>;
305
313
  update(id: IdArg<T["id"]>, data: T["update"]): Op<void>;
314
+ /** Writes the row `key` names — the id, or the fields of ONE unique index (a `@unique` field, all the fields of
315
+ * a `@unique(<name>)` group): an update of `data` when it exists, an insert of key + data when not. `data` is
316
+ * in the insert form (plain values, `{ id }` for a relation; no `$` operators). `onInsert` adds fields used
317
+ * only when the row is created. Resolves to the id, like `insert`. */
318
+ upsert<K extends Partial<T["insert"]> | T["id"]>(key: K, data: Omit<T["insert"], keyof K>, options?: {
319
+ onInsert?: Partial<T["insert"]>;
320
+ }): Op<T["id"]>;
321
+ /** The row written, by `select`'s shape — as stored, whichever path the upsert took. */
322
+ upsert<
323
+ K extends Partial<T["insert"]> | T["id"],
324
+ S extends T["query"]
325
+ >(key: K, data: Omit<T["insert"], keyof K>, options: {
326
+ onInsert?: Partial<T["insert"]>;
327
+ select: S;
328
+ }): Promise<Rows<T, S>>;
306
329
  /** Applies `data` to every row the chain's `where` matches (all rows without one); resolves to the number of rows. */
307
330
  updateMany(data: T["update"]): Op<number>;
308
331
  /** @deprecated pass the filter through the chain: `db.user.where(w).updateMany(data)` (removed in the next minor). */
@@ -376,6 +399,9 @@ type QueryLayerOptions = {
376
399
  run(op: MarciOp): Promise<any>;
377
400
  /** Hook for `insert` payloads (the lecodes SDK generates uuid ids here). */
378
401
  prepareInsert?(model: string, data: any): any;
402
+ /** Hook for the `onInsert` part of an `upsert` — the fields used only when the row is created (the lecodes SDK
403
+ * puts a uuid id here). Receives what the caller gave (or undefined), returns what is sent. */
404
+ prepareUpsert?(model: string, onInsert: any): any;
379
405
  };
380
406
  /**
381
407
  * The query builder over a set of models and a `run` function. Returns `op(descriptor)` — a lazy operation
@@ -386,7 +412,24 @@ declare function createQueryLayer(options: QueryLayerOptions): {
386
412
  op: (descriptor: MarciOp) => any;
387
413
  collection: (model: string) => any;
388
414
  };
389
- declare function request(method: string, url: string, body?: any): Promise<any>;
415
+ /** The class of a refused request — the server's `kind`: a constraint (`unique`, `foreign_key`, `restrict`,
416
+ * `duplicate_id`), `not_found`, `bad_request`, `unauthorized`, or `internal` for a fault of the server. */
417
+ type MarciErrorKind = "unique" | "foreign_key" | "restrict" | "duplicate_id" | "not_found" | "bad_request" | "unauthorized" | "internal";
418
+ /** A failed request: `kind` is what to switch on, `field` the `Model.field` a constraint is about, the message is for a person. */
419
+ declare class MarciError extends Error {
420
+ name: "MarciError";
421
+ readonly status: number;
422
+ readonly kind: MarciErrorKind;
423
+ readonly field?: string;
424
+ constructor(status: number, message: string, kind: MarciErrorKind, field?: string);
425
+ }
426
+ /** Options of the HTTP transport — the second argument of `marcidb(url, options)`. */
427
+ interface HttpOptions {
428
+ /** Sent as `Authorization: Bearer <token>` on every request: for a server started with `--token`, or an
429
+ * embedded app's maintenance port opened with one (`db.listen({ token })`). */
430
+ token?: string;
431
+ }
432
+ declare function request(method: string, url: string, body?: any, options?: HttpOptions): Promise<any>;
390
433
  /**
391
434
  * A binary-eligible read over HTTP. Advertises `Accept: <binary>, json` plus the client's schema
392
435
  * fingerprint (`X-Marci-Schema`) and inspects the *response* content-type:
@@ -395,7 +438,7 @@ declare function request(method: string, url: string, body?: any): Promise<any>;
395
438
  * schema/shape mismatch — so it sent the normal JSON body; no second round-trip needed).
396
439
  * A single request handles both outcomes; `run()` in the generated client branches on the return shape.
397
440
  */
398
- declare function requestBinary(url: string, body: any, schemaHash: string): Promise<Uint8Array | {
441
+ declare function requestBinary(url: string, body: any, schemaHash: string, options?: HttpOptions): Promise<Uint8Array | {
399
442
  json: any;
400
443
  }>;
401
444
  /**
@@ -408,4 +451,4 @@ declare function createDecoderRegistry(MODELS: Record<string, FieldDesc[]>): {
408
451
  decodeBuffer(decoder: (reader: any) => any, bytes: Uint8Array, many: boolean): any;
409
452
  };
410
453
  declare function encodeId(id: any): string;
411
- export { AggregateKeys, AggregateResult, BareId, Collection, CompareNumValue, CompareRefListValue, CompareRefValue, CompareStrValue, CompareValue, CustomSearch, CustomSearchValue, Effective, FieldDesc, FieldOps, FullTextSearch, GetResult, IdArg, Journal, JournalArgs, JournalEntry, JournalOp, JournalOptions, JsonCondition, JsonPathWhere, JsonType, JsonValue, MarciOp, MarciTransport, ModelTypes, ModelsMeta, Never, NumOps, Only, Op, PrimitiveListUpdate, Query, QueryLayerOptions, RefListUpdate, RefListUpdateOrdered, RefListUpdateStruct, RefUpdate, RefUpdateStruct, Resolve, Rows, ServiceKeys, StrOps, Sub, UpdateNumValue, ValueOps, VectorSearch, WhereValue, createDecoderRegistry, createQueryLayer, encodeId, request, requestBinary };
454
+ export { AggregateKeys, AggregateResult, BareId, Collection, CompareNumValue, CompareRefListValue, CompareRefValue, CompareStrValue, CompareValue, CustomSearch, CustomSearchValue, Effective, FieldDesc, FieldOps, FullTextSearch, GetResult, HttpOptions, IdArg, Journal, JournalArgs, JournalEntry, JournalOp, JournalOptions, JsonCondition, JsonPathWhere, JsonType, JsonValue, MarciError, MarciErrorKind, MarciOp, MarciTransport, ModelTypes, ModelsMeta, Never, NumOps, Only, Op, PrimitiveListUpdate, Query, QueryLayerOptions, RefListUpdate, RefListUpdateOrdered, RefListUpdateStruct, RefUpdate, RefUpdateStruct, Resolve, Rows, ServiceKeys, StrOps, Sub, UpdateNumValue, ValueOps, VectorSearch, WhereValue, createDecoderRegistry, createQueryLayer, encodeId, request, requestBinary };
package/runtime/index.js CHANGED
@@ -101,6 +101,11 @@ function createQueryLayer(options) {
101
101
  return w ? { $where: w } : {};
102
102
  };
103
103
  const findMany = () => ({ model, action: "findMany", query: build() });
104
+ const readBack = (written, select) => written.then((id) => run({ model, action: "findFirst", query: resolveShape(model, { ...select, $where: id }) })).then((row) => {
105
+ if (row === null)
106
+ throw new Error(`marcidb: ${model}: the row just written is gone`);
107
+ return row;
108
+ });
104
109
  return {
105
110
  get __op() {
106
111
  return findMany();
@@ -112,7 +117,12 @@ function createQueryLayer(options) {
112
117
  catch: (onRejected) => run(findMany()).catch(onRejected),
113
118
  finally: (onFinally) => run(findMany()).finally(onFinally),
114
119
  where: (where) => make({ ...st, where: and(st.where, where) }),
115
- order: (field, direction) => make({ ...st, order: typeof field === "string" ? { [field]: direction ?? "asc" } : field }),
120
+ order: (field, direction) => {
121
+ const given = typeof field === "string" ? [{ [field]: direction ?? "asc" }] : Array.isArray(field) ? field : Object.entries(field).map(([k, v]) => ({ [k]: v }));
122
+ const prev = st.order === undefined ? [] : Array.isArray(st.order) ? st.order : [st.order];
123
+ const keys = [...prev, ...given];
124
+ return make({ ...st, order: keys.length === 1 ? keys[0] : keys });
125
+ },
116
126
  limit: (n) => make({ ...st, limit: n }),
117
127
  skip: (n) => make({ ...st, skip: n }),
118
128
  after: (id) => make({ ...st, cursor: idObject(model, id) }),
@@ -128,8 +138,16 @@ function createQueryLayer(options) {
128
138
  delete q.$where;
129
139
  return Object.assign(op({ model, action: "aggregate", query: q }), { __select: q });
130
140
  },
131
- insert: (data) => op({ model, action: "insert", data: options.prepareInsert ? options.prepareInsert(model, data) : data }),
141
+ insert: (data, extra) => {
142
+ const inserted = op({ model, action: "insert", data: options.prepareInsert ? options.prepareInsert(model, data) : data });
143
+ return extra?.select ? readBack(inserted, extra.select) : inserted;
144
+ },
132
145
  update: (id, data) => op({ model, action: "update", id: idObject(model, id), data }),
146
+ upsert: (key, data, o) => {
147
+ const onInsert = options.prepareUpsert ? options.prepareUpsert(model, o?.onInsert) : o?.onInsert;
148
+ const written = op({ model, action: "upsert", key, data, ...onInsert !== undefined ? { onInsert } : {} });
149
+ return o?.select ? readBack(written, o.select) : written;
150
+ },
133
151
  updateMany: (a, b) => b === undefined ? op({ model, action: "updateMany", query: whereOnly(), data: a }) : op({ model, action: "updateMany", query: whereOnly(a), data: b }),
134
152
  delete: (id) => op({ model, action: "delete", id: idObject(model, id) }),
135
153
  deleteMany: () => {
@@ -149,33 +167,55 @@ function createQueryLayer(options) {
149
167
  }
150
168
 
151
169
  // src/index.ts
152
- async function request(method, url, body) {
170
+ class MarciError extends Error {
171
+ name = "MarciError";
172
+ status;
173
+ kind;
174
+ field;
175
+ constructor(status, message, kind, field) {
176
+ super(message);
177
+ this.status = status;
178
+ this.kind = kind;
179
+ this.field = field;
180
+ }
181
+ }
182
+ var kindOfStatus = (status) => status === 404 ? "not_found" : status === 401 ? "unauthorized" : status >= 500 ? "internal" : "bad_request";
183
+ async function failure(res) {
184
+ const text = await res.text();
185
+ try {
186
+ const body = JSON.parse(text);
187
+ if (body && typeof body.error === "string")
188
+ return new MarciError(res.status, body.error, body.kind ?? kindOfStatus(res.status), body.field);
189
+ } catch {}
190
+ return new MarciError(res.status, text || `HTTP ${res.status}`, kindOfStatus(res.status));
191
+ }
192
+ function optionHeaders(options) {
193
+ return options?.token ? { Authorization: `Bearer ${options.token}` } : {};
194
+ }
195
+ async function request(method, url, body, options) {
153
196
  const res = await fetch(url, {
154
197
  method,
155
- headers: { "Content-Type": "application/json" },
198
+ headers: { "Content-Type": "application/json", ...optionHeaders(options) },
156
199
  body: body !== undefined ? JSON.stringify(body) : undefined
157
200
  });
158
- if (!res.ok) {
159
- const text = await res.text();
160
- throw new Error(`MarciDB [${res.status}]: ${text}`);
161
- }
201
+ if (!res.ok)
202
+ throw await failure(res);
162
203
  return res.json();
163
204
  }
164
205
  var BINARY_MEDIA_TYPE = "application/x-marcidb-rows";
165
- async function requestBinary(url, body, schemaHash) {
206
+ async function requestBinary(url, body, schemaHash, options) {
166
207
  const res = await fetch(url, {
167
208
  method: "POST",
168
209
  headers: {
169
210
  "Content-Type": "application/json",
170
211
  Accept: `${BINARY_MEDIA_TYPE}, application/json`,
171
- "X-Marci-Schema": schemaHash
212
+ "X-Marci-Schema": schemaHash,
213
+ ...optionHeaders(options)
172
214
  },
173
215
  body: JSON.stringify(body ?? {})
174
216
  });
175
- if (!res.ok) {
176
- const text = await res.text();
177
- throw new Error(`MarciDB [${res.status}]: ${text}`);
178
- }
217
+ if (!res.ok)
218
+ throw await failure(res);
179
219
  const contentType = res.headers.get("content-type") ?? "";
180
220
  if (contentType.includes(BINARY_MEDIA_TYPE)) {
181
221
  return new Uint8Array(await res.arrayBuffer());
@@ -377,6 +417,7 @@ function encodeId(id) {
377
417
  return String(id);
378
418
  }
379
419
  export {
420
+ MarciError,
380
421
  createDecoderRegistry,
381
422
  createQueryLayer,
382
423
  encodeId,