@ianwremmel/dispatch 0.32.1-bootstrap.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/.claude-plugin/plugin.json +59 -0
- package/.mcp.json +8 -0
- package/LICENSE +21 -0
- package/README.md +93 -0
- package/agents/.gitkeep +0 -0
- package/agents/build-graph.md +99 -0
- package/agents/milestone-reviewer.md +50 -0
- package/agents/pr-worker.md +172 -0
- package/agents/ticket-worker.md +97 -0
- package/bin/dispatch +101 -0
- package/bin/dispatch-mcp +19 -0
- package/bin/pr-status +931 -0
- package/commands/.gitkeep +0 -0
- package/commands/orchestrate.md +6 -0
- package/hooks/.gitkeep +0 -0
- package/hooks/claim-guard.mts +98 -0
- package/hooks/hooks.json +15 -0
- package/package.json +46 -0
- package/skills/.gitkeep +0 -0
- package/skills/land/SKILL.md +238 -0
- package/skills/land/credentials-dedicated.md +33 -0
- package/skills/land/credentials-shared.md +76 -0
- package/skills/land/mode-solo.md +76 -0
- package/skills/land/mode-team.md +103 -0
- package/skills/land/reference.md +152 -0
- package/skills/land/ticket.md +94 -0
- package/skills/orchestrate/SKILL.md +87 -0
- package/skills/tracker-adapter-linear/SKILL.md +142 -0
- package/src/commands/CLAUDE.md +12 -0
- package/src/commands/claim/check.mts +88 -0
- package/src/commands/claim/guard.mts +95 -0
- package/src/commands/claim/status.mts +49 -0
- package/src/commands/edge/add.mts +44 -0
- package/src/commands/edge/rm.mts +44 -0
- package/src/commands/edge/set.mts +56 -0
- package/src/commands/greet.mts +34 -0
- package/src/commands/mcp/ack.mts +43 -0
- package/src/commands/mcp/ping.mts +61 -0
- package/src/commands/mcp/status.mts +89 -0
- package/src/commands/mcp.mts +155 -0
- package/src/commands/milestone/rm.mts +35 -0
- package/src/commands/milestone/set.mts +49 -0
- package/src/commands/outcome/rm.mts +36 -0
- package/src/commands/outcome/set.mts +86 -0
- package/src/commands/pr/rm.mts +33 -0
- package/src/commands/pr/set.mts +110 -0
- package/src/commands/pr/yield.mts +114 -0
- package/src/commands/project/rm.mts +33 -0
- package/src/commands/project/set.mts +50 -0
- package/src/commands/queue.mts +41 -0
- package/src/commands/refresh/done.mts +42 -0
- package/src/commands/refresh/status.mts +40 -0
- package/src/commands/refresh.mts +56 -0
- package/src/commands/review/record.mts +46 -0
- package/src/commands/review/release.mts +49 -0
- package/src/commands/status.mts +85 -0
- package/src/commands/ticket/missing.mts +31 -0
- package/src/commands/ticket/rm.mts +33 -0
- package/src/commands/ticket/set.mts +134 -0
- package/src/commands/worker/rm.mts +46 -0
- package/src/commands/worker/set.mts +63 -0
- package/src/lib/cli/CLAUDE.md +13 -0
- package/src/lib/cli/cli.mts +226 -0
- package/src/lib/cli/index.mts +1 -0
- package/src/lib/command/CLAUDE.md +26 -0
- package/src/lib/command/__fixtures__/bad-export/oops.mts +1 -0
- package/src/lib/command/__fixtures__/bad-name/mismatch.mts +19 -0
- package/src/lib/command/__fixtures__/commands/cli-only.mts +20 -0
- package/src/lib/command/__fixtures__/commands/greet.mts +39 -0
- package/src/lib/command/__fixtures__/commands/math/add.mts +32 -0
- package/src/lib/command/__fixtures__/commands/mcp-only.mts +20 -0
- package/src/lib/command/__fixtures__/commands/needs-token.mts +19 -0
- package/src/lib/command/__fixtures__/commands/store/get.mts +26 -0
- package/src/lib/command/__fixtures__/commands/store.mts +26 -0
- package/src/lib/command/abstract-command.mts +104 -0
- package/src/lib/command/discovery.mts +100 -0
- package/src/lib/command/env.mts +19 -0
- package/src/lib/command/index.mts +6 -0
- package/src/lib/command/parse.mts +64 -0
- package/src/lib/command/test-support.mts +81 -0
- package/src/lib/command/transports.mts +17 -0
- package/src/lib/command/types.mts +53 -0
- package/src/lib/db/CLAUDE.md +13 -0
- package/src/lib/db/database.mts +160 -0
- package/src/lib/db/index.mts +4 -0
- package/src/lib/db/schema.mts +195 -0
- package/src/lib/db/time.mts +24 -0
- package/src/lib/db/with-database.mts +56 -0
- package/src/lib/errors/CLAUDE.md +18 -0
- package/src/lib/errors/command-error.mts +13 -0
- package/src/lib/errors/data-error.mts +12 -0
- package/src/lib/errors/definition-error.mts +6 -0
- package/src/lib/errors/dispatch-error.mts +27 -0
- package/src/lib/errors/ensure.mts +22 -0
- package/src/lib/errors/environment-error.mts +7 -0
- package/src/lib/errors/index.mts +8 -0
- package/src/lib/errors/json-rpc-error.mts +18 -0
- package/src/lib/errors/usage-error.mts +7 -0
- package/src/lib/graph/CLAUDE.md +17 -0
- package/src/lib/graph/anomalies.mts +110 -0
- package/src/lib/graph/derive.mts +96 -0
- package/src/lib/graph/index.mts +26 -0
- package/src/lib/graph/pipeline.mts +410 -0
- package/src/lib/graph/queries.mts +207 -0
- package/src/lib/graph/rows.mts +99 -0
- package/src/lib/graph/types.mts +137 -0
- package/src/lib/liveness/CLAUDE.md +14 -0
- package/src/lib/liveness/index.mts +10 -0
- package/src/lib/liveness/liveness.mts +147 -0
- package/src/lib/liveness/retire.mts +63 -0
- package/src/lib/logger/CLAUDE.md +12 -0
- package/src/lib/logger/index.mts +2 -0
- package/src/lib/logger/logger.mts +58 -0
- package/src/lib/logger/stream-sink.mts +23 -0
- package/src/lib/mcp/CLAUDE.md +21 -0
- package/src/lib/mcp/channel.mts +41 -0
- package/src/lib/mcp/dispatch.mts +60 -0
- package/src/lib/mcp/drain.mts +83 -0
- package/src/lib/mcp/index.mts +5 -0
- package/src/lib/mcp/mcp.mts +267 -0
- package/src/lib/mcp/tools.mts +77 -0
- package/src/lib/model/CLAUDE.md +8 -0
- package/src/lib/model/index.mts +3 -0
- package/src/lib/model/repo-caps.mts +95 -0
- package/src/lib/model/status.mts +91 -0
- package/src/lib/model/types.mts +83 -0
- package/src/lib/refresh/index.mts +2 -0
- package/src/lib/refresh/placeholders.mts +43 -0
- package/src/lib/refresh/refresh-service.mts +203 -0
- package/src/lib/schedule/CLAUDE.md +18 -0
- package/src/lib/schedule/caps.mts +113 -0
- package/src/lib/schedule/correlate.mts +69 -0
- package/src/lib/schedule/index.mts +7 -0
- package/src/lib/schedule/scheduler.mts +355 -0
- package/src/lib/schedule/tick.mts +266 -0
- package/src/lib/stores/CLAUDE.md +24 -0
- package/src/lib/stores/coordination.mts +359 -0
- package/src/lib/stores/cursor.mts +41 -0
- package/src/lib/stores/edge.mts +138 -0
- package/src/lib/stores/fetch-request.mts +346 -0
- package/src/lib/stores/index.mts +19 -0
- package/src/lib/stores/materialize.mts +69 -0
- package/src/lib/stores/milestone.mts +74 -0
- package/src/lib/stores/notice.mts +57 -0
- package/src/lib/stores/policy.mts +48 -0
- package/src/lib/stores/pr-event.mts +94 -0
- package/src/lib/stores/pr.mts +167 -0
- package/src/lib/stores/project.mts +79 -0
- package/src/lib/stores/refresh.mts +197 -0
- package/src/lib/stores/review.mts +113 -0
- package/src/lib/stores/session.mts +170 -0
- package/src/lib/stores/ticket.mts +246 -0
- package/src/lib/stores/watch.mts +360 -0
- package/src/lib/stores/worker.mts +121 -0
- package/src/lib/watch/adopt.mts +151 -0
- package/src/lib/watch/arm.mts +48 -0
- package/src/lib/watch/cadence.mts +45 -0
- package/src/lib/watch/diff.mts +274 -0
- package/src/lib/watch/index.mts +11 -0
- package/src/lib/watch/marker.mts +24 -0
- package/src/lib/watch/payload.mts +56 -0
- package/src/lib/watch/poll.mts +87 -0
- package/src/lib/watch/render.mts +61 -0
- package/src/lib/watch/snapshot.mts +312 -0
- package/src/main.mts +18 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import {readdir} from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {fileURLToPath, pathToFileURL} from 'node:url';
|
|
4
|
+
|
|
5
|
+
import {AbstractCommand} from './abstract-command.mts';
|
|
6
|
+
import {DefinitionError, ensure} from '../errors/index.mts';
|
|
7
|
+
|
|
8
|
+
export interface CommandNode {
|
|
9
|
+
readonly name: string;
|
|
10
|
+
command: AbstractCommand | undefined;
|
|
11
|
+
readonly children: Map<string, CommandNode>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function makeNode(name: string): CommandNode {
|
|
15
|
+
return {name, command: undefined, children: new Map()};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Walk `commandsDir`, load each `.mts` command, and assemble the invocation tree. */
|
|
19
|
+
export async function discover(
|
|
20
|
+
commandsDir: string | URL
|
|
21
|
+
): Promise<CommandNode> {
|
|
22
|
+
const dir =
|
|
23
|
+
typeof commandsDir === 'string' ? commandsDir : fileURLToPath(commandsDir);
|
|
24
|
+
const root = makeNode('');
|
|
25
|
+
|
|
26
|
+
const entries = await readdir(dir, {recursive: true, withFileTypes: true});
|
|
27
|
+
const files = entries
|
|
28
|
+
.filter(
|
|
29
|
+
(entry) =>
|
|
30
|
+
entry.isFile() &&
|
|
31
|
+
entry.name.endsWith('.mts') &&
|
|
32
|
+
!entry.name.endsWith('.test.mts')
|
|
33
|
+
)
|
|
34
|
+
.map((entry) => path.relative(dir, path.join(entry.parentPath, entry.name)))
|
|
35
|
+
.sort();
|
|
36
|
+
|
|
37
|
+
for (const relative of files) {
|
|
38
|
+
const segments = relative.slice(0, -'.mts'.length).split(path.sep);
|
|
39
|
+
const command = await loadCommand(dir, relative, segments);
|
|
40
|
+
insert(root, segments, command);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return root;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function loadCommand(
|
|
47
|
+
dir: string,
|
|
48
|
+
relative: string,
|
|
49
|
+
segments: string[]
|
|
50
|
+
): Promise<AbstractCommand> {
|
|
51
|
+
const href = pathToFileURL(path.join(dir, relative)).href;
|
|
52
|
+
const module = (await import(href)) as Record<string, unknown>;
|
|
53
|
+
const exported = module.Command;
|
|
54
|
+
|
|
55
|
+
ensure(
|
|
56
|
+
typeof exported === 'function' &&
|
|
57
|
+
exported.prototype instanceof AbstractCommand,
|
|
58
|
+
() =>
|
|
59
|
+
new DefinitionError(
|
|
60
|
+
`${relative} must export a Command class extending AbstractCommand`,
|
|
61
|
+
{
|
|
62
|
+
hint: `add "export class Command extends AbstractCommand { … }" to ${relative}`,
|
|
63
|
+
}
|
|
64
|
+
)
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const Ctor = exported as new () => AbstractCommand;
|
|
68
|
+
const command = new Ctor();
|
|
69
|
+
const expected = segments[segments.length - 1] ?? '';
|
|
70
|
+
|
|
71
|
+
ensure(
|
|
72
|
+
command.name === expected,
|
|
73
|
+
() =>
|
|
74
|
+
new DefinitionError(
|
|
75
|
+
`${relative} declares name "${command.name}" but its file requires "${expected}"`,
|
|
76
|
+
{
|
|
77
|
+
hint: `rename the command to "${expected}", or move the file to "${command.name}.mts"`,
|
|
78
|
+
}
|
|
79
|
+
)
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
return command;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function insert(
|
|
86
|
+
root: CommandNode,
|
|
87
|
+
segments: string[],
|
|
88
|
+
command: AbstractCommand
|
|
89
|
+
): void {
|
|
90
|
+
let node = root;
|
|
91
|
+
for (const segment of segments) {
|
|
92
|
+
let child = node.children.get(segment);
|
|
93
|
+
if (child === undefined) {
|
|
94
|
+
child = makeNode(segment);
|
|
95
|
+
node.children.set(segment, child);
|
|
96
|
+
}
|
|
97
|
+
node = child;
|
|
98
|
+
}
|
|
99
|
+
node.command = command;
|
|
100
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import {EnvironmentError, ensure} from '../errors/index.mts';
|
|
2
|
+
|
|
3
|
+
/** Throw if any variable a command declared in `env` is absent from the environment. */
|
|
4
|
+
export function assertEnv(
|
|
5
|
+
required: readonly string[],
|
|
6
|
+
env: NodeJS.ProcessEnv
|
|
7
|
+
): void {
|
|
8
|
+
const missing = required.filter((key) => env[key] === undefined);
|
|
9
|
+
ensure(
|
|
10
|
+
missing.length === 0,
|
|
11
|
+
() =>
|
|
12
|
+
new EnvironmentError(
|
|
13
|
+
`missing required environment: ${missing.join(', ')}`,
|
|
14
|
+
{
|
|
15
|
+
hint: `set ${missing.join(', ')} before running this command`,
|
|
16
|
+
}
|
|
17
|
+
)
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type {OptionsRecord} from './abstract-command.mts';
|
|
2
|
+
import {assertUsage} from '../errors/index.mts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Turn a raw values map (keyed by option name) into a validated values record.
|
|
6
|
+
* Transport-neutral: the cli builds `raw` from argv, an MCP server from JSON.
|
|
7
|
+
*/
|
|
8
|
+
export function parseOptions(
|
|
9
|
+
options: OptionsRecord,
|
|
10
|
+
raw: Readonly<Record<string, string | boolean>>
|
|
11
|
+
): Record<string, unknown> {
|
|
12
|
+
const result: Record<string, unknown> = {};
|
|
13
|
+
|
|
14
|
+
for (const [key, option] of Object.entries(options)) {
|
|
15
|
+
let provided: string | number | boolean | undefined = raw[key];
|
|
16
|
+
|
|
17
|
+
if (provided === undefined) {
|
|
18
|
+
if (option.type === 'boolean') {
|
|
19
|
+
result[key] = false;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (option.default === undefined) {
|
|
23
|
+
assertUsage(!option.required, `missing required option: ${key}`);
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
// Route the default through the same coercion/choices path as a
|
|
27
|
+
// provided value, so a numeric default given as a string still
|
|
28
|
+
// resolves to a number and a string default is still validated.
|
|
29
|
+
provided = option.default;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (option.type === 'boolean') {
|
|
33
|
+
result[key] = provided === true || provided === 'true';
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const text = String(provided);
|
|
38
|
+
if (option.type === 'number') {
|
|
39
|
+
// Accept a plain decimal literal only. `Number()` alone would also admit
|
|
40
|
+
// hex/binary/octal (`0x1F`), `Infinity`, and whitespace-padded input,
|
|
41
|
+
// yielding a value whose meaning does not match a `number` option; the
|
|
42
|
+
// regex rejects those, and `Number.isFinite` rejects exponent overflow
|
|
43
|
+
// (`1e999`).
|
|
44
|
+
const value = Number(text);
|
|
45
|
+
assertUsage(
|
|
46
|
+
/^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/.test(text) &&
|
|
47
|
+
Number.isFinite(value),
|
|
48
|
+
`option ${key} expects a number, got "${text}"`
|
|
49
|
+
);
|
|
50
|
+
result[key] = value;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (option.choices !== undefined) {
|
|
55
|
+
assertUsage(
|
|
56
|
+
option.choices.includes(text),
|
|
57
|
+
`option ${key} must be one of: ${option.choices.join(', ')}`
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
result[key] = text;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import {mkdtemp} from 'node:fs/promises';
|
|
2
|
+
import {tmpdir} from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import {createLogger} from '../logger/index.mts';
|
|
6
|
+
import type {CoreLogger} from '../logger/index.mts';
|
|
7
|
+
import type {Ticket} from '../model/index.mts';
|
|
8
|
+
import type {AbstractCommand} from './abstract-command.mts';
|
|
9
|
+
import {assertEnv} from './env.mts';
|
|
10
|
+
import {parseOptions} from './parse.mts';
|
|
11
|
+
|
|
12
|
+
/* eslint-disable @typescript-eslint/no-empty-function --
|
|
13
|
+
* A logger that discards every call, so a test's output only ever reflects `io`. */
|
|
14
|
+
const SILENT: CoreLogger = {
|
|
15
|
+
error: () => {},
|
|
16
|
+
warn: () => {},
|
|
17
|
+
info: () => {},
|
|
18
|
+
debug: () => {},
|
|
19
|
+
trace: () => {},
|
|
20
|
+
log: () => {},
|
|
21
|
+
};
|
|
22
|
+
/* eslint-enable @typescript-eslint/no-empty-function */
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Run a command the way a transport would, and return what it wrote to `io`.
|
|
26
|
+
* `raw` goes through `parseOptions`, so defaults and `choices` apply exactly as
|
|
27
|
+
* they would from argv or JSON — pass every value as a string except booleans.
|
|
28
|
+
* Also runs `assertEnv` before `run`, matching both real transports (the cli
|
|
29
|
+
* and the MCP server), so a command that declares required `env` fails here
|
|
30
|
+
* the same way it would in production.
|
|
31
|
+
*/
|
|
32
|
+
export async function runCommand(
|
|
33
|
+
command: AbstractCommand,
|
|
34
|
+
raw: Record<string, string | boolean>,
|
|
35
|
+
env: NodeJS.ProcessEnv
|
|
36
|
+
): Promise<string> {
|
|
37
|
+
const parsed = parseOptions(command.options, raw);
|
|
38
|
+
assertEnv(command.env, env);
|
|
39
|
+
let captured = '';
|
|
40
|
+
await command.run(parsed, {
|
|
41
|
+
log: createLogger(SILENT),
|
|
42
|
+
env,
|
|
43
|
+
io: {
|
|
44
|
+
write: (chunk) => {
|
|
45
|
+
captured += chunk;
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
return captured;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* An environment pointing at a graph database of this test's own, in a fresh
|
|
54
|
+
* temp directory. Commands resolve `--db` through `DISPATCH_DB`, so a test that
|
|
55
|
+
* forgets this one writes the developer's real graph.
|
|
56
|
+
*/
|
|
57
|
+
export async function tempEnv(): Promise<NodeJS.ProcessEnv> {
|
|
58
|
+
const dir = await mkdtemp(path.join(tmpdir(), 'dispatch-cmd-'));
|
|
59
|
+
return {DISPATCH_DB: path.join(dir, 'graph.db')};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A ticket whose every optional field is empty, so a test asserting on one of
|
|
64
|
+
* them is asserting on something it set itself.
|
|
65
|
+
*/
|
|
66
|
+
export function ticket(id: string, project: string): Ticket {
|
|
67
|
+
return {
|
|
68
|
+
id,
|
|
69
|
+
project,
|
|
70
|
+
url: `https://example.test/${id}`,
|
|
71
|
+
title: id,
|
|
72
|
+
status: 'available',
|
|
73
|
+
targetKind: 'pr',
|
|
74
|
+
requiresHuman: false,
|
|
75
|
+
injected: false,
|
|
76
|
+
priority: null,
|
|
77
|
+
branchHint: null,
|
|
78
|
+
labels: [],
|
|
79
|
+
updatedAt: null,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type {AbstractCommand} from './abstract-command.mts';
|
|
2
|
+
|
|
3
|
+
export interface ResolvedTransports {
|
|
4
|
+
readonly cli: boolean;
|
|
5
|
+
readonly mcp: boolean;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A command's transport availability with defaults filled in, so gating code
|
|
10
|
+
* reads definite booleans instead of an optional partial. An unstated
|
|
11
|
+
* transport is available.
|
|
12
|
+
*/
|
|
13
|
+
export function resolveTransports(
|
|
14
|
+
command: AbstractCommand
|
|
15
|
+
): ResolvedTransports {
|
|
16
|
+
return {cli: true, mcp: true, ...command.transports};
|
|
17
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import {AbstractCommand} from './abstract-command.mts';
|
|
2
|
+
import type {ParsedOptions, CommandContext} from './abstract-command.mts';
|
|
3
|
+
|
|
4
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters, @typescript-eslint/no-unused-vars
|
|
5
|
+
function expectType<T>(_value: T): void {
|
|
6
|
+
// asserts the argument's static type is assignable to T
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const options = {
|
|
10
|
+
force: {
|
|
11
|
+
type: 'boolean',
|
|
12
|
+
description: 'd',
|
|
13
|
+
positional: false,
|
|
14
|
+
required: false,
|
|
15
|
+
},
|
|
16
|
+
count: {
|
|
17
|
+
type: 'number',
|
|
18
|
+
description: 'd',
|
|
19
|
+
positional: false,
|
|
20
|
+
required: false,
|
|
21
|
+
default: 1,
|
|
22
|
+
},
|
|
23
|
+
format: {
|
|
24
|
+
type: 'string',
|
|
25
|
+
description: 'd',
|
|
26
|
+
positional: false,
|
|
27
|
+
required: true,
|
|
28
|
+
choices: ['json', 'text'],
|
|
29
|
+
},
|
|
30
|
+
who: {type: 'string', description: 'd', positional: true, required: false},
|
|
31
|
+
} as const;
|
|
32
|
+
|
|
33
|
+
class Sample extends AbstractCommand {
|
|
34
|
+
readonly name = 'sample';
|
|
35
|
+
readonly summary = 's';
|
|
36
|
+
readonly env = [];
|
|
37
|
+
readonly options = options;
|
|
38
|
+
|
|
39
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
40
|
+
async run(
|
|
41
|
+
parsed: ParsedOptions<typeof options>,
|
|
42
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
43
|
+
_ctx: CommandContext
|
|
44
|
+
): Promise<void> {
|
|
45
|
+
expectType<boolean>(parsed.force);
|
|
46
|
+
expectType<number>(parsed.count);
|
|
47
|
+
expectType<'json' | 'text'>(parsed.format);
|
|
48
|
+
expectType<string | undefined>(parsed.who);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Heterogeneous storage must compile: a subclass widens to AbstractCommand.
|
|
53
|
+
expectType<AbstractCommand[]>([new Sample()]);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# db
|
|
2
|
+
|
|
3
|
+
Low-level SQLite: the connection and the schema. No domain knowledge lives here.
|
|
4
|
+
|
|
5
|
+
- `database.mts` — `Database`: `open` (pragmas incl. `foreign_keys = ON`, schema
|
|
6
|
+
bootstrap, version refusal), `transaction` (BEGIN IMMEDIATE), `guard` (maps a
|
|
7
|
+
locked/unwritable DB to `EnvironmentError`), and `run`/`get`/`all`.
|
|
8
|
+
- `schema.mts` — the `SCHEMA` DDL and `SCHEMA_VERSION`. STRICT tables; the DB is
|
|
9
|
+
a rebuildable cache, so a version mismatch is refused rather than migrated.
|
|
10
|
+
- `time.mts` — `nowIso` and `assertInstant` (RFC 3339 validation). Timestamps are
|
|
11
|
+
TEXT ISO-8601 UTC.
|
|
12
|
+
- `with-database.mts` — `withDatabase` (open/close around a command body),
|
|
13
|
+
`resolveDbPath` (flag → `DISPATCH_DB` → XDG), and the shared `DB_OPTION`.
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import {mkdir} from 'node:fs/promises';
|
|
2
|
+
import {dirname} from 'node:path';
|
|
3
|
+
import {DatabaseSync} from 'node:sqlite';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
DefinitionError,
|
|
7
|
+
DispatchError,
|
|
8
|
+
EnvironmentError,
|
|
9
|
+
} from '../errors/index.mts';
|
|
10
|
+
import {SCHEMA, SCHEMA_VERSION} from './schema.mts';
|
|
11
|
+
|
|
12
|
+
export type SqlValue = string | number | null;
|
|
13
|
+
export type Row = Record<string, unknown>;
|
|
14
|
+
|
|
15
|
+
/* eslint-disable @typescript-eslint/require-await --
|
|
16
|
+
* The async signatures are the point of this class. `node:sqlite` is synchronous
|
|
17
|
+
* today; these methods are async so an async driver later is a change behind this
|
|
18
|
+
* facade, not a rewrite of every call site. */
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The dispatch database: one SQLite file holding everything the CLI persists.
|
|
22
|
+
* Owns the connection — pragmas, schema bootstrap, version enforcement,
|
|
23
|
+
* transactions — and nothing about what the tables mean; the stores sit on top.
|
|
24
|
+
*/
|
|
25
|
+
export class Database {
|
|
26
|
+
readonly #db: DatabaseSync;
|
|
27
|
+
|
|
28
|
+
private constructor(db: DatabaseSync) {
|
|
29
|
+
this.#db = db;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
static async open(path: string): Promise<Database> {
|
|
33
|
+
if (path !== ':memory:') {
|
|
34
|
+
try {
|
|
35
|
+
await mkdir(dirname(path), {recursive: true});
|
|
36
|
+
} catch (cause) {
|
|
37
|
+
throw new EnvironmentError(
|
|
38
|
+
'cannot create the directory for the dispatch database',
|
|
39
|
+
{cause, hint: 'check the path is writable, or point --db elsewhere.'}
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
const raw = new DatabaseSync(path);
|
|
45
|
+
raw.exec('PRAGMA journal_mode = WAL');
|
|
46
|
+
raw.exec('PRAGMA busy_timeout = 5000');
|
|
47
|
+
raw.exec('PRAGMA foreign_keys = ON');
|
|
48
|
+
const db = new Database(raw);
|
|
49
|
+
db.#bootstrap(path);
|
|
50
|
+
return db;
|
|
51
|
+
} catch (cause) {
|
|
52
|
+
if (cause instanceof DispatchError) throw cause;
|
|
53
|
+
throw new EnvironmentError('cannot open the dispatch database', {
|
|
54
|
+
cause,
|
|
55
|
+
hint: 'check the file is a readable, writable SQLite database and the disk is not full. If it is locked, another dispatch command is mid-write — retry shortly. Deleting the file forces a rebuild.',
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
#bootstrap(path: string): void {
|
|
61
|
+
this.#db.exec(
|
|
62
|
+
'CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT'
|
|
63
|
+
);
|
|
64
|
+
const row = this.#db
|
|
65
|
+
.prepare("SELECT value FROM meta WHERE key = 'schema_version'")
|
|
66
|
+
.get() as Row | undefined;
|
|
67
|
+
Database.assertVersion(this, path, row?.value as string | undefined);
|
|
68
|
+
this.#db.exec(SCHEMA);
|
|
69
|
+
if (row === undefined) {
|
|
70
|
+
this.#db
|
|
71
|
+
.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)')
|
|
72
|
+
.run('schema_version', String(SCHEMA_VERSION));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Refuse a file another schema version wrote. Exposed static so a test can
|
|
78
|
+
* exercise the guard without a second connection. `found` defaults to the
|
|
79
|
+
* value recorded in `meta`.
|
|
80
|
+
*/
|
|
81
|
+
static assertVersion(db: Database, path: string, found?: string): void {
|
|
82
|
+
const recorded =
|
|
83
|
+
found ??
|
|
84
|
+
(db.get("SELECT value FROM meta WHERE key = 'schema_version'")?.value as
|
|
85
|
+
string | undefined);
|
|
86
|
+
if (recorded !== undefined && recorded !== String(SCHEMA_VERSION)) {
|
|
87
|
+
throw new EnvironmentError(
|
|
88
|
+
`the dispatch database at ${path} was written by schema version ${recorded}, not ${String(SCHEMA_VERSION)}`,
|
|
89
|
+
{
|
|
90
|
+
hint: `delete ${path} and re-run a full sync. Claims and recorded reviews go with it — release or re-record what still matters first.`,
|
|
91
|
+
}
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async close(): Promise<void> {
|
|
97
|
+
this.#db.close();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The body must be synchronous: COMMIT runs as soon as it returns, so an
|
|
102
|
+
* async body would run its post-await statements outside the transaction.
|
|
103
|
+
* A returned promise is therefore refused outright.
|
|
104
|
+
*/
|
|
105
|
+
async transaction<T>(body: () => T): Promise<T> {
|
|
106
|
+
return this.guard(() => {
|
|
107
|
+
this.#db.exec('BEGIN IMMEDIATE');
|
|
108
|
+
try {
|
|
109
|
+
const result = body();
|
|
110
|
+
if (result instanceof Promise) {
|
|
111
|
+
throw new DefinitionError('transaction bodies must be synchronous', {
|
|
112
|
+
hint: 'an async body would commit before its awaits ran; move the awaits outside the transaction.',
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
this.#db.exec('COMMIT');
|
|
116
|
+
return result;
|
|
117
|
+
} catch (error) {
|
|
118
|
+
try {
|
|
119
|
+
this.#db.exec('ROLLBACK');
|
|
120
|
+
} catch {
|
|
121
|
+
// A failing ROLLBACK must not replace the error that caused it.
|
|
122
|
+
}
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
guard<T>(body: () => T): T {
|
|
129
|
+
try {
|
|
130
|
+
return body();
|
|
131
|
+
} catch (cause) {
|
|
132
|
+
if (cause instanceof DispatchError) throw cause;
|
|
133
|
+
throw new EnvironmentError(
|
|
134
|
+
'the dispatch database rejected an operation',
|
|
135
|
+
{
|
|
136
|
+
cause,
|
|
137
|
+
hint: 'if the database is locked, another dispatch command is mid-write — retry shortly. Otherwise check the file is a writable SQLite database and the disk is not full.',
|
|
138
|
+
}
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
run(sql: string, params: SqlValue[] = []): number {
|
|
144
|
+
return this.guard(() =>
|
|
145
|
+
Number(this.#db.prepare(sql).run(...params).changes)
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
get(sql: string, params: SqlValue[] = []): Row | undefined {
|
|
150
|
+
return this.guard(
|
|
151
|
+
() => this.#db.prepare(sql).get(...params) as Row | undefined
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
all(sql: string, params: SqlValue[] = []): Row[] {
|
|
156
|
+
return this.guard(() => this.#db.prepare(sql).all(...params) as Row[]);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/* eslint-enable @typescript-eslint/require-await */
|