ts-server-lib 0.0.48
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 +1 -0
- package/README.md +8 -0
- package/db/TSJournal.d.ts +108 -0
- package/db/TSJournal.js +229 -0
- package/db/TSMongo.d.ts +103 -0
- package/db/TSMongo.js +516 -0
- package/db/TSRQW.d.ts +625 -0
- package/db/TSRQW.js +1204 -0
- package/db/TSRedis.d.ts +530 -0
- package/db/TSRedis.js +1368 -0
- package/db/TSRedisTB.d.ts +80 -0
- package/db/TSRedisTB.js +178 -0
- package/package.json +85 -0
- package/ussd/TSUssdMenu.d.ts +139 -0
- package/ussd/TSUssdMenu.js +368 -0
- package/ussd/TSUssdScreen.d.ts +58 -0
- package/ussd/TSUssdScreen.js +218 -0
- package/ussd/index.d.ts +3 -0
- package/ussd/index.js +19 -0
- package/ussd/providers/AfricasTalking.d.ts +3 -0
- package/ussd/providers/AfricasTalking.js +17 -0
- package/ussd/providers/AirtelDRC.d.ts +9 -0
- package/ussd/providers/AirtelDRC.js +31 -0
- package/ussd/providers/OrangeDRC.d.ts +5 -0
- package/ussd/providers/OrangeDRC.js +213 -0
- package/ussd/providers/VodacomDRC.d.ts +9 -0
- package/ussd/providers/VodacomDRC.js +48 -0
- package/ussd/providers/_.d.ts +55 -0
- package/ussd/providers/_.js +83 -0
- package/ussd/providers/index.d.ts +13 -0
- package/ussd/providers/index.js +56 -0
- package/utils/TSFifo.d.ts +109 -0
- package/utils/TSFifo.js +145 -0
- package/utils/TSFile.d.ts +36 -0
- package/utils/TSFile.js +244 -0
- package/utils/TSHash.d.ts +19 -0
- package/utils/TSHash.js +71 -0
- package/utils/TSRequest.d.ts +248 -0
- package/utils/TSRequest.js +689 -0
- package/utils/TSStub.d.ts +159 -0
- package/utils/TSStub.js +296 -0
- package/utils/abort.d.ts +18 -0
- package/utils/abort.js +97 -0
- package/utils/mime.json +11358 -0
- package/utils/object-keys.d.ts +39 -0
- package/utils/object-keys.js +52 -0
package/LICENSE
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
This is not free
|
package/README.md
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
export interface JournalEntryBase {
|
|
2
|
+
key: string;
|
|
3
|
+
operation: string;
|
|
4
|
+
/** When the key was first claimed. Expiry is measured from this; it never moves (settle preserves it). */
|
|
5
|
+
at: number;
|
|
6
|
+
}
|
|
7
|
+
/** Sent, outcome unknown. Carries the request verbatim — the only thing that can be replayed. */
|
|
8
|
+
export interface InDoubtEntry<T = unknown> extends JournalEntryBase {
|
|
9
|
+
settled: false;
|
|
10
|
+
request: T;
|
|
11
|
+
}
|
|
12
|
+
/** Decided — and deliberately WITHOUT the request (retaining it makes memory a function of throughput). */
|
|
13
|
+
export interface SettledEntry extends JournalEntryBase {
|
|
14
|
+
settled: true;
|
|
15
|
+
reply: unknown;
|
|
16
|
+
}
|
|
17
|
+
export type JournalEntry<T = unknown> = InDoubtEntry<T> | SettledEntry;
|
|
18
|
+
/**
|
|
19
|
+
* What `claim` needs. `settled` is the journal's to assign; `at` too for a fresh claim — but a DURABLE adapter
|
|
20
|
+
* restoring state on restart may pass the ORIGINAL `at` so expiry keeps measuring from the first claim (not from
|
|
21
|
+
* the replay). Omit it in normal use.
|
|
22
|
+
*/
|
|
23
|
+
export type JournalClaim<T = unknown> = Pick<InDoubtEntry<T>, 'key' | 'operation' | 'request'> & {
|
|
24
|
+
at?: number;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Every method is async so a network-backed store fits without changing a caller. Three writing methods, not four:
|
|
28
|
+
* `claim` maps onto a single conditional write; `settle` records the outcome and drops the request; `discard`
|
|
29
|
+
* forgets a record for a request proven never to have taken effect. `lookup` reads; `inDoubt` lists the unsettled.
|
|
30
|
+
*/
|
|
31
|
+
export interface Journal<T = unknown> {
|
|
32
|
+
/** Record intent + report the prior record if the key was already claimed (settled -> do not act again). */
|
|
33
|
+
claim(entry: JournalClaim<T>): Promise<JournalEntry<T> | null>;
|
|
34
|
+
/** Read a record without creating one. */
|
|
35
|
+
lookup(key: string): Promise<JournalEntry<T> | null>;
|
|
36
|
+
/** Record the outcome once known (drops the retained request). */
|
|
37
|
+
settle(key: string, reply: unknown): Promise<void>;
|
|
38
|
+
/** Forget a record for a request that never reached the downstream (never-sent). */
|
|
39
|
+
discard(key: string): Promise<void>;
|
|
40
|
+
/** Requests begun but never settled — the ones whose fate is unknown. */
|
|
41
|
+
inDoubt(): Promise<InDoubtEntry<T>[]>;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Bounding shared by map-backed journals: entries held in insertion order, `at` never moving after claim, so age
|
|
45
|
+
* rises along that order and both helpers can stop at the first entry that must stay.
|
|
46
|
+
*/
|
|
47
|
+
export declare function dropExpired<T>(entries: Map<string, JournalEntry<T>>, unsettled: Set<string>, cutoff: number): number;
|
|
48
|
+
/** Hold the entry ceiling, oldest first. Returns how many it had to remove. */
|
|
49
|
+
export declare function holdCeiling<T>(entries: Map<string, JournalEntry<T>>, unsettled: Set<string>, maxEntries: number): number;
|
|
50
|
+
export interface TSJournalOptions {
|
|
51
|
+
/** How long a decision stays replayable. */
|
|
52
|
+
ttlMs?: number;
|
|
53
|
+
/** Memory this journal may occupy — the real ceiling; entry count is derived from it. */
|
|
54
|
+
maxBytes?: number;
|
|
55
|
+
/** Entry ceiling, if you would rather set it directly than via `maxBytes`. */
|
|
56
|
+
maxEntries?: number;
|
|
57
|
+
/** Expected requests/second — avoids reserving a window larger than traffic can fill. */
|
|
58
|
+
ratePerSecond?: number;
|
|
59
|
+
/** Time source — injectable for determinism; defaults to the system clock. */
|
|
60
|
+
now?: () => number;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Single-process journal: survives retries within a process, not a restart. All operations are O(1) except
|
|
64
|
+
* `inDoubt` (O(number still open) via the `#unsettled` index — never a full scan) and `#prune` (amortised, stops at
|
|
65
|
+
* the first live entry). Swap in a durable adapter when losing in-doubt records on restart matters.
|
|
66
|
+
*/
|
|
67
|
+
export declare class TSJournal<T = unknown> implements Journal<T> {
|
|
68
|
+
#private;
|
|
69
|
+
constructor(options?: TSJournalOptions | number, legacyMaxEntries?: number);
|
|
70
|
+
/** What the journal is holding; `windowCappedByMemory` warns when the ceiling shortens the retention window. */
|
|
71
|
+
stats(): {
|
|
72
|
+
entries: number;
|
|
73
|
+
inDoubt: number;
|
|
74
|
+
maxEntries: number;
|
|
75
|
+
evicted: number;
|
|
76
|
+
windowMs: number;
|
|
77
|
+
windowCappedByMemory: boolean;
|
|
78
|
+
};
|
|
79
|
+
get size(): {
|
|
80
|
+
entries: number;
|
|
81
|
+
inDoubt: number;
|
|
82
|
+
};
|
|
83
|
+
/**
|
|
84
|
+
* Read-only snapshot of every held entry, in insertion (claim-time) order — for a DURABLE adapter that composes
|
|
85
|
+
* this index and needs to persist/compact it (write the live set to disk/a table). Yields the entries by
|
|
86
|
+
* reference: serialize them, do not mutate them.
|
|
87
|
+
*/
|
|
88
|
+
entries(): IterableIterator<JournalEntry<T>>;
|
|
89
|
+
claim(entry: JournalClaim<T>): Promise<JournalEntry<T> | null>;
|
|
90
|
+
lookup(key: string): Promise<JournalEntry<T> | null>;
|
|
91
|
+
discard(key: string): Promise<void>;
|
|
92
|
+
settle(key: string, reply: unknown): Promise<void>;
|
|
93
|
+
inDoubt(): Promise<InDoubtEntry<T>[]>;
|
|
94
|
+
}
|
|
95
|
+
/** One conformance check result. */
|
|
96
|
+
export interface JournalContractCheck {
|
|
97
|
+
name: string;
|
|
98
|
+
ok: boolean;
|
|
99
|
+
detail: string;
|
|
100
|
+
}
|
|
101
|
+
type JournalFactory<T> = () => Promise<Journal<T>> | Journal<T>;
|
|
102
|
+
/**
|
|
103
|
+
* The contract every `Journal` must satisfy, as runnable checks — ships with the port so an adapter author (Redis, a
|
|
104
|
+
* table, a TSRQW stream) proves conformance without reading the prose. Zero dependencies. Pass a factory that makes a
|
|
105
|
+
* fresh journal and a sample request value.
|
|
106
|
+
*/
|
|
107
|
+
export declare function checkJournalContract<T>(create: JournalFactory<T>, sampleRequest: T): Promise<JournalContractCheck[]>;
|
|
108
|
+
export {};
|
package/db/TSJournal.js
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Canonical idempotency + in-doubt Journal — the domain-neutral generalisation of the pattern proven in the MTS
|
|
3
|
+
// driver. One record of every effect-causing request, read two ways:
|
|
4
|
+
//
|
|
5
|
+
// * already settled -> return the recorded decision, never act again
|
|
6
|
+
// * still in flight -> "in doubt", replay/verify it to find out
|
|
7
|
+
//
|
|
8
|
+
// Replay is safe only when the downstream deduplicates on the request's own id (MTS does on `ticketId`, a payment
|
|
9
|
+
// provider on `orderId`, ...). This module is a PORT with a built-in in-memory implementation and ZERO runtime
|
|
10
|
+
// imports, so nothing here ties the library to a filesystem, a database, or a queue. `TSJournal` covers a
|
|
11
|
+
// single process; anything durable — Redis, a table, a TSRQW stream (see lookupById/claimById) — is an adapter the
|
|
12
|
+
// application injects. `checkJournalContract` lets an adapter author prove conformance without re-reading the prose.
|
|
13
|
+
//
|
|
14
|
+
// `claim` folds "have we seen this?" and "record intent" into ONE conditional write (Redis `SET NX`, a unique-key
|
|
15
|
+
// insert, a conditional put) so it is atomic across processes sharing a store — a read-then-write would race.
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.TSJournal = void 0;
|
|
18
|
+
exports.dropExpired = dropExpired;
|
|
19
|
+
exports.holdCeiling = holdCeiling;
|
|
20
|
+
exports.checkJournalContract = checkJournalContract;
|
|
21
|
+
/**
|
|
22
|
+
* Bounding shared by map-backed journals: entries held in insertion order, `at` never moving after claim, so age
|
|
23
|
+
* rises along that order and both helpers can stop at the first entry that must stay.
|
|
24
|
+
*/
|
|
25
|
+
function dropExpired(entries, unsettled, cutoff) {
|
|
26
|
+
let dropped = 0;
|
|
27
|
+
for (const [key, entry] of entries) {
|
|
28
|
+
if (entry.at >= cutoff)
|
|
29
|
+
return dropped;
|
|
30
|
+
entries.delete(key);
|
|
31
|
+
unsettled.delete(key);
|
|
32
|
+
dropped++;
|
|
33
|
+
}
|
|
34
|
+
return dropped;
|
|
35
|
+
}
|
|
36
|
+
/** Hold the entry ceiling, oldest first. Returns how many it had to remove. */
|
|
37
|
+
function holdCeiling(entries, unsettled, maxEntries) {
|
|
38
|
+
let evicted = 0;
|
|
39
|
+
while (entries.size > maxEntries) {
|
|
40
|
+
const oldest = entries.keys().next();
|
|
41
|
+
if (oldest.done === true)
|
|
42
|
+
break;
|
|
43
|
+
entries.delete(oldest.value);
|
|
44
|
+
unsettled.delete(oldest.value);
|
|
45
|
+
evicted++;
|
|
46
|
+
}
|
|
47
|
+
return evicted;
|
|
48
|
+
}
|
|
49
|
+
/** Bytes assumed per entry when sizing the memory budget (measured ~256B with typical keys). */
|
|
50
|
+
const ESTIMATED_BYTES_PER_ENTRY = 256;
|
|
51
|
+
/**
|
|
52
|
+
* Single-process journal: survives retries within a process, not a restart. All operations are O(1) except
|
|
53
|
+
* `inDoubt` (O(number still open) via the `#unsettled` index — never a full scan) and `#prune` (amortised, stops at
|
|
54
|
+
* the first live entry). Swap in a durable adapter when losing in-doubt records on restart matters.
|
|
55
|
+
*/
|
|
56
|
+
class TSJournal {
|
|
57
|
+
#entries = new Map();
|
|
58
|
+
#unsettled = new Set();
|
|
59
|
+
#ttlMs;
|
|
60
|
+
#maxEntries;
|
|
61
|
+
#evicted = 0;
|
|
62
|
+
#now;
|
|
63
|
+
constructor(options = {}, legacyMaxEntries) {
|
|
64
|
+
const opts = typeof options === 'number'
|
|
65
|
+
? { ttlMs: options, ...(legacyMaxEntries !== undefined ? { maxEntries: legacyMaxEntries } : {}) }
|
|
66
|
+
: options;
|
|
67
|
+
this.#ttlMs = opts.ttlMs ?? 6 * 60 * 60_000;
|
|
68
|
+
this.#now = opts.now ?? (() => Date.now());
|
|
69
|
+
const byBudget = Math.floor((opts.maxBytes ?? 64 * 1024 * 1024) / ESTIMATED_BYTES_PER_ENTRY);
|
|
70
|
+
const byTraffic = opts.ratePerSecond !== undefined && opts.ratePerSecond > 0
|
|
71
|
+
? Math.ceil(opts.ratePerSecond * (this.#ttlMs / 1000))
|
|
72
|
+
: Number.POSITIVE_INFINITY;
|
|
73
|
+
this.#maxEntries = Math.max(1, Math.min(opts.maxEntries ?? Number.POSITIVE_INFINITY, byBudget, byTraffic));
|
|
74
|
+
}
|
|
75
|
+
/** What the journal is holding; `windowCappedByMemory` warns when the ceiling shortens the retention window. */
|
|
76
|
+
stats() {
|
|
77
|
+
const oldest = this.#entries.values().next();
|
|
78
|
+
const windowMs = oldest.done === true ? 0 : this.#now() - oldest.value.at;
|
|
79
|
+
return {
|
|
80
|
+
entries: this.#entries.size,
|
|
81
|
+
inDoubt: this.#unsettled.size,
|
|
82
|
+
maxEntries: this.#maxEntries,
|
|
83
|
+
evicted: this.#evicted,
|
|
84
|
+
windowMs,
|
|
85
|
+
windowCappedByMemory: this.#entries.size >= this.#maxEntries
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
get size() {
|
|
89
|
+
return { entries: this.#entries.size, inDoubt: this.#unsettled.size };
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Read-only snapshot of every held entry, in insertion (claim-time) order — for a DURABLE adapter that composes
|
|
93
|
+
* this index and needs to persist/compact it (write the live set to disk/a table). Yields the entries by
|
|
94
|
+
* reference: serialize them, do not mutate them.
|
|
95
|
+
*/
|
|
96
|
+
entries() {
|
|
97
|
+
return this.#entries.values();
|
|
98
|
+
}
|
|
99
|
+
async claim(entry) {
|
|
100
|
+
this.#prune();
|
|
101
|
+
const previous = this.#entries.get(entry.key);
|
|
102
|
+
if (previous !== undefined)
|
|
103
|
+
return previous;
|
|
104
|
+
// `request` kept by reference (do not mutate a request after claiming it); dropped at `settle`. `at` defaults to
|
|
105
|
+
// now, but a durable adapter may restore the original claim time on replay.
|
|
106
|
+
this.#entries.set(entry.key, { key: entry.key, operation: entry.operation, request: entry.request, settled: false, at: entry.at ?? this.#now() });
|
|
107
|
+
this.#unsettled.add(entry.key);
|
|
108
|
+
this.#enforceCeiling();
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
async lookup(key) {
|
|
112
|
+
return this.#entries.get(key) ?? null;
|
|
113
|
+
}
|
|
114
|
+
async discard(key) {
|
|
115
|
+
this.#entries.delete(key);
|
|
116
|
+
this.#unsettled.delete(key);
|
|
117
|
+
}
|
|
118
|
+
async settle(key, reply) {
|
|
119
|
+
const entry = this.#entries.get(key);
|
|
120
|
+
if (entry === undefined)
|
|
121
|
+
return;
|
|
122
|
+
// Rebuilt field-by-field so `request` is dropped; `at` carried over (expiry measures from first claim).
|
|
123
|
+
this.#entries.set(key, { key: entry.key, operation: entry.operation, at: entry.at, settled: true, reply });
|
|
124
|
+
this.#unsettled.delete(key);
|
|
125
|
+
}
|
|
126
|
+
async inDoubt() {
|
|
127
|
+
const open = [];
|
|
128
|
+
for (const key of this.#unsettled) {
|
|
129
|
+
const entry = this.#entries.get(key);
|
|
130
|
+
if (entry?.settled === false)
|
|
131
|
+
open.push(entry);
|
|
132
|
+
}
|
|
133
|
+
return open;
|
|
134
|
+
}
|
|
135
|
+
#prune() {
|
|
136
|
+
dropExpired(this.#entries, this.#unsettled, this.#now() - this.#ttlMs);
|
|
137
|
+
}
|
|
138
|
+
#enforceCeiling() {
|
|
139
|
+
this.#evicted += holdCeiling(this.#entries, this.#unsettled, this.#maxEntries);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
exports.TSJournal = TSJournal;
|
|
143
|
+
/** claim: first wins (null), a repeat returns the prior unsettled entry, and settle does not move the claim time. */
|
|
144
|
+
async function checkClaimLifecycle(create, entry, rec) {
|
|
145
|
+
const j = await create();
|
|
146
|
+
const first = await j.claim(entry);
|
|
147
|
+
rec('first claim returns null', first === null, `got ${JSON.stringify(first)}`);
|
|
148
|
+
const second = await j.claim(entry);
|
|
149
|
+
rec('second claim returns the prior entry', second?.key === entry.key, `got ${JSON.stringify(second?.key)}`);
|
|
150
|
+
rec('prior entry is unsettled', second?.settled === false, `settled=${String(second?.settled)}`);
|
|
151
|
+
// Age must not move when an entry settles: expiry measures from the first claim, and pruning relies on age rising
|
|
152
|
+
// in insertion order — an adapter that refreshes `at` on settle would extend retention silently.
|
|
153
|
+
const claimedAt = second?.at;
|
|
154
|
+
await j.settle(entry.key, { code: 0 });
|
|
155
|
+
const afterSettle = await j.claim(entry);
|
|
156
|
+
rec('settle preserves the original claim time', afterSettle?.at === claimedAt, `claimed ${String(claimedAt)}, after ${String(afterSettle?.at)}`);
|
|
157
|
+
}
|
|
158
|
+
/** lookup: a read never writes, finds a claimed key, and reflects unsettled → settled. */
|
|
159
|
+
async function checkLookup(create, entry, rec) {
|
|
160
|
+
const j = await create();
|
|
161
|
+
const missing = await j.lookup('op:never');
|
|
162
|
+
rec('lookup of an unknown key returns null', missing === null, `got ${JSON.stringify(missing)}`);
|
|
163
|
+
const openAfterLookup = await j.inDoubt();
|
|
164
|
+
rec('lookup creates nothing', openAfterLookup.every((e) => e.key !== 'op:never'), `${openAfterLookup.length} in doubt after a lookup`);
|
|
165
|
+
await j.claim(entry);
|
|
166
|
+
const found = await j.lookup(entry.key);
|
|
167
|
+
rec('lookup finds a claimed key', found?.key === entry.key, `got ${JSON.stringify(found?.key)}`);
|
|
168
|
+
rec('lookup reports it unsettled', found?.settled === false, `settled=${String(found?.settled)}`);
|
|
169
|
+
await j.settle(entry.key, { code: 0 });
|
|
170
|
+
const decided = await j.lookup(entry.key);
|
|
171
|
+
rec('lookup reports it settled', decided?.settled === true, `settled=${String(decided?.settled)}`);
|
|
172
|
+
}
|
|
173
|
+
/** discard: a never-sent request leaves nothing to recover and is re-claimable. */
|
|
174
|
+
async function checkDiscard(create, entry, rec) {
|
|
175
|
+
const j = await create();
|
|
176
|
+
await j.claim(entry);
|
|
177
|
+
await j.discard(entry.key);
|
|
178
|
+
const gone = await j.lookup(entry.key);
|
|
179
|
+
rec('discard removes the record', gone === null, `got ${JSON.stringify(gone?.key)}`);
|
|
180
|
+
const open = await j.inDoubt();
|
|
181
|
+
rec('a discarded key is not in doubt', open.every((e) => e.key !== entry.key), `${open.length} in doubt`);
|
|
182
|
+
const reclaimed = await j.claim(entry);
|
|
183
|
+
rec('a discarded key can be claimed again', reclaimed === null, `got ${JSON.stringify(reclaimed)}`);
|
|
184
|
+
}
|
|
185
|
+
/** settled entry: reports settled, carries the reply, and drops the request payload. */
|
|
186
|
+
async function checkSettledEntry(create, entry, rec) {
|
|
187
|
+
const j = await create();
|
|
188
|
+
await j.claim(entry);
|
|
189
|
+
await j.settle(entry.key, { code: 0 });
|
|
190
|
+
const seen = await j.claim(entry);
|
|
191
|
+
rec('claim after settle reports settled', seen?.settled === true, `settled=${String(seen?.settled)}`);
|
|
192
|
+
const decided = seen?.settled === true ? seen : undefined;
|
|
193
|
+
rec('settled entry carries the reply', decided?.reply?.code === 0, `reply=${JSON.stringify(decided?.reply)}`);
|
|
194
|
+
rec('settled entry does not retain the request', decided !== undefined && !('request' in decided), `keys=${decided === undefined ? 'none' : Object.keys(decided).join(',')}`);
|
|
195
|
+
}
|
|
196
|
+
/** inDoubt: an unsettled claim is in doubt; settling removes it. */
|
|
197
|
+
async function checkInDoubt(create, entry, rec) {
|
|
198
|
+
const j = await create();
|
|
199
|
+
await j.claim(entry);
|
|
200
|
+
const before = await j.inDoubt();
|
|
201
|
+
rec('unsettled appears in inDoubt', before.some((e) => e.key === entry.key), `${before.length} in doubt`);
|
|
202
|
+
await j.settle(entry.key, { code: 0 });
|
|
203
|
+
const after = await j.inDoubt();
|
|
204
|
+
rec('settled leaves inDoubt', !after.some((e) => e.key === entry.key), `${after.length} in doubt`);
|
|
205
|
+
}
|
|
206
|
+
/** The property that makes `claim` safe to share between processes: even when several race, exactly one wins. */
|
|
207
|
+
async function checkConcurrency(create, entry, rec) {
|
|
208
|
+
const j = await create();
|
|
209
|
+
const claims = await Promise.all(Array.from({ length: 8 }, () => j.claim(entry)));
|
|
210
|
+
const winners = claims.filter((c) => c === null).length;
|
|
211
|
+
rec('concurrent claims elect one winner', winners === 1, `${winners} of 8 saw null`);
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* The contract every `Journal` must satisfy, as runnable checks — ships with the port so an adapter author (Redis, a
|
|
215
|
+
* table, a TSRQW stream) proves conformance without reading the prose. Zero dependencies. Pass a factory that makes a
|
|
216
|
+
* fresh journal and a sample request value.
|
|
217
|
+
*/
|
|
218
|
+
async function checkJournalContract(create, sampleRequest) {
|
|
219
|
+
const results = [];
|
|
220
|
+
const rec = (name, ok, detail = '') => { results.push({ name, ok, detail }); };
|
|
221
|
+
const entry = { key: 'op:1', operation: 'test-op', request: sampleRequest };
|
|
222
|
+
await checkClaimLifecycle(create, entry, rec);
|
|
223
|
+
await checkLookup(create, entry, rec);
|
|
224
|
+
await checkDiscard(create, entry, rec);
|
|
225
|
+
await checkSettledEntry(create, entry, rec);
|
|
226
|
+
await checkInDoubt(create, entry, rec);
|
|
227
|
+
await checkConcurrency(create, entry, rec);
|
|
228
|
+
return results;
|
|
229
|
+
}
|
package/db/TSMongo.d.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TSMongo – MongoDB connection (pooling, health, index registration).
|
|
3
|
+
* Static API like TSRedis. Use TSMongo.connect(), TSMongo.getDatabase(), etc.
|
|
4
|
+
*/
|
|
5
|
+
import { MongoClient, Db } from 'mongodb';
|
|
6
|
+
export { MongoClient, Db, ObjectId, ReadPreference, type Collection, type Filter, type Document, type FindOptions, type ClientSession, type TransactionOptions, type CommandStartedEvent, type CommandSucceededEvent, type CommandFailedEvent, type ReadPreferenceLike, type UpdateFilter, type Sort, type IndexDescription } from 'mongodb';
|
|
7
|
+
export type MongoEventLevel = 'error' | 'warn' | 'info';
|
|
8
|
+
/**
|
|
9
|
+
* Structured event emitted by TSMongo for significant pool lifecycle moments.
|
|
10
|
+
*
|
|
11
|
+
* Wire a handler once at service startup via `TSMongo.setEventHandler()` to route
|
|
12
|
+
* these events into your structured logger with correlation context.
|
|
13
|
+
*
|
|
14
|
+
* When no handler is registered, events fall back to `console.error` / `console.warn`
|
|
15
|
+
* using the `TSMongo:ERROR` / `TSMongo:WARN` prefix convention.
|
|
16
|
+
*/
|
|
17
|
+
export interface MongoEvent {
|
|
18
|
+
level: MongoEventLevel;
|
|
19
|
+
/** Low-cardinality dot-namespaced event identifier. */
|
|
20
|
+
event: string;
|
|
21
|
+
message: string;
|
|
22
|
+
timestamp: string;
|
|
23
|
+
data: Record<string, unknown>;
|
|
24
|
+
}
|
|
25
|
+
export type MongoEventHandler = (event: MongoEvent) => void;
|
|
26
|
+
/**
|
|
27
|
+
* Register a structured event handler for TSMongo lifecycle events.
|
|
28
|
+
*
|
|
29
|
+
* Call once at service startup before `TSMongo.connect()`. Pass `null` to
|
|
30
|
+
* remove a previously registered handler and revert to console fallback.
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* TSMongo.setEventHandler((evt) => {
|
|
34
|
+
* logger[evt.level]('TSMongo event', { operation: 'core.database', event: evt.event, ...evt.data });
|
|
35
|
+
* });
|
|
36
|
+
*/
|
|
37
|
+
declare function setEventHandlerImpl(handler: MongoEventHandler | null): void;
|
|
38
|
+
export interface ConnectionPoolStats {
|
|
39
|
+
totalConnections: number;
|
|
40
|
+
checkedOut: number;
|
|
41
|
+
availableConnections: number;
|
|
42
|
+
waitQueueSize: number;
|
|
43
|
+
maxPoolSize: number;
|
|
44
|
+
minPoolSize: number;
|
|
45
|
+
totalCheckouts: number;
|
|
46
|
+
totalCheckins: number;
|
|
47
|
+
connectionCreated: number;
|
|
48
|
+
connectionClosed: number;
|
|
49
|
+
waitQueueTimeouts: number;
|
|
50
|
+
lastWaitQueueTimeout: Date | null;
|
|
51
|
+
}
|
|
52
|
+
export interface MongoConfig {
|
|
53
|
+
uri: string;
|
|
54
|
+
dbName?: string;
|
|
55
|
+
maxPoolSize?: number;
|
|
56
|
+
minPoolSize?: number;
|
|
57
|
+
maxIdleTimeMS?: number;
|
|
58
|
+
waitQueueTimeoutMS?: number;
|
|
59
|
+
maxWaitingRequests?: number;
|
|
60
|
+
connectTimeoutMS?: number;
|
|
61
|
+
socketTimeoutMS?: number;
|
|
62
|
+
serverSelectionTimeoutMS?: number;
|
|
63
|
+
readPreference?: 'primary' | 'primaryPreferred' | 'secondary' | 'secondaryPreferred' | 'nearest';
|
|
64
|
+
writeConcern?: 'majority' | number;
|
|
65
|
+
retryWrites?: boolean;
|
|
66
|
+
retryReads?: boolean;
|
|
67
|
+
compressors?: ('snappy' | 'zlib' | 'zstd')[];
|
|
68
|
+
monitorCommands?: boolean;
|
|
69
|
+
}
|
|
70
|
+
export declare const DEFAULT_MONGO_CONFIG: Omit<Required<MongoConfig>, 'uri' | 'dbName' | 'compressors' | 'monitorCommands'>;
|
|
71
|
+
declare function registerIndexesImpl(collection: string, indexes: Array<{
|
|
72
|
+
key: Record<string, 1 | -1>;
|
|
73
|
+
unique?: boolean;
|
|
74
|
+
}>): void;
|
|
75
|
+
export declare class TSMongo {
|
|
76
|
+
static connect: typeof connectImpl;
|
|
77
|
+
static getDatabase: typeof getDatabaseImpl;
|
|
78
|
+
static getClient: typeof getClientImpl;
|
|
79
|
+
static close: typeof closeImpl;
|
|
80
|
+
static checkHealth: typeof checkHealthImpl;
|
|
81
|
+
static registerIndexes: typeof registerIndexesImpl;
|
|
82
|
+
static getConnectionPoolStats: typeof getConnectionPoolStatsImpl;
|
|
83
|
+
static getPoolHealthStatus: typeof getPoolHealthStatusImpl;
|
|
84
|
+
static getDatabaseStats: typeof getDatabaseStatsImpl;
|
|
85
|
+
static setEventHandler: typeof setEventHandlerImpl;
|
|
86
|
+
}
|
|
87
|
+
declare function connectImpl(uri: string, config?: Partial<MongoConfig>): Promise<Db>;
|
|
88
|
+
declare function getDatabaseImpl(): Db;
|
|
89
|
+
declare function getClientImpl(): MongoClient;
|
|
90
|
+
declare function closeImpl(): Promise<void>;
|
|
91
|
+
declare function getConnectionPoolStatsImpl(): ConnectionPoolStats;
|
|
92
|
+
declare function getPoolHealthStatusImpl(): {
|
|
93
|
+
status: 'healthy' | 'warning' | 'critical';
|
|
94
|
+
utilizationPercent: number;
|
|
95
|
+
message: string;
|
|
96
|
+
};
|
|
97
|
+
declare function checkHealthImpl(): Promise<{
|
|
98
|
+
healthy: boolean;
|
|
99
|
+
latencyMs: number;
|
|
100
|
+
connections: number;
|
|
101
|
+
checkedOut: number;
|
|
102
|
+
}>;
|
|
103
|
+
declare function getDatabaseStatsImpl(): Promise<Record<string, unknown>>;
|