@drej/sqlite 0.1.2 → 0.2.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/CHANGELOG.md +17 -0
- package/package.json +9 -4
- package/src/adapter.ts +76 -6
- package/test/adapter.test.ts +189 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# @drej/sqlite
|
|
2
2
|
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 2fd33e0: feat: run management API
|
|
8
|
+
|
|
9
|
+
Add `RunStatus` enum, `RunDetails` type, and `ListRunsOptions` for filtering. Replace `listRuns()` with `listRunDetails()`, `listAllRunDetails()`, `getRunDetails()`, and `deleteRun()` on both `IStorageAdapter` and `DrejClient`. Add `WorkflowRun.status` property that tracks execution state as events are consumed.
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- Updated dependencies [22b8a32]
|
|
14
|
+
- Updated dependencies [799b6dd]
|
|
15
|
+
- Updated dependencies [8d9d8bb]
|
|
16
|
+
- Updated dependencies [2fd33e0]
|
|
17
|
+
- Updated dependencies [0d94c2a]
|
|
18
|
+
- @drej/core@0.3.0
|
|
19
|
+
|
|
3
20
|
## 0.1.2
|
|
4
21
|
|
|
5
22
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drej/sqlite",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"publishConfig": {
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public"
|
|
6
|
+
},
|
|
5
7
|
"main": "./src/index.ts",
|
|
6
8
|
"dependencies": {
|
|
7
9
|
"@drej/core": "workspace:*"
|
|
8
10
|
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"test": "bun test"
|
|
13
|
+
},
|
|
9
14
|
"devDependencies": {
|
|
10
|
-
"bun-types": "
|
|
11
|
-
"typescript": "
|
|
15
|
+
"bun-types": "1.3.14",
|
|
16
|
+
"typescript": "6.0.3"
|
|
12
17
|
}
|
|
13
18
|
}
|
package/src/adapter.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
|
-
import type { IStorageAdapter, LedgerEntry, LedgerEvent } from "@drej/core";
|
|
2
|
+
import type { IStorageAdapter, LedgerEntry, LedgerEvent, RunDetails, ListRunsOptions } from "@drej/core";
|
|
3
|
+
import { RunStatus } from "@drej/core";
|
|
3
4
|
import { MIGRATION_SQL } from "./migrations";
|
|
4
5
|
|
|
5
6
|
type Row = {
|
|
@@ -13,6 +14,59 @@ type Row = {
|
|
|
13
14
|
ts: number;
|
|
14
15
|
};
|
|
15
16
|
|
|
17
|
+
type AggRow = {
|
|
18
|
+
wf_name: string;
|
|
19
|
+
run_id: string;
|
|
20
|
+
started_at: number | null;
|
|
21
|
+
completed_at: number | null;
|
|
22
|
+
terminal_event: string | null;
|
|
23
|
+
error_msg: string | null;
|
|
24
|
+
step_count: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const AGG_SQL = (whereClause: string) => `
|
|
28
|
+
WITH agg AS (
|
|
29
|
+
SELECT
|
|
30
|
+
wf_name,
|
|
31
|
+
run_id,
|
|
32
|
+
MIN(CASE WHEN event = 'run_started' THEN ts END) AS started_at,
|
|
33
|
+
MAX(CASE WHEN event IN ('workflow_complete', 'workflow_failed') THEN ts END) AS completed_at,
|
|
34
|
+
MAX(CASE WHEN event IN ('workflow_complete', 'workflow_failed') THEN event END) AS terminal_event,
|
|
35
|
+
MAX(CASE WHEN event = 'workflow_failed' THEN error END) AS error_msg,
|
|
36
|
+
CAST(COUNT(CASE WHEN event = 'step_complete' THEN 1 END) AS INTEGER) AS step_count
|
|
37
|
+
FROM drej_events
|
|
38
|
+
${whereClause}
|
|
39
|
+
GROUP BY wf_name, run_id
|
|
40
|
+
)
|
|
41
|
+
SELECT * FROM agg WHERE started_at IS NOT NULL ORDER BY started_at DESC
|
|
42
|
+
`;
|
|
43
|
+
|
|
44
|
+
function terminalToStatus(event: string | null): RunStatus {
|
|
45
|
+
if (event === "workflow_complete") return RunStatus.Completed;
|
|
46
|
+
if (event === "workflow_failed") return RunStatus.Failed;
|
|
47
|
+
return RunStatus.Running;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function aggRowToDetails(row: AggRow): RunDetails {
|
|
51
|
+
return {
|
|
52
|
+
workflowName: row.wf_name,
|
|
53
|
+
runId: row.run_id,
|
|
54
|
+
status: terminalToStatus(row.terminal_event),
|
|
55
|
+
startedAt: row.started_at!,
|
|
56
|
+
completedAt: row.completed_at ?? undefined,
|
|
57
|
+
stepCount: row.step_count,
|
|
58
|
+
error: row.error_msg ?? undefined,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function applyOpts(details: RunDetails[], opts?: ListRunsOptions): RunDetails[] {
|
|
63
|
+
let result = details;
|
|
64
|
+
if (opts?.before != null) result = result.filter((d) => d.startedAt < opts.before!);
|
|
65
|
+
if (opts?.status != null) result = result.filter((d) => d.status === opts.status);
|
|
66
|
+
if (opts?.limit != null) result = result.slice(0, opts.limit);
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
|
|
16
70
|
function rowToEntry(row: Row): LedgerEntry {
|
|
17
71
|
return {
|
|
18
72
|
runId: row.run_id,
|
|
@@ -86,12 +140,28 @@ export class SQLiteAdapter implements IStorageAdapter {
|
|
|
86
140
|
return row ? rowToEntry(row) : null;
|
|
87
141
|
}
|
|
88
142
|
|
|
89
|
-
async
|
|
143
|
+
async listRunDetails(workflowName: string, opts?: ListRunsOptions): Promise<RunDetails[]> {
|
|
90
144
|
const rows = this.db
|
|
91
|
-
.prepare<
|
|
92
|
-
`SELECT DISTINCT run_id FROM drej_events WHERE wf_name = ?`,
|
|
93
|
-
)
|
|
145
|
+
.prepare<AggRow, [string]>(AGG_SQL("WHERE wf_name = ?"))
|
|
94
146
|
.all(workflowName);
|
|
95
|
-
return rows.map(
|
|
147
|
+
return applyOpts(rows.map(aggRowToDetails), opts);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async listAllRunDetails(opts?: ListRunsOptions): Promise<RunDetails[]> {
|
|
151
|
+
const rows = this.db.prepare<AggRow, []>(AGG_SQL("")).all();
|
|
152
|
+
return applyOpts(rows.map(aggRowToDetails), opts);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async getRunDetails(workflowName: string, runId: string): Promise<RunDetails | null> {
|
|
156
|
+
const row = this.db
|
|
157
|
+
.prepare<AggRow, [string, string]>(AGG_SQL("WHERE wf_name = ? AND run_id = ?"))
|
|
158
|
+
.get(workflowName, runId);
|
|
159
|
+
return row ? aggRowToDetails(row) : null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async deleteRun(workflowName: string, runId: string): Promise<void> {
|
|
163
|
+
this.db
|
|
164
|
+
.prepare<void, [string, string]>(`DELETE FROM drej_events WHERE wf_name = ? AND run_id = ?`)
|
|
165
|
+
.run(workflowName, runId);
|
|
96
166
|
}
|
|
97
167
|
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { LedgerEvent, RunStatus, type LedgerEntry } from "@drej/core";
|
|
3
|
+
import { SQLiteAdapter } from "../src/adapter.ts";
|
|
4
|
+
|
|
5
|
+
function entry(overrides?: Partial<LedgerEntry>): LedgerEntry {
|
|
6
|
+
return {
|
|
7
|
+
ts: Date.now(),
|
|
8
|
+
workflowName: "test-wf",
|
|
9
|
+
runId: "run-1",
|
|
10
|
+
stepIndex: 0,
|
|
11
|
+
event: LedgerEvent.StepStart,
|
|
12
|
+
...overrides,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("SQLiteAdapter", () => {
|
|
17
|
+
let db: SQLiteAdapter;
|
|
18
|
+
|
|
19
|
+
beforeEach(async () => {
|
|
20
|
+
db = new SQLiteAdapter(":memory:");
|
|
21
|
+
await db.connect();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterEach(async () => {
|
|
25
|
+
await db.close();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// ── append / readAll ────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
describe("append / readAll", () => {
|
|
31
|
+
it("stores and retrieves an entry", async () => {
|
|
32
|
+
await db.append(entry({ event: LedgerEvent.StepStart }));
|
|
33
|
+
const rows = await db.readAll("test-wf", "run-1");
|
|
34
|
+
expect(rows).toHaveLength(1);
|
|
35
|
+
expect(rows[0].event).toBe(LedgerEvent.StepStart);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("returns entries in ascending timestamp order", async () => {
|
|
39
|
+
await db.append(entry({ ts: 2000, event: LedgerEvent.StepComplete }));
|
|
40
|
+
await db.append(entry({ ts: 1000, event: LedgerEvent.StepStart }));
|
|
41
|
+
const rows = await db.readAll("test-wf", "run-1");
|
|
42
|
+
expect(rows[0].ts).toBe(1000);
|
|
43
|
+
expect(rows[1].ts).toBe(2000);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("scopes to the given workflow name and run id", async () => {
|
|
47
|
+
await db.append(entry({ workflowName: "wf-a", runId: "r1" }));
|
|
48
|
+
await db.append(entry({ workflowName: "wf-b", runId: "r2" }));
|
|
49
|
+
expect(await db.readAll("wf-a", "r1")).toHaveLength(1);
|
|
50
|
+
expect(await db.readAll("wf-b", "r2")).toHaveLength(1);
|
|
51
|
+
expect(await db.readAll("wf-a", "r2")).toHaveLength(0);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("serialises and deserialises JSON payload", async () => {
|
|
55
|
+
await db.append(entry({ payload: { key: "value", n: 42 } }));
|
|
56
|
+
const rows = await db.readAll("test-wf", "run-1");
|
|
57
|
+
expect(rows[0].payload).toEqual({ key: "value", n: 42 });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("preserves null payload as undefined", async () => {
|
|
61
|
+
await db.append(entry({ payload: undefined }));
|
|
62
|
+
const rows = await db.readAll("test-wf", "run-1");
|
|
63
|
+
expect(rows[0].payload).toBeUndefined();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("preserves the branch field", async () => {
|
|
67
|
+
await db.append(entry({ branch: 2 }));
|
|
68
|
+
const rows = await db.readAll("test-wf", "run-1");
|
|
69
|
+
expect(rows[0].branch).toBe(2);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("returns undefined branch when not set", async () => {
|
|
73
|
+
await db.append(entry());
|
|
74
|
+
const rows = await db.readAll("test-wf", "run-1");
|
|
75
|
+
expect(rows[0].branch).toBeUndefined();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// ── lastCheckpoint ──────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
describe("lastCheckpoint", () => {
|
|
82
|
+
it("returns null when no checkpoint exists", async () => {
|
|
83
|
+
expect(await db.lastCheckpoint("test-wf", "run-1")).toBeNull();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("returns the most recent checkpoint", async () => {
|
|
87
|
+
await db.append(entry({ ts: 1000, event: LedgerEvent.Checkpoint, payload: { step: 1 } }));
|
|
88
|
+
await db.append(entry({ ts: 2000, event: LedgerEvent.Checkpoint, payload: { step: 2 } }));
|
|
89
|
+
const cp = await db.lastCheckpoint("test-wf", "run-1");
|
|
90
|
+
expect((cp!.payload as any).step).toBe(2);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("ignores non-checkpoint events", async () => {
|
|
94
|
+
await db.append(entry({ event: LedgerEvent.StepStart }));
|
|
95
|
+
expect(await db.lastCheckpoint("test-wf", "run-1")).toBeNull();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// ── run details ─────────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
async function seedRun(wf: string, runId: string, terminal: LedgerEvent.WorkflowComplete | LedgerEvent.WorkflowFailed, error?: string) {
|
|
102
|
+
const ts = Date.now();
|
|
103
|
+
await db.append(entry({ workflowName: wf, runId, ts, stepIndex: -1, event: LedgerEvent.RunStarted }));
|
|
104
|
+
await db.append(entry({ workflowName: wf, runId, ts: ts + 1, stepIndex: 0, event: LedgerEvent.StepComplete }));
|
|
105
|
+
await db.append(entry({ workflowName: wf, runId, ts: ts + 2, stepIndex: -1, event: terminal, error }));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
describe("getRunDetails", () => {
|
|
109
|
+
it("returns null for an unknown run", async () => {
|
|
110
|
+
expect(await db.getRunDetails("wf", "no-such-run")).toBeNull();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("returns Completed status", async () => {
|
|
114
|
+
await seedRun("wf", "run-1", LedgerEvent.WorkflowComplete);
|
|
115
|
+
const d = await db.getRunDetails("wf", "run-1");
|
|
116
|
+
expect(d?.status).toBe(RunStatus.Completed);
|
|
117
|
+
expect(d?.stepCount).toBe(1);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("returns Failed status with error message", async () => {
|
|
121
|
+
await seedRun("wf", "run-1", LedgerEvent.WorkflowFailed, "boom");
|
|
122
|
+
const d = await db.getRunDetails("wf", "run-1");
|
|
123
|
+
expect(d?.status).toBe(RunStatus.Failed);
|
|
124
|
+
expect(d?.error).toBe("boom");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("returns Running when no terminal event present", async () => {
|
|
128
|
+
await db.append(entry({ stepIndex: -1, event: LedgerEvent.RunStarted }));
|
|
129
|
+
const d = await db.getRunDetails("test-wf", "run-1");
|
|
130
|
+
expect(d?.status).toBe(RunStatus.Running);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("listRunDetails", () => {
|
|
135
|
+
it("scopes to a single workflow", async () => {
|
|
136
|
+
await seedRun("wf-a", "r1", LedgerEvent.WorkflowComplete);
|
|
137
|
+
await seedRun("wf-b", "r2", LedgerEvent.WorkflowComplete);
|
|
138
|
+
const results = await db.listRunDetails("wf-a");
|
|
139
|
+
expect(results).toHaveLength(1);
|
|
140
|
+
expect(results[0].workflowName).toBe("wf-a");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("filters by status", async () => {
|
|
144
|
+
await seedRun("wf", "r1", LedgerEvent.WorkflowComplete);
|
|
145
|
+
await seedRun("wf", "r2", LedgerEvent.WorkflowFailed);
|
|
146
|
+
const completed = await db.listRunDetails("wf", { status: RunStatus.Completed });
|
|
147
|
+
expect(completed).toHaveLength(1);
|
|
148
|
+
expect(completed[0].runId).toBe("r1");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("respects the limit option", async () => {
|
|
152
|
+
await seedRun("wf", "r1", LedgerEvent.WorkflowComplete);
|
|
153
|
+
await seedRun("wf", "r2", LedgerEvent.WorkflowComplete);
|
|
154
|
+
expect(await db.listRunDetails("wf", { limit: 1 })).toHaveLength(1);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("listAllRunDetails", () => {
|
|
159
|
+
it("returns runs across all workflows", async () => {
|
|
160
|
+
await seedRun("wf-a", "r1", LedgerEvent.WorkflowComplete);
|
|
161
|
+
await seedRun("wf-b", "r2", LedgerEvent.WorkflowComplete);
|
|
162
|
+
expect(await db.listAllRunDetails()).toHaveLength(2);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("filters by status across workflows", async () => {
|
|
166
|
+
await seedRun("wf-a", "r1", LedgerEvent.WorkflowComplete);
|
|
167
|
+
await seedRun("wf-b", "r2", LedgerEvent.WorkflowFailed);
|
|
168
|
+
const failed = await db.listAllRunDetails({ status: RunStatus.Failed });
|
|
169
|
+
expect(failed).toHaveLength(1);
|
|
170
|
+
expect(failed[0].workflowName).toBe("wf-b");
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe("deleteRun", () => {
|
|
175
|
+
it("removes all entries for the run", async () => {
|
|
176
|
+
await seedRun("wf", "run-1", LedgerEvent.WorkflowComplete);
|
|
177
|
+
await db.deleteRun("wf", "run-1");
|
|
178
|
+
expect(await db.readAll("wf", "run-1")).toHaveLength(0);
|
|
179
|
+
expect(await db.getRunDetails("wf", "run-1")).toBeNull();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("does not affect other runs", async () => {
|
|
183
|
+
await seedRun("wf", "r1", LedgerEvent.WorkflowComplete);
|
|
184
|
+
await seedRun("wf", "r2", LedgerEvent.WorkflowComplete);
|
|
185
|
+
await db.deleteRun("wf", "r1");
|
|
186
|
+
expect(await db.getRunDetails("wf", "r2")).not.toBeNull();
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
});
|