@hasna/events 0.1.13 → 0.1.15
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 +198 -13
- package/README.md +274 -26
- package/dist/app-event.js +382 -0
- package/dist/catalog.js +191 -0
- package/dist/cli/index.js +1680 -107
- package/dist/commander.js +882 -92
- package/dist/durable-spool.js +184 -0
- package/dist/durable-worker.js +378 -0
- package/dist/durable.js +2232 -0
- package/dist/index.js +868 -71
- package/dist/storage.js +140 -4
- package/dist/transports.js +36 -7
- package/fixtures/hasna.app_event.v1.json +108 -0
- package/hasna.contract.json +70 -0
- package/package.json +46 -12
- package/schemas/hasna.app_event.v1.json +186 -0
- package/types/app-event.d.ts +130 -0
- package/types/catalog.d.ts +136 -0
- package/{dist → types}/commander.d.ts +14 -0
- package/types/durable-spool.d.ts +30 -0
- package/types/durable-worker.d.ts +27 -0
- package/types/durable.d.ts +112 -0
- package/{dist → types}/index.d.ts +23 -8
- package/types/redaction.d.ts +4 -0
- package/{dist → types}/storage.d.ts +16 -3
- package/{dist → types}/transports.d.ts +8 -1
- package/{dist → types}/types.d.ts +64 -2
- /package/{dist → types}/cli/index.d.ts +0 -0
- /package/{dist → types}/filter-options.d.ts +0 -0
- /package/{dist → types}/filter.d.ts +0 -0
- /package/{dist → types}/signing.d.ts +0 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// src/durable-spool.ts
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import {
|
|
4
|
+
chmod,
|
|
5
|
+
link,
|
|
6
|
+
mkdir,
|
|
7
|
+
open,
|
|
8
|
+
readdir,
|
|
9
|
+
readFile,
|
|
10
|
+
stat,
|
|
11
|
+
unlink
|
|
12
|
+
} from "node:fs/promises";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
// src/redaction.ts
|
|
16
|
+
function redactSensitiveKeys(event, replacement = "[REDACTED]") {
|
|
17
|
+
return redactValue(event, replacement);
|
|
18
|
+
}
|
|
19
|
+
function shouldRedactKey(key) {
|
|
20
|
+
return /secret|token|password|api[_-]?key|authorization/i.test(key);
|
|
21
|
+
}
|
|
22
|
+
function redactValue(value, replacement) {
|
|
23
|
+
if (Array.isArray(value))
|
|
24
|
+
return value.map((item) => redactValue(item, replacement));
|
|
25
|
+
if (!value || typeof value !== "object")
|
|
26
|
+
return value;
|
|
27
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [
|
|
28
|
+
key,
|
|
29
|
+
shouldRedactKey(key) ? replacement : redactValue(item, replacement)
|
|
30
|
+
]));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// src/durable-spool.ts
|
|
34
|
+
class DurableEventSpool {
|
|
35
|
+
dataDir;
|
|
36
|
+
inboxDir;
|
|
37
|
+
constructor(options) {
|
|
38
|
+
if (!options.dataDir)
|
|
39
|
+
throw new Error("DurableEventSpool requires dataDir");
|
|
40
|
+
this.dataDir = options.dataDir;
|
|
41
|
+
this.inboxDir = join(options.dataDir, "spool", "inbox");
|
|
42
|
+
}
|
|
43
|
+
async enqueue(input) {
|
|
44
|
+
const event = redactSensitiveKeys(createSpoolEvent(input));
|
|
45
|
+
await this.ensureInbox();
|
|
46
|
+
const finalPath = this.pathFor(event);
|
|
47
|
+
const tempPath = join(this.inboxDir, `.tmp-${process.pid}-${randomUUID()}`);
|
|
48
|
+
const handle = await open(tempPath, "wx", 384);
|
|
49
|
+
try {
|
|
50
|
+
await handle.writeFile(`${JSON.stringify(event)}
|
|
51
|
+
`, "utf8");
|
|
52
|
+
await handle.sync();
|
|
53
|
+
} finally {
|
|
54
|
+
await handle.close();
|
|
55
|
+
}
|
|
56
|
+
let stored = false;
|
|
57
|
+
try {
|
|
58
|
+
await link(tempPath, finalPath);
|
|
59
|
+
stored = true;
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (!isNodeError(error, "EEXIST")) {
|
|
62
|
+
await unlink(tempPath).catch(() => {
|
|
63
|
+
return;
|
|
64
|
+
});
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
await this.assertSameIdentity(finalPath, event);
|
|
68
|
+
}
|
|
69
|
+
await unlink(tempPath);
|
|
70
|
+
await this.syncInbox();
|
|
71
|
+
return { event, stored, deduped: !stored };
|
|
72
|
+
}
|
|
73
|
+
async recover(options = {}) {
|
|
74
|
+
await this.ensureInbox();
|
|
75
|
+
const olderThanMs = Math.max(0, options.olderThanMs ?? 60000);
|
|
76
|
+
const threshold = Date.now() - olderThanMs;
|
|
77
|
+
const result = { recovered: 0, deduped: 0, cleaned: 0 };
|
|
78
|
+
const names = (await readdir(this.inboxDir)).filter((name) => name.startsWith(".tmp-")).sort();
|
|
79
|
+
for (const name of names) {
|
|
80
|
+
const tempPath = join(this.inboxDir, name);
|
|
81
|
+
const details = await stat(tempPath).catch(() => {
|
|
82
|
+
return;
|
|
83
|
+
});
|
|
84
|
+
if (!details || details.mtimeMs > threshold)
|
|
85
|
+
continue;
|
|
86
|
+
let event;
|
|
87
|
+
try {
|
|
88
|
+
event = parseEnvelope(await readFile(tempPath, "utf8"));
|
|
89
|
+
} catch {
|
|
90
|
+
await unlink(tempPath).catch(() => {
|
|
91
|
+
return;
|
|
92
|
+
});
|
|
93
|
+
result.cleaned += 1;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const finalPath = this.pathFor(event);
|
|
97
|
+
try {
|
|
98
|
+
await link(tempPath, finalPath);
|
|
99
|
+
result.recovered += 1;
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (!isNodeError(error, "EEXIST"))
|
|
102
|
+
throw error;
|
|
103
|
+
await this.assertSameIdentity(finalPath, event);
|
|
104
|
+
result.deduped += 1;
|
|
105
|
+
}
|
|
106
|
+
await unlink(tempPath).catch(() => {
|
|
107
|
+
return;
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
if (result.recovered || result.deduped || result.cleaned)
|
|
111
|
+
await this.syncInbox();
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
async close() {}
|
|
115
|
+
pathFor(event) {
|
|
116
|
+
const identity = event.dedupeKey ?? event.id;
|
|
117
|
+
const digest = createHash("sha256").update(identity, "utf8").digest("hex");
|
|
118
|
+
return join(this.inboxDir, `${digest}.json`);
|
|
119
|
+
}
|
|
120
|
+
async assertSameIdentity(path, event) {
|
|
121
|
+
const existing = parseEnvelope(await readFile(path, "utf8"));
|
|
122
|
+
const matches = existing.id === event.id || event.dedupeKey !== undefined && existing.dedupeKey === event.dedupeKey;
|
|
123
|
+
if (!matches)
|
|
124
|
+
throw new Error("Durable spool identity collision");
|
|
125
|
+
}
|
|
126
|
+
async ensureInbox() {
|
|
127
|
+
const spoolDir = join(this.dataDir, "spool");
|
|
128
|
+
await mkdir(this.inboxDir, { recursive: true, mode: 448 });
|
|
129
|
+
await chmod(this.dataDir, 448);
|
|
130
|
+
await chmod(spoolDir, 448);
|
|
131
|
+
await chmod(this.inboxDir, 448);
|
|
132
|
+
await this.syncDirectory(this.dataDir);
|
|
133
|
+
await this.syncDirectory(spoolDir);
|
|
134
|
+
}
|
|
135
|
+
async syncInbox() {
|
|
136
|
+
await this.syncDirectory(this.inboxDir);
|
|
137
|
+
}
|
|
138
|
+
async syncDirectory(path) {
|
|
139
|
+
const directory = await open(path, "r");
|
|
140
|
+
try {
|
|
141
|
+
await directory.sync();
|
|
142
|
+
} finally {
|
|
143
|
+
await directory.close();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function createSpoolEvent(input) {
|
|
148
|
+
return {
|
|
149
|
+
id: input.id ?? randomUUID(),
|
|
150
|
+
source: input.source,
|
|
151
|
+
type: input.type,
|
|
152
|
+
time: input.time instanceof Date ? input.time.toISOString() : input.time ?? new Date().toISOString(),
|
|
153
|
+
subject: input.subject,
|
|
154
|
+
severity: input.severity ?? "info",
|
|
155
|
+
data: input.data ?? {},
|
|
156
|
+
message: input.message,
|
|
157
|
+
dedupeKey: input.dedupeKey,
|
|
158
|
+
schemaVersion: input.schemaVersion ?? "1.0",
|
|
159
|
+
metadata: input.metadata ?? {}
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
function parseEnvelope(raw) {
|
|
163
|
+
const value = JSON.parse(raw);
|
|
164
|
+
if (!value || typeof value !== "object")
|
|
165
|
+
throw new Error("Invalid durable event spool record");
|
|
166
|
+
for (const field of ["id", "source", "type", "time", "schemaVersion"]) {
|
|
167
|
+
if (typeof value[field] !== "string" || value[field].length === 0) {
|
|
168
|
+
throw new Error("Invalid durable event spool record");
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (!value.data || typeof value.data !== "object" || Array.isArray(value.data)) {
|
|
172
|
+
throw new Error("Invalid durable event spool record");
|
|
173
|
+
}
|
|
174
|
+
if (!value.metadata || typeof value.metadata !== "object" || Array.isArray(value.metadata)) {
|
|
175
|
+
throw new Error("Invalid durable event spool record");
|
|
176
|
+
}
|
|
177
|
+
return value;
|
|
178
|
+
}
|
|
179
|
+
function isNodeError(error, code) {
|
|
180
|
+
return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
|
|
181
|
+
}
|
|
182
|
+
export {
|
|
183
|
+
DurableEventSpool
|
|
184
|
+
};
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/durable-worker.ts
|
|
3
|
+
import { chmodSync, mkdirSync, watch } from "fs";
|
|
4
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
5
|
+
import { join as join2 } from "path";
|
|
6
|
+
|
|
7
|
+
// src/durable-spool.ts
|
|
8
|
+
import { createHash, randomUUID } from "crypto";
|
|
9
|
+
import {
|
|
10
|
+
chmod,
|
|
11
|
+
link,
|
|
12
|
+
mkdir,
|
|
13
|
+
open,
|
|
14
|
+
readdir,
|
|
15
|
+
readFile,
|
|
16
|
+
stat,
|
|
17
|
+
unlink
|
|
18
|
+
} from "fs/promises";
|
|
19
|
+
import { join } from "path";
|
|
20
|
+
|
|
21
|
+
// src/redaction.ts
|
|
22
|
+
function redactPaths(event, paths, replacement = "[REDACTED]") {
|
|
23
|
+
if (paths.length === 0)
|
|
24
|
+
return event;
|
|
25
|
+
const copy = structuredClone(event);
|
|
26
|
+
for (const path of paths) {
|
|
27
|
+
setPath(copy, path, replacement);
|
|
28
|
+
}
|
|
29
|
+
return copy;
|
|
30
|
+
}
|
|
31
|
+
function redactSensitiveKeys(event, replacement = "[REDACTED]") {
|
|
32
|
+
return redactValue(event, replacement);
|
|
33
|
+
}
|
|
34
|
+
function shouldRedactKey(key) {
|
|
35
|
+
return /secret|token|password|api[_-]?key|authorization/i.test(key);
|
|
36
|
+
}
|
|
37
|
+
function redactValue(value, replacement) {
|
|
38
|
+
if (Array.isArray(value))
|
|
39
|
+
return value.map((item) => redactValue(item, replacement));
|
|
40
|
+
if (!value || typeof value !== "object")
|
|
41
|
+
return value;
|
|
42
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [
|
|
43
|
+
key,
|
|
44
|
+
shouldRedactKey(key) ? replacement : redactValue(item, replacement)
|
|
45
|
+
]));
|
|
46
|
+
}
|
|
47
|
+
function setPath(input, path, replacement) {
|
|
48
|
+
const parts = path.split(".");
|
|
49
|
+
let cursor = input;
|
|
50
|
+
for (const part of parts.slice(0, -1)) {
|
|
51
|
+
const next = cursor[part];
|
|
52
|
+
if (!next || typeof next !== "object")
|
|
53
|
+
return;
|
|
54
|
+
cursor = next;
|
|
55
|
+
}
|
|
56
|
+
const last = parts.at(-1);
|
|
57
|
+
if (last && last in cursor)
|
|
58
|
+
cursor[last] = replacement;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// src/durable-spool.ts
|
|
62
|
+
class DurableEventSpool {
|
|
63
|
+
dataDir;
|
|
64
|
+
inboxDir;
|
|
65
|
+
constructor(options) {
|
|
66
|
+
if (!options.dataDir)
|
|
67
|
+
throw new Error("DurableEventSpool requires dataDir");
|
|
68
|
+
this.dataDir = options.dataDir;
|
|
69
|
+
this.inboxDir = join(options.dataDir, "spool", "inbox");
|
|
70
|
+
}
|
|
71
|
+
async enqueue(input) {
|
|
72
|
+
const event = redactSensitiveKeys(createSpoolEvent(input));
|
|
73
|
+
await this.ensureInbox();
|
|
74
|
+
const finalPath = this.pathFor(event);
|
|
75
|
+
const tempPath = join(this.inboxDir, `.tmp-${process.pid}-${randomUUID()}`);
|
|
76
|
+
const handle = await open(tempPath, "wx", 384);
|
|
77
|
+
try {
|
|
78
|
+
await handle.writeFile(`${JSON.stringify(event)}
|
|
79
|
+
`, "utf8");
|
|
80
|
+
await handle.sync();
|
|
81
|
+
} finally {
|
|
82
|
+
await handle.close();
|
|
83
|
+
}
|
|
84
|
+
let stored = false;
|
|
85
|
+
try {
|
|
86
|
+
await link(tempPath, finalPath);
|
|
87
|
+
stored = true;
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if (!isNodeError(error, "EEXIST")) {
|
|
90
|
+
await unlink(tempPath).catch(() => {
|
|
91
|
+
return;
|
|
92
|
+
});
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
await this.assertSameIdentity(finalPath, event);
|
|
96
|
+
}
|
|
97
|
+
await unlink(tempPath);
|
|
98
|
+
await this.syncInbox();
|
|
99
|
+
return { event, stored, deduped: !stored };
|
|
100
|
+
}
|
|
101
|
+
async recover(options = {}) {
|
|
102
|
+
await this.ensureInbox();
|
|
103
|
+
const olderThanMs = Math.max(0, options.olderThanMs ?? 60000);
|
|
104
|
+
const threshold = Date.now() - olderThanMs;
|
|
105
|
+
const result = { recovered: 0, deduped: 0, cleaned: 0 };
|
|
106
|
+
const names = (await readdir(this.inboxDir)).filter((name) => name.startsWith(".tmp-")).sort();
|
|
107
|
+
for (const name of names) {
|
|
108
|
+
const tempPath = join(this.inboxDir, name);
|
|
109
|
+
const details = await stat(tempPath).catch(() => {
|
|
110
|
+
return;
|
|
111
|
+
});
|
|
112
|
+
if (!details || details.mtimeMs > threshold)
|
|
113
|
+
continue;
|
|
114
|
+
let event;
|
|
115
|
+
try {
|
|
116
|
+
event = parseEnvelope(await readFile(tempPath, "utf8"));
|
|
117
|
+
} catch {
|
|
118
|
+
await unlink(tempPath).catch(() => {
|
|
119
|
+
return;
|
|
120
|
+
});
|
|
121
|
+
result.cleaned += 1;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const finalPath = this.pathFor(event);
|
|
125
|
+
try {
|
|
126
|
+
await link(tempPath, finalPath);
|
|
127
|
+
result.recovered += 1;
|
|
128
|
+
} catch (error) {
|
|
129
|
+
if (!isNodeError(error, "EEXIST"))
|
|
130
|
+
throw error;
|
|
131
|
+
await this.assertSameIdentity(finalPath, event);
|
|
132
|
+
result.deduped += 1;
|
|
133
|
+
}
|
|
134
|
+
await unlink(tempPath).catch(() => {
|
|
135
|
+
return;
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
if (result.recovered || result.deduped || result.cleaned)
|
|
139
|
+
await this.syncInbox();
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
142
|
+
async close() {}
|
|
143
|
+
pathFor(event) {
|
|
144
|
+
const identity = event.dedupeKey ?? event.id;
|
|
145
|
+
const digest = createHash("sha256").update(identity, "utf8").digest("hex");
|
|
146
|
+
return join(this.inboxDir, `${digest}.json`);
|
|
147
|
+
}
|
|
148
|
+
async assertSameIdentity(path, event) {
|
|
149
|
+
const existing = parseEnvelope(await readFile(path, "utf8"));
|
|
150
|
+
const matches = existing.id === event.id || event.dedupeKey !== undefined && existing.dedupeKey === event.dedupeKey;
|
|
151
|
+
if (!matches)
|
|
152
|
+
throw new Error("Durable spool identity collision");
|
|
153
|
+
}
|
|
154
|
+
async ensureInbox() {
|
|
155
|
+
const spoolDir = join(this.dataDir, "spool");
|
|
156
|
+
await mkdir(this.inboxDir, { recursive: true, mode: 448 });
|
|
157
|
+
await chmod(this.dataDir, 448);
|
|
158
|
+
await chmod(spoolDir, 448);
|
|
159
|
+
await chmod(this.inboxDir, 448);
|
|
160
|
+
await this.syncDirectory(this.dataDir);
|
|
161
|
+
await this.syncDirectory(spoolDir);
|
|
162
|
+
}
|
|
163
|
+
async syncInbox() {
|
|
164
|
+
await this.syncDirectory(this.inboxDir);
|
|
165
|
+
}
|
|
166
|
+
async syncDirectory(path) {
|
|
167
|
+
const directory = await open(path, "r");
|
|
168
|
+
try {
|
|
169
|
+
await directory.sync();
|
|
170
|
+
} finally {
|
|
171
|
+
await directory.close();
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function createSpoolEvent(input) {
|
|
176
|
+
return {
|
|
177
|
+
id: input.id ?? randomUUID(),
|
|
178
|
+
source: input.source,
|
|
179
|
+
type: input.type,
|
|
180
|
+
time: input.time instanceof Date ? input.time.toISOString() : input.time ?? new Date().toISOString(),
|
|
181
|
+
subject: input.subject,
|
|
182
|
+
severity: input.severity ?? "info",
|
|
183
|
+
data: input.data ?? {},
|
|
184
|
+
message: input.message,
|
|
185
|
+
dedupeKey: input.dedupeKey,
|
|
186
|
+
schemaVersion: input.schemaVersion ?? "1.0",
|
|
187
|
+
metadata: input.metadata ?? {}
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
function parseEnvelope(raw) {
|
|
191
|
+
const value = JSON.parse(raw);
|
|
192
|
+
if (!value || typeof value !== "object")
|
|
193
|
+
throw new Error("Invalid durable event spool record");
|
|
194
|
+
for (const field of ["id", "source", "type", "time", "schemaVersion"]) {
|
|
195
|
+
if (typeof value[field] !== "string" || value[field].length === 0) {
|
|
196
|
+
throw new Error("Invalid durable event spool record");
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (!value.data || typeof value.data !== "object" || Array.isArray(value.data)) {
|
|
200
|
+
throw new Error("Invalid durable event spool record");
|
|
201
|
+
}
|
|
202
|
+
if (!value.metadata || typeof value.metadata !== "object" || Array.isArray(value.metadata)) {
|
|
203
|
+
throw new Error("Invalid durable event spool record");
|
|
204
|
+
}
|
|
205
|
+
return value;
|
|
206
|
+
}
|
|
207
|
+
function isNodeError(error, code) {
|
|
208
|
+
return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// src/durable-worker.ts
|
|
212
|
+
var MAX_TIMER_DELAY_MS = 2147483647;
|
|
213
|
+
async function runDurableWorker(options) {
|
|
214
|
+
const workerId = options.workerId ?? randomUUID2();
|
|
215
|
+
const limit = positiveInteger(options.limit, 100, "limit");
|
|
216
|
+
const leaseMs = positiveInteger(options.leaseMs, 60000, "leaseMs");
|
|
217
|
+
const debounceMs = nonNegativeInteger(options.debounceMs, 50, "debounceMs");
|
|
218
|
+
const reconcileMs = positiveInteger(options.reconcileMs, 30000, "reconcileMs");
|
|
219
|
+
const watchRestartMs = positiveInteger(options.watchRestartMs, 1000, "watchRestartMs");
|
|
220
|
+
const spool = new DurableEventSpool({ dataDir: options.broker.dataDir });
|
|
221
|
+
const inboxDir = spool.inboxDir;
|
|
222
|
+
mkdirSync(inboxDir, { recursive: true, mode: 448 });
|
|
223
|
+
chmodSync(join2(options.broker.dataDir, "spool"), 448);
|
|
224
|
+
chmodSync(inboxDir, 448);
|
|
225
|
+
const totals = {
|
|
226
|
+
workerId,
|
|
227
|
+
cycles: 0,
|
|
228
|
+
imported: 0,
|
|
229
|
+
deduped: 0,
|
|
230
|
+
delivered: 0,
|
|
231
|
+
retried: 0,
|
|
232
|
+
dead: 0,
|
|
233
|
+
lost: 0
|
|
234
|
+
};
|
|
235
|
+
return new Promise((resolve, reject) => {
|
|
236
|
+
let watcher;
|
|
237
|
+
let debounceTimer;
|
|
238
|
+
let retryTimer;
|
|
239
|
+
let reconcileTimer;
|
|
240
|
+
let restartTimer;
|
|
241
|
+
let running = false;
|
|
242
|
+
let rerun = false;
|
|
243
|
+
let stopped = false;
|
|
244
|
+
const clearRetryTimer = () => {
|
|
245
|
+
if (retryTimer)
|
|
246
|
+
clearTimeout(retryTimer);
|
|
247
|
+
retryTimer = undefined;
|
|
248
|
+
};
|
|
249
|
+
const stop = () => {
|
|
250
|
+
if (stopped)
|
|
251
|
+
return;
|
|
252
|
+
stopped = true;
|
|
253
|
+
watcher?.close();
|
|
254
|
+
if (debounceTimer)
|
|
255
|
+
clearTimeout(debounceTimer);
|
|
256
|
+
clearRetryTimer();
|
|
257
|
+
if (reconcileTimer)
|
|
258
|
+
clearInterval(reconcileTimer);
|
|
259
|
+
if (restartTimer)
|
|
260
|
+
clearTimeout(restartTimer);
|
|
261
|
+
options.signal.removeEventListener("abort", stop);
|
|
262
|
+
if (!running)
|
|
263
|
+
resolve(totals);
|
|
264
|
+
};
|
|
265
|
+
const scheduleRetryWake = () => {
|
|
266
|
+
clearRetryTimer();
|
|
267
|
+
if (stopped)
|
|
268
|
+
return;
|
|
269
|
+
const nextWakeAt = options.broker.nextWakeAt();
|
|
270
|
+
if (nextWakeAt === undefined)
|
|
271
|
+
return;
|
|
272
|
+
const delay = Math.min(MAX_TIMER_DELAY_MS, Math.max(0, nextWakeAt - Date.now()));
|
|
273
|
+
retryTimer = setTimeout(() => {
|
|
274
|
+
retryTimer = undefined;
|
|
275
|
+
runCycle();
|
|
276
|
+
}, delay);
|
|
277
|
+
};
|
|
278
|
+
const runCycle = async () => {
|
|
279
|
+
if (stopped)
|
|
280
|
+
return;
|
|
281
|
+
if (running) {
|
|
282
|
+
rerun = true;
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
running = true;
|
|
286
|
+
clearRetryTimer();
|
|
287
|
+
try {
|
|
288
|
+
await spool.recover();
|
|
289
|
+
const imported = options.broker.importSpool({ limit });
|
|
290
|
+
const drained = await options.broker.drain({ workerId, limit, leaseMs });
|
|
291
|
+
const cycle = { imported, drained };
|
|
292
|
+
totals.cycles += 1;
|
|
293
|
+
totals.imported += imported.imported;
|
|
294
|
+
totals.deduped += imported.deduped;
|
|
295
|
+
totals.delivered += drained.delivered;
|
|
296
|
+
totals.retried += drained.retried;
|
|
297
|
+
totals.dead += drained.dead;
|
|
298
|
+
totals.lost += drained.lost;
|
|
299
|
+
await options.onCycle?.(cycle);
|
|
300
|
+
if (imported.scanned >= limit || drained.claimed >= limit)
|
|
301
|
+
rerun = true;
|
|
302
|
+
} catch (error) {
|
|
303
|
+
reject(error);
|
|
304
|
+
stop();
|
|
305
|
+
return;
|
|
306
|
+
} finally {
|
|
307
|
+
running = false;
|
|
308
|
+
}
|
|
309
|
+
if (stopped) {
|
|
310
|
+
resolve(totals);
|
|
311
|
+
} else if (rerun) {
|
|
312
|
+
rerun = false;
|
|
313
|
+
queueMicrotask(() => {
|
|
314
|
+
runCycle();
|
|
315
|
+
});
|
|
316
|
+
} else {
|
|
317
|
+
scheduleRetryWake();
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
const scheduleDebouncedCycle = () => {
|
|
321
|
+
if (stopped)
|
|
322
|
+
return;
|
|
323
|
+
if (debounceTimer)
|
|
324
|
+
clearTimeout(debounceTimer);
|
|
325
|
+
debounceTimer = setTimeout(() => {
|
|
326
|
+
debounceTimer = undefined;
|
|
327
|
+
runCycle();
|
|
328
|
+
}, debounceMs);
|
|
329
|
+
};
|
|
330
|
+
const startWatcher = () => {
|
|
331
|
+
if (stopped)
|
|
332
|
+
return;
|
|
333
|
+
watcher?.close();
|
|
334
|
+
try {
|
|
335
|
+
watcher = watch(inboxDir, (_eventType, filename) => {
|
|
336
|
+
if (!filename || filename.toString().endsWith(".json"))
|
|
337
|
+
scheduleDebouncedCycle();
|
|
338
|
+
});
|
|
339
|
+
watcher.on("error", () => {
|
|
340
|
+
watcher?.close();
|
|
341
|
+
watcher = undefined;
|
|
342
|
+
scheduleDebouncedCycle();
|
|
343
|
+
if (!stopped)
|
|
344
|
+
restartTimer = setTimeout(startWatcher, watchRestartMs);
|
|
345
|
+
});
|
|
346
|
+
} catch {
|
|
347
|
+
scheduleDebouncedCycle();
|
|
348
|
+
if (!stopped)
|
|
349
|
+
restartTimer = setTimeout(startWatcher, watchRestartMs);
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
options.signal.addEventListener("abort", stop, { once: true });
|
|
353
|
+
if (options.signal.aborted) {
|
|
354
|
+
stop();
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
startWatcher();
|
|
358
|
+
reconcileTimer = setInterval(() => {
|
|
359
|
+
runCycle();
|
|
360
|
+
}, reconcileMs);
|
|
361
|
+
runCycle();
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
function positiveInteger(value, fallback, name) {
|
|
365
|
+
const resolved = value ?? fallback;
|
|
366
|
+
if (!Number.isInteger(resolved) || resolved < 1)
|
|
367
|
+
throw new Error(`${name} must be a positive integer`);
|
|
368
|
+
return resolved;
|
|
369
|
+
}
|
|
370
|
+
function nonNegativeInteger(value, fallback, name) {
|
|
371
|
+
const resolved = value ?? fallback;
|
|
372
|
+
if (!Number.isInteger(resolved) || resolved < 0)
|
|
373
|
+
throw new Error(`${name} must be a non-negative integer`);
|
|
374
|
+
return resolved;
|
|
375
|
+
}
|
|
376
|
+
export {
|
|
377
|
+
runDurableWorker
|
|
378
|
+
};
|