marcidb-client 0.15.0 → 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.15.0",
4
+ "version": "0.16.0",
5
5
  "license": "MIT",
6
6
  "author": {
7
7
  "name": "den59k"
@@ -423,7 +423,13 @@ declare class MarciError extends Error {
423
423
  readonly field?: string;
424
424
  constructor(status: number, message: string, kind: MarciErrorKind, field?: string);
425
425
  }
426
- declare function request(method: string, url: string, body?: any): Promise<any>;
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>;
427
433
  /**
428
434
  * A binary-eligible read over HTTP. Advertises `Accept: <binary>, json` plus the client's schema
429
435
  * fingerprint (`X-Marci-Schema`) and inspects the *response* content-type:
@@ -432,7 +438,7 @@ declare function request(method: string, url: string, body?: any): Promise<any>;
432
438
  * schema/shape mismatch — so it sent the normal JSON body; no second round-trip needed).
433
439
  * A single request handles both outcomes; `run()` in the generated client branches on the return shape.
434
440
  */
435
- 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 | {
436
442
  json: any;
437
443
  }>;
438
444
  /**
@@ -445,4 +451,4 @@ declare function createDecoderRegistry(MODELS: Record<string, FieldDesc[]>): {
445
451
  decodeBuffer(decoder: (reader: any) => any, bytes: Uint8Array, many: boolean): any;
446
452
  };
447
453
  declare function encodeId(id: any): string;
448
- 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, 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 };
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
@@ -189,10 +189,13 @@ async function failure(res) {
189
189
  } catch {}
190
190
  return new MarciError(res.status, text || `HTTP ${res.status}`, kindOfStatus(res.status));
191
191
  }
192
- async function request(method, url, body) {
192
+ function optionHeaders(options) {
193
+ return options?.token ? { Authorization: `Bearer ${options.token}` } : {};
194
+ }
195
+ async function request(method, url, body, options) {
193
196
  const res = await fetch(url, {
194
197
  method,
195
- headers: { "Content-Type": "application/json" },
198
+ headers: { "Content-Type": "application/json", ...optionHeaders(options) },
196
199
  body: body !== undefined ? JSON.stringify(body) : undefined
197
200
  });
198
201
  if (!res.ok)
@@ -200,13 +203,14 @@ async function request(method, url, body) {
200
203
  return res.json();
201
204
  }
202
205
  var BINARY_MEDIA_TYPE = "application/x-marcidb-rows";
203
- async function requestBinary(url, body, schemaHash) {
206
+ async function requestBinary(url, body, schemaHash, options) {
204
207
  const res = await fetch(url, {
205
208
  method: "POST",
206
209
  headers: {
207
210
  "Content-Type": "application/json",
208
211
  Accept: `${BINARY_MEDIA_TYPE}, application/json`,
209
- "X-Marci-Schema": schemaHash
212
+ "X-Marci-Schema": schemaHash,
213
+ ...optionHeaders(options)
210
214
  },
211
215
  body: JSON.stringify(body ?? {})
212
216
  });