@loomwork/plugin-sqlite 0.0.0-stage → 0.1.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/LICENSE +202 -0
- package/NOTICE +8 -0
- package/README.md +62 -2
- package/dist/SqliteArchiveStore.d.ts +22 -0
- package/dist/SqliteArchiveStore.d.ts.map +1 -0
- package/dist/SqliteArchiveStore.js +129 -0
- package/dist/SqliteArchiveStore.js.map +1 -0
- package/dist/SqliteBranchRegistry.d.ts +26 -0
- package/dist/SqliteBranchRegistry.d.ts.map +1 -0
- package/dist/SqliteBranchRegistry.js +154 -0
- package/dist/SqliteBranchRegistry.js.map +1 -0
- package/dist/SqliteContentStore.d.ts +55 -0
- package/dist/SqliteContentStore.d.ts.map +1 -0
- package/dist/SqliteContentStore.js +150 -0
- package/dist/SqliteContentStore.js.map +1 -0
- package/dist/SqliteDurableStateStore.d.ts +26 -0
- package/dist/SqliteDurableStateStore.d.ts.map +1 -0
- package/dist/SqliteDurableStateStore.js +81 -0
- package/dist/SqliteDurableStateStore.js.map +1 -0
- package/dist/SqliteEventStore.d.ts +55 -0
- package/dist/SqliteEventStore.d.ts.map +1 -0
- package/dist/SqliteEventStore.js +297 -0
- package/dist/SqliteEventStore.js.map +1 -0
- package/dist/SqliteLeaseStore.d.ts +25 -0
- package/dist/SqliteLeaseStore.d.ts.map +1 -0
- package/dist/SqliteLeaseStore.js +107 -0
- package/dist/SqliteLeaseStore.js.map +1 -0
- package/dist/SqliteMemoryStore.d.ts +52 -0
- package/dist/SqliteMemoryStore.d.ts.map +1 -0
- package/dist/SqliteMemoryStore.js +149 -0
- package/dist/SqliteMemoryStore.js.map +1 -0
- package/dist/SqliteReadModelStore.d.ts +38 -0
- package/dist/SqliteReadModelStore.d.ts.map +1 -0
- package/dist/SqliteReadModelStore.js +292 -0
- package/dist/SqliteReadModelStore.js.map +1 -0
- package/dist/SqliteSnapshotStore.d.ts +31 -0
- package/dist/SqliteSnapshotStore.d.ts.map +1 -0
- package/dist/SqliteSnapshotStore.js +101 -0
- package/dist/SqliteSnapshotStore.js.map +1 -0
- package/dist/SqliteTimerStore.d.ts +19 -0
- package/dist/SqliteTimerStore.d.ts.map +1 -0
- package/dist/SqliteTimerStore.js +106 -0
- package/dist/SqliteTimerStore.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -0
- package/dist/preset.d.ts +31 -0
- package/dist/preset.d.ts.map +1 -0
- package/dist/preset.js +51 -0
- package/dist/preset.js.map +1 -0
- package/dist/schema.d.ts +3 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +103 -0
- package/dist/schema.js.map +1 -0
- package/dist/snapshotSchema.d.ts +3 -0
- package/dist/snapshotSchema.d.ts.map +1 -0
- package/dist/snapshotSchema.js +19 -0
- package/dist/snapshotSchema.js.map +1 -0
- package/package.json +62 -3
- package/src/SqliteArchiveStore.ts +189 -0
- package/src/SqliteBranchRegistry.ts +199 -0
- package/src/SqliteContentStore.ts +195 -0
- package/src/SqliteDurableStateStore.ts +101 -0
- package/src/SqliteEventStore.ts +414 -0
- package/src/SqliteLeaseStore.ts +165 -0
- package/src/SqliteMemoryStore.ts +160 -0
- package/src/SqliteReadModelStore.ts +378 -0
- package/src/SqliteSnapshotStore.ts +157 -0
- package/src/SqliteTimerStore.ts +124 -0
- package/src/index.ts +13 -0
- package/src/preset.ts +66 -0
- package/src/schema.ts +100 -0
- package/src/snapshotSchema.ts +17 -0
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import type { EventEnvelope } from "@loomwork/core";
|
|
2
|
+
import type { AppendIfUnchangedResult, EventStore, TipObservation } from "@loomwork/event-runtime";
|
|
3
|
+
import {
|
|
4
|
+
DuplicateEnvelopeIdError,
|
|
5
|
+
DuplicateSequenceError,
|
|
6
|
+
SubscriberRegistry,
|
|
7
|
+
creationMarker,
|
|
8
|
+
} from "@loomwork/event-runtime";
|
|
9
|
+
import Database from "better-sqlite3";
|
|
10
|
+
import { migrate } from "./schema";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* #306 — normalize the `UNIQUE (execution_id, sequence)` violation to the portable
|
|
14
|
+
* {@link DuplicateSequenceError} every backend raises, so a caller need not know which
|
|
15
|
+
* store it is talking to (or match on driver text) to recognise a lost sequence race.
|
|
16
|
+
*
|
|
17
|
+
* Narrowed to THAT constraint by name: the table also has a PRIMARY KEY on `id`, and a
|
|
18
|
+
* duplicate id is a different bug (a re-used envelope id) that must not be reported as a
|
|
19
|
+
* sequence conflict. Anything else propagates untouched.
|
|
20
|
+
*/
|
|
21
|
+
function isDuplicateSequenceViolation(err: unknown): boolean {
|
|
22
|
+
if (!isConstraintViolation(err)) return false;
|
|
23
|
+
return err.message.includes("execution_id") && err.message.includes("sequence");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* #309 — the table's OTHER key: `PRIMARY KEY (id)`. Distinguished from the sequence
|
|
28
|
+
* constraint by the column named in the violation, so each maps to its own error and
|
|
29
|
+
* neither is reported as the other.
|
|
30
|
+
*/
|
|
31
|
+
function isDuplicateIdViolation(err: unknown): boolean {
|
|
32
|
+
if (!isConstraintViolation(err)) return false;
|
|
33
|
+
return err.message.includes("events.id") && !err.message.includes("execution_id");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isConstraintViolation(err: unknown): err is Error {
|
|
37
|
+
if (!(err instanceof Error)) return false;
|
|
38
|
+
const code = (err as { code?: unknown }).code;
|
|
39
|
+
return typeof code === "string" && code.startsWith("SQLITE_CONSTRAINT");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface EventRow {
|
|
43
|
+
id: string;
|
|
44
|
+
workflow_id: string;
|
|
45
|
+
execution_id: string;
|
|
46
|
+
event_type: string;
|
|
47
|
+
sequence: number;
|
|
48
|
+
timestamp: number;
|
|
49
|
+
payload: string;
|
|
50
|
+
metadata: string | null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function rowToEnvelope(row: EventRow): EventEnvelope {
|
|
54
|
+
return {
|
|
55
|
+
id: row.id,
|
|
56
|
+
workflowId: row.workflow_id,
|
|
57
|
+
executionId: row.execution_id,
|
|
58
|
+
eventType: row.event_type,
|
|
59
|
+
sequence: row.sequence,
|
|
60
|
+
timestamp: row.timestamp,
|
|
61
|
+
payload: JSON.parse(row.payload) as unknown,
|
|
62
|
+
...(row.metadata !== null && { metadata: JSON.parse(row.metadata) as Record<string, unknown> }),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export class SqliteEventStore implements EventStore {
|
|
67
|
+
private readonly db: Database.Database;
|
|
68
|
+
private pageFirstStmt: Database.Statement | undefined;
|
|
69
|
+
private pageAfterStmt: Database.Statement | undefined;
|
|
70
|
+
private readonly subscribers = new SubscriberRegistry();
|
|
71
|
+
|
|
72
|
+
constructor(dbPath: string) {
|
|
73
|
+
this.db = new Database(dbPath);
|
|
74
|
+
try {
|
|
75
|
+
this.db.pragma("journal_mode = WAL");
|
|
76
|
+
this.db.pragma("synchronous = NORMAL");
|
|
77
|
+
migrate(this.db);
|
|
78
|
+
} catch (err) {
|
|
79
|
+
this.db.close(); // don't leak the file handle if setup fails on a bad/locked file
|
|
80
|
+
throw err;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async append(envelope: EventEnvelope): Promise<void> {
|
|
85
|
+
const stmt = this.db.prepare<
|
|
86
|
+
[string, string, string, string, number, number, string, string | null]
|
|
87
|
+
>(`
|
|
88
|
+
INSERT INTO events (id, workflow_id, execution_id, event_type, sequence, timestamp, payload, metadata)
|
|
89
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
90
|
+
`);
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
stmt.run(
|
|
94
|
+
envelope.id,
|
|
95
|
+
envelope.workflowId,
|
|
96
|
+
envelope.executionId,
|
|
97
|
+
envelope.eventType,
|
|
98
|
+
envelope.sequence,
|
|
99
|
+
envelope.timestamp,
|
|
100
|
+
JSON.stringify(envelope.payload),
|
|
101
|
+
envelope.metadata !== undefined ? JSON.stringify(envelope.metadata) : null
|
|
102
|
+
);
|
|
103
|
+
} catch (err) {
|
|
104
|
+
if (isDuplicateSequenceViolation(err)) {
|
|
105
|
+
throw new DuplicateSequenceError(envelope.executionId, envelope.sequence, err);
|
|
106
|
+
}
|
|
107
|
+
if (isDuplicateIdViolation(err)) {
|
|
108
|
+
throw new DuplicateEnvelopeIdError(envelope.id, envelope.executionId, err);
|
|
109
|
+
}
|
|
110
|
+
throw err;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
this.notify(envelope.executionId, envelope);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* ADR 0074 D1 — bulk append in ONE transaction. better-sqlite3's `transaction()` wrapper
|
|
118
|
+
* is synchronous and rolls back on throw, which gives the required atomicity directly:
|
|
119
|
+
* a UNIQUE(execution_id, sequence) violation part-way aborts the whole statement group,
|
|
120
|
+
* so no execution is left with a hole.
|
|
121
|
+
*
|
|
122
|
+
* Subscribers fire only after the transaction commits — a subscriber must never observe
|
|
123
|
+
* a batch that could still roll back.
|
|
124
|
+
*/
|
|
125
|
+
async appendBatch(envelopes: readonly EventEnvelope[]): Promise<void> {
|
|
126
|
+
if (envelopes.length === 0) return;
|
|
127
|
+
const stmt = this.db.prepare<
|
|
128
|
+
[string, string, string, string, number, number, string, string | null]
|
|
129
|
+
>(`
|
|
130
|
+
INSERT INTO events (id, workflow_id, execution_id, event_type, sequence, timestamp, payload, metadata)
|
|
131
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
132
|
+
`);
|
|
133
|
+
const insertAll = this.db.transaction((batch: readonly EventEnvelope[]) => {
|
|
134
|
+
for (const envelope of batch) {
|
|
135
|
+
stmt.run(
|
|
136
|
+
envelope.id,
|
|
137
|
+
envelope.workflowId,
|
|
138
|
+
envelope.executionId,
|
|
139
|
+
envelope.eventType,
|
|
140
|
+
envelope.sequence,
|
|
141
|
+
envelope.timestamp,
|
|
142
|
+
JSON.stringify(envelope.payload),
|
|
143
|
+
envelope.metadata !== undefined ? JSON.stringify(envelope.metadata) : null
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
try {
|
|
148
|
+
insertAll(envelopes);
|
|
149
|
+
} catch (err) {
|
|
150
|
+
if (isDuplicateSequenceViolation(err) || isDuplicateIdViolation(err)) {
|
|
151
|
+
// SQLite names the constraint, not the row, so locate the offender ourselves —
|
|
152
|
+
// an error that cannot say WHICH slot was taken sends the reader back to the log.
|
|
153
|
+
// Error path only, so the extra query costs nothing on the hot path. The
|
|
154
|
+
// transaction has rolled back, so a slot found occupied now was occupied before.
|
|
155
|
+
throw this.duplicateIn(envelopes, err);
|
|
156
|
+
}
|
|
157
|
+
throw err;
|
|
158
|
+
}
|
|
159
|
+
for (const envelope of envelopes) this.notify(envelope.executionId, envelope);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Which envelope in a rejected batch claimed something already taken — a
|
|
164
|
+
* `(executionId, sequence)` slot or an envelope `id`, either because the log already held
|
|
165
|
+
* it or because another envelope in the SAME batch did. Slot first, so a writer that lost
|
|
166
|
+
* a sequence race is told THAT rather than told about the id its own naming convention
|
|
167
|
+
* derived from the sequence it lost.
|
|
168
|
+
*/
|
|
169
|
+
private duplicateIn(envelopes: readonly EventEnvelope[], cause: unknown): Error {
|
|
170
|
+
const taken = this.db.prepare<[string, number], { one: number }>(
|
|
171
|
+
"SELECT 1 AS one FROM events WHERE execution_id = ? AND sequence = ?"
|
|
172
|
+
);
|
|
173
|
+
const idTaken = this.db.prepare<[string], { one: number }>(
|
|
174
|
+
"SELECT 1 AS one FROM events WHERE id = ?"
|
|
175
|
+
);
|
|
176
|
+
const claimedIds = new Set<string>();
|
|
177
|
+
const claimed = new Set<string>();
|
|
178
|
+
for (const e of envelopes) {
|
|
179
|
+
const key = `${e.executionId}\u0000${e.sequence}`;
|
|
180
|
+
if (claimed.has(key) || taken.get(e.executionId, e.sequence) !== undefined) {
|
|
181
|
+
return new DuplicateSequenceError(e.executionId, e.sequence, cause);
|
|
182
|
+
}
|
|
183
|
+
if (claimedIds.has(e.id) || idTaken.get(e.id) !== undefined) {
|
|
184
|
+
return new DuplicateEnvelopeIdError(e.id, e.executionId, cause);
|
|
185
|
+
}
|
|
186
|
+
claimed.add(key);
|
|
187
|
+
claimedIds.add(e.id);
|
|
188
|
+
}
|
|
189
|
+
return cause instanceof Error ? cause : new Error(String(cause));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* SPI 2.2.0 (ADR 0116 amendment) — existence, head and the (always 0: SQLite does not fence)
|
|
194
|
+
* token are checked and the row inserted inside ONE `BEGIN IMMEDIATE` transaction, so a
|
|
195
|
+
* retention delete or another process's append cannot slip between the check and the write.
|
|
196
|
+
*/
|
|
197
|
+
async appendIfUnchanged(
|
|
198
|
+
executionId: string,
|
|
199
|
+
event: EventEnvelope | null,
|
|
200
|
+
expect?: TipObservation
|
|
201
|
+
): Promise<AppendIfUnchangedResult> {
|
|
202
|
+
if (event !== null && (expect === undefined || event.executionId !== executionId)) {
|
|
203
|
+
throw new TypeError(
|
|
204
|
+
"appendIfUnchanged: an event needs `expect` and the matching executionId"
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
const run = this.db.transaction((): AppendIfUnchangedResult => {
|
|
208
|
+
const first = this.db
|
|
209
|
+
.prepare<[string], { id: string; timestamp: number; incarnation: number | null }>(
|
|
210
|
+
"SELECT id, timestamp, incarnation FROM events WHERE execution_id = ? AND sequence = 0"
|
|
211
|
+
)
|
|
212
|
+
.get(executionId);
|
|
213
|
+
if (first === undefined) {
|
|
214
|
+
return { appended: false, reason: "gone", head: null, fence: 0, anchor: null };
|
|
215
|
+
}
|
|
216
|
+
// ADR 0129: the store-assigned incarnation; a pre-migration log keeps the legacy marker.
|
|
217
|
+
const anchor = first.incarnation === null ? creationMarker(first) : String(first.incarnation);
|
|
218
|
+
const tip = this.db
|
|
219
|
+
.prepare<[string], { tip: number }>(
|
|
220
|
+
"SELECT MAX(sequence) AS tip FROM events WHERE execution_id = ?"
|
|
221
|
+
)
|
|
222
|
+
.get(executionId);
|
|
223
|
+
const head = tip?.tip ?? 0;
|
|
224
|
+
if (event === null) return { appended: false, reason: "observed", head, fence: 0, anchor };
|
|
225
|
+
const want = expect as TipObservation;
|
|
226
|
+
if (
|
|
227
|
+
head !== want.head ||
|
|
228
|
+
want.fence !== 0 ||
|
|
229
|
+
anchor !== want.anchor ||
|
|
230
|
+
event.sequence !== head + 1
|
|
231
|
+
) {
|
|
232
|
+
return { appended: false, reason: "moved", head, fence: 0, anchor };
|
|
233
|
+
}
|
|
234
|
+
this.db
|
|
235
|
+
.prepare(
|
|
236
|
+
`INSERT INTO events (id, workflow_id, execution_id, event_type, sequence, timestamp, payload, metadata)
|
|
237
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
|
238
|
+
)
|
|
239
|
+
.run(
|
|
240
|
+
event.id,
|
|
241
|
+
event.workflowId,
|
|
242
|
+
event.executionId,
|
|
243
|
+
event.eventType,
|
|
244
|
+
event.sequence,
|
|
245
|
+
event.timestamp,
|
|
246
|
+
JSON.stringify(event.payload),
|
|
247
|
+
event.metadata !== undefined ? JSON.stringify(event.metadata) : null
|
|
248
|
+
);
|
|
249
|
+
return { appended: true };
|
|
250
|
+
});
|
|
251
|
+
let result: AppendIfUnchangedResult;
|
|
252
|
+
try {
|
|
253
|
+
result = run.immediate();
|
|
254
|
+
} catch (err) {
|
|
255
|
+
if (isDuplicateSequenceViolation(err)) {
|
|
256
|
+
throw new DuplicateSequenceError(executionId, event?.sequence ?? -1, err);
|
|
257
|
+
}
|
|
258
|
+
if (isDuplicateIdViolation(err)) {
|
|
259
|
+
throw new DuplicateEnvelopeIdError(event?.id ?? "", executionId, err);
|
|
260
|
+
}
|
|
261
|
+
throw err;
|
|
262
|
+
}
|
|
263
|
+
if (result.appended && event !== null) this.notify(executionId, event);
|
|
264
|
+
return result;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async read(executionId: string): Promise<EventEnvelope[]> {
|
|
268
|
+
const rows = this.db
|
|
269
|
+
.prepare<[string], EventRow>(
|
|
270
|
+
"SELECT * FROM events WHERE execution_id = ? ORDER BY sequence ASC"
|
|
271
|
+
)
|
|
272
|
+
.all(executionId);
|
|
273
|
+
return rows.map(rowToEnvelope);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async readFrom(executionId: string, fromSequence: number): Promise<EventEnvelope[]> {
|
|
277
|
+
const rows = this.db
|
|
278
|
+
.prepare<[string, number], EventRow>(
|
|
279
|
+
"SELECT * FROM events WHERE execution_id = ? AND sequence >= ? ORDER BY sequence ASC"
|
|
280
|
+
)
|
|
281
|
+
.all(executionId, fromSequence);
|
|
282
|
+
return rows.map(rowToEnvelope);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async head(executionId: string): Promise<number | null> {
|
|
286
|
+
const row = this.db
|
|
287
|
+
.prepare<[string], { tip: number | null }>(
|
|
288
|
+
"SELECT MAX(sequence) AS tip FROM events WHERE execution_id = ?"
|
|
289
|
+
)
|
|
290
|
+
.get(executionId);
|
|
291
|
+
return row?.tip ?? null;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async readRange(
|
|
295
|
+
executionId: string,
|
|
296
|
+
fromSequence: number,
|
|
297
|
+
toSequence: number
|
|
298
|
+
): Promise<EventEnvelope[]> {
|
|
299
|
+
if (toSequence < fromSequence) return [];
|
|
300
|
+
const rows = this.db
|
|
301
|
+
.prepare<[string, number, number], EventRow>(
|
|
302
|
+
"SELECT * FROM events WHERE execution_id = ? AND sequence >= ? AND sequence <= ? ORDER BY sequence ASC"
|
|
303
|
+
)
|
|
304
|
+
.all(executionId, fromSequence, toSequence);
|
|
305
|
+
return rows.map(rowToEnvelope);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async copyPrefix(
|
|
309
|
+
fromExecutionId: string,
|
|
310
|
+
toExecutionId: string,
|
|
311
|
+
throughSequence: number,
|
|
312
|
+
metadata: Record<string, unknown>
|
|
313
|
+
): Promise<void> {
|
|
314
|
+
// Copy the prefix entirely inside SQLite — payload stays as stored TEXT (no
|
|
315
|
+
// JS parse/stringify), id/execution_id are re-stamped, and provenance metadata
|
|
316
|
+
// is merged via json_patch (JSON1). O(P) rows, zero JS serialization.
|
|
317
|
+
this.db
|
|
318
|
+
.prepare<[string, string, string, string, number]>(
|
|
319
|
+
`INSERT INTO events (id, workflow_id, execution_id, event_type, sequence, timestamp, payload, metadata)
|
|
320
|
+
SELECT ? || '-' || sequence, workflow_id, ?, event_type, sequence, timestamp, payload,
|
|
321
|
+
json_patch(COALESCE(metadata, '{}'), json(?))
|
|
322
|
+
FROM events WHERE execution_id = ? AND sequence <= ?`
|
|
323
|
+
)
|
|
324
|
+
.run(
|
|
325
|
+
toExecutionId,
|
|
326
|
+
toExecutionId,
|
|
327
|
+
JSON.stringify(metadata),
|
|
328
|
+
fromExecutionId,
|
|
329
|
+
throughSequence
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async subscribe(
|
|
334
|
+
executionId: string,
|
|
335
|
+
handler: (event: EventEnvelope) => void
|
|
336
|
+
): Promise<() => void> {
|
|
337
|
+
return this.subscribers.add(executionId, handler);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async deleteExecution(executionId: string): Promise<void> {
|
|
341
|
+
this.db.prepare<[string]>("DELETE FROM events WHERE execution_id = ?").run(executionId);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async deleteThrough(executionId: string, through: number): Promise<void> {
|
|
345
|
+
// ADR 0139 D8 - one statement, sequence 0 included; events above `through` are untouched.
|
|
346
|
+
this.db
|
|
347
|
+
.prepare<[string, number]>("DELETE FROM events WHERE execution_id = ? AND sequence <= ?")
|
|
348
|
+
.run(executionId, through);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async truncatePrefix(executionId: string, throughSequence: number): Promise<void> {
|
|
352
|
+
// Preserve sequence 0 (bootstrap anchor); drop (0..throughSequence].
|
|
353
|
+
this.db
|
|
354
|
+
.prepare<[string, number]>(
|
|
355
|
+
"DELETE FROM events WHERE execution_id = ? AND sequence > 0 AND sequence <= ?"
|
|
356
|
+
)
|
|
357
|
+
.run(executionId, throughSequence);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async truncatePrefixKeeping(
|
|
361
|
+
executionId: string,
|
|
362
|
+
throughSequence: number,
|
|
363
|
+
keepEventTypes: readonly string[]
|
|
364
|
+
): Promise<void> {
|
|
365
|
+
// ADR 0106 — one statement: sequence 0 and every kept-type event survive in place. The
|
|
366
|
+
// kept types ride as one JSON array parameter, so the statement text never varies.
|
|
367
|
+
this.db
|
|
368
|
+
.prepare<[string, number, string]>(
|
|
369
|
+
"DELETE FROM events WHERE execution_id = ? AND sequence > 0 AND sequence <= ? AND event_type NOT IN (SELECT value FROM json_each(?))"
|
|
370
|
+
)
|
|
371
|
+
.run(executionId, throughSequence, JSON.stringify(keepEventTypes));
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async listExecutions(): Promise<string[]> {
|
|
375
|
+
// The (execution_id, sequence) index makes DISTINCT a cheap index-only scan.
|
|
376
|
+
const rows = this.db
|
|
377
|
+
.prepare<[], { execution_id: string }>("SELECT DISTINCT execution_id FROM events")
|
|
378
|
+
.all();
|
|
379
|
+
return rows.map((r) => r.execution_id);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async listExecutionsPage(opts?: {
|
|
383
|
+
cursor?: string;
|
|
384
|
+
limit?: number;
|
|
385
|
+
}): Promise<{ executionIds: string[]; nextCursor?: string }> {
|
|
386
|
+
// SP-1 (SPI 1.2.0) — keyset over the same index: lexicographic, bounded memory.
|
|
387
|
+
// Statements prepared lazily once (audit: no per-page re-parse on big scans).
|
|
388
|
+
const limit = opts?.limit ?? 1000;
|
|
389
|
+
this.pageFirstStmt ??= this.db.prepare(
|
|
390
|
+
"SELECT DISTINCT execution_id FROM events ORDER BY execution_id LIMIT ?"
|
|
391
|
+
);
|
|
392
|
+
this.pageAfterStmt ??= this.db.prepare(
|
|
393
|
+
"SELECT DISTINCT execution_id FROM events WHERE execution_id > ? ORDER BY execution_id LIMIT ?"
|
|
394
|
+
);
|
|
395
|
+
const rows =
|
|
396
|
+
opts?.cursor === undefined
|
|
397
|
+
? (this.pageFirstStmt.all(limit + 1) as { execution_id: string }[])
|
|
398
|
+
: (this.pageAfterStmt.all(opts.cursor, limit + 1) as { execution_id: string }[]);
|
|
399
|
+
const executionIds = rows.slice(0, limit).map((r) => r.execution_id);
|
|
400
|
+
const last = executionIds[executionIds.length - 1];
|
|
401
|
+
return {
|
|
402
|
+
executionIds,
|
|
403
|
+
...(rows.length > limit && last !== undefined && { nextCursor: last }),
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async close(): Promise<void> {
|
|
408
|
+
this.db.close();
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
private notify(_executionId: string, event: EventEnvelope): void {
|
|
412
|
+
this.subscribers.emit(event);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AcquireOptions,
|
|
3
|
+
type Lease,
|
|
4
|
+
type LeaseStore,
|
|
5
|
+
assertNoBatchSteal,
|
|
6
|
+
assertSteal,
|
|
7
|
+
assertTtl,
|
|
8
|
+
} from "@loomwork/event-runtime";
|
|
9
|
+
import Database from "better-sqlite3";
|
|
10
|
+
import { migrate } from "./schema";
|
|
11
|
+
|
|
12
|
+
interface LeaseRow {
|
|
13
|
+
execution_id: string;
|
|
14
|
+
owner: string;
|
|
15
|
+
expires_at: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const rowToLease = (r: LeaseRow): Lease => ({
|
|
19
|
+
executionId: r.execution_id,
|
|
20
|
+
owner: r.owner,
|
|
21
|
+
expiresAt: r.expires_at,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* SQLite {@link LeaseStore} (#380). Single-host by nature — better-sqlite3 is synchronous and
|
|
26
|
+
* the file is the boundary — which is the right scope for it: leases answer "which of my nodes
|
|
27
|
+
* owns this", and a single-host deployment has one. Multi-node wants Postgres.
|
|
28
|
+
*/
|
|
29
|
+
export class SqliteLeaseStore implements LeaseStore {
|
|
30
|
+
readonly scope = "host" as const;
|
|
31
|
+
private readonly db: Database.Database;
|
|
32
|
+
|
|
33
|
+
constructor(dbPath: string) {
|
|
34
|
+
this.db = new Database(dbPath);
|
|
35
|
+
this.db.pragma("journal_mode = WAL");
|
|
36
|
+
this.db.pragma("synchronous = NORMAL");
|
|
37
|
+
migrate(this.db);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async acquire(
|
|
41
|
+
executionId: string,
|
|
42
|
+
owner: string,
|
|
43
|
+
ttlMs: number,
|
|
44
|
+
now = Date.now(),
|
|
45
|
+
options?: AcquireOptions
|
|
46
|
+
): Promise<Lease | null> {
|
|
47
|
+
assertTtl(ttlMs);
|
|
48
|
+
const steal = assertSteal(options);
|
|
49
|
+
return this.acquireOne(this.acquireStmt(), executionId, owner, ttlMs, now, steal);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// One statement, so two processes on the same file cannot both win: the insert takes a
|
|
53
|
+
// free row, and the conflict clause takes it only when it is ours, already expired, or the
|
|
54
|
+
// caller holds a steal proof (validated by `acquire` before this runs; never in a batch).
|
|
55
|
+
private acquireStmt(): Database.Statement<[string, string, number, string, number, number]> {
|
|
56
|
+
return this.db.prepare<[string, string, number, string, number, number]>(
|
|
57
|
+
`INSERT INTO leases (execution_id, owner, expires_at) VALUES (?, ?, ?)
|
|
58
|
+
ON CONFLICT(execution_id) DO UPDATE SET owner = excluded.owner, expires_at = excluded.expires_at
|
|
59
|
+
WHERE leases.owner = ? OR leases.expires_at <= ? OR ? = 1`
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private acquireOne(
|
|
64
|
+
stmt: ReturnType<SqliteLeaseStore["acquireStmt"]>,
|
|
65
|
+
executionId: string,
|
|
66
|
+
owner: string,
|
|
67
|
+
ttlMs: number,
|
|
68
|
+
now: number,
|
|
69
|
+
steal: boolean
|
|
70
|
+
): Lease | null {
|
|
71
|
+
const expiresAt = now + ttlMs;
|
|
72
|
+
const info = stmt.run(executionId, owner, expiresAt, owner, now, steal ? 1 : 0);
|
|
73
|
+
return info.changes > 0 ? { executionId, owner, expiresAt } : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Many leases in one transaction (SPI 2.2.0): per id exactly {@link acquire}, atomically. `steal` is rejected. */
|
|
77
|
+
async acquireMany(
|
|
78
|
+
executionIds: readonly string[],
|
|
79
|
+
owner: string,
|
|
80
|
+
ttlMs: number,
|
|
81
|
+
now = Date.now(),
|
|
82
|
+
options?: AcquireOptions
|
|
83
|
+
): Promise<Lease[]> {
|
|
84
|
+
assertTtl(ttlMs);
|
|
85
|
+
assertNoBatchSteal(options);
|
|
86
|
+
const ids = [...new Set(executionIds)];
|
|
87
|
+
const stmt = this.acquireStmt();
|
|
88
|
+
return this.db.transaction(() => {
|
|
89
|
+
const out: Lease[] = [];
|
|
90
|
+
for (const id of ids) {
|
|
91
|
+
const lease = this.acquireOne(stmt, id, owner, ttlMs, now, false);
|
|
92
|
+
if (lease !== null) out.push(lease);
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
})();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The HELD set: ids whose lease `owner` still holds, extended. Never resurrects a missing row. */
|
|
99
|
+
async renewMany(
|
|
100
|
+
executionIds: readonly string[],
|
|
101
|
+
owner: string,
|
|
102
|
+
ttlMs: number,
|
|
103
|
+
now = Date.now()
|
|
104
|
+
): Promise<string[]> {
|
|
105
|
+
assertTtl(ttlMs);
|
|
106
|
+
const ids = [...new Set(executionIds)];
|
|
107
|
+
const stmt = this.db.prepare<[number, string, string]>(
|
|
108
|
+
"UPDATE leases SET expires_at = ? WHERE execution_id = ? AND owner = ?"
|
|
109
|
+
);
|
|
110
|
+
return this.db.transaction(() =>
|
|
111
|
+
ids.filter((id) => stmt.run(now + ttlMs, id, owner).changes > 0)
|
|
112
|
+
)();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async releaseMany(executionIds: readonly string[], owner: string): Promise<void> {
|
|
116
|
+
const ids = [...new Set(executionIds)];
|
|
117
|
+
const stmt = this.db.prepare<[string, string]>(
|
|
118
|
+
"DELETE FROM leases WHERE execution_id = ? AND owner = ?"
|
|
119
|
+
);
|
|
120
|
+
this.db.transaction(() => {
|
|
121
|
+
for (const id of ids) stmt.run(id, owner);
|
|
122
|
+
})();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async renew(
|
|
126
|
+
executionId: string,
|
|
127
|
+
owner: string,
|
|
128
|
+
ttlMs: number,
|
|
129
|
+
now = Date.now()
|
|
130
|
+
): Promise<boolean> {
|
|
131
|
+
assertTtl(ttlMs);
|
|
132
|
+
const info = this.db
|
|
133
|
+
.prepare<[number, string, string]>(
|
|
134
|
+
"UPDATE leases SET expires_at = ? WHERE execution_id = ? AND owner = ?"
|
|
135
|
+
)
|
|
136
|
+
.run(now + ttlMs, executionId, owner);
|
|
137
|
+
return info.changes > 0;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async expired(now = Date.now(), limit = 100): Promise<Lease[]> {
|
|
141
|
+
const rows = this.db
|
|
142
|
+
.prepare<[number, number], LeaseRow>(
|
|
143
|
+
"SELECT * FROM leases WHERE expires_at <= ? ORDER BY expires_at ASC LIMIT ?"
|
|
144
|
+
)
|
|
145
|
+
.all(now, limit);
|
|
146
|
+
return rows.map(rowToLease);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async get(executionId: string): Promise<Lease | null> {
|
|
150
|
+
const row = this.db
|
|
151
|
+
.prepare<[string], LeaseRow>("SELECT * FROM leases WHERE execution_id = ?")
|
|
152
|
+
.get(executionId);
|
|
153
|
+
return row ? rowToLease(row) : null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async release(executionId: string, owner: string): Promise<void> {
|
|
157
|
+
this.db
|
|
158
|
+
.prepare<[string, string]>("DELETE FROM leases WHERE execution_id = ? AND owner = ?")
|
|
159
|
+
.run(executionId, owner);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async close(): Promise<void> {
|
|
163
|
+
this.db.close();
|
|
164
|
+
}
|
|
165
|
+
}
|