@yydb/yydb 0.1.1 → 0.1.2

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.
@@ -1,173 +1,351 @@
1
- import { spawn } from 'node:child_process';
2
1
  import fs from 'node:fs';
3
- import net from 'node:net';
4
2
  import path from 'node:path';
5
- import { connect } from '@yydb/yydb-client/node';
3
+ import { emitSource, } from '@voml/vos';
6
4
  import { defineMicro as buildMicro, microFingerprint, udfWireKind, MicroSessionRegistry, } from '../shared/micro.js';
7
- import { resolveYydbCli } from '../native/resolve.js';
8
- async function freeLoopbackPort() {
9
- return await new Promise((resolve, reject) => {
10
- const server = net.createServer();
11
- server.listen(0, '127.0.0.1', () => {
12
- const address = server.address();
13
- if (!address || typeof address === 'string') {
14
- server.close();
15
- reject(new Error('could not allocate loopback port'));
16
- return;
17
- }
18
- const { port } = address;
19
- server.close((error) => {
20
- if (error)
21
- reject(error);
22
- else
23
- resolve(port);
24
- });
25
- });
26
- server.on('error', reject);
27
- });
28
- }
29
- async function waitForTcp(host, port, timeoutMs) {
30
- const deadline = Date.now() + timeoutMs;
31
- while (Date.now() < deadline) {
32
- try {
33
- await new Promise((resolve, reject) => {
34
- const socket = net.connect({ host, port }, () => {
35
- socket.end();
36
- resolve();
37
- });
38
- socket.on('error', reject);
39
- });
40
- return;
5
+ import { loadNative } from '../native/binding.js';
6
+ import { YydbError } from '../shared/errors.js';
7
+ /**
8
+ * A pre-parsed, pre-lowered VOS query plan.
9
+ *
10
+ * Created by {@link Database.prepare}. The actual lowered op tree lives on
11
+ * the native side in a thread-local registry keyed by `statementId`; this
12
+ * class is a thin JS handle. Bind parameters and re-execute via
13
+ * {@link Statement.execute} without re-parsing or re-lowering the source.
14
+ *
15
+ * ```ts
16
+ * const stmt = await db.prepare('User.all().take($limit).collect()');
17
+ * const rows = await stmt.execute({ limit: 100 });
18
+ * // re-execute with different bind
19
+ * const rows2 = await stmt.execute({ limit: 50 });
20
+ * // release when done
21
+ * stmt.close();
22
+ * ```
23
+ */
24
+ export class Statement {
25
+ native;
26
+ db;
27
+ closed = false;
28
+ constructor(native, db) {
29
+ this.native = native;
30
+ this.db = db;
31
+ }
32
+ /** The fingerprint-prefixed unique id for this statement. */
33
+ get statementId() { return this.native.statementId(); }
34
+ /** Parameter descriptors — empty array when the statement takes no parameters. */
35
+ get parameters() {
36
+ return JSON.parse(this.native.parametersJson());
37
+ }
38
+ /** Full statement metadata. */
39
+ get info() {
40
+ return JSON.parse(this.native.info());
41
+ }
42
+ /** Whether this statement has been closed and can no longer execute. */
43
+ get isClosed() { return this.closed; }
44
+ /**
45
+ * Execute this prepared statement with optional named parameter bindings.
46
+ *
47
+ * Missing parameters, extraneous parameters or mistyped parameters raise
48
+ * `YydbError` with code `yydb.query.bind`.
49
+ *
50
+ * @param params Named parameter values — keys match the parameter names
51
+ * in the VOS source (`$name` → `{ name: value }`). Omit when the statement
52
+ * takes no parameters.
53
+ */
54
+ async execute(params) {
55
+ if (this.closed) {
56
+ throw new YydbError('yydb.statement.closed', 'statement has been closed');
41
57
  }
42
- catch {
43
- await new Promise((r) => setTimeout(r, 40));
58
+ const paramsJson = params !== undefined && params !== null ? JSON.stringify(params) : null;
59
+ const rowsJson = this.db.queryPrepared(this.native, paramsJson);
60
+ return JSON.parse(rowsJson);
61
+ }
62
+ /** Release this statement's plan from the native registry. */
63
+ close() {
64
+ if (!this.closed) {
65
+ this.native.close();
66
+ this.closed = true;
44
67
  }
45
68
  }
46
- throw new Error(`yydb engine did not accept connections on ${host}:${port}`);
47
69
  }
48
70
  /**
49
- * Node-facing YYDB handle. Looks like one library; owns a private loopback
50
- * `yydb serve` child under the hood.
71
+ * Transaction-local handle passed to {@link Database.transaction} callbacks.
72
+ * All CRUD methods forward to the underlying native handle while the
73
+ * transaction is in flight. Commit/rollback are owned by {@link Database}.
74
+ */
75
+ export class TransactionHandle {
76
+ native;
77
+ constructor(native) {
78
+ this.native = native;
79
+ }
80
+ async get(key) { return this.native.get(key); }
81
+ async put(key, value) {
82
+ const bytes = typeof value === 'string' ? new TextEncoder().encode(value) : value;
83
+ this.native.put(key, bytes);
84
+ }
85
+ async delete(key) { this.native.delete(key); }
86
+ async scan(options = {}) {
87
+ const rowsJson = this.native.scanPrefix(options.prefix ?? '', options.after ?? null, options.limit ?? 1000);
88
+ const rows = JSON.parse(rowsJson);
89
+ return rows.map((r) => ({ key: r.key, value: new Uint8Array(r.value) }));
90
+ }
91
+ async batch(changes) {
92
+ const body = changes.map((c) => ({
93
+ key: c.key,
94
+ value: c.value === null ? null : typeof c.value === 'string' ? Array.from(new TextEncoder().encode(c.value)) : Array.from(c.value),
95
+ }));
96
+ this.native.writeBatch(JSON.stringify(body));
97
+ }
98
+ async compareExchange(key, expected, replacement) {
99
+ const toBytes = (v) => v === null ? null : typeof v === 'string' ? new TextEncoder().encode(v) : v;
100
+ return this.native.compareExchange(key, toBytes(expected), toBytes(replacement));
101
+ }
102
+ async query(source) {
103
+ return JSON.parse(this.native.queryVos(source));
104
+ }
105
+ async execute(source) { this.native.executeVosWrite(source); }
106
+ }
107
+ /**
108
+ * Node-facing YYDB local database handle.
109
+ *
110
+ * Directly owns a native `yydb::Connection` via the Node-API binding. No
111
+ * child process, no loopback TCP, no wire protocol in the hot path.
51
112
  */
52
113
  export class Database {
53
114
  path;
54
- endpoint;
55
- client;
56
- child;
115
+ native;
57
116
  microRegistry;
58
- constructor(dbPath, endpoint, client, child) {
117
+ constructor(dbPath, native) {
59
118
  this.path = dbPath;
60
- this.endpoint = endpoint;
61
- this.client = client;
62
- this.child = child;
119
+ this.native = native;
63
120
  this.microRegistry = new MicroSessionRegistry();
64
- this.client.setMicroHostHandler((payload) => this.microRegistry.invokeFromWire(payload));
65
121
  }
66
- /**
67
- * Open (create) a `.yydb` file. Spawns the engine on `127.0.0.1` automatically.
68
- */
122
+ /** Open (create when missing) the database at `dbPath`. */
69
123
  static async open(dbPath, options = {}) {
70
124
  const resolved = path.resolve(dbPath);
71
- fs.mkdirSync(path.dirname(resolved), { recursive: true });
72
- const cli = options.cli ?? options.binary ?? resolveYydbCli();
73
- const port = options.port ?? (await freeLoopbackPort());
74
- const bind = `127.0.0.1:${port}`;
75
- const args = [cli, 'serve', resolved, '--bind', bind, ...(options.serveArgs ?? [])];
76
- const child = spawn(process.execPath, args, {
77
- stdio: 'pipe',
78
- windowsHide: true,
79
- });
80
- let stderr = '';
81
- child.stderr?.setEncoding('utf8');
82
- child.stderr?.on('data', (chunk) => {
83
- stderr += chunk;
84
- });
85
- const exitPromise = new Promise((_, reject) => {
86
- child.once('exit', (code, signal) => {
87
- reject(new Error(`yydb engine exited early (code=${code}, signal=${signal}): ${stderr.trim()}`));
88
- });
89
- child.once('error', reject);
90
- });
91
- try {
92
- await Promise.race([waitForTcp('127.0.0.1', port, 8000), exitPromise]);
93
- const client = await connect(bind);
94
- child.removeAllListeners('exit');
95
- child.removeAllListeners('error');
96
- child.on('exit', () => {
97
- /* closed via Database.close or crash */
98
- });
99
- return new Database(resolved, bind, client, child);
100
- }
101
- catch (error) {
102
- child.kill('SIGTERM');
103
- throw error;
125
+ if (options.mkdir !== false) {
126
+ fs.mkdirSync(path.dirname(resolved), { recursive: true });
104
127
  }
128
+ const native = loadNative();
129
+ const db = native.Database.open(resolved);
130
+ return new Database(resolved, db);
105
131
  }
106
- async serverVersion() {
107
- return this.client.serverVersion();
132
+ /** Engine version string (same as `yydb --version`). */
133
+ serverVersion() {
134
+ return loadNative().version();
108
135
  }
109
- async info() {
110
- return this.client.info();
136
+ /** Basic diagnostics: path, schema, record count, journal mode. */
137
+ info() {
138
+ const lines = [
139
+ `path=${this.native.path() ?? '<memory>'}`,
140
+ `schema.version=${this.native.schemaVersion() ?? ''}`,
141
+ `records=${this.native.recordCount()}`,
142
+ `journal_mode=${this.native.journalMode()}`,
143
+ ];
144
+ return lines.join('\n') + '\n';
111
145
  }
146
+ /** Published schema metadata, or `null` before the first schema. */
112
147
  async getSchema() {
113
- return this.client.getSchema();
148
+ const version = this.native.schemaVersion();
149
+ const document = this.native.schemaDocument();
150
+ if (version === null || document === null) {
151
+ return null;
152
+ }
153
+ return { version, document };
114
154
  }
115
- async ensureSchema(version, document) {
116
- await this.client.ensureSchema(version, document);
155
+ /**
156
+ * Validate and publish a VOS schema document.
157
+ *
158
+ * Accepts either a raw `.vos` source string, one `@voml/vos` descriptor
159
+ * (`v.table()`, `v.class()`) or a list of descriptors. When descriptors
160
+ * are supplied, they are rendered to source via `emitSource` first.
161
+ */
162
+ async ensureSchema(...input) {
163
+ let document;
164
+ if (typeof input[0] === 'string') {
165
+ document = input[0];
166
+ }
167
+ else {
168
+ document = emitSource(input);
169
+ }
170
+ this.native.ensureSchema(document);
117
171
  }
172
+ /** Read one record, or `null` when the key is absent. */
118
173
  async get(key) {
119
- return this.client.get(key);
174
+ return this.native.get(key);
120
175
  }
176
+ /** Insert or replace one record. */
121
177
  async put(key, value) {
122
- await this.client.put(key, value);
178
+ const bytes = typeof value === 'string' ? new TextEncoder().encode(value) : value;
179
+ this.native.put(key, bytes);
180
+ }
181
+ /** Delete one record by key. Silently succeeds when the key is absent. */
182
+ async delete(key) {
183
+ this.native.delete(key);
184
+ }
185
+ /** List key/value pairs in lexical order. `prefix` defaults to `""` (all keys). */
186
+ async scan(options = {}) {
187
+ const rowsJson = this.native.scanPrefix(options.prefix ?? '', options.after ?? null, options.limit ?? 1000);
188
+ const rows = JSON.parse(rowsJson);
189
+ return rows.map((r) => ({ key: r.key, value: new Uint8Array(r.value) }));
190
+ }
191
+ /** Atomically write or delete many keys in one call. */
192
+ async batch(changes) {
193
+ const body = changes.map((c) => ({
194
+ key: c.key,
195
+ value: c.value === null
196
+ ? null
197
+ : typeof c.value === 'string'
198
+ ? Array.from(new TextEncoder().encode(c.value))
199
+ : Array.from(c.value),
200
+ }));
201
+ this.native.writeBatch(JSON.stringify(body));
123
202
  }
124
203
  /**
125
- * Build a typed session-local micro definition. Does not register it with
126
- * the engine until [`registerMicro`](./database.ts) is called.
204
+ * Compare-and-swap: replace or delete `key` only when its current value
205
+ * exactly equals `expected`. Both `expected` and `replacement` may be
206
+ * `null` (meaning absent / delete). Returns `true` when the swap applied.
127
207
  */
208
+ async compareExchange(key, expected, replacement) {
209
+ const toBytes = (v) => v === null ? null : typeof v === 'string' ? new TextEncoder().encode(v) : v;
210
+ return this.native.compareExchange(key, toBytes(expected), toBytes(replacement));
211
+ }
212
+ /** Fold the WAL into the main file and truncate the sidecar. */
213
+ async checkpoint() {
214
+ this.native.checkpoint();
215
+ }
216
+ /** Checkpoint then copy a self-contained `.yydb` main file to `dest`. */
217
+ async backup(dest) {
218
+ this.native.backup(dest);
219
+ }
220
+ /** Copy main + WAL + SHM without checkpoint; opening `dest` replays the WAL tail. */
221
+ async backupWithWal(dest) {
222
+ this.native.backupWithWal(dest);
223
+ }
224
+ /** Run read-only consistency probes against the database. */
225
+ async doctor() {
226
+ return JSON.parse(this.native.doctor());
227
+ }
228
+ /** Fold WAL + reclaim orphan blobs (`.yydx` layouts only). */
229
+ async compactYydx() {
230
+ return JSON.parse(this.native.compactYydx());
231
+ }
232
+ /**
233
+ * Run `fn` inside an explicit transaction. The transaction commits on
234
+ * normal return and rolls back on throw. Nested calls are not supported.
235
+ *
236
+ * ```ts
237
+ * await db.transaction(async (tx) => {
238
+ * await tx.put('a', '1');
239
+ * await tx.put('b', '2');
240
+ * });
241
+ * ```
242
+ */
243
+ async transaction(fn) {
244
+ this.native.begin();
245
+ const tx = new TransactionHandle(this.native);
246
+ try {
247
+ const result = await fn(tx);
248
+ this.native.commit();
249
+ return result;
250
+ }
251
+ catch (err) {
252
+ try {
253
+ this.native.rollback();
254
+ }
255
+ catch {
256
+ // rollback already failed, don't mask the original error
257
+ }
258
+ throw YydbError.fromDiagnosticJson(err instanceof Error ? err.message : String(err)) ?? err;
259
+ }
260
+ }
261
+ /**
262
+ * Prepare a VOS read pipeline into a reusable query plan.
263
+ *
264
+ * Returns a {@link Statement} handle. The actual lowered op tree lives
265
+ * on the native side in a thread-local registry — you can call
266
+ * `statement.execute({ paramName: value })` cheaply multiple times
267
+ * without re-parsing or re-lowering the source.
268
+ *
269
+ * Prepare requires no schema binding; plan building is a pure parse +
270
+ * lower pass. Execute still needs `ensureSchema` first.
271
+ *
272
+ * ```ts
273
+ * const stmt = await db.prepare('User.all().take($limit).collect()');
274
+ * const rows = await stmt.execute({ limit: 100 });
275
+ * const rows2 = await stmt.execute({ limit: 50 });
276
+ * stmt.close();
277
+ * ```
278
+ */
279
+ async prepare(source) {
280
+ const nativeStmt = this.native.prepare(source);
281
+ return new Statement(nativeStmt, this.native);
282
+ }
283
+ /** Execute a VOS read pipeline and return typed rows. */
284
+ async query(source) {
285
+ const rowsJson = this.native.queryVos(source);
286
+ return JSON.parse(rowsJson);
287
+ }
288
+ /** Execute a VOS write program. */
289
+ async execute(source) {
290
+ this.native.executeVosWrite(source);
291
+ }
292
+ /**
293
+ * Publish a new schema version, migrating existing records when their
294
+ * shape no longer matches.
295
+ *
296
+ * Accepts raw `.vos` source or `@voml/vos` descriptors (same as
297
+ * {@link Database.ensureSchema}). Use `renames` to tell the engine which
298
+ * old type/field names map to the new ones — records that would otherwise
299
+ * look structurally unrelated get preserved.
300
+ */
301
+ async migrateSchema(documentOrSource, ...rest) {
302
+ let document;
303
+ let options = {};
304
+ const all = [documentOrSource, ...rest];
305
+ const last = all[all.length - 1];
306
+ if (last &&
307
+ typeof last === 'object' &&
308
+ !('table' in last) &&
309
+ !('class' in last) &&
310
+ !('emit' in last) &&
311
+ ('renames' in last || Object.keys(last).length === 0)) {
312
+ options = all.pop() ?? {};
313
+ }
314
+ if (typeof all[0] === 'string') {
315
+ document = all[0];
316
+ }
317
+ else {
318
+ document = emitSource(all);
319
+ }
320
+ const renamesJson = options.renames?.length
321
+ ? JSON.stringify(options.renames)
322
+ : null;
323
+ this.native.migrateSchema(document, renamesJson);
324
+ }
325
+ /** Build a typed session-local micro definition (without registering). */
128
326
  defineMicro(definition) {
129
327
  return buildMicro(definition);
130
328
  }
131
329
  /**
132
- * Install a [`defineMicro`](./database.ts) definition into the current
133
- * session registry and sync metadata to the private engine. Host micros are
134
- * session-local and never written to `.yydb`.
330
+ * Install a session-local host micro into both the local registry and the
331
+ * native engine. Host micros never persist across processes.
135
332
  */
136
333
  async registerMicro(definition) {
137
334
  const handle = this.microRegistry.register(definition);
138
- await this.client.registerMicro({
139
- hostId: handle.hostId,
140
- handleVersion: handle.implementationVersion,
141
- udfVersion: definition.version,
142
- name: definition.name,
143
- functionId: handle.functionId,
144
- args: definition.args.map(udfWireKind),
145
- returns: udfWireKind(definition.returns),
146
- fingerprint: microFingerprint(definition.name, definition.version),
147
- });
335
+ const argTypes = definition.args.map((d) => udfWireKind(d));
336
+ const returnType = udfWireKind(definition.returns);
337
+ this.native.registerMicro(definition.name, definition.version, argTypes, returnType, handle.hostId, handle.functionId, handle.implementationVersion, microFingerprint(definition.name, definition.version));
148
338
  return handle;
149
339
  }
150
- /** Invoke a registered scalar UDF on the private engine. */
340
+ /** Invoke a registered scalar UDF on the native engine. */
151
341
  async callScalar(name, version, args) {
152
- return this.client.callScalar(name, version, args);
342
+ const argsJson = JSON.stringify(args);
343
+ const resultJson = this.native.callScalar(name, version, argsJson);
344
+ return JSON.parse(resultJson);
153
345
  }
154
- /** Stop the private engine process. */
346
+ /** Release the native handle (best-effort; the engine also drops on GC). */
155
347
  async close() {
156
- this.client.close();
157
- const child = this.child;
158
- this.child = null;
159
- if (!child || child.exitCode !== null || child.killed) {
160
- return;
161
- }
162
- await new Promise((resolve) => {
163
- child.once('exit', () => resolve());
164
- child.kill('SIGTERM');
165
- setTimeout(() => {
166
- if (child.exitCode === null && !child.killed) {
167
- child.kill('SIGKILL');
168
- }
169
- resolve();
170
- }, 2000);
171
- });
348
+ // Native connections drop cleanly when the handle is garbage-collected.
349
+ // No explicit close() is exposed from yydb::Connection.
172
350
  }
173
351
  }
package/dist/cli/cli.js CHANGED
@@ -1,16 +1,22 @@
1
- import { dirname, join } from 'node:path';
1
+ import fs from 'node:fs';
2
+ import { dirname, join, resolve } from 'node:path';
2
3
  import { fileURLToPath } from 'node:url';
3
4
  import { createCli } from '@vmz/commander';
5
+ import { formatDiagnostic } from '@vmz/diagnostic';
6
+ import { YydbError } from '../shared/errors.js';
4
7
  import { registerInfoCommand } from './info-cmd.js';
5
8
  import { registerInitCommand } from './init-cmd.js';
6
9
  import { registerServeCommand } from './serve-cmd.js';
10
+ import { registerVerifyCommand } from './verify-cmd.js';
7
11
  import { registerVersionCommand } from './version-cmd.js';
12
+ const CLI_CATALOG = JSON.parse(fs.readFileSync(resolve(import.meta.dirname, '../../locales/en-US/cli.json'), 'utf8'));
8
13
  function buildYydbCli() {
9
14
  const localesRoot = join(dirname(fileURLToPath(import.meta.url)), '../../locales');
10
15
  const cli = createCli('yydb').locales(localesRoot).intro('cli.intro');
11
16
  registerVersionCommand(cli);
12
17
  registerInitCommand(cli);
13
18
  registerInfoCommand(cli);
19
+ registerVerifyCommand(cli);
14
20
  registerServeCommand(cli);
15
21
  return cli;
16
22
  }
@@ -19,6 +25,27 @@ export async function runCli(argv) {
19
25
  return await buildYydbCli().parse(argv);
20
26
  }
21
27
  catch (error) {
28
+ const parsed = error instanceof Error
29
+ ? YydbError.fromDiagnosticJson(error.message)
30
+ : null;
31
+ if (parsed) {
32
+ // Structured engine error from N-API / wire / WASM envelope.
33
+ const severity = parsed.retryable ? 'warning' : 'error';
34
+ const text = formatDiagnostic({
35
+ path: '',
36
+ severity,
37
+ code: parsed.code,
38
+ args: parsed.params,
39
+ message: error instanceof Error ? error.message : String(error),
40
+ }, {
41
+ locale: 'en-US',
42
+ catalog: CLI_CATALOG,
43
+ style: 'compact',
44
+ });
45
+ console.error(text);
46
+ return parsed.retryable ? 13 : 1;
47
+ }
48
+ // Plain text fallback (commander parse failures, unknown errors).
22
49
  console.error(`error: ${error instanceof Error ? error.message : String(error)}`);
23
50
  return 1;
24
51
  }
@@ -1,10 +1,17 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { formatDiagnostic } from '@vmz/diagnostic';
1
4
  import { loadNative } from '../native/binding.js';
2
5
  import { requiredPositional } from './options.js';
6
+ const CLI_CATALOG = JSON.parse(fs.readFileSync(path.resolve(import.meta.dirname, '../../locales/en-US/cli.json'), 'utf8'));
3
7
  export function registerInfoCommand(cli) {
4
- cli.command('info', 'cli.cmd.info').action((options) => cmdInfo(options));
8
+ cli.command('info', 'cli.cmd.info')
9
+ .option('--doctor', 'cli.opt.doctor')
10
+ .action((options) => cmdInfo(options));
5
11
  }
6
12
  export function cmdInfo(options) {
7
13
  const dbPath = requiredPositional(options, 'info');
14
+ const wantDoctor = Boolean(options.doctor);
8
15
  const database = loadNative().Database.open(dbPath);
9
16
  const lines = [`path=${database.path() ?? '<memory>'}`];
10
17
  const schemaVersion = database.schemaVersion();
@@ -25,6 +32,28 @@ export function cmdInfo(options) {
25
32
  lines.push(`shm=${shm}`);
26
33
  lines.push(`wal_frames=${database.walFrameCount()}`);
27
34
  console.log(lines.join('\n'));
35
+ if (wantDoctor) {
36
+ const report = JSON.parse(database.doctor());
37
+ if (report.issues.length === 0) {
38
+ console.log(`doctor=clean orphan_objects=${report.orphan_object_count}`);
39
+ }
40
+ else {
41
+ for (const issue of report.issues) {
42
+ const severity = issue.severity === 'error' ? 'error' : 'warning';
43
+ console.error(formatDiagnostic({
44
+ path: dbPath,
45
+ severity,
46
+ code: issue.code,
47
+ message: issue.message,
48
+ }, {
49
+ locale: 'en-US',
50
+ catalog: CLI_CATALOG,
51
+ style: 'compact',
52
+ }));
53
+ }
54
+ console.error(`doctor=issues(${report.issues.length}) orphans=${report.orphan_object_count}`);
55
+ }
56
+ }
28
57
  return 0;
29
58
  }
30
59
  function summarizeDocument(document) {
@@ -0,0 +1,11 @@
1
+ import type { Cli, ParsedOptions } from '@vmz/commander';
2
+ export declare function registerVerifyCommand(cli: Cli): void;
3
+ /**
4
+ * Run read-only consistency probes against a YYDB database file.
5
+ *
6
+ * Exit codes:
7
+ * 0 — clean (no issues)
8
+ * 1 — doctor found at least one error (database may be corrupt)
9
+ * 13 — only warnings present (readable but should be inspected)
10
+ */
11
+ export declare function cmdVerify(options: ParsedOptions): number;
@@ -0,0 +1,73 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { formatDiagnostic } from '@vmz/diagnostic';
4
+ import { loadNative } from '../native/binding.js';
5
+ import { requiredPositional } from './options.js';
6
+ /**
7
+ * Inline CLI catalog — commander's `.locales()` resolution is not exposed
8
+ * outside the CLI builder, so we ship a static copy here. Only one locale is
9
+ * loaded at runtime; future locale awareness should resolve the chosen locale
10
+ * from commander internals once they surface it.
11
+ */
12
+ const CLI_CATALOG = JSON.parse(fs.readFileSync(path.resolve(import.meta.dirname, '../../locales/en-US/cli.json'), 'utf8'));
13
+ const DIAGNOSTIC_STYLE = 'compact';
14
+ const DIAGNOSTIC_LOCALE = 'en-US';
15
+ export function registerVerifyCommand(cli) {
16
+ cli.command('verify', 'cli.cmd.verify').action((options) => cmdVerify(options));
17
+ }
18
+ /**
19
+ * Run read-only consistency probes against a YYDB database file.
20
+ *
21
+ * Exit codes:
22
+ * 0 — clean (no issues)
23
+ * 1 — doctor found at least one error (database may be corrupt)
24
+ * 13 — only warnings present (readable but should be inspected)
25
+ */
26
+ export function cmdVerify(options) {
27
+ const dbPath = requiredPositional(options, 'verify');
28
+ let report;
29
+ try {
30
+ const native = loadNative();
31
+ const db = native.Database.open(dbPath);
32
+ report = JSON.parse(db.doctor());
33
+ }
34
+ catch (err) {
35
+ const message = err instanceof Error ? err.message : String(err);
36
+ console.error(formatDiagnostic({
37
+ path: dbPath,
38
+ severity: 'error',
39
+ code: 'cli.err.open_failed',
40
+ args: { path: dbPath, reason: message },
41
+ }, {
42
+ locale: DIAGNOSTIC_LOCALE,
43
+ catalog: CLI_CATALOG,
44
+ style: DIAGNOSTIC_STYLE,
45
+ }));
46
+ return 1;
47
+ }
48
+ for (const issue of report.issues) {
49
+ const severity = issue.severity === 'error' ? 'error' : 'warning';
50
+ console.error(formatDiagnostic({
51
+ path: dbPath,
52
+ severity,
53
+ code: issue.code,
54
+ message: issue.message,
55
+ }, {
56
+ locale: DIAGNOSTIC_LOCALE,
57
+ catalog: CLI_CATALOG,
58
+ style: DIAGNOSTIC_STYLE,
59
+ }));
60
+ }
61
+ const errCount = report.issues.filter((i) => i.severity === 'error').length;
62
+ const warnCount = report.issues.length - errCount;
63
+ if (errCount > 0) {
64
+ console.log(`exit=1 errors=${errCount} warnings=${warnCount}`);
65
+ return 1;
66
+ }
67
+ if (warnCount > 0) {
68
+ console.log(`exit=13 errors=0 warnings=${warnCount}`);
69
+ return 13;
70
+ }
71
+ console.log(`exit=0 clean last_commit=${report.last_commit_sequence}`);
72
+ return 0;
73
+ }