wicked-bus 2.2.0 → 2.2.1

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/commands/cli.js CHANGED
@@ -4,8 +4,22 @@
4
4
  * wicked-bus CLI entry point.
5
5
  */
6
6
 
7
+ import { readFileSync } from 'node:fs';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { dirname, join } from 'node:path';
7
10
  import { WBError, EXIT_CODES } from '../lib/errors.js';
8
11
 
12
+ // Resolve the package version from package.json (single source of truth).
13
+ function readVersion() {
14
+ try {
15
+ const here = dirname(fileURLToPath(import.meta.url));
16
+ const pkg = JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8'));
17
+ return pkg.version || '0.0.0';
18
+ } catch (_e) {
19
+ return '0.0.0';
20
+ }
21
+ }
22
+
9
23
  // Argument parser. Returns flags + positional args (anything that isn't --flag
10
24
  // or its value). Positional args are needed for subcommands like `dlq list`.
11
25
  function parseArgs(argv) {
@@ -56,6 +70,15 @@ function handleError(err) {
56
70
  async function main() {
57
71
  const argv = process.argv.slice(2);
58
72
  const command = argv[0];
73
+
74
+ // `--version` / `-v` prints the package version and exits 0. Handled before
75
+ // command dispatch so health probes (e.g. wicked-loom's doctor) get a clean
76
+ // version string instead of the usage/help JSON.
77
+ if (command === '--version' || command === '-v') {
78
+ process.stdout.write(readVersion() + '\n');
79
+ process.exit(0);
80
+ }
81
+
59
82
  const flagArgv = argv.slice(1);
60
83
  const args = parseArgs(flagArgv);
61
84
 
package/lib/atomic.js ADDED
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Cross-platform atomic file placement helpers.
3
+ *
4
+ * POSIX `rename(2)` atomically replaces an existing destination. Windows
5
+ * `MoveFileEx`-backed `fs.rename` REJECTS a rename onto an existing file with
6
+ * `EPERM` / `EEXIST` / `EACCES`, and may transiently fail with `EBUSY` when the
7
+ * destination (or a directory entry) is briefly held open by AV/indexer/another
8
+ * handle. POSIX never sees these for an overwrite.
9
+ *
10
+ * This module centralizes the cross-platform fallback so every "write to a temp
11
+ * file, then rename it into final position" site behaves identically and can't
12
+ * regress independently.
13
+ *
14
+ * @module lib/atomic
15
+ */
16
+
17
+ import fs from 'node:fs';
18
+
19
+ // Number of times we retry a transient EBUSY on win32 before giving up, and the
20
+ // backoff between attempts. Kept tiny — EBUSY here is from a momentary handle
21
+ // (AV scan / indexer), not sustained contention. Synchronous spin-wait so the
22
+ // helper stays drop-in for the existing synchronous CAS/fs codepaths.
23
+ const WIN32_EBUSY_RETRIES = 5;
24
+ const WIN32_EBUSY_BACKOFF_MS = 20;
25
+
26
+ function sleepSync(ms) {
27
+ // Busy-ish wait without pulling in a dependency. Used only on the win32 EBUSY
28
+ // retry path, which is rare and short.
29
+ const end = Date.now() + ms;
30
+ while (Date.now() < end) { /* spin */ }
31
+ }
32
+
33
+ /**
34
+ * Rename `tmp` onto `target`, overwriting any existing `target` atomically.
35
+ *
36
+ * On POSIX a single `renameSync` already overwrites atomically. On win32, if the
37
+ * rename is rejected because `target` exists (EPERM/EEXIST/EACCES) we unlink the
38
+ * destination then retry the rename; a transient EBUSY is retried with a short
39
+ * backoff. On any unrecoverable failure the `tmp` file is cleaned up before the
40
+ * error is rethrown so we never leak partial temp files.
41
+ *
42
+ * @param {string} tmp path to the fully-written temp file
43
+ * @param {string} target final destination path
44
+ * @param {object} [opts]
45
+ * @param {string[]} [opts.swallowCodes] on POSIX-or-win32, rethrow is skipped
46
+ * for these error codes (e.g. ['EEXIST']
47
+ * for content-addressed no-op races). The
48
+ * tmp file is still cleaned up.
49
+ */
50
+ export function atomicRename(tmp, target, opts = {}) {
51
+ const swallow = new Set(opts.swallowCodes ?? []);
52
+ const isWin = process.platform === 'win32';
53
+
54
+ try {
55
+ fs.renameSync(tmp, target);
56
+ return;
57
+ } catch (e) {
58
+ // win32: rename-over-existing is rejected. Unlink the destination and retry.
59
+ if (isWin && (e.code === 'EPERM' || e.code === 'EEXIST' || e.code === 'EACCES' || e.code === 'EBUSY')) {
60
+ let lastErr = e;
61
+ for (let attempt = 0; attempt <= WIN32_EBUSY_RETRIES; attempt++) {
62
+ try {
63
+ // Remove the destination so the rename has a clear path. The
64
+ // destination may legitimately not exist (pure EBUSY on the source),
65
+ // so a failing unlink is non-fatal.
66
+ try { fs.unlinkSync(target); } catch (_e) { /* may not exist */ }
67
+ fs.renameSync(tmp, target);
68
+ return;
69
+ } catch (retryErr) {
70
+ lastErr = retryErr;
71
+ // Only EBUSY is worth retrying — it's the transient AV/indexer hold.
72
+ if (retryErr.code === 'EBUSY' && attempt < WIN32_EBUSY_RETRIES) {
73
+ sleepSync(WIN32_EBUSY_BACKOFF_MS);
74
+ continue;
75
+ }
76
+ break;
77
+ }
78
+ }
79
+ // Exhausted retries (or a non-EBUSY error on the fallback path).
80
+ try { fs.unlinkSync(tmp); } catch (_e) { /* avoid leaking the temp file */ }
81
+ if (swallow.has(lastErr.code)) return;
82
+ throw lastErr;
83
+ }
84
+
85
+ // POSIX (or a win32 error we don't special-case). Clean up the temp file.
86
+ try { fs.unlinkSync(tmp); } catch (_e) { /* avoid leaking the temp file */ }
87
+ if (swallow.has(e.code)) return;
88
+ throw e;
89
+ }
90
+ }
package/lib/cas.js CHANGED
@@ -23,6 +23,7 @@ import { createHash } from 'node:crypto';
23
23
  import { createRequire } from 'node:module';
24
24
  import { WBError } from './errors.js';
25
25
  import { archiveDir, listBuckets } from './archive.js';
26
+ import { atomicRename } from './atomic.js';
26
27
 
27
28
  const require = createRequire(import.meta.url);
28
29
 
@@ -102,14 +103,11 @@ export function put(dataDir, content, opts = {}) {
102
103
  }
103
104
  }
104
105
 
105
- try {
106
- fs.renameSync(tmp, target);
107
- } catch (e) {
108
- // Race: another writer just moved their copy into place. Same SHA
109
- // identical content safe to drop ours.
110
- try { fs.unlinkSync(tmp); } catch (_e) { /* ignore */ }
111
- if (e.code !== 'EEXIST') throw e;
112
- }
106
+ // Cross-platform atomic placement. POSIX rename overwrites atomically;
107
+ // Windows rejects rename-over-existing (EPERM/EEXIST/EACCES) and may hit a
108
+ // transient EBUSY — atomicRename() handles both. EEXIST is swallowed because
109
+ // a concurrent writer placing identical content (same SHA) is a safe no-op.
110
+ atomicRename(tmp, target, { swallowCodes: ['EEXIST'] });
113
111
 
114
112
  return sha;
115
113
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wicked-bus",
3
- "version": "2.2.0",
3
+ "version": "2.2.1",
4
4
  "description": "Lightweight, local-first SQLite event bus for AI agents and developer tools",
5
5
  "type": "module",
6
6
  "exports": {