@zukhruf/fencing 0.0.0-stage → 0.3.17

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ezzabuzaid
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,141 @@
1
- # Temporary Holding Version
1
+ # @zukhruf/fencing
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A holder can lose its lease and not know it. When it continues, it can write over the work of a newer holder. This package gives each grant a fencing token: an integer that grows with each grant. A fenced resource keeps the highest token that it accepted, and it refuses a write with a lower token. `@zukhruf/mutex` gives a fencing token on each lease.
4
+
5
+ The words in these documents have one meaning each. See the glossary in [CONTEXT.md](./CONTEXT.md).
6
+
7
+ ## The problem
8
+
9
+ Process A holds a key and reads the stock. Then process A freezes, for example in a long garbage collection or after a `SIGSTOP`. The issuer decides that A stopped, and it grants the key to process B. B sells the last item. Then A continues, and A also sells the last item.
10
+
11
+ ```
12
+ holder A: [token 33] read stock=1 ......frozen...... write stock=0 ✗ stale write
13
+ holder B: [token 34] read stock=1, write stock=0
14
+ result: two customers got the last item
15
+ ```
16
+
17
+ A is a **stale holder**. The signal of A's lease can abort, but A can write before the issuer sees the loss. Thus the signal is a warning. Only the resource can refuse the write, because the write goes directly to the resource.
18
+
19
+ ## The resource checks the token
20
+
21
+ With fencing tokens, B writes first with token 34. The resource keeps 34. Then A writes with token 33, and the resource refuses it:
22
+
23
+ ```
24
+ B writes with token 34 → resource: highest = 34, write accepted
25
+ A writes with token 33 → resource: 33 < 34, write refused ✓
26
+ ```
27
+
28
+ The holder does not check its token. The resource does. A holder cannot know that a newer holder exists, but the resource saw the newer token.
29
+
30
+ In SQL, add a `fence` column, and do the check and the write in one statement:
31
+
32
+ ```ts
33
+ import { DatabaseSync } from 'node:sqlite';
34
+
35
+ import type { FencingToken } from '@zukhruf/fencing';
36
+
37
+ const database = new DatabaseSync('shop.db', { readBigInts: true });
38
+ database.exec(`
39
+ CREATE TABLE IF NOT EXISTS stock (
40
+ product TEXT PRIMARY KEY,
41
+ quantity INTEGER NOT NULL,
42
+ fence INTEGER NOT NULL DEFAULT 0
43
+ )
44
+ `);
45
+
46
+ /** Reserves one item, unless a holder with a newer token wrote this product. */
47
+ function reserve(product: string, token: FencingToken): boolean {
48
+ const { changes } = database
49
+ .prepare(
50
+ `UPDATE stock SET quantity = quantity - 1, fence = ?
51
+ WHERE product = ? AND fence <= ? AND quantity > 0`,
52
+ )
53
+ .run(token.value, product, token.value);
54
+ return Number(changes) > 0;
55
+ }
56
+ ```
57
+
58
+ Follow these rules:
59
+
60
+ - **Refuse only lower tokens.** Use `fence <= :token`, not `fence < :token`. One holder can write two times with the same token.
61
+ - **Do the check and the write in one statement.** If you read the fence first and write later, a stale write can occur between the two.
62
+ - **Compare the tokens as integers.** Use a 64-bit integer column, for example `INTEGER` in SQLite or `BIGINT` in PostgreSQL. In `node:sqlite`, bind `token.value` (a `bigint`), and open the database with `readBigInts: true`.
63
+ - **Do not make a token a JavaScript number.** A number above 2^53 loses its last digits, and the tokens of `EpochTokenSource` get there from epoch 2^21. `JSON.stringify` refuses a `bigint`. Send the decimal text of the token instead.
64
+
65
+ Fencing tokens protect only a fenced resource. If a stale holder sends an email or calls an API that does not compare tokens, the token does not stop it.
66
+
67
+ ## A token as text
68
+
69
+ `token.toString()` gives the decimal digits of the token, for example `"34"`. `FencingToken.parse(text)` reads that text back. It returns `null` for each other text, as `URL.parse` does:
70
+
71
+ ```ts
72
+ import { FencingToken } from '@zukhruf/fencing';
73
+
74
+ FencingToken.parse('34'); // FencingToken { value: 34n }
75
+ FencingToken.parse('-1'); // null: a token is never negative
76
+ FencingToken.parse(' 34'); // null
77
+ FencingToken.parse('0x22'); // null
78
+ ```
79
+
80
+ `BigInt` alone accepts more: `BigInt('')` is `0n`, `BigInt(' 34')` is `34n`, and `BigInt('0x22')` is `34n`. Thus use `parse` for each token that you read from a message or a header.
81
+
82
+ ## One key, one token source
83
+
84
+ A token has no name of its key and no name of its token source. Two tokens of different keys, or of different token sources, cannot be compared. A counter in memory and a counter in a file can both give token 7 to two different holders.
85
+
86
+ Thus a fenced resource compares the tokens of one key, from one token source. Give each protected row, file, or object its own key. Give all issuers of that key the same token source.
87
+
88
+ ## Token sources
89
+
90
+ A **token source** makes the tokens of an issuer. The issuer asks it for the next token of a key, one time for each grant. The issuer never asks for two tokens of one key at the same time: a mutex holds the key while it asks.
91
+
92
+ | Token source | Tokens | Durable |
93
+ | --------------------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------ |
94
+ | `CounterTokenSource` | 1, 2, 3, … in memory, one counter for all keys. | No. It starts again at 1 with the process. |
95
+ | `MonotonicClockTokenSource` | The monotonic clock of the process (`process.hrtime.bigint()`). Each token is higher than the last one. | No. Its order holds only in one process. |
96
+ | `FileTokenSource` | 1, 2, 3, … for each key, in a file `<key>.fence` in the directory that you give. | Yes. |
97
+ | `EpochTokenSource` | `epoch << 32 \| counter`. The counter starts at 1 and is one counter for all keys. | Yes, when each start uses a higher epoch. |
98
+
99
+ Select a token source:
100
+
101
+ - **The resource does not live longer than the process**, for example a cache in memory: use `CounterTokenSource`.
102
+ - **Worker threads of one process share a resource**: use `MonotonicClockTokenSource`. All threads of a process read the same monotonic clock, so a thread gets a higher token than the thread that held the key before it, with no shared memory.
103
+ - **The resource is durable**, for example a database or a file: use `FileTokenSource`. The resource keeps the highest token after your process stops. A source in memory starts again at 1, and then the resource refuses each new write.
104
+ - **A leader issues the grants, and a new leader can take over**: use `EpochTokenSource` with the epoch of the leader's term, for example `term.epoch` of `@zukhruf/election`.
105
+
106
+ ```ts
107
+ import { FileTokenSource } from '@zukhruf/fencing';
108
+
109
+ const tokens = new FileTokenSource('/var/lib/my-app/fences');
110
+ const token = await tokens.next('product:42');
111
+ ```
112
+
113
+ `FileTokenSource` makes the directory if it is absent. It keeps the counter of each key as decimal text, and it replaces the file in one step and syncs it to the disk before it gives the token. On Linux and macOS, a power loss cannot make it give one token two times. The name of the file comes from the key with `safeFileName` of `@zukhruf/fs`. Processes that use earlier versions of `@zukhruf/mutex` find the same files.
114
+
115
+ `EpochTokenSource` puts the epoch in the high 32 bits and the counter in the low 32 bits, as ZooKeeper does in its `zxid`. Each token of a newer epoch is higher than each token of an older epoch. A new leader thus outranks all grants of the leader before it. The epoch must be from 0 to 2^31 − 1, so that each token fits a signed 64-bit integer. One epoch gives at most 2^32 − 1 tokens. After that, `next` throws a `RangeError`.
116
+
117
+ ## A fenced lease
118
+
119
+ An issuer of fenced access gives the holder a `FencedLease`: a `Lease` of `@zukhruf/lease` that also has a `token`. The signal warns the holder. The token protects the resource.
120
+
121
+ ```ts
122
+ import type { FencedLease } from '@zukhruf/fencing';
123
+
124
+ async function sell(lease: FencedLease, product: string) {
125
+ lease.signal.throwIfAborted();
126
+ if (!reserve(product, lease.token)) throw new Error('A newer holder wrote.');
127
+ }
128
+ ```
129
+
130
+ ## Why not a random value, a UUID, or a time
131
+
132
+ A resource can refuse a stale holder only when the tokens of one key are strictly in the order of the grants. A random value or a UUID has no order. A UUIDv7 has an order by time only to the millisecond, and it is random in that millisecond. In a test in Node 26, 50,160 of 100,000 UUIDv7 values from one thread were lower than the value before them. A clock of the wall can go back. See [ADR 0001](./docs/adr/0001-a-fencing-token-is-a-number-the-resource-checks.md).
133
+
134
+ ## The same concepts in other systems
135
+
136
+ | This package | Other systems |
137
+ | ----------------------------- | ---------------------------------------------------------------------------------------------------------------- |
138
+ | Fencing token | Kleppmann, "How to do distributed locking" (2016); Chubby sequencer; Hazelcast `FencedLock` fence; etcd revision |
139
+ | The resource checks the token | Chubby `CheckSequencer()`; an SQL `UPDATE … WHERE fence <= ?` |
140
+ | `EpochTokenSource` | ZooKeeper `zxid`: epoch in the high 32 bits, counter in the low 32 bits; Raft term; Kafka leader epoch |
141
+ | `FencedLease` | Hazelcast `FencedLock`; etcd `concurrency.Mutex` with its session and the revision of its key |
@@ -0,0 +1,11 @@
1
+ import { FencingToken } from './fencing-token.ts';
2
+ import type { TokenSource } from './token-source.ts';
3
+ /**
4
+ * Counts in memory, so tokens restart when the process does. Only fence
5
+ * resources that do not outlive the process with it.
6
+ */
7
+ export declare class CounterTokenSource implements TokenSource {
8
+ #private;
9
+ next(_key: string): Promise<FencingToken>;
10
+ }
11
+ //# sourceMappingURL=counter-token-source.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"counter-token-source.d.ts","sourceRoot":"","sources":["../src/counter-token-source.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAClD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAErD;;;GAGG;AACH,qBAAa,kBAAmB,YAAW,WAAW;;IAG9C,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC;CAIhD"}
@@ -0,0 +1,13 @@
1
+ import { FencingToken } from "./fencing-token.js";
2
+ /**
3
+ * Counts in memory, so tokens restart when the process does. Only fence
4
+ * resources that do not outlive the process with it.
5
+ */
6
+ export class CounterTokenSource {
7
+ #last = 0n;
8
+ async next(_key) {
9
+ this.#last++;
10
+ return new FencingToken(this.#last);
11
+ }
12
+ }
13
+ //# sourceMappingURL=counter-token-source.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"counter-token-source.js","sourceRoot":"","sources":["../src/counter-token-source.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAGlD;;;GAGG;AACH,MAAM,OAAO,kBAAkB;IAC7B,KAAK,GAAG,EAAE,CAAC;IAEX,KAAK,CAAC,IAAI,CAAC,IAAY;QACrB,IAAI,CAAC,KAAK,EAAE,CAAC;QACb,OAAO,IAAI,YAAY,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IACtC,CAAC;CACF"}
@@ -0,0 +1,13 @@
1
+ import { FencingToken } from './fencing-token.ts';
2
+ import type { TokenSource } from './token-source.ts';
3
+ /**
4
+ * Packs `epoch << 32 | sequence`, so every token minted in a newer epoch beats
5
+ * every token from an older one: a new leader outranks all of its
6
+ * predecessor's grants. The result fits a signed 64-bit integer.
7
+ */
8
+ export declare class EpochTokenSource implements TokenSource {
9
+ #private;
10
+ constructor(epoch: bigint);
11
+ next(_key: string): Promise<FencingToken>;
12
+ }
13
+ //# sourceMappingURL=epoch-token-source.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"epoch-token-source.d.ts","sourceRoot":"","sources":["../src/epoch-token-source.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAClD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAMrD;;;;GAIG;AACH,qBAAa,gBAAiB,YAAW,WAAW;;gBAItC,KAAK,EAAE,MAAM;IAOnB,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC;CAOhD"}
@@ -0,0 +1,27 @@
1
+ import { FencingToken } from "./fencing-token.js";
2
+ const SEQUENCE_BITS = 32n;
3
+ const EPOCH_LIMIT = 1n << 31n;
4
+ const SEQUENCE_LIMIT = 1n << SEQUENCE_BITS;
5
+ /**
6
+ * Packs `epoch << 32 | sequence`, so every token minted in a newer epoch beats
7
+ * every token from an older one: a new leader outranks all of its
8
+ * predecessor's grants. The result fits a signed 64-bit integer.
9
+ */
10
+ export class EpochTokenSource {
11
+ #epoch;
12
+ #sequence = 0n;
13
+ constructor(epoch) {
14
+ if (epoch < 0n || epoch >= EPOCH_LIMIT) {
15
+ throw new RangeError(`Epoch ${epoch} is outside [0, ${EPOCH_LIMIT}).`);
16
+ }
17
+ this.#epoch = epoch;
18
+ }
19
+ async next(_key) {
20
+ this.#sequence++;
21
+ if (this.#sequence >= SEQUENCE_LIMIT) {
22
+ throw new RangeError(`Epoch ${this.#epoch} has no tokens left.`);
23
+ }
24
+ return new FencingToken((this.#epoch << SEQUENCE_BITS) | this.#sequence);
25
+ }
26
+ }
27
+ //# sourceMappingURL=epoch-token-source.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"epoch-token-source.js","sourceRoot":"","sources":["../src/epoch-token-source.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAGlD,MAAM,aAAa,GAAG,GAAG,CAAC;AAC1B,MAAM,WAAW,GAAG,EAAE,IAAI,GAAG,CAAC;AAC9B,MAAM,cAAc,GAAG,EAAE,IAAI,aAAa,CAAC;AAE3C;;;;GAIG;AACH,MAAM,OAAO,gBAAgB;IAClB,MAAM,CAAS;IACxB,SAAS,GAAG,EAAE,CAAC;IAEf,YAAY,KAAa;QACvB,IAAI,KAAK,GAAG,EAAE,IAAI,KAAK,IAAI,WAAW,EAAE,CAAC;YACvC,MAAM,IAAI,UAAU,CAAC,SAAS,KAAK,mBAAmB,WAAW,IAAI,CAAC,CAAC;QACzE,CAAC;QACD,IAAI,CAAC,MAAM,GAAG,KAAK,CAAC;IACtB,CAAC;IAED,KAAK,CAAC,IAAI,CAAC,IAAY;QACrB,IAAI,CAAC,SAAS,EAAE,CAAC;QACjB,IAAI,IAAI,CAAC,SAAS,IAAI,cAAc,EAAE,CAAC;YACrC,MAAM,IAAI,UAAU,CAAC,SAAS,IAAI,CAAC,MAAM,sBAAsB,CAAC,CAAC;QACnE,CAAC;QACD,OAAO,IAAI,YAAY,CAAC,CAAC,IAAI,CAAC,MAAM,IAAI,aAAa,CAAC,GAAG,IAAI,CAAC,SAAS,CAAC,CAAC;IAC3E,CAAC;CACF"}
@@ -0,0 +1,11 @@
1
+ import type { Lease } from '@zukhruf/lease';
2
+ import type { FencingToken } from './fencing-token.ts';
3
+ /**
4
+ * What an issuer of fenced access, such as a mutex or a single flight, gives
5
+ * its holder: the lease warns the holder, and the token lets a fenced resource
6
+ * refuse the holder's writes once a newer holder has written.
7
+ */
8
+ export interface FencedLease extends Lease {
9
+ readonly token: FencingToken;
10
+ }
11
+ //# sourceMappingURL=fenced-lease.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fenced-lease.d.ts","sourceRoot":"","sources":["../src/fenced-lease.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,gBAAgB,CAAC;AAE5C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAEvD;;;;GAIG;AACH,MAAM,WAAW,WAAY,SAAQ,KAAK;IACxC,QAAQ,CAAC,KAAK,EAAE,YAAY,CAAC;CAC9B"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=fenced-lease.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fenced-lease.js","sourceRoot":"","sources":["../src/fenced-lease.ts"],"names":[],"mappings":""}
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Grows with every grant. A resource that rejects writes carrying a token
3
+ * older than the newest it has seen ignores holders that were superseded
4
+ * without knowing it (frozen, partitioned, or outlived by a failover).
5
+ */
6
+ export declare class FencingToken {
7
+ readonly value: bigint;
8
+ constructor(value: bigint);
9
+ /**
10
+ * Reads decimal digits, the text that `toString` writes, or returns null for
11
+ * any other text. `BigInt` alone would also accept `''`, `' 1'` and `'0x10'`,
12
+ * and a token is never negative.
13
+ */
14
+ static parse(text: string): FencingToken | null;
15
+ isNewerThan(other: FencingToken): boolean;
16
+ toString(): string;
17
+ }
18
+ //# sourceMappingURL=fencing-token.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fencing-token.d.ts","sourceRoot":"","sources":["../src/fencing-token.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,qBAAa,YAAY;IACvB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;gBAEX,KAAK,EAAE,MAAM;IAIzB;;;;OAIG;IACH,MAAM,CAAC,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,YAAY,GAAG,IAAI;IAI/C,WAAW,CAAC,KAAK,EAAE,YAAY,GAAG,OAAO;IAIzC,QAAQ,IAAI,MAAM;CAGnB"}
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Grows with every grant. A resource that rejects writes carrying a token
3
+ * older than the newest it has seen ignores holders that were superseded
4
+ * without knowing it (frozen, partitioned, or outlived by a failover).
5
+ */
6
+ export class FencingToken {
7
+ value;
8
+ constructor(value) {
9
+ this.value = value;
10
+ }
11
+ /**
12
+ * Reads decimal digits, the text that `toString` writes, or returns null for
13
+ * any other text. `BigInt` alone would also accept `''`, `' 1'` and `'0x10'`,
14
+ * and a token is never negative.
15
+ */
16
+ static parse(text) {
17
+ return /^\d+$/.test(text) ? new FencingToken(BigInt(text)) : null;
18
+ }
19
+ isNewerThan(other) {
20
+ return this.value > other.value;
21
+ }
22
+ toString() {
23
+ return this.value.toString();
24
+ }
25
+ }
26
+ //# sourceMappingURL=fencing-token.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fencing-token.js","sourceRoot":"","sources":["../src/fencing-token.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,MAAM,OAAO,YAAY;IACd,KAAK,CAAS;IAEvB,YAAY,KAAa;QACvB,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;IACrB,CAAC;IAED;;;;OAIG;IACH,MAAM,CAAC,KAAK,CAAC,IAAY;QACvB,OAAO,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,YAAY,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;IACpE,CAAC;IAED,WAAW,CAAC,KAAmB;QAC7B,OAAO,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC;IAClC,CAAC;IAED,QAAQ;QACN,OAAO,IAAI,CAAC,KAAK,CAAC,QAAQ,EAAE,CAAC;IAC/B,CAAC;CACF"}
@@ -0,0 +1,13 @@
1
+ import { FencingToken } from './fencing-token.ts';
2
+ import type { TokenSource } from './token-source.ts';
3
+ /**
4
+ * Keeps one counter file per key in `directory`, so tokens keep growing across
5
+ * process restarts and can fence durable resources. Relies on the caller holding
6
+ * the key's lock: read, increment and replace are not atomic on their own.
7
+ */
8
+ export declare class FileTokenSource implements TokenSource {
9
+ #private;
10
+ constructor(directory: string);
11
+ next(key: string): Promise<FencingToken>;
12
+ }
13
+ //# sourceMappingURL=file-token-source.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"file-token-source.d.ts","sourceRoot":"","sources":["../src/file-token-source.ts"],"names":[],"mappings":"AAUA,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAClD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAErD;;;;GAIG;AACH,qBAAa,eAAgB,YAAW,WAAW;;gBAGrC,SAAS,EAAE,MAAM;IAIvB,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC;CAQ/C"}
@@ -0,0 +1,34 @@
1
+ import { mkdir, readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { draftSuffixLength, durableWrite, isErrno, safeFileName, } from '@zukhruf/fs';
4
+ import { FencingToken } from "./fencing-token.js";
5
+ /**
6
+ * Keeps one counter file per key in `directory`, so tokens keep growing across
7
+ * process restarts and can fence durable resources. Relies on the caller holding
8
+ * the key's lock: read, increment and replace are not atomic on their own.
9
+ */
10
+ export class FileTokenSource {
11
+ #directory;
12
+ constructor(directory) {
13
+ this.#directory = directory;
14
+ }
15
+ async next(key) {
16
+ await mkdir(this.#directory, { recursive: true });
17
+ const name = safeFileName(key, '.fence'.length + draftSuffixLength);
18
+ const path = join(this.#directory, `${name}.fence`);
19
+ const token = (await readCounter(path)) + 1n;
20
+ await durableWrite(path, token.toString());
21
+ return new FencingToken(token);
22
+ }
23
+ }
24
+ async function readCounter(path) {
25
+ try {
26
+ return BigInt(await readFile(path, 'utf8'));
27
+ }
28
+ catch (error) {
29
+ if (isErrno(error, 'ENOENT'))
30
+ return 0n;
31
+ throw error;
32
+ }
33
+ }
34
+ //# sourceMappingURL=file-token-source.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"file-token-source.js","sourceRoot":"","sources":["../src/file-token-source.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AACnD,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAEjC,OAAO,EACL,iBAAiB,EACjB,YAAY,EACZ,OAAO,EACP,YAAY,GACb,MAAM,aAAa,CAAC;AAErB,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAGlD;;;;GAIG;AACH,MAAM,OAAO,eAAe;IACjB,UAAU,CAAS;IAE5B,YAAY,SAAiB;QAC3B,IAAI,CAAC,UAAU,GAAG,SAAS,CAAC;IAC9B,CAAC;IAED,KAAK,CAAC,IAAI,CAAC,GAAW;QACpB,MAAM,KAAK,CAAC,IAAI,CAAC,UAAU,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;QAClD,MAAM,IAAI,GAAG,YAAY,CAAC,GAAG,EAAE,QAAQ,CAAC,MAAM,GAAG,iBAAiB,CAAC,CAAC;QACpE,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE,GAAG,IAAI,QAAQ,CAAC,CAAC;QACpD,MAAM,KAAK,GAAG,CAAC,MAAM,WAAW,CAAC,IAAI,CAAC,CAAC,GAAG,EAAE,CAAC;QAC7C,MAAM,YAAY,CAAC,IAAI,EAAE,KAAK,CAAC,QAAQ,EAAE,CAAC,CAAC;QAC3C,OAAO,IAAI,YAAY,CAAC,KAAK,CAAC,CAAC;IACjC,CAAC;CACF;AAED,KAAK,UAAU,WAAW,CAAC,IAAY;IACrC,IAAI,CAAC;QACH,OAAO,MAAM,CAAC,MAAM,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC;IAC9C,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,IAAI,OAAO,CAAC,KAAK,EAAE,QAAQ,CAAC;YAAE,OAAO,EAAE,CAAC;QACxC,MAAM,KAAK,CAAC;IACd,CAAC;AACH,CAAC"}
@@ -0,0 +1,8 @@
1
+ export { CounterTokenSource } from './counter-token-source.ts';
2
+ export { EpochTokenSource } from './epoch-token-source.ts';
3
+ export type { FencedLease } from './fenced-lease.ts';
4
+ export { FencingToken } from './fencing-token.ts';
5
+ export { FileTokenSource } from './file-token-source.ts';
6
+ export { MonotonicClockTokenSource } from './monotonic-clock-token-source.ts';
7
+ export type { TokenSource } from './token-source.ts';
8
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAC/D,OAAO,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAC3D,YAAY,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AACrD,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAClD,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AACzD,OAAO,EAAE,yBAAyB,EAAE,MAAM,mCAAmC,CAAC;AAC9E,YAAY,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export { CounterTokenSource } from "./counter-token-source.js";
2
+ export { EpochTokenSource } from "./epoch-token-source.js";
3
+ export { FencingToken } from "./fencing-token.js";
4
+ export { FileTokenSource } from "./file-token-source.js";
5
+ export { MonotonicClockTokenSource } from "./monotonic-clock-token-source.js";
6
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAC/D,OAAO,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAE3D,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAClD,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AACzD,OAAO,EAAE,yBAAyB,EAAE,MAAM,mCAAmC,CAAC"}
@@ -0,0 +1,12 @@
1
+ import { FencingToken } from './fencing-token.ts';
2
+ import type { TokenSource } from './token-source.ts';
3
+ /**
4
+ * Reads the process-wide monotonic clock, which every worker thread shares, so
5
+ * grants serialized by a lock read strictly later times without shared memory.
6
+ * Tokens restart when the process does.
7
+ */
8
+ export declare class MonotonicClockTokenSource implements TokenSource {
9
+ #private;
10
+ next(_key: string): Promise<FencingToken>;
11
+ }
12
+ //# sourceMappingURL=monotonic-clock-token-source.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"monotonic-clock-token-source.d.ts","sourceRoot":"","sources":["../src/monotonic-clock-token-source.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAClD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAErD;;;;GAIG;AACH,qBAAa,yBAA0B,YAAW,WAAW;;IAGrD,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC;CAKhD"}
@@ -0,0 +1,15 @@
1
+ import { FencingToken } from "./fencing-token.js";
2
+ /**
3
+ * Reads the process-wide monotonic clock, which every worker thread shares, so
4
+ * grants serialized by a lock read strictly later times without shared memory.
5
+ * Tokens restart when the process does.
6
+ */
7
+ export class MonotonicClockTokenSource {
8
+ #last = 0n;
9
+ async next(_key) {
10
+ const now = process.hrtime.bigint();
11
+ this.#last = now > this.#last ? now : this.#last + 1n;
12
+ return new FencingToken(this.#last);
13
+ }
14
+ }
15
+ //# sourceMappingURL=monotonic-clock-token-source.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"monotonic-clock-token-source.js","sourceRoot":"","sources":["../src/monotonic-clock-token-source.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAGlD;;;;GAIG;AACH,MAAM,OAAO,yBAAyB;IACpC,KAAK,GAAG,EAAE,CAAC;IAEX,KAAK,CAAC,IAAI,CAAC,IAAY;QACrB,MAAM,GAAG,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC;QACpC,IAAI,CAAC,KAAK,GAAG,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,GAAG,EAAE,CAAC;QACtD,OAAO,IAAI,YAAY,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IACtC,CAAC;CACF"}
@@ -0,0 +1,9 @@
1
+ import type { FencingToken } from './fencing-token.ts';
2
+ /**
3
+ * Mints fencing tokens. Callers mint for one key one at a time (a mutex holds
4
+ * the key's lock while it calls `next`), so each token is newer than the last.
5
+ */
6
+ export interface TokenSource {
7
+ next(key: string): Promise<FencingToken>;
8
+ }
9
+ //# sourceMappingURL=token-source.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"token-source.d.ts","sourceRoot":"","sources":["../src/token-source.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAEvD;;;GAGG;AACH,MAAM,WAAW,WAAW;IAC1B,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,CAAC;CAC1C"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=token-source.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"token-source.js","sourceRoot":"","sources":["../src/token-source.ts"],"names":[],"mappings":""}
package/package.json CHANGED
@@ -1,6 +1,47 @@
1
1
  {
2
2
  "name": "@zukhruf/fencing",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.17",
4
+ "description": "Fencing tokens: a number that grows with each grant, so a protected resource refuses the writes of a stale holder. Token sources that count in memory, in files, or by epoch, and the fenced lease that carries a token.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/JanuaryLabs/zukhruf",
9
+ "directory": "packages/fencing"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/JanuaryLabs/zukhruf/issues"
13
+ },
14
+ "homepage": "https://github.com/JanuaryLabs/zukhruf/tree/main/packages/fencing#readme",
15
+ "keywords": [
16
+ "fencing",
17
+ "fencing-token",
18
+ "lock",
19
+ "lease",
20
+ "stale",
21
+ "distributed"
22
+ ],
23
+ "type": "module",
24
+ "types": "./dist/index.d.ts",
25
+ "exports": {
26
+ "./package.json": "./package.json",
27
+ ".": {
28
+ "@zukhruf/source": "./src/index.ts",
29
+ "types": "./dist/index.d.ts",
30
+ "import": "./dist/index.js",
31
+ "default": "./dist/index.js"
32
+ }
33
+ },
34
+ "files": [
35
+ "dist",
36
+ "!dist/spec/**",
37
+ "!**/*.tsbuildinfo",
38
+ "!**/*.test.*"
39
+ ],
40
+ "publishConfig": {
41
+ "access": "public"
42
+ },
43
+ "dependencies": {
44
+ "@zukhruf/fs": "^0.3.16",
45
+ "@zukhruf/lease": "^0.3.16"
46
+ }
47
+ }