@xyo-network/event-kit-node 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -0
- package/dist/node/filesystemCursorStore.d.ts +10 -0
- package/dist/node/filesystemCursorStore.d.ts.map +1 -0
- package/dist/node/filesystemPublisherOutbox.d.ts +16 -0
- package/dist/node/filesystemPublisherOutbox.d.ts.map +1 -0
- package/dist/node/filesystemWakeInbox.d.ts +30 -0
- package/dist/node/filesystemWakeInbox.d.ts.map +1 -0
- package/dist/node/filesystemWakeQueue.d.ts +34 -0
- package/dist/node/filesystemWakeQueue.d.ts.map +1 -0
- package/dist/node/index.d.ts +11 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.mjs +446 -0
- package/dist/node/index.mjs.map +7 -0
- package/dist/node/sqliteWakeQueue.d.ts +25 -0
- package/dist/node/sqliteWakeQueue.d.ts.map +1 -0
- package/package.json +80 -0
package/README.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# `@xyo-network/event-kit-node`
|
|
2
|
+
|
|
3
|
+
Node.js durability adapters for Event Kit:
|
|
4
|
+
|
|
5
|
+
- `createFilesystemCursorStore` — publisher cursor for the wake publisher actor
|
|
6
|
+
- `createFilesystemPublisherOutbox` — durable pending-wake slot for retry stability
|
|
7
|
+
- `createFilesystemWakeQueue` — crash-safe at-least-once `WakeQueue`
|
|
8
|
+
- `createSqliteWakeQueue` — `WakeQueue` over `node:sqlite`, no new dependencies
|
|
9
|
+
- `createFilesystemWakeInbox` — durable `AtomicWakeInbox` over that queue
|
|
10
|
+
|
|
11
|
+
## Durability model
|
|
12
|
+
|
|
13
|
+
`createFilesystemWakeInbox` commits in one order and that order is the
|
|
14
|
+
correctness property: the wake reaches the durable queue **before** either
|
|
15
|
+
identity record is written.
|
|
16
|
+
|
|
17
|
+
- A crash in the gap redelivers the wake, which the idempotent consumer absorbs.
|
|
18
|
+
- The reverse order could claim `wakeId` for a wake that was never queued and
|
|
19
|
+
wedge it permanently, which D-002 §6 forbids.
|
|
20
|
+
|
|
21
|
+
Records and queue entries are published with `link`, never `rename`. `rename` is
|
|
22
|
+
unsound as a claim: when two callers race the same source, both can observe
|
|
23
|
+
success once the paths resolve to one inode, and the entry is handed out twice.
|
|
24
|
+
`link` fails with `EEXIST`, so exactly one caller wins and the loser re-reads the
|
|
25
|
+
winner's receipt.
|
|
26
|
+
|
|
27
|
+
A claim is `link(pending -> inflight)` then `unlink(pending)`. A consumer that
|
|
28
|
+
dies mid-handler leaves its entry in `inflight/`; `recoverInflight()` returns it
|
|
29
|
+
to `pending/` at startup. A crash between the link and the unlink leaves one
|
|
30
|
+
inode in both sets, and recovery keeps the pending copy.
|
|
31
|
+
|
|
32
|
+
## Queue providers
|
|
33
|
+
|
|
34
|
+
Both queues implement the shared lease-based `WakeQueue` port and pass the
|
|
35
|
+
conformance suite in `@xyo-network/event-kit-testing`, so they are swappable
|
|
36
|
+
with each other and with the in-process memory provider.
|
|
37
|
+
|
|
38
|
+
SQLite makes the lease a single statement: `UPDATE … WHERE state = 'pending'`,
|
|
39
|
+
where the affected-row count decides the winner. That is the transactional form
|
|
40
|
+
of what the filesystem provider approximates with `link`. WAL lets several
|
|
41
|
+
processes on one host share the file; it does not coordinate across hosts.
|
|
42
|
+
|
|
43
|
+
`node:sqlite` is built in, so this adds no dependency. It does emit an
|
|
44
|
+
ExperimentalWarning.
|
|
45
|
+
|
|
46
|
+
## Boundary
|
|
47
|
+
|
|
48
|
+
This survives process death, not host loss, and does not coordinate across
|
|
49
|
+
machines. Gate 5's durable-adapter clause is satisfied for a single node only; a
|
|
50
|
+
multi-node deployment needs a transactional store or a cloud queue with native
|
|
51
|
+
deduplication, which remains Phase 5b.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { PublisherCursorStore } from '@xyo-network/event-kit-actor';
|
|
2
|
+
export interface FilesystemCursorStoreOptions {
|
|
3
|
+
/** Absolute or relative path to the cursor JSON file. */
|
|
4
|
+
readonly path: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Persists the publisher cursor as `{ "lastProcessedBlock": number }` on disk.
|
|
8
|
+
*/
|
|
9
|
+
export declare function createFilesystemCursorStore(options: FilesystemCursorStoreOptions): PublisherCursorStore;
|
|
10
|
+
//# sourceMappingURL=filesystemCursorStore.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"filesystemCursorStore.d.ts","sourceRoot":"","sources":["../../src/filesystemCursorStore.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,8BAA8B,CAAA;AAExE,MAAM,WAAW,4BAA4B;IAC3C,yDAAyD;IACzD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CACtB;AAMD;;GAEG;AACH,wBAAgB,2BAA2B,CACzC,OAAO,EAAE,4BAA4B,GACpC,oBAAoB,CAsBtB"}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { PublisherOutbox } from '@xyo-network/event-kit-actor';
|
|
2
|
+
export interface FilesystemPublisherOutboxOptions {
|
|
3
|
+
/** Absolute or relative path to the pending-wake JSON file. */
|
|
4
|
+
readonly path: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Durable single-slot publisher outbox.
|
|
8
|
+
*
|
|
9
|
+
* The publisher emits at most one wake per pass, so one slot is enough: the
|
|
10
|
+
* entry is written before the first delivery attempt and cleared only after the
|
|
11
|
+
* cursor advances. A crash in between leaves it on disk, and the next pass
|
|
12
|
+
* retries the same `wakeId`/`issuedAt` — and therefore the same `eventHash` —
|
|
13
|
+
* under a freshly minted JWT.
|
|
14
|
+
*/
|
|
15
|
+
export declare function createFilesystemPublisherOutbox(options: FilesystemPublisherOutboxOptions): PublisherOutbox;
|
|
16
|
+
//# sourceMappingURL=filesystemPublisherOutbox.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"filesystemPublisherOutbox.d.ts","sourceRoot":"","sources":["../../src/filesystemPublisherOutbox.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAe,eAAe,EAAE,MAAM,8BAA8B,CAAA;AAIhF,MAAM,WAAW,gCAAgC;IAC/C,+DAA+D;IAC/D,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CACtB;AAED;;;;;;;;GAQG;AACH,wBAAgB,+BAA+B,CAC7C,OAAO,EAAE,gCAAgC,GACxC,eAAe,CAkCjB"}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { AtomicWakeInbox } from '@xyo-network/event-kit-protocol';
|
|
2
|
+
import type { FilesystemWakeQueue } from './filesystemWakeQueue.ts';
|
|
3
|
+
export interface FilesystemWakeInboxOptions {
|
|
4
|
+
/** Directory owning the `tokens/` and `wakes/` record sets. */
|
|
5
|
+
readonly path: string;
|
|
6
|
+
/** Durable sink receiving each newly admitted wake inside the commit. */
|
|
7
|
+
readonly queue: FilesystemWakeQueue;
|
|
8
|
+
}
|
|
9
|
+
export interface FilesystemWakeInbox extends AtomicWakeInbox {
|
|
10
|
+
/** Deletes token records past `retainJwtUntil`. Returns how many were removed. */
|
|
11
|
+
pruneExpiredTokens: (nowSeconds: number) => Promise<number>;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Crash-safe single-node `AtomicWakeInbox`.
|
|
15
|
+
*
|
|
16
|
+
* Commit order is the correctness property: the wake reaches the durable queue
|
|
17
|
+
* **before** either identity record is written. A crash in the gap redelivers
|
|
18
|
+
* the wake, which the idempotent consumer absorbs; the reverse order could
|
|
19
|
+
* claim `wakeId` for a wake that was never queued and wedge it permanently.
|
|
20
|
+
* D-002 §6 forbids burning a valid `jti` ahead of a fallible enqueue.
|
|
21
|
+
*
|
|
22
|
+
* Identity records are published by `link`, which fails rather than overwrites,
|
|
23
|
+
* so a concurrent writer loses the race and re-reads the winner's receipt.
|
|
24
|
+
*
|
|
25
|
+
* This survives process death, not host loss, and does not coordinate across
|
|
26
|
+
* machines. A multi-node deployment needs a transactional store or a cloud
|
|
27
|
+
* queue with native deduplication.
|
|
28
|
+
*/
|
|
29
|
+
export declare function createFilesystemWakeInbox(options: FilesystemWakeInboxOptions): FilesystemWakeInbox;
|
|
30
|
+
//# sourceMappingURL=filesystemWakeInbox.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"filesystemWakeInbox.d.ts","sourceRoot":"","sources":["../../src/filesystemWakeInbox.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAEV,eAAe,EAKhB,MAAM,iCAAiC,CAAA;AAExC,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAA;AAGnE,MAAM,WAAW,0BAA0B;IACzC,+DAA+D;IAC/D,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,yEAAyE;IACzE,QAAQ,CAAC,KAAK,EAAE,mBAAmB,CAAA;CACpC;AAED,MAAM,WAAW,mBAAoB,SAAQ,eAAe;IAC1D,kFAAkF;IAClF,kBAAkB,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAA;CAC5D;AAkBD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,yBAAyB,CACvC,OAAO,EAAE,0BAA0B,GAClC,mBAAmB,CAwHrB"}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { WakeQueue } from '@xyo-network/event-kit-protocol';
|
|
2
|
+
export interface FilesystemWakeQueueOptions {
|
|
3
|
+
/** Directory owning the `pending/` and `inflight/` entry sets. */
|
|
4
|
+
readonly path: string;
|
|
5
|
+
}
|
|
6
|
+
export type FilesystemWakeQueue = WakeQueue;
|
|
7
|
+
/**
|
|
8
|
+
* Directory-backed at-least-once wake queue.
|
|
9
|
+
*
|
|
10
|
+
* Entries are published by `link`, never `rename`. `rename` is unsound as a
|
|
11
|
+
* claim: when two callers race the same source, both can observe success once
|
|
12
|
+
* the paths resolve to one inode, and the entry is then handed out twice. `link`
|
|
13
|
+
* fails with `EEXIST`, so exactly one claimer wins.
|
|
14
|
+
*
|
|
15
|
+
* A claim is `link(pending -> inflight)` followed by `unlink(pending)`. A crash
|
|
16
|
+
* between the two leaves the entry in both sets as one inode; recovery drops the
|
|
17
|
+
* inflight copy and the entry stays pending. A consumer that dies mid-handler
|
|
18
|
+
* leaves its entry inflight for `recoverInflight` rather than dropping it.
|
|
19
|
+
*
|
|
20
|
+
* Ordering is approximate FIFO by entry name. Single-node only: this survives
|
|
21
|
+
* process death, not host loss, and does not coordinate across machines.
|
|
22
|
+
*/
|
|
23
|
+
export declare function createFilesystemWakeQueue(options: FilesystemWakeQueueOptions): FilesystemWakeQueue;
|
|
24
|
+
/** Writes and fsyncs `body` so the bytes survive a crash before the caller publishes it. */
|
|
25
|
+
export declare function writeDurableFile(path: string, body: string): Promise<void>;
|
|
26
|
+
/**
|
|
27
|
+
* Best-effort directory fsync so a publish survives power loss. Some platforms
|
|
28
|
+
* and filesystems reject opening a directory for sync; the link itself is still
|
|
29
|
+
* atomic, so a rejection is not fatal.
|
|
30
|
+
*/
|
|
31
|
+
export declare function syncDirectory(path: string): Promise<void>;
|
|
32
|
+
/** Reads and parses a JSON file, treating an absent or unreadable file as empty. */
|
|
33
|
+
export declare function readJsonFile<T>(path: string): Promise<T | undefined>;
|
|
34
|
+
//# sourceMappingURL=filesystemWakeQueue.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"filesystemWakeQueue.d.ts","sourceRoot":"","sources":["../../src/filesystemWakeQueue.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAGV,SAAS,EACV,MAAM,iCAAiC,CAAA;AAExC,MAAM,WAAW,0BAA0B;IACzC,kEAAkE;IAClE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CACtB;AAED,MAAM,MAAM,mBAAmB,GAAG,SAAS,CAAA;AAE3C;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,yBAAyB,CACvC,OAAO,EAAE,0BAA0B,GAClC,mBAAmB,CAwFrB;AAED,4FAA4F;AAC5F,wBAAsB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAQhF;AAED;;;;GAIG;AACH,wBAAsB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAU/D;AAED,oFAAoF;AACpF,wBAAsB,YAAY,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,GAAG,SAAS,CAAC,CAY1E"}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type { FilesystemCursorStoreOptions } from './filesystemCursorStore.ts';
|
|
2
|
+
export { createFilesystemCursorStore } from './filesystemCursorStore.ts';
|
|
3
|
+
export type { FilesystemPublisherOutboxOptions } from './filesystemPublisherOutbox.ts';
|
|
4
|
+
export { createFilesystemPublisherOutbox } from './filesystemPublisherOutbox.ts';
|
|
5
|
+
export type { FilesystemWakeInbox, FilesystemWakeInboxOptions, } from './filesystemWakeInbox.ts';
|
|
6
|
+
export { createFilesystemWakeInbox } from './filesystemWakeInbox.ts';
|
|
7
|
+
export type { FilesystemWakeQueue, FilesystemWakeQueueOptions, } from './filesystemWakeQueue.ts';
|
|
8
|
+
export { createFilesystemWakeQueue } from './filesystemWakeQueue.ts';
|
|
9
|
+
export type { SqliteWakeQueue, SqliteWakeQueueOptions, } from './sqliteWakeQueue.ts';
|
|
10
|
+
export { createSqliteWakeQueue } from './sqliteWakeQueue.ts';
|
|
11
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,4BAA4B,EAAE,MAAM,4BAA4B,CAAA;AAC9E,OAAO,EAAE,2BAA2B,EAAE,MAAM,4BAA4B,CAAA;AACxE,YAAY,EAAE,gCAAgC,EAAE,MAAM,gCAAgC,CAAA;AACtF,OAAO,EAAE,+BAA+B,EAAE,MAAM,gCAAgC,CAAA;AAChF,YAAY,EACV,mBAAmB,EACnB,0BAA0B,GAC3B,MAAM,0BAA0B,CAAA;AACjC,OAAO,EAAE,yBAAyB,EAAE,MAAM,0BAA0B,CAAA;AACpE,YAAY,EACV,mBAAmB,EACnB,0BAA0B,GAC3B,MAAM,0BAA0B,CAAA;AACjC,OAAO,EAAE,yBAAyB,EAAE,MAAM,0BAA0B,CAAA;AACpE,YAAY,EACV,eAAe,EACf,sBAAsB,GACvB,MAAM,sBAAsB,CAAA;AAC7B,OAAO,EAAE,qBAAqB,EAAE,MAAM,sBAAsB,CAAA"}
|
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
// src/filesystemCursorStore.ts
|
|
2
|
+
import {
|
|
3
|
+
mkdir,
|
|
4
|
+
readFile,
|
|
5
|
+
writeFile
|
|
6
|
+
} from "node:fs/promises";
|
|
7
|
+
import PATH from "node:path";
|
|
8
|
+
function createFilesystemCursorStore(options) {
|
|
9
|
+
const filePath = PATH.resolve(options.path);
|
|
10
|
+
return {
|
|
11
|
+
async load() {
|
|
12
|
+
try {
|
|
13
|
+
const raw = await readFile(filePath, "utf8");
|
|
14
|
+
const parsed = JSON.parse(raw);
|
|
15
|
+
if (typeof parsed.lastProcessedBlock !== "number" || !Number.isFinite(parsed.lastProcessedBlock)) {
|
|
16
|
+
return void 0;
|
|
17
|
+
}
|
|
18
|
+
return parsed.lastProcessedBlock;
|
|
19
|
+
} catch (error) {
|
|
20
|
+
if (isNotFound(error)) return void 0;
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
async save(lastProcessedBlock) {
|
|
25
|
+
await mkdir(PATH.dirname(filePath), { recursive: true });
|
|
26
|
+
const body = { lastProcessedBlock };
|
|
27
|
+
await writeFile(filePath, `${JSON.stringify(body)}
|
|
28
|
+
`, "utf8");
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function isNotFound(error) {
|
|
33
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// src/filesystemPublisherOutbox.ts
|
|
37
|
+
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
38
|
+
import {
|
|
39
|
+
mkdir as mkdir3,
|
|
40
|
+
rename,
|
|
41
|
+
unlink as unlink2
|
|
42
|
+
} from "node:fs/promises";
|
|
43
|
+
import PATH3 from "node:path";
|
|
44
|
+
|
|
45
|
+
// src/filesystemWakeQueue.ts
|
|
46
|
+
import { randomBytes } from "node:crypto";
|
|
47
|
+
import {
|
|
48
|
+
link,
|
|
49
|
+
mkdir as mkdir2,
|
|
50
|
+
open,
|
|
51
|
+
readdir,
|
|
52
|
+
unlink
|
|
53
|
+
} from "node:fs/promises";
|
|
54
|
+
import PATH2 from "node:path";
|
|
55
|
+
function createFilesystemWakeQueue(options) {
|
|
56
|
+
const root = PATH2.resolve(options.path);
|
|
57
|
+
const pendingDir = PATH2.join(root, "pending");
|
|
58
|
+
const inflightDir = PATH2.join(root, "inflight");
|
|
59
|
+
const tempDir = PATH2.join(root, "tmp");
|
|
60
|
+
let sequence = 0;
|
|
61
|
+
async function ensureDirs() {
|
|
62
|
+
await mkdir2(pendingDir, { recursive: true });
|
|
63
|
+
await mkdir2(inflightDir, { recursive: true });
|
|
64
|
+
await mkdir2(tempDir, { recursive: true });
|
|
65
|
+
}
|
|
66
|
+
async function returnToPending(name) {
|
|
67
|
+
try {
|
|
68
|
+
await link(PATH2.join(inflightDir, name), PATH2.join(pendingDir, name));
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (!isAlreadyExists(error) && !isNotFound2(error)) throw error;
|
|
71
|
+
}
|
|
72
|
+
await unlink(PATH2.join(inflightDir, name)).catch(ignoreNotFound);
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
async append(wake) {
|
|
76
|
+
await ensureDirs();
|
|
77
|
+
const name = entryName(wake, sequence += 1);
|
|
78
|
+
const tempPath = PATH2.join(tempDir, name);
|
|
79
|
+
await writeDurableFile(tempPath, `${JSON.stringify(wake)}
|
|
80
|
+
`);
|
|
81
|
+
try {
|
|
82
|
+
await link(tempPath, PATH2.join(pendingDir, name));
|
|
83
|
+
await syncDirectory(pendingDir);
|
|
84
|
+
} finally {
|
|
85
|
+
await unlink(tempPath).catch(ignoreNotFound);
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
async claim() {
|
|
89
|
+
await ensureDirs();
|
|
90
|
+
const names = (await readdir(pendingDir)).sort();
|
|
91
|
+
for (const name of names) {
|
|
92
|
+
const inflightPath = PATH2.join(inflightDir, name);
|
|
93
|
+
const pendingPath = PATH2.join(pendingDir, name);
|
|
94
|
+
try {
|
|
95
|
+
await link(pendingPath, inflightPath);
|
|
96
|
+
} catch (error) {
|
|
97
|
+
if (isAlreadyExists(error) || isNotFound2(error)) continue;
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
await unlink(pendingPath).catch(ignoreNotFound);
|
|
101
|
+
const wake = await readJsonFile(inflightPath);
|
|
102
|
+
if (wake === void 0) {
|
|
103
|
+
await unlink(inflightPath).catch(ignoreNotFound);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
wake,
|
|
108
|
+
async ack() {
|
|
109
|
+
await unlink(inflightPath).catch(ignoreNotFound);
|
|
110
|
+
},
|
|
111
|
+
async release() {
|
|
112
|
+
await returnToPending(name);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
return void 0;
|
|
117
|
+
},
|
|
118
|
+
async pendingCount() {
|
|
119
|
+
await ensureDirs();
|
|
120
|
+
return (await readdir(pendingDir)).length;
|
|
121
|
+
},
|
|
122
|
+
async recoverInflight() {
|
|
123
|
+
await ensureDirs();
|
|
124
|
+
const names = await readdir(inflightDir);
|
|
125
|
+
let recovered = 0;
|
|
126
|
+
for (const name of names) {
|
|
127
|
+
await returnToPending(name);
|
|
128
|
+
recovered += 1;
|
|
129
|
+
}
|
|
130
|
+
return recovered;
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
async function writeDurableFile(path, body) {
|
|
135
|
+
const handle = await open(path, "w");
|
|
136
|
+
try {
|
|
137
|
+
await handle.writeFile(body, "utf8");
|
|
138
|
+
await handle.sync();
|
|
139
|
+
} finally {
|
|
140
|
+
await handle.close();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
async function syncDirectory(path) {
|
|
144
|
+
let handle;
|
|
145
|
+
try {
|
|
146
|
+
handle = await open(path, "r");
|
|
147
|
+
await handle.sync();
|
|
148
|
+
} catch {
|
|
149
|
+
return;
|
|
150
|
+
} finally {
|
|
151
|
+
await handle?.close();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
async function readJsonFile(path) {
|
|
155
|
+
try {
|
|
156
|
+
const handle = await open(path, "r");
|
|
157
|
+
try {
|
|
158
|
+
const raw = await handle.readFile("utf8");
|
|
159
|
+
return JSON.parse(raw);
|
|
160
|
+
} finally {
|
|
161
|
+
await handle.close();
|
|
162
|
+
}
|
|
163
|
+
} catch {
|
|
164
|
+
return void 0;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
function entryName(wake, sequence) {
|
|
168
|
+
const stamp = String(Date.now()).padStart(15, "0");
|
|
169
|
+
const ordinal = String(sequence).padStart(6, "0");
|
|
170
|
+
return `${stamp}-${ordinal}-${randomBytes(6).toString("hex")}-${wake.wakeId}.json`;
|
|
171
|
+
}
|
|
172
|
+
function ignoreNotFound(error) {
|
|
173
|
+
if (!isNotFound2(error)) throw error;
|
|
174
|
+
}
|
|
175
|
+
function isNotFound2(error) {
|
|
176
|
+
return errorCode(error) === "ENOENT";
|
|
177
|
+
}
|
|
178
|
+
function isAlreadyExists(error) {
|
|
179
|
+
return errorCode(error) === "EEXIST";
|
|
180
|
+
}
|
|
181
|
+
function errorCode(error) {
|
|
182
|
+
return typeof error === "object" && error !== null && "code" in error ? error.code : void 0;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// src/filesystemPublisherOutbox.ts
|
|
186
|
+
function createFilesystemPublisherOutbox(options) {
|
|
187
|
+
const filePath = PATH3.resolve(options.path);
|
|
188
|
+
return {
|
|
189
|
+
async clear() {
|
|
190
|
+
try {
|
|
191
|
+
await unlink2(filePath);
|
|
192
|
+
} catch (error) {
|
|
193
|
+
if (!isNotFound3(error)) throw error;
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
async load() {
|
|
197
|
+
return await readJsonFile(filePath);
|
|
198
|
+
},
|
|
199
|
+
async save(pending) {
|
|
200
|
+
await mkdir3(PATH3.dirname(filePath), { recursive: true });
|
|
201
|
+
const tempPath = `${filePath}.${process.pid}.${randomBytes2(6).toString("hex")}.tmp`;
|
|
202
|
+
await writeDurableFile(tempPath, `${JSON.stringify(pending)}
|
|
203
|
+
`);
|
|
204
|
+
try {
|
|
205
|
+
await rename(tempPath, filePath);
|
|
206
|
+
} catch (error) {
|
|
207
|
+
try {
|
|
208
|
+
await unlink2(tempPath);
|
|
209
|
+
} catch {
|
|
210
|
+
}
|
|
211
|
+
throw error;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
function isNotFound3(error) {
|
|
217
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// src/filesystemWakeInbox.ts
|
|
221
|
+
import { createHash, randomBytes as randomBytes3 } from "node:crypto";
|
|
222
|
+
import {
|
|
223
|
+
link as link2,
|
|
224
|
+
mkdir as mkdir4,
|
|
225
|
+
open as open2,
|
|
226
|
+
readdir as readdir2,
|
|
227
|
+
unlink as unlink3
|
|
228
|
+
} from "node:fs/promises";
|
|
229
|
+
import PATH4 from "node:path";
|
|
230
|
+
function createFilesystemWakeInbox(options) {
|
|
231
|
+
const root = PATH4.resolve(options.path);
|
|
232
|
+
const tokensDir = PATH4.join(root, "tokens");
|
|
233
|
+
const wakesDir = PATH4.join(root, "wakes");
|
|
234
|
+
const tempDir = PATH4.join(root, "tmp");
|
|
235
|
+
let tail = Promise.resolve();
|
|
236
|
+
function exclusive(work) {
|
|
237
|
+
const run = tail.then(work, work);
|
|
238
|
+
tail = Promise.allSettled([run]);
|
|
239
|
+
return run;
|
|
240
|
+
}
|
|
241
|
+
async function ensureDirs() {
|
|
242
|
+
await mkdir4(tokensDir, { recursive: true });
|
|
243
|
+
await mkdir4(wakesDir, { recursive: true });
|
|
244
|
+
await mkdir4(tempDir, { recursive: true });
|
|
245
|
+
}
|
|
246
|
+
function tokenPath(attempt) {
|
|
247
|
+
return PATH4.join(tokensDir, `${digest([attempt.audience, attempt.publisher, attempt.jwtId])}.json`);
|
|
248
|
+
}
|
|
249
|
+
function wakePath(attempt) {
|
|
250
|
+
return PATH4.join(wakesDir, `${digest([attempt.audience, attempt.publisher, attempt.wakeId])}.json`);
|
|
251
|
+
}
|
|
252
|
+
async function bindToken(attempt, wake) {
|
|
253
|
+
const record = {
|
|
254
|
+
...wake,
|
|
255
|
+
jwtId: attempt.jwtId,
|
|
256
|
+
retainJwtUntil: attempt.retainJwtUntil
|
|
257
|
+
};
|
|
258
|
+
await writeRecordExclusive(tempDir, tokenPath(attempt), record);
|
|
259
|
+
}
|
|
260
|
+
async function matchRecorded(attempt) {
|
|
261
|
+
const token = await readRecord(tokenPath(attempt));
|
|
262
|
+
if (token !== void 0) {
|
|
263
|
+
return token.eventHash === attempt.eventHash && token.wakeId === attempt.wakeId ? { kind: "duplicate", receipt: token.receipt } : { kind: "jwt-id-collision" };
|
|
264
|
+
}
|
|
265
|
+
const wake = await readRecord(wakePath(attempt));
|
|
266
|
+
if (wake === void 0) return void 0;
|
|
267
|
+
if (wake.eventHash !== attempt.eventHash) return { kind: "wake-id-collision" };
|
|
268
|
+
await bindToken(attempt, wake);
|
|
269
|
+
return { kind: "duplicate", receipt: wake.receipt };
|
|
270
|
+
}
|
|
271
|
+
return {
|
|
272
|
+
async resolveAttempt(attempt) {
|
|
273
|
+
return await exclusive(async () => {
|
|
274
|
+
await ensureDirs();
|
|
275
|
+
return await matchRecorded(attempt) ?? { kind: "new" };
|
|
276
|
+
});
|
|
277
|
+
},
|
|
278
|
+
async commitAuthorized({ attempt, wake }) {
|
|
279
|
+
return await exclusive(async () => {
|
|
280
|
+
await ensureDirs();
|
|
281
|
+
const recorded = await matchRecorded(attempt);
|
|
282
|
+
if (recorded !== void 0) return recorded;
|
|
283
|
+
const receipt = Object.freeze({
|
|
284
|
+
audience: attempt.audience,
|
|
285
|
+
eventHash: attempt.eventHash,
|
|
286
|
+
id: digest([attempt.audience, attempt.publisher, attempt.wakeId]),
|
|
287
|
+
publisher: attempt.publisher,
|
|
288
|
+
wakeId: attempt.wakeId
|
|
289
|
+
});
|
|
290
|
+
const record = {
|
|
291
|
+
audience: attempt.audience,
|
|
292
|
+
eventHash: attempt.eventHash,
|
|
293
|
+
publisher: attempt.publisher,
|
|
294
|
+
receipt,
|
|
295
|
+
wakeId: attempt.wakeId
|
|
296
|
+
};
|
|
297
|
+
await options.queue.append(wake);
|
|
298
|
+
const claimed = await writeRecordExclusive(tempDir, wakePath(attempt), record);
|
|
299
|
+
if (!claimed) {
|
|
300
|
+
const winner = await readRecord(wakePath(attempt));
|
|
301
|
+
if (winner === void 0) throw new Error("wake record vanished during commit");
|
|
302
|
+
if (winner.eventHash !== attempt.eventHash) return { kind: "wake-id-collision" };
|
|
303
|
+
await bindToken(attempt, winner);
|
|
304
|
+
return { kind: "duplicate", receipt: winner.receipt };
|
|
305
|
+
}
|
|
306
|
+
await bindToken(attempt, record);
|
|
307
|
+
return { kind: "enqueued", receipt };
|
|
308
|
+
});
|
|
309
|
+
},
|
|
310
|
+
async pruneExpiredTokens(nowSeconds) {
|
|
311
|
+
await ensureDirs();
|
|
312
|
+
let removed = 0;
|
|
313
|
+
for (const name of await readdir2(tokensDir)) {
|
|
314
|
+
const path = PATH4.join(tokensDir, name);
|
|
315
|
+
const token = await readRecord(path);
|
|
316
|
+
if (token === void 0 || token.retainJwtUntil > nowSeconds) continue;
|
|
317
|
+
await unlink3(path).catch(ignoreNotFound2);
|
|
318
|
+
removed += 1;
|
|
319
|
+
}
|
|
320
|
+
return removed;
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
async function writeRecordExclusive(tempDir, path, record) {
|
|
325
|
+
const tempPath = PATH4.join(tempDir, `${PATH4.basename(path)}.${process.pid}.${randomSuffix()}`);
|
|
326
|
+
await writeDurableFile(tempPath, `${JSON.stringify(record)}
|
|
327
|
+
`);
|
|
328
|
+
try {
|
|
329
|
+
await link2(tempPath, path);
|
|
330
|
+
await syncDirectory(PATH4.dirname(path));
|
|
331
|
+
return true;
|
|
332
|
+
} catch (error) {
|
|
333
|
+
if (isAlreadyExists2(error)) return false;
|
|
334
|
+
throw error;
|
|
335
|
+
} finally {
|
|
336
|
+
await unlink3(tempPath).catch(ignoreNotFound2);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
async function readRecord(path) {
|
|
340
|
+
try {
|
|
341
|
+
const handle = await open2(path, "r");
|
|
342
|
+
try {
|
|
343
|
+
const raw = await handle.readFile("utf8");
|
|
344
|
+
return JSON.parse(raw);
|
|
345
|
+
} finally {
|
|
346
|
+
await handle.close();
|
|
347
|
+
}
|
|
348
|
+
} catch (error) {
|
|
349
|
+
if (isNotFound4(error)) return void 0;
|
|
350
|
+
throw error;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
function digest(parts) {
|
|
354
|
+
return createHash("sha256").update(JSON.stringify(parts)).digest("hex");
|
|
355
|
+
}
|
|
356
|
+
function randomSuffix() {
|
|
357
|
+
return randomBytes3(8).toString("hex");
|
|
358
|
+
}
|
|
359
|
+
function ignoreNotFound2(error) {
|
|
360
|
+
if (!isNotFound4(error)) throw error;
|
|
361
|
+
}
|
|
362
|
+
function isNotFound4(error) {
|
|
363
|
+
return errorCode2(error) === "ENOENT";
|
|
364
|
+
}
|
|
365
|
+
function isAlreadyExists2(error) {
|
|
366
|
+
return errorCode2(error) === "EEXIST";
|
|
367
|
+
}
|
|
368
|
+
function errorCode2(error) {
|
|
369
|
+
return typeof error === "object" && error !== null && "code" in error ? error.code : void 0;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// src/sqliteWakeQueue.ts
|
|
373
|
+
import { mkdir as mkdir5 } from "node:fs/promises";
|
|
374
|
+
import PATH5 from "node:path";
|
|
375
|
+
import { DatabaseSync } from "node:sqlite";
|
|
376
|
+
async function createSqliteWakeQueue(options) {
|
|
377
|
+
const inMemory = options.path === ":memory:";
|
|
378
|
+
const file = inMemory ? options.path : PATH5.resolve(options.path);
|
|
379
|
+
if (!inMemory) await mkdir5(PATH5.dirname(file), { recursive: true });
|
|
380
|
+
const database = new DatabaseSync(file);
|
|
381
|
+
database.exec(`
|
|
382
|
+
PRAGMA journal_mode = WAL;
|
|
383
|
+
PRAGMA synchronous = FULL;
|
|
384
|
+
CREATE TABLE IF NOT EXISTS wake_queue (
|
|
385
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
386
|
+
body TEXT NOT NULL,
|
|
387
|
+
state TEXT NOT NULL DEFAULT 'pending'
|
|
388
|
+
);
|
|
389
|
+
CREATE INDEX IF NOT EXISTS wake_queue_state ON wake_queue (state, id);
|
|
390
|
+
`);
|
|
391
|
+
const insert = database.prepare("INSERT INTO wake_queue (body) VALUES (?)");
|
|
392
|
+
const nextPending = database.prepare(
|
|
393
|
+
"SELECT id, body FROM wake_queue WHERE state = 'pending' ORDER BY id LIMIT 1"
|
|
394
|
+
);
|
|
395
|
+
const lease = database.prepare(
|
|
396
|
+
"UPDATE wake_queue SET state = 'inflight' WHERE id = ? AND state = 'pending'"
|
|
397
|
+
);
|
|
398
|
+
const remove = database.prepare("DELETE FROM wake_queue WHERE id = ?");
|
|
399
|
+
const restore = database.prepare(
|
|
400
|
+
"UPDATE wake_queue SET state = 'pending' WHERE id = ? AND state = 'inflight'"
|
|
401
|
+
);
|
|
402
|
+
const countPending = database.prepare(
|
|
403
|
+
"SELECT COUNT(*) AS total FROM wake_queue WHERE state = 'pending'"
|
|
404
|
+
);
|
|
405
|
+
const recoverAll = database.prepare(
|
|
406
|
+
"UPDATE wake_queue SET state = 'pending' WHERE state = 'inflight'"
|
|
407
|
+
);
|
|
408
|
+
return {
|
|
409
|
+
async append(wake) {
|
|
410
|
+
insert.run(JSON.stringify(wake));
|
|
411
|
+
},
|
|
412
|
+
async claim() {
|
|
413
|
+
for (; ; ) {
|
|
414
|
+
const row = nextPending.get();
|
|
415
|
+
if (row === void 0) return void 0;
|
|
416
|
+
if (lease.run(row.id).changes !== 1) continue;
|
|
417
|
+
return {
|
|
418
|
+
wake: JSON.parse(row.body),
|
|
419
|
+
async ack() {
|
|
420
|
+
remove.run(row.id);
|
|
421
|
+
},
|
|
422
|
+
async release() {
|
|
423
|
+
restore.run(row.id);
|
|
424
|
+
}
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
},
|
|
428
|
+
close() {
|
|
429
|
+
database.close();
|
|
430
|
+
},
|
|
431
|
+
async pendingCount() {
|
|
432
|
+
return Number(countPending.get().total);
|
|
433
|
+
},
|
|
434
|
+
async recoverInflight() {
|
|
435
|
+
return Number(recoverAll.run().changes);
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
export {
|
|
440
|
+
createFilesystemCursorStore,
|
|
441
|
+
createFilesystemPublisherOutbox,
|
|
442
|
+
createFilesystemWakeInbox,
|
|
443
|
+
createFilesystemWakeQueue,
|
|
444
|
+
createSqliteWakeQueue
|
|
445
|
+
};
|
|
446
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../src/filesystemCursorStore.ts", "../../src/filesystemPublisherOutbox.ts", "../../src/filesystemWakeQueue.ts", "../../src/filesystemWakeInbox.ts", "../../src/sqliteWakeQueue.ts"],
|
|
4
|
+
"sourcesContent": ["import {\n mkdir, readFile, writeFile,\n} from 'node:fs/promises'\nimport PATH from 'node:path'\n\nimport type { PublisherCursorStore } from '@xyo-network/event-kit-actor'\n\nexport interface FilesystemCursorStoreOptions {\n /** Absolute or relative path to the cursor JSON file. */\n readonly path: string\n}\n\ninterface CursorFile {\n readonly lastProcessedBlock: number\n}\n\n/**\n * Persists the publisher cursor as `{ \"lastProcessedBlock\": number }` on disk.\n */\nexport function createFilesystemCursorStore(\n options: FilesystemCursorStoreOptions,\n): PublisherCursorStore {\n const filePath = PATH.resolve(options.path)\n return {\n async load(): Promise<number | undefined> {\n try {\n const raw = await readFile(filePath, 'utf8')\n const parsed = JSON.parse(raw) as CursorFile\n if (typeof parsed.lastProcessedBlock !== 'number' || !Number.isFinite(parsed.lastProcessedBlock)) {\n return undefined\n }\n return parsed.lastProcessedBlock\n } catch (error) {\n if (isNotFound(error)) return undefined\n throw error\n }\n },\n async save(lastProcessedBlock: number): Promise<void> {\n await mkdir(PATH.dirname(filePath), { recursive: true })\n const body: CursorFile = { lastProcessedBlock }\n await writeFile(filePath, `${JSON.stringify(body)}\\n`, 'utf8')\n },\n }\n}\n\nfunction isNotFound(error: unknown): boolean {\n return typeof error === 'object'\n && error !== null\n && 'code' in error\n && (error as { code?: unknown }).code === 'ENOENT'\n}\n", "import { randomBytes } from 'node:crypto'\nimport {\n mkdir, rename, unlink,\n} from 'node:fs/promises'\nimport PATH from 'node:path'\n\nimport type { PendingWake, PublisherOutbox } from '@xyo-network/event-kit-actor'\n\nimport { readJsonFile, writeDurableFile } from './filesystemWakeQueue.ts'\n\nexport interface FilesystemPublisherOutboxOptions {\n /** Absolute or relative path to the pending-wake JSON file. */\n readonly path: string\n}\n\n/**\n * Durable single-slot publisher outbox.\n *\n * The publisher emits at most one wake per pass, so one slot is enough: the\n * entry is written before the first delivery attempt and cleared only after the\n * cursor advances. A crash in between leaves it on disk, and the next pass\n * retries the same `wakeId`/`issuedAt` \u2014 and therefore the same `eventHash` \u2014\n * under a freshly minted JWT.\n */\nexport function createFilesystemPublisherOutbox(\n options: FilesystemPublisherOutboxOptions,\n): PublisherOutbox {\n const filePath = PATH.resolve(options.path)\n\n return {\n async clear(): Promise<void> {\n try {\n await unlink(filePath)\n } catch (error) {\n if (!isNotFound(error)) throw error\n }\n },\n\n async load(): Promise<PendingWake | undefined> {\n return await readJsonFile<PendingWake>(filePath)\n },\n\n async save(pending: PendingWake): Promise<void> {\n await mkdir(PATH.dirname(filePath), { recursive: true })\n // Write-then-rename so a crash mid-write cannot leave a partial entry.\n // `rename` is sound here: one writer, and replacing the slot is intended.\n const tempPath = `${filePath}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`\n await writeDurableFile(tempPath, `${JSON.stringify(pending)}\\n`)\n try {\n await rename(tempPath, filePath)\n } catch (error) {\n try {\n await unlink(tempPath)\n } catch {\n // A leftover temp file is harmless; the rename failure is what matters.\n }\n throw error\n }\n },\n }\n}\n\nfunction isNotFound(error: unknown): boolean {\n return typeof error === 'object'\n && error !== null\n && 'code' in error\n && (error as { code?: unknown }).code === 'ENOENT'\n}\n", "import { randomBytes } from 'node:crypto'\nimport {\n link, mkdir, open, readdir, unlink,\n} from 'node:fs/promises'\nimport PATH from 'node:path'\n\nimport type {\n AdmittedWake,\n LeasedWake,\n WakeQueue,\n} from '@xyo-network/event-kit-protocol'\n\nexport interface FilesystemWakeQueueOptions {\n /** Directory owning the `pending/` and `inflight/` entry sets. */\n readonly path: string\n}\n\nexport type FilesystemWakeQueue = WakeQueue\n\n/**\n * Directory-backed at-least-once wake queue.\n *\n * Entries are published by `link`, never `rename`. `rename` is unsound as a\n * claim: when two callers race the same source, both can observe success once\n * the paths resolve to one inode, and the entry is then handed out twice. `link`\n * fails with `EEXIST`, so exactly one claimer wins.\n *\n * A claim is `link(pending -> inflight)` followed by `unlink(pending)`. A crash\n * between the two leaves the entry in both sets as one inode; recovery drops the\n * inflight copy and the entry stays pending. A consumer that dies mid-handler\n * leaves its entry inflight for `recoverInflight` rather than dropping it.\n *\n * Ordering is approximate FIFO by entry name. Single-node only: this survives\n * process death, not host loss, and does not coordinate across machines.\n */\nexport function createFilesystemWakeQueue(\n options: FilesystemWakeQueueOptions,\n): FilesystemWakeQueue {\n const root = PATH.resolve(options.path)\n const pendingDir = PATH.join(root, 'pending')\n const inflightDir = PATH.join(root, 'inflight')\n const tempDir = PATH.join(root, 'tmp')\n let sequence = 0\n\n async function ensureDirs(): Promise<void> {\n await mkdir(pendingDir, { recursive: true })\n await mkdir(inflightDir, { recursive: true })\n await mkdir(tempDir, { recursive: true })\n }\n\n /** Moves one entry back to `pending/`, tolerating an existing half-claimed copy. */\n async function returnToPending(name: string): Promise<void> {\n try {\n await link(PATH.join(inflightDir, name), PATH.join(pendingDir, name))\n } catch (error) {\n // EEXIST means a crash left both links to the same entry; the pending copy wins.\n if (!isAlreadyExists(error) && !isNotFound(error)) throw error\n }\n await unlink(PATH.join(inflightDir, name)).catch(ignoreNotFound)\n }\n\n return {\n async append(wake: AdmittedWake): Promise<void> {\n await ensureDirs()\n const name = entryName(wake, sequence += 1)\n const tempPath = PATH.join(tempDir, name)\n await writeDurableFile(tempPath, `${JSON.stringify(wake)}\\n`)\n try {\n await link(tempPath, PATH.join(pendingDir, name))\n await syncDirectory(pendingDir)\n } finally {\n await unlink(tempPath).catch(ignoreNotFound)\n }\n },\n\n async claim(): Promise<LeasedWake | undefined> {\n await ensureDirs()\n const names = (await readdir(pendingDir)).sort()\n for (const name of names) {\n const inflightPath = PATH.join(inflightDir, name)\n const pendingPath = PATH.join(pendingDir, name)\n try {\n // The link is the claim: a second claimer gets EEXIST and moves on.\n await link(pendingPath, inflightPath)\n } catch (error) {\n if (isAlreadyExists(error) || isNotFound(error)) continue\n throw error\n }\n await unlink(pendingPath).catch(ignoreNotFound)\n\n const wake = await readJsonFile<AdmittedWake>(inflightPath)\n if (wake === undefined) {\n await unlink(inflightPath).catch(ignoreNotFound)\n continue\n }\n\n return {\n wake,\n async ack(): Promise<void> {\n await unlink(inflightPath).catch(ignoreNotFound)\n },\n async release(): Promise<void> {\n await returnToPending(name)\n },\n }\n }\n return undefined\n },\n\n async pendingCount(): Promise<number> {\n await ensureDirs()\n return (await readdir(pendingDir)).length\n },\n\n async recoverInflight(): Promise<number> {\n await ensureDirs()\n const names = await readdir(inflightDir)\n let recovered = 0\n for (const name of names) {\n await returnToPending(name)\n recovered += 1\n }\n return recovered\n },\n }\n}\n\n/** Writes and fsyncs `body` so the bytes survive a crash before the caller publishes it. */\nexport async function writeDurableFile(path: string, body: string): Promise<void> {\n const handle = await open(path, 'w')\n try {\n await handle.writeFile(body, 'utf8')\n await handle.sync()\n } finally {\n await handle.close()\n }\n}\n\n/**\n * Best-effort directory fsync so a publish survives power loss. Some platforms\n * and filesystems reject opening a directory for sync; the link itself is still\n * atomic, so a rejection is not fatal.\n */\nexport async function syncDirectory(path: string): Promise<void> {\n let handle\n try {\n handle = await open(path, 'r')\n await handle.sync()\n } catch {\n return\n } finally {\n await handle?.close()\n }\n}\n\n/** Reads and parses a JSON file, treating an absent or unreadable file as empty. */\nexport async function readJsonFile<T>(path: string): Promise<T | undefined> {\n try {\n const handle = await open(path, 'r')\n try {\n const raw = await handle.readFile('utf8')\n return JSON.parse(raw) as T\n } finally {\n await handle.close()\n }\n } catch {\n return undefined\n }\n}\n\n/** Sorts approximately FIFO; the random suffix keeps concurrent writers from colliding. */\nfunction entryName(wake: AdmittedWake, sequence: number): string {\n const stamp = String(Date.now()).padStart(15, '0')\n const ordinal = String(sequence).padStart(6, '0')\n return `${stamp}-${ordinal}-${randomBytes(6).toString('hex')}-${wake.wakeId}.json`\n}\n\nfunction ignoreNotFound(error: unknown): void {\n if (!isNotFound(error)) throw error\n}\n\nfunction isNotFound(error: unknown): boolean {\n return errorCode(error) === 'ENOENT'\n}\n\nfunction isAlreadyExists(error: unknown): boolean {\n return errorCode(error) === 'EEXIST'\n}\n\nfunction errorCode(error: unknown): unknown {\n return typeof error === 'object' && error !== null && 'code' in error\n ? (error as { code?: unknown }).code\n : undefined\n}\n", "import { createHash, randomBytes } from 'node:crypto'\nimport {\n link, mkdir, open, readdir, unlink,\n} from 'node:fs/promises'\nimport PATH from 'node:path'\n\nimport type {\n AdmittedWake,\n AtomicWakeInbox,\n WakeAdmissionAttempt,\n WakeAdmissionReceipt,\n WakeAttemptResolution,\n WakeCommitResult,\n} from '@xyo-network/event-kit-protocol'\n\nimport type { FilesystemWakeQueue } from './filesystemWakeQueue.ts'\nimport { syncDirectory, writeDurableFile } from './filesystemWakeQueue.ts'\n\nexport interface FilesystemWakeInboxOptions {\n /** Directory owning the `tokens/` and `wakes/` record sets. */\n readonly path: string\n /** Durable sink receiving each newly admitted wake inside the commit. */\n readonly queue: FilesystemWakeQueue\n}\n\nexport interface FilesystemWakeInbox extends AtomicWakeInbox {\n /** Deletes token records past `retainJwtUntil`. Returns how many were removed. */\n pruneExpiredTokens: (nowSeconds: number) => Promise<number>\n}\n\ninterface WakeRecord {\n readonly audience: string\n readonly eventHash: string\n readonly publisher: string\n readonly receipt: WakeAdmissionReceipt\n readonly wakeId: string\n}\n\ninterface TokenRecord extends WakeRecord {\n readonly jwtId: string\n readonly retainJwtUntil: number\n}\n\n/** The already-recorded outcomes, common to both the resolve and commit unions. */\ntype RecordedResolution = Exclude<WakeAttemptResolution, { readonly kind: 'new' }>\n\n/**\n * Crash-safe single-node `AtomicWakeInbox`.\n *\n * Commit order is the correctness property: the wake reaches the durable queue\n * **before** either identity record is written. A crash in the gap redelivers\n * the wake, which the idempotent consumer absorbs; the reverse order could\n * claim `wakeId` for a wake that was never queued and wedge it permanently.\n * D-002 \u00A76 forbids burning a valid `jti` ahead of a fallible enqueue.\n *\n * Identity records are published by `link`, which fails rather than overwrites,\n * so a concurrent writer loses the race and re-reads the winner's receipt.\n *\n * This survives process death, not host loss, and does not coordinate across\n * machines. A multi-node deployment needs a transactional store or a cloud\n * queue with native deduplication.\n */\nexport function createFilesystemWakeInbox(\n options: FilesystemWakeInboxOptions,\n): FilesystemWakeInbox {\n const root = PATH.resolve(options.path)\n const tokensDir = PATH.join(root, 'tokens')\n const wakesDir = PATH.join(root, 'wakes')\n const tempDir = PATH.join(root, 'tmp')\n let tail: Promise<unknown> = Promise.resolve()\n\n /**\n * Serializes this process's critical section. Cross-process safety comes from\n * the exclusive record creates; this keeps one process from racing itself.\n */\n function exclusive<T>(work: () => Promise<T>): Promise<T> {\n const run = tail.then(work, work)\n tail = Promise.allSettled([run])\n return run\n }\n\n async function ensureDirs(): Promise<void> {\n await mkdir(tokensDir, { recursive: true })\n await mkdir(wakesDir, { recursive: true })\n await mkdir(tempDir, { recursive: true })\n }\n\n function tokenPath(attempt: WakeAdmissionAttempt): string {\n return PATH.join(tokensDir, `${digest([attempt.audience, attempt.publisher, attempt.jwtId])}.json`)\n }\n\n function wakePath(attempt: WakeAdmissionAttempt): string {\n return PATH.join(wakesDir, `${digest([attempt.audience, attempt.publisher, attempt.wakeId])}.json`)\n }\n\n /** Binds `attempt`'s token to an already-committed wake receipt. */\n async function bindToken(attempt: WakeAdmissionAttempt, wake: WakeRecord): Promise<void> {\n const record: TokenRecord = {\n ...wake,\n jwtId: attempt.jwtId,\n retainJwtUntil: attempt.retainJwtUntil,\n }\n await writeRecordExclusive(tempDir, tokenPath(attempt), record)\n }\n\n async function matchRecorded(attempt: WakeAdmissionAttempt): Promise<RecordedResolution | undefined> {\n const token = await readRecord<TokenRecord>(tokenPath(attempt))\n if (token !== undefined) {\n return token.eventHash === attempt.eventHash && token.wakeId === attempt.wakeId\n ? { kind: 'duplicate', receipt: token.receipt }\n : { kind: 'jwt-id-collision' }\n }\n\n const wake = await readRecord<WakeRecord>(wakePath(attempt))\n if (wake === undefined) return undefined\n if (wake.eventHash !== attempt.eventHash) return { kind: 'wake-id-collision' }\n await bindToken(attempt, wake)\n return { kind: 'duplicate', receipt: wake.receipt }\n }\n\n return {\n async resolveAttempt(attempt: WakeAdmissionAttempt): Promise<WakeAttemptResolution> {\n return await exclusive(async () => {\n await ensureDirs()\n return await matchRecorded(attempt) ?? { kind: 'new' }\n })\n },\n\n async commitAuthorized({ attempt, wake }: {\n readonly attempt: WakeAdmissionAttempt\n readonly wake: AdmittedWake\n }): Promise<WakeCommitResult> {\n return await exclusive(async () => {\n await ensureDirs()\n const recorded = await matchRecorded(attempt)\n if (recorded !== undefined) return recorded\n\n const receipt: WakeAdmissionReceipt = Object.freeze({\n audience: attempt.audience,\n eventHash: attempt.eventHash,\n id: digest([attempt.audience, attempt.publisher, attempt.wakeId]),\n publisher: attempt.publisher,\n wakeId: attempt.wakeId,\n })\n const record: WakeRecord = {\n audience: attempt.audience,\n eventHash: attempt.eventHash,\n publisher: attempt.publisher,\n receipt,\n wakeId: attempt.wakeId,\n }\n\n // Durable queue first: a crash after this point redelivers, which the\n // consumer deduplicates. Claiming the identity first could wedge the wake.\n await options.queue.append(wake)\n\n const claimed = await writeRecordExclusive(tempDir, wakePath(attempt), record)\n if (!claimed) {\n // Another writer committed this wakeId between our check and our write.\n const winner = await readRecord<WakeRecord>(wakePath(attempt))\n if (winner === undefined) throw new Error('wake record vanished during commit')\n if (winner.eventHash !== attempt.eventHash) return { kind: 'wake-id-collision' }\n await bindToken(attempt, winner)\n return { kind: 'duplicate', receipt: winner.receipt }\n }\n\n await bindToken(attempt, record)\n return { kind: 'enqueued', receipt }\n })\n },\n\n async pruneExpiredTokens(nowSeconds: number): Promise<number> {\n await ensureDirs()\n let removed = 0\n for (const name of await readdir(tokensDir)) {\n const path = PATH.join(tokensDir, name)\n const token = await readRecord<TokenRecord>(path)\n if (token === undefined || token.retainJwtUntil > nowSeconds) continue\n await unlink(path).catch(ignoreNotFound)\n removed += 1\n }\n return removed\n },\n }\n}\n\n/**\n * Atomically publishes a record. Returns `false` when another writer already\n * claimed the path, which is how a cross-process race is detected.\n *\n * The content is written and fsynced to a temp file first, then `link`ed into\n * place: `link` fails when the destination exists (unlike `rename`, which would\n * overwrite the winner) and publishes only complete bytes, so a concurrent\n * reader can never observe a half-written record.\n */\nasync function writeRecordExclusive(tempDir: string, path: string, record: unknown): Promise<boolean> {\n const tempPath = PATH.join(tempDir, `${PATH.basename(path)}.${process.pid}.${randomSuffix()}`)\n await writeDurableFile(tempPath, `${JSON.stringify(record)}\\n`)\n try {\n await link(tempPath, path)\n await syncDirectory(PATH.dirname(path))\n return true\n } catch (error) {\n if (isAlreadyExists(error)) return false\n throw error\n } finally {\n await unlink(tempPath).catch(ignoreNotFound)\n }\n}\n\nasync function readRecord<T>(path: string): Promise<T | undefined> {\n try {\n const handle = await open(path, 'r')\n try {\n const raw = await handle.readFile('utf8')\n return JSON.parse(raw) as T\n } finally {\n await handle.close()\n }\n } catch (error) {\n if (isNotFound(error)) return undefined\n throw error\n }\n}\n\nfunction digest(parts: readonly string[]): string {\n return createHash('sha256').update(JSON.stringify(parts)).digest('hex')\n}\n\nfunction randomSuffix(): string {\n return randomBytes(8).toString('hex')\n}\n\nfunction ignoreNotFound(error: unknown): void {\n if (!isNotFound(error)) throw error\n}\n\nfunction isNotFound(error: unknown): boolean {\n return errorCode(error) === 'ENOENT'\n}\n\nfunction isAlreadyExists(error: unknown): boolean {\n return errorCode(error) === 'EEXIST'\n}\n\nfunction errorCode(error: unknown): unknown {\n return typeof error === 'object' && error !== null && 'code' in error\n ? (error as { code?: unknown }).code\n : undefined\n}\n", "import { mkdir } from 'node:fs/promises'\nimport PATH from 'node:path'\nimport { DatabaseSync } from 'node:sqlite'\n\nimport type {\n AdmittedWake,\n LeasedWake,\n WakeQueue,\n} from '@xyo-network/event-kit-protocol'\n\nexport interface SqliteWakeQueueOptions {\n /** Database file path, or `:memory:` for a throwaway store. */\n readonly path: string\n}\n\nexport interface SqliteWakeQueue extends WakeQueue {\n /** Releases the underlying handle. */\n close: () => void\n}\n\ninterface QueueRow {\n readonly body: string\n readonly id: number\n}\n\n/**\n * SQLite-backed `WakeQueue`.\n *\n * Every state change is a single statement under WAL, so a claim cannot be\n * observed half-applied and two callers cannot lease one row: the claim is an\n * `UPDATE \u2026 WHERE state = 'pending'` whose affected-row count decides the\n * winner. That is the transactional form of the lease the filesystem provider\n * approximates with `link`.\n *\n * WAL also permits concurrent readers with one writer, so several processes on\n * one host can share the file. It does not coordinate across hosts.\n *\n * Uses Node's built-in `node:sqlite`, which still emits an ExperimentalWarning.\n */\nexport async function createSqliteWakeQueue(\n options: SqliteWakeQueueOptions,\n): Promise<SqliteWakeQueue> {\n const inMemory = options.path === ':memory:'\n const file = inMemory ? options.path : PATH.resolve(options.path)\n if (!inMemory) await mkdir(PATH.dirname(file), { recursive: true })\n\n const database = new DatabaseSync(file)\n database.exec(`\n PRAGMA journal_mode = WAL;\n PRAGMA synchronous = FULL;\n CREATE TABLE IF NOT EXISTS wake_queue (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n body TEXT NOT NULL,\n state TEXT NOT NULL DEFAULT 'pending'\n );\n CREATE INDEX IF NOT EXISTS wake_queue_state ON wake_queue (state, id);\n `)\n\n const insert = database.prepare('INSERT INTO wake_queue (body) VALUES (?)')\n const nextPending = database.prepare(\n 'SELECT id, body FROM wake_queue WHERE state = \\'pending\\' ORDER BY id LIMIT 1',\n )\n // The affected-row count is the lease: a loser updates zero rows and retries.\n const lease = database.prepare(\n 'UPDATE wake_queue SET state = \\'inflight\\' WHERE id = ? AND state = \\'pending\\'',\n )\n const remove = database.prepare('DELETE FROM wake_queue WHERE id = ?')\n const restore = database.prepare(\n 'UPDATE wake_queue SET state = \\'pending\\' WHERE id = ? AND state = \\'inflight\\'',\n )\n const countPending = database.prepare(\n 'SELECT COUNT(*) AS total FROM wake_queue WHERE state = \\'pending\\'',\n )\n const recoverAll = database.prepare(\n 'UPDATE wake_queue SET state = \\'pending\\' WHERE state = \\'inflight\\'',\n )\n\n return {\n async append(wake: AdmittedWake): Promise<void> {\n insert.run(JSON.stringify(wake))\n },\n\n async claim(): Promise<LeasedWake | undefined> {\n for (;;) {\n const row = nextPending.get() as QueueRow | undefined\n if (row === undefined) return undefined\n if (lease.run(row.id).changes !== 1) continue // another claimer won it\n\n return {\n wake: JSON.parse(row.body) as AdmittedWake,\n async ack(): Promise<void> {\n remove.run(row.id)\n },\n async release(): Promise<void> {\n restore.run(row.id)\n },\n }\n }\n },\n\n close(): void {\n database.close()\n },\n\n async pendingCount(): Promise<number> {\n return Number((countPending.get() as { total: number }).total)\n },\n\n async recoverInflight(): Promise<number> {\n return Number(recoverAll.run().changes)\n },\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": ["randomBytes", "mkdir", "unlink", "PATH", "mkdir", "PATH", "isNotFound", "PATH", "unlink", "isNotFound", "mkdir", "randomBytes", "randomBytes", "link", "mkdir", "open", "readdir", "unlink", "PATH", "PATH", "mkdir", "readdir", "unlink", "ignoreNotFound", "link", "isAlreadyExists", "open", "isNotFound", "randomBytes", "errorCode", "mkdir", "PATH"]
|
|
7
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { WakeQueue } from '@xyo-network/event-kit-protocol';
|
|
2
|
+
export interface SqliteWakeQueueOptions {
|
|
3
|
+
/** Database file path, or `:memory:` for a throwaway store. */
|
|
4
|
+
readonly path: string;
|
|
5
|
+
}
|
|
6
|
+
export interface SqliteWakeQueue extends WakeQueue {
|
|
7
|
+
/** Releases the underlying handle. */
|
|
8
|
+
close: () => void;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* SQLite-backed `WakeQueue`.
|
|
12
|
+
*
|
|
13
|
+
* Every state change is a single statement under WAL, so a claim cannot be
|
|
14
|
+
* observed half-applied and two callers cannot lease one row: the claim is an
|
|
15
|
+
* `UPDATE … WHERE state = 'pending'` whose affected-row count decides the
|
|
16
|
+
* winner. That is the transactional form of the lease the filesystem provider
|
|
17
|
+
* approximates with `link`.
|
|
18
|
+
*
|
|
19
|
+
* WAL also permits concurrent readers with one writer, so several processes on
|
|
20
|
+
* one host can share the file. It does not coordinate across hosts.
|
|
21
|
+
*
|
|
22
|
+
* Uses Node's built-in `node:sqlite`, which still emits an ExperimentalWarning.
|
|
23
|
+
*/
|
|
24
|
+
export declare function createSqliteWakeQueue(options: SqliteWakeQueueOptions): Promise<SqliteWakeQueue>;
|
|
25
|
+
//# sourceMappingURL=sqliteWakeQueue.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sqliteWakeQueue.d.ts","sourceRoot":"","sources":["../../src/sqliteWakeQueue.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAGV,SAAS,EACV,MAAM,iCAAiC,CAAA;AAExC,MAAM,WAAW,sBAAsB;IACrC,+DAA+D;IAC/D,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CACtB;AAED,MAAM,WAAW,eAAgB,SAAQ,SAAS;IAChD,sCAAsC;IACtC,KAAK,EAAE,MAAM,IAAI,CAAA;CAClB;AAOD;;;;;;;;;;;;;GAaG;AACH,wBAAsB,qBAAqB,CACzC,OAAO,EAAE,sBAAsB,GAC9B,OAAO,CAAC,eAAe,CAAC,CAuE1B"}
|
package/package.json
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@xyo-network/event-kit-node",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Node adapters for Event Kit (filesystem publisher cursor)",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"xyo",
|
|
7
|
+
"xl1",
|
|
8
|
+
"event-kit",
|
|
9
|
+
"node",
|
|
10
|
+
"typescript"
|
|
11
|
+
],
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/XYOracleNetwork/event-kit/issues"
|
|
14
|
+
},
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/XYOracleNetwork/event-kit.git",
|
|
18
|
+
"directory": "packages/node"
|
|
19
|
+
},
|
|
20
|
+
"license": "UNLICENSED",
|
|
21
|
+
"sideEffects": false,
|
|
22
|
+
"type": "module",
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"types": "./dist/node/index.d.ts",
|
|
26
|
+
"default": "./dist/node/index.mjs"
|
|
27
|
+
},
|
|
28
|
+
"./package.json": "./package.json",
|
|
29
|
+
"./README.md": "./README.md"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist",
|
|
33
|
+
"README.md"
|
|
34
|
+
],
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@xyo-network/event-kit-protocol": "~0.0.1",
|
|
37
|
+
"@xyo-network/event-kit-actor": "~0.0.1"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@ariestools/actor": "~1.3.0",
|
|
41
|
+
"@ariestools/lib-neutral": "~9.1.0",
|
|
42
|
+
"@ariestools/sdk": "~8.1.9",
|
|
43
|
+
"@ariestools/toolchain": "~9.1.0",
|
|
44
|
+
"@ariestools/tsconfig": "~9.1.0",
|
|
45
|
+
"@opentelemetry/api": "~1.9.1",
|
|
46
|
+
"@types/node": "~26.2.0",
|
|
47
|
+
"@xyo-network/sdk": "~7.2.3",
|
|
48
|
+
"@xyo-network/sdk-protocol": "~7.2.4",
|
|
49
|
+
"@xyo-network/xl1-sdk": "~5.3.1",
|
|
50
|
+
"async-mutex": "~0.5.0",
|
|
51
|
+
"eslint": "~10.8.1",
|
|
52
|
+
"eslint-import-resolver-typescript": "~4.4.5",
|
|
53
|
+
"ethers": "~6.17.0",
|
|
54
|
+
"typescript": "~6.0.3",
|
|
55
|
+
"vite": "~8.2.1",
|
|
56
|
+
"vitest": "~4.1.11",
|
|
57
|
+
"zod": "~4.4.3",
|
|
58
|
+
"@xyo-network/event-kit-http": "~0.0.1",
|
|
59
|
+
"@xyo-network/event-kit-schemas": "~0.0.1",
|
|
60
|
+
"@xyo-network/event-kit-testing": "~0.0.1",
|
|
61
|
+
"@xyo-network/event-kit-xl1": "~0.0.1"
|
|
62
|
+
},
|
|
63
|
+
"peerDependencies": {
|
|
64
|
+
"@ariestools/actor": "^1.3",
|
|
65
|
+
"@ariestools/sdk": "^8.1",
|
|
66
|
+
"@opentelemetry/api": "^1.9",
|
|
67
|
+
"@xyo-network/sdk": "^7.2",
|
|
68
|
+
"@xyo-network/sdk-protocol": "^7.2",
|
|
69
|
+
"zod": "^4.4"
|
|
70
|
+
},
|
|
71
|
+
"engines": {
|
|
72
|
+
"node": ">=22"
|
|
73
|
+
},
|
|
74
|
+
"publishConfig": {
|
|
75
|
+
"access": "restricted"
|
|
76
|
+
},
|
|
77
|
+
"scripts": {
|
|
78
|
+
"test": "vitest run"
|
|
79
|
+
}
|
|
80
|
+
}
|