wicked-bus 1.0.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 +153 -0
- package/commands/cli.js +129 -0
- package/commands/cmd-ack.js +31 -0
- package/commands/cmd-cleanup.js +44 -0
- package/commands/cmd-deregister.js +26 -0
- package/commands/cmd-emit.js +52 -0
- package/commands/cmd-init.js +30 -0
- package/commands/cmd-list.js +39 -0
- package/commands/cmd-register.js +29 -0
- package/commands/cmd-replay.js +65 -0
- package/commands/cmd-status.js +70 -0
- package/commands/cmd-subscribe.js +102 -0
- package/install.mjs +83 -0
- package/lib/config.js +87 -0
- package/lib/db.js +72 -0
- package/lib/emit.js +111 -0
- package/lib/errors.js +45 -0
- package/lib/index.cjs +20 -0
- package/lib/index.js +13 -0
- package/lib/paths.js +69 -0
- package/lib/poll.js +212 -0
- package/lib/register.js +164 -0
- package/lib/schema.sql +68 -0
- package/lib/sweep.js +71 -0
- package/lib/validate.js +156 -0
- package/package.json +56 -0
- package/scripts/postinstall.js +14 -0
- package/skills/wicked-bus/emit/SKILL.md +147 -0
- package/skills/wicked-bus/init/SKILL.md +94 -0
- package/skills/wicked-bus/naming/SKILL.md +151 -0
- package/skills/wicked-bus/query/SKILL.md +177 -0
- package/skills/wicked-bus/subscribe/SKILL.md +164 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wicked-bus subscribe command -- streaming NDJSON poll.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { loadConfig } from '../lib/config.js';
|
|
6
|
+
import { openDb } from '../lib/db.js';
|
|
7
|
+
import { poll, ack } from '../lib/poll.js';
|
|
8
|
+
import { register } from '../lib/register.js';
|
|
9
|
+
import { startSweep } from '../lib/sweep.js';
|
|
10
|
+
|
|
11
|
+
export async function cmdSubscribe(args, globals) {
|
|
12
|
+
const configOverrides = {};
|
|
13
|
+
if (globals.db_path) configOverrides.db_path = globals.db_path;
|
|
14
|
+
if (globals.log_level) configOverrides.log_level = globals.log_level;
|
|
15
|
+
|
|
16
|
+
const config = loadConfig(configOverrides);
|
|
17
|
+
const db = openDb(config);
|
|
18
|
+
|
|
19
|
+
const plugin = args.plugin;
|
|
20
|
+
const filter = args.filter;
|
|
21
|
+
const pollIntervalMs = Number(args['poll-interval-ms']) || 1000;
|
|
22
|
+
const batchSize = Number(args['batch-size']) || 100;
|
|
23
|
+
const noAck = args['no-ack'] === true;
|
|
24
|
+
|
|
25
|
+
let cursorId = args['cursor-id'] || null;
|
|
26
|
+
|
|
27
|
+
// If no cursor-id, try implicit cursor lookup or register
|
|
28
|
+
if (!cursorId) {
|
|
29
|
+
// Look for an existing active subscription matching plugin + filter
|
|
30
|
+
const existing = db.prepare(`
|
|
31
|
+
SELECT s.subscription_id, c.cursor_id
|
|
32
|
+
FROM subscriptions s
|
|
33
|
+
JOIN cursors c ON c.subscription_id = s.subscription_id
|
|
34
|
+
WHERE s.plugin = ? AND s.event_type_filter = ?
|
|
35
|
+
AND s.role = 'subscriber'
|
|
36
|
+
AND s.deregistered_at IS NULL
|
|
37
|
+
AND c.deregistered_at IS NULL
|
|
38
|
+
`).all(plugin, filter);
|
|
39
|
+
|
|
40
|
+
if (existing.length === 1) {
|
|
41
|
+
cursorId = existing[0].cursor_id;
|
|
42
|
+
} else if (existing.length > 1) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
'Multiple active subscriptions match plugin + filter. ' +
|
|
45
|
+
'Provide --cursor-id to disambiguate.'
|
|
46
|
+
);
|
|
47
|
+
} else {
|
|
48
|
+
// Auto-register
|
|
49
|
+
const cursorInit = args['cursor-init'] || 'latest';
|
|
50
|
+
const reg = register(db, { plugin, role: 'subscriber', filter, cursor_init: cursorInit });
|
|
51
|
+
cursorId = reg.cursor_id;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Start background sweep
|
|
56
|
+
const sweepHandle = startSweep(db, config);
|
|
57
|
+
|
|
58
|
+
// Graceful shutdown
|
|
59
|
+
let running = true;
|
|
60
|
+
const shutdown = () => {
|
|
61
|
+
running = false;
|
|
62
|
+
if (sweepHandle) clearInterval(sweepHandle);
|
|
63
|
+
db.close();
|
|
64
|
+
process.exit(0);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
process.on('SIGINT', shutdown);
|
|
68
|
+
process.on('SIGTERM', shutdown);
|
|
69
|
+
|
|
70
|
+
// Poll loop
|
|
71
|
+
while (running) {
|
|
72
|
+
try {
|
|
73
|
+
const events = poll(db, cursorId, { batchSize });
|
|
74
|
+
for (const event of events) {
|
|
75
|
+
// Output NDJSON
|
|
76
|
+
process.stdout.write(JSON.stringify(event) + '\n');
|
|
77
|
+
|
|
78
|
+
// Auto-ack unless --no-ack
|
|
79
|
+
if (!noAck) {
|
|
80
|
+
ack(db, cursorId, event.event_id);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
} catch (err) {
|
|
84
|
+
// Output error but continue polling if possible
|
|
85
|
+
process.stderr.write(JSON.stringify({
|
|
86
|
+
error: err.error || 'UNKNOWN',
|
|
87
|
+
code: err.code || 'POLL_ERROR',
|
|
88
|
+
message: err.message,
|
|
89
|
+
}) + '\n');
|
|
90
|
+
|
|
91
|
+
// Fatal errors: WB-003, WB-006
|
|
92
|
+
if (err.error === 'WB-003' || err.error === 'WB-006') {
|
|
93
|
+
if (sweepHandle) clearInterval(sweepHandle);
|
|
94
|
+
db.close();
|
|
95
|
+
throw err;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Wait before next poll
|
|
100
|
+
await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
|
|
101
|
+
}
|
|
102
|
+
}
|
package/install.mjs
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// wicked-bus installer — detects CLIs and installs skills
|
|
3
|
+
|
|
4
|
+
import { existsSync, mkdirSync, cpSync, readdirSync } from "node:fs";
|
|
5
|
+
import { join, resolve, basename } from "node:path";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { argv } from "node:process";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
|
|
10
|
+
const __dirname = fileURLToPath(new URL(".", import.meta.url));
|
|
11
|
+
const skillsSource = join(__dirname, "skills");
|
|
12
|
+
const home = homedir();
|
|
13
|
+
|
|
14
|
+
const CLI_TARGETS = [
|
|
15
|
+
{ name: "claude", dir: join(home, ".claude", "skills"), platform: "claude" },
|
|
16
|
+
{ name: "gemini", dir: join(home, ".gemini", "skills"), platform: "gemini" },
|
|
17
|
+
{ name: "copilot", dir: join(home, ".github", "skills"), platform: "copilot" },
|
|
18
|
+
{ name: "codex", dir: join(home, ".codex", "skills"), platform: "codex" },
|
|
19
|
+
{ name: "cursor", dir: join(home, ".cursor", "skills"), platform: "cursor" },
|
|
20
|
+
{ name: "kiro", dir: join(home, ".kiro", "skills"), platform: "kiro" },
|
|
21
|
+
{ name: "antigravity", dir: join(home, ".antigravity", "skills"), platform: "antigravity" },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
console.log("wicked-bus installer\n");
|
|
25
|
+
|
|
26
|
+
const args = argv.slice(2);
|
|
27
|
+
const argValue = (a) => a.split("=")[1];
|
|
28
|
+
const cliArg = args.find((a) => a.startsWith("--cli="));
|
|
29
|
+
const pathArg = args.find((a) => a.startsWith("--path="));
|
|
30
|
+
|
|
31
|
+
let targets;
|
|
32
|
+
|
|
33
|
+
if (pathArg) {
|
|
34
|
+
const rawPath = argValue(pathArg);
|
|
35
|
+
if (!rawPath) {
|
|
36
|
+
console.error("Error: --path requires a value (e.g. --path=~/.claude)");
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
const customPath = resolve(rawPath.replace(/^~/, home));
|
|
40
|
+
const dirName = basename(customPath).replace(/^\./, "");
|
|
41
|
+
targets = [{
|
|
42
|
+
name: dirName,
|
|
43
|
+
dir: join(customPath, "skills"),
|
|
44
|
+
platform: dirName,
|
|
45
|
+
}];
|
|
46
|
+
console.log(`Custom path: ${customPath}\n`);
|
|
47
|
+
} else {
|
|
48
|
+
const detected = CLI_TARGETS.filter((t) => existsSync(resolve(t.dir, "..")));
|
|
49
|
+
|
|
50
|
+
if (detected.length === 0) {
|
|
51
|
+
console.log("No supported AI CLIs detected. Supported: claude, gemini, copilot, codex, cursor, kiro, antigravity");
|
|
52
|
+
console.log("Install skills manually by copying the skills/ directory.");
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
console.log(`Detected CLIs: ${detected.map((d) => d.name).join(", ")}\n`);
|
|
57
|
+
|
|
58
|
+
const cliFilter = cliArg ? argValue(cliArg).split(",") : null;
|
|
59
|
+
targets = cliFilter ? detected.filter((d) => cliFilter.includes(d.name)) : detected;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Copy skills to each target CLI
|
|
63
|
+
const skillDirs = readdirSync(skillsSource).filter((d) => !d.startsWith("."));
|
|
64
|
+
|
|
65
|
+
for (const target of targets) {
|
|
66
|
+
console.log(`Installing to ${target.name} (${target.dir})...`);
|
|
67
|
+
mkdirSync(target.dir, { recursive: true });
|
|
68
|
+
|
|
69
|
+
for (const skill of skillDirs) {
|
|
70
|
+
const src = join(skillsSource, skill);
|
|
71
|
+
const dest = join(target.dir, skill);
|
|
72
|
+
cpSync(src, dest, { recursive: true });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
console.log(` ${skillDirs.length} skills installed`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
console.log(`\nwicked-bus skills installed! Available skills:`);
|
|
79
|
+
console.log(` wicked-bus/init — Initialize or connect to the bus`);
|
|
80
|
+
console.log(` wicked-bus/emit — Publish events`);
|
|
81
|
+
console.log(` wicked-bus/subscribe — Consume events`);
|
|
82
|
+
console.log(` wicked-bus/naming — Event naming conventions`);
|
|
83
|
+
console.log(` wicked-bus/query — Query and debug the bus`);
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration loading and validation.
|
|
3
|
+
* @module lib/config
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { resolveDataDir } from './paths.js';
|
|
9
|
+
|
|
10
|
+
export const DEFAULTS = {
|
|
11
|
+
ttl_hours: 72,
|
|
12
|
+
dedup_ttl_hours: 24,
|
|
13
|
+
sweep_interval_minutes: 15,
|
|
14
|
+
archive_mode: false,
|
|
15
|
+
log_level: 'warn',
|
|
16
|
+
db_path: null,
|
|
17
|
+
max_payload_bytes: 1048576,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const VALID_LOG_LEVELS = ['debug', 'info', 'warn', 'error'];
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Load config from <dataDir>/config.json, merged with defaults.
|
|
24
|
+
* Malformed JSON is silently ignored (defaults used).
|
|
25
|
+
* @param {object} [overrides] - CLI flag overrides (e.g. { db_path, log_level })
|
|
26
|
+
* @returns {object}
|
|
27
|
+
*/
|
|
28
|
+
export function loadConfig(overrides = {}) {
|
|
29
|
+
let userConfig = {};
|
|
30
|
+
try {
|
|
31
|
+
const dataDir = resolveDataDir();
|
|
32
|
+
const configPath = join(dataDir, 'config.json');
|
|
33
|
+
const raw = readFileSync(configPath, 'utf8');
|
|
34
|
+
userConfig = JSON.parse(raw);
|
|
35
|
+
} catch (_) {
|
|
36
|
+
// File missing or malformed JSON -- use defaults
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const config = { ...DEFAULTS, ...userConfig, ...overrides };
|
|
40
|
+
|
|
41
|
+
// Remove null/undefined overrides so defaults aren't clobbered
|
|
42
|
+
for (const key of Object.keys(overrides)) {
|
|
43
|
+
if (overrides[key] == null) delete config[key];
|
|
44
|
+
if (overrides[key] == null && DEFAULTS[key] != null) {
|
|
45
|
+
config[key] = userConfig[key] != null ? userConfig[key] : DEFAULTS[key];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Validate
|
|
50
|
+
if (config.dedup_ttl_hours > config.ttl_hours) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`Invalid config: dedup_ttl_hours (${config.dedup_ttl_hours}) must be <= ttl_hours (${config.ttl_hours})`
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
if (config.sweep_interval_minutes < 0) {
|
|
56
|
+
throw new Error('Invalid config: sweep_interval_minutes must be >= 0');
|
|
57
|
+
}
|
|
58
|
+
if (config.max_payload_bytes < 1) {
|
|
59
|
+
throw new Error('Invalid config: max_payload_bytes must be >= 1');
|
|
60
|
+
}
|
|
61
|
+
if (!VALID_LOG_LEVELS.includes(config.log_level)) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`Invalid config: log_level must be one of ${VALID_LOG_LEVELS.join(', ')}`
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return config;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Write the default config to <dataDir>/config.json.
|
|
72
|
+
* Does not overwrite if file already exists unless force=true.
|
|
73
|
+
* @param {string} dataDir
|
|
74
|
+
* @param {boolean} [force=false]
|
|
75
|
+
*/
|
|
76
|
+
export function writeDefaultConfig(dataDir, force = false) {
|
|
77
|
+
const configPath = join(dataDir, 'config.json');
|
|
78
|
+
if (!force) {
|
|
79
|
+
try {
|
|
80
|
+
readFileSync(configPath);
|
|
81
|
+
return; // Already exists
|
|
82
|
+
} catch (_) {
|
|
83
|
+
// File doesn't exist, write it
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
writeFileSync(configPath, JSON.stringify(DEFAULTS, null, 2) + '\n', 'utf8');
|
|
87
|
+
}
|
package/lib/db.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Database connection manager.
|
|
3
|
+
* @module lib/db
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readFileSync } from 'node:fs';
|
|
7
|
+
import { join, dirname } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { createRequire } from 'node:module';
|
|
10
|
+
import { resolveDbPath, ensureDataDir } from './paths.js';
|
|
11
|
+
|
|
12
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const require = createRequire(import.meta.url);
|
|
14
|
+
const SCHEMA_SQL_PATH = join(__dirname, 'schema.sql');
|
|
15
|
+
const MAX_SUPPORTED_SCHEMA_VERSION = 1;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Open (or create) the SQLite database, apply PRAGMAs and schema.
|
|
19
|
+
* better-sqlite3 is synchronous, so this function is synchronous.
|
|
20
|
+
* @param {object} [config] - Merged config object
|
|
21
|
+
* @returns {import('better-sqlite3').Database}
|
|
22
|
+
*/
|
|
23
|
+
export function openDb(config = {}) {
|
|
24
|
+
const Database = require('better-sqlite3');
|
|
25
|
+
|
|
26
|
+
ensureDataDir();
|
|
27
|
+
const dbPath = resolveDbPath(config);
|
|
28
|
+
|
|
29
|
+
const db = new Database(dbPath);
|
|
30
|
+
|
|
31
|
+
// Apply PRAGMAs in order
|
|
32
|
+
db.pragma('journal_mode = WAL');
|
|
33
|
+
db.pragma('synchronous = NORMAL');
|
|
34
|
+
db.pragma('foreign_keys = ON');
|
|
35
|
+
db.pragma('busy_timeout = 5000');
|
|
36
|
+
|
|
37
|
+
// Execute schema DDL (idempotent)
|
|
38
|
+
const schemaSql = readFileSync(SCHEMA_SQL_PATH, 'utf8');
|
|
39
|
+
// Split by semicolons and execute each statement
|
|
40
|
+
// Filter out PRAGMA lines since we already applied them
|
|
41
|
+
const statements = schemaSql
|
|
42
|
+
.split(';')
|
|
43
|
+
.map(s => s.trim())
|
|
44
|
+
.filter(s => s.length > 0 && !s.toUpperCase().startsWith('PRAGMA'));
|
|
45
|
+
|
|
46
|
+
for (const stmt of statements) {
|
|
47
|
+
db.exec(stmt + ';');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Schema version check
|
|
51
|
+
checkSchemaVersion(db);
|
|
52
|
+
|
|
53
|
+
return db;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Check that the DB schema version is supported.
|
|
58
|
+
* @param {import('better-sqlite3').Database} db
|
|
59
|
+
*/
|
|
60
|
+
function checkSchemaVersion(db) {
|
|
61
|
+
const row = db.prepare(
|
|
62
|
+
'SELECT MAX(version) as max_version FROM schema_migrations'
|
|
63
|
+
).get();
|
|
64
|
+
|
|
65
|
+
if (row && row.max_version > MAX_SUPPORTED_SCHEMA_VERSION) {
|
|
66
|
+
process.stderr.write(JSON.stringify({
|
|
67
|
+
error: 'SCHEMA_VERSION_MISMATCH',
|
|
68
|
+
message: `Database schema version ${row.max_version} is newer than supported (${MAX_SUPPORTED_SCHEMA_VERSION}). Run: npm install -g wicked-bus`,
|
|
69
|
+
}) + '\n');
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
}
|
package/lib/emit.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Event emission with idempotency and TTL computation.
|
|
3
|
+
* @module lib/emit
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
7
|
+
import { validateEvent } from './validate.js';
|
|
8
|
+
import { WBError } from './errors.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Emit an event to the bus.
|
|
12
|
+
* @param {import('better-sqlite3').Database} db
|
|
13
|
+
* @param {object} config - Merged config
|
|
14
|
+
* @param {object} event - Event data
|
|
15
|
+
* @param {string} event.event_type
|
|
16
|
+
* @param {string} event.domain
|
|
17
|
+
* @param {string} [event.subdomain]
|
|
18
|
+
* @param {object|string} event.payload
|
|
19
|
+
* @param {string} [event.schema_version]
|
|
20
|
+
* @param {string} [event.idempotency_key]
|
|
21
|
+
* @param {number} [event.ttl_hours] - Per-event TTL override
|
|
22
|
+
* @param {string|object} [event.metadata]
|
|
23
|
+
* @returns {{ event_id: number, idempotency_key: string }}
|
|
24
|
+
*/
|
|
25
|
+
export function emit(db, config, event) {
|
|
26
|
+
// Validate
|
|
27
|
+
validateEvent(event, config);
|
|
28
|
+
|
|
29
|
+
const idempotencyKey = event.idempotency_key || uuidv4();
|
|
30
|
+
const emittedAt = Date.now();
|
|
31
|
+
const ttlHours = event.ttl_hours != null ? event.ttl_hours : config.ttl_hours;
|
|
32
|
+
const expiresAt = emittedAt + (ttlHours * 3_600_000);
|
|
33
|
+
const dedupExpiresAt = emittedAt + (config.dedup_ttl_hours * 3_600_000);
|
|
34
|
+
|
|
35
|
+
const payloadStr = typeof event.payload === 'string'
|
|
36
|
+
? event.payload
|
|
37
|
+
: JSON.stringify(event.payload);
|
|
38
|
+
|
|
39
|
+
const metadataStr = event.metadata != null
|
|
40
|
+
? (typeof event.metadata === 'string' ? event.metadata : JSON.stringify(event.metadata))
|
|
41
|
+
: null;
|
|
42
|
+
|
|
43
|
+
const schemaVersion = event.schema_version || '1.0.0';
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const subdomain = event.subdomain || '';
|
|
47
|
+
|
|
48
|
+
const stmt = db.prepare(`
|
|
49
|
+
INSERT INTO events (
|
|
50
|
+
event_type, domain, subdomain, payload, schema_version,
|
|
51
|
+
idempotency_key, emitted_at, expires_at, dedup_expires_at, metadata
|
|
52
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
53
|
+
`);
|
|
54
|
+
|
|
55
|
+
const result = stmt.run(
|
|
56
|
+
event.event_type,
|
|
57
|
+
event.domain,
|
|
58
|
+
subdomain,
|
|
59
|
+
payloadStr,
|
|
60
|
+
schemaVersion,
|
|
61
|
+
idempotencyKey,
|
|
62
|
+
emittedAt,
|
|
63
|
+
expiresAt,
|
|
64
|
+
dedupExpiresAt,
|
|
65
|
+
metadataStr
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
event_id: Number(result.lastInsertRowid),
|
|
70
|
+
idempotency_key: idempotencyKey,
|
|
71
|
+
};
|
|
72
|
+
} catch (err) {
|
|
73
|
+
// Duplicate idempotency_key
|
|
74
|
+
if (err.code === 'SQLITE_CONSTRAINT_UNIQUE') {
|
|
75
|
+
const existing = db.prepare(
|
|
76
|
+
'SELECT event_id FROM events WHERE idempotency_key = ?'
|
|
77
|
+
).get(idempotencyKey);
|
|
78
|
+
|
|
79
|
+
throw new WBError('WB-002', 'DUPLICATE_EVENT', {
|
|
80
|
+
message: `Duplicate idempotency_key: ${idempotencyKey}`,
|
|
81
|
+
original_event_id: existing ? existing.event_id : null,
|
|
82
|
+
idempotency_key: idempotencyKey,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Disk full
|
|
87
|
+
if (
|
|
88
|
+
(err.code && err.code.includes('SQLITE_FULL')) ||
|
|
89
|
+
(err.code === 'ENOSPC') ||
|
|
90
|
+
(err.message && err.message.includes('SQLITE_FULL'))
|
|
91
|
+
) {
|
|
92
|
+
const dbPath = db.name;
|
|
93
|
+
try { db.close(); } catch (_) { /* ignore close errors */ }
|
|
94
|
+
// Re-open to verify integrity
|
|
95
|
+
try {
|
|
96
|
+
const Database = db.constructor;
|
|
97
|
+
const verifyDb = new Database(dbPath);
|
|
98
|
+
verifyDb.pragma('integrity_check');
|
|
99
|
+
verifyDb.close();
|
|
100
|
+
} catch (_) { /* ignore verification errors */ }
|
|
101
|
+
|
|
102
|
+
throw new WBError('WB-004', 'DISK_FULL', {
|
|
103
|
+
message: 'Database disk is full',
|
|
104
|
+
sqlite_error: err.code || 'SQLITE_FULL',
|
|
105
|
+
db_path: dbPath,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
throw err;
|
|
110
|
+
}
|
|
111
|
+
}
|
package/lib/errors.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wicked-bus error classes and error codes.
|
|
3
|
+
* @module lib/errors
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const ERROR_CODES = {
|
|
7
|
+
'WB-001': 'INVALID_EVENT_SCHEMA',
|
|
8
|
+
'WB-002': 'DUPLICATE_EVENT',
|
|
9
|
+
'WB-003': 'CURSOR_BEHIND_TTL_WINDOW',
|
|
10
|
+
'WB-004': 'DISK_FULL',
|
|
11
|
+
'WB-005': 'SCHEMA_VERSION_UNSUPPORTED',
|
|
12
|
+
'WB-006': 'CURSOR_NOT_FOUND',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const EXIT_CODES = {
|
|
16
|
+
'WB-001': 1,
|
|
17
|
+
'WB-002': 2,
|
|
18
|
+
'WB-003': 3,
|
|
19
|
+
'WB-004': 4,
|
|
20
|
+
'WB-005': 5,
|
|
21
|
+
'WB-006': 6,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export class WBError extends Error {
|
|
25
|
+
/**
|
|
26
|
+
* @param {string} error - Error code, e.g. 'WB-001'
|
|
27
|
+
* @param {string} code - Machine-readable name, e.g. 'INVALID_EVENT_SCHEMA'
|
|
28
|
+
* @param {object} context - Additional context
|
|
29
|
+
*/
|
|
30
|
+
constructor(error, code, context = {}) {
|
|
31
|
+
super(context.message || code);
|
|
32
|
+
this.error = error;
|
|
33
|
+
this.code = code;
|
|
34
|
+
this.context = context;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
toJSON() {
|
|
38
|
+
return {
|
|
39
|
+
error: this.error,
|
|
40
|
+
code: this.code,
|
|
41
|
+
message: this.message,
|
|
42
|
+
context: this.context,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
}
|
package/lib/index.cjs
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CJS shim for wicked-bus.
|
|
3
|
+
* Usage: const bus = await import('wicked-bus');
|
|
4
|
+
* Or: import('wicked-bus').then(bus => { ... });
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
let _mod;
|
|
8
|
+
module.exports = new Proxy({}, {
|
|
9
|
+
get(_, prop) {
|
|
10
|
+
if (!_mod) {
|
|
11
|
+
throw new Error(
|
|
12
|
+
'wicked-bus CJS shim: module not yet loaded. ' +
|
|
13
|
+
'Use: const bus = await import("wicked-bus")'
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
return _mod[prop];
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
import('./index.js').then(m => { _mod = m; });
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wicked-bus public API.
|
|
3
|
+
* @module wicked-bus
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export { emit } from './emit.js';
|
|
7
|
+
export { poll, ack, matchesFilter } from './poll.js';
|
|
8
|
+
export { register, deregister } from './register.js';
|
|
9
|
+
export { openDb } from './db.js';
|
|
10
|
+
export { loadConfig } from './config.js';
|
|
11
|
+
export { resolveDataDir, ensureDataDir, resolveDbPath } from './paths.js';
|
|
12
|
+
export { startSweep, runSweep } from './sweep.js';
|
|
13
|
+
export { WBError, ERROR_CODES, EXIT_CODES } from './errors.js';
|
package/lib/paths.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-platform data directory resolution.
|
|
3
|
+
* @module lib/paths
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { homedir } from 'node:os';
|
|
8
|
+
import { mkdirSync } from 'node:fs';
|
|
9
|
+
|
|
10
|
+
const SUBDIR = join('.something-wicked', 'wicked-bus');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Resolve the wicked-bus data directory path (pure resolution, no side effects).
|
|
14
|
+
* Priority: WICKED_BUS_DATA_DIR env > platform-specific home.
|
|
15
|
+
* @returns {string}
|
|
16
|
+
*/
|
|
17
|
+
export function resolveDataDir() {
|
|
18
|
+
const envDir = process.env.WICKED_BUS_DATA_DIR;
|
|
19
|
+
if (envDir) return envDir;
|
|
20
|
+
|
|
21
|
+
let base;
|
|
22
|
+
if (process.platform === 'win32') {
|
|
23
|
+
base =
|
|
24
|
+
process.env.APPDATA ||
|
|
25
|
+
process.env.USERPROFILE ||
|
|
26
|
+
process.env.HOME ||
|
|
27
|
+
null;
|
|
28
|
+
if (!base) {
|
|
29
|
+
// Last resort on Windows
|
|
30
|
+
try { base = homedir(); } catch (_) { /* ignore */ }
|
|
31
|
+
}
|
|
32
|
+
if (!base) {
|
|
33
|
+
base = process.cwd();
|
|
34
|
+
}
|
|
35
|
+
} else {
|
|
36
|
+
base = process.env.HOME || null;
|
|
37
|
+
if (!base) {
|
|
38
|
+
try { base = homedir(); } catch (_) { /* ignore */ }
|
|
39
|
+
}
|
|
40
|
+
if (!base) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
'Cannot resolve data directory: $HOME is not set. ' +
|
|
43
|
+
'Set WICKED_BUS_DATA_DIR or export HOME.'
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return join(base, SUBDIR);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Ensure the data directory exists (creates recursively).
|
|
53
|
+
* @returns {string} The data directory path.
|
|
54
|
+
*/
|
|
55
|
+
export function ensureDataDir() {
|
|
56
|
+
const dir = resolveDataDir();
|
|
57
|
+
mkdirSync(dir, { recursive: true });
|
|
58
|
+
return dir;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Resolve the full database file path.
|
|
63
|
+
* @param {object} [config] - Config object; uses config.db_path if set.
|
|
64
|
+
* @returns {string}
|
|
65
|
+
*/
|
|
66
|
+
export function resolveDbPath(config) {
|
|
67
|
+
if (config && config.db_path) return config.db_path;
|
|
68
|
+
return join(resolveDataDir(), 'bus.db');
|
|
69
|
+
}
|