@zukhruf/election 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 +21 -0
- package/README.md +187 -2
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/leader-election.d.ts +39 -0
- package/dist/leader-election.d.ts.map +1 -0
- package/dist/leader-election.js +77 -0
- package/dist/leader-election.js.map +1 -0
- package/dist/sqlite/sqlite-election.d.ts +33 -0
- package/dist/sqlite/sqlite-election.d.ts.map +1 -0
- package/dist/sqlite/sqlite-election.js +55 -0
- package/dist/sqlite/sqlite-election.js.map +1 -0
- package/dist/term.d.ts +32 -0
- package/dist/term.d.ts.map +1 -0
- package/dist/term.js +91 -0
- package/dist/term.js.map +1 -0
- package/package.json +46 -4
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,188 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @zukhruf/election
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Candidates campaign for one claim, and the candidate that wins it leads for one term. Each term has an epoch that is higher than the epoch of each earlier term, so a newer leader always outranks an older one. A term is a lease on leadership. It ends when the leader resigns or its process dies. With a backend that can take the claim away, a term can also be lost while the leader still runs, and the signal of the term tells its leader so.
|
|
4
|
+
|
|
5
|
+
The words in these documents have one meaning each. See the glossary in [CONTEXT.md](./CONTEXT.md).
|
|
6
|
+
|
|
7
|
+
## Use it
|
|
8
|
+
|
|
9
|
+
This project is an experiment.
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npm install @zukhruf/election
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Elect a leader of one host
|
|
16
|
+
|
|
17
|
+
`SqliteElection` elects one leader among the processes of one host that use the same directory. The claim is the `FileLock` of `@zukhruf/fs` on `claimFile`: an exclusive SQLite transaction. The operating system holds that lock until the leader's process dies, so a dead leader frees the claim at once, and a living leader never loses it.
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
21
|
+
import { tmpdir } from 'node:os';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
|
|
24
|
+
import { SqliteElection } from '@zukhruf/election';
|
|
25
|
+
|
|
26
|
+
const directory = await mkdtemp(join(tmpdir(), 'jobs-'));
|
|
27
|
+
const election = new SqliteElection({
|
|
28
|
+
directory,
|
|
29
|
+
claimFile: 'jobs.lock',
|
|
30
|
+
epochFile: 'jobs.epoch',
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const first = await election.campaign();
|
|
34
|
+
console.log('first term:', first?.epoch);
|
|
35
|
+
console.log('a second candidate wins:', await election.campaign());
|
|
36
|
+
|
|
37
|
+
await first?.resign();
|
|
38
|
+
await using second = await election.campaign({ timeout: 1000 });
|
|
39
|
+
console.log('next term:', second?.epoch);
|
|
40
|
+
await rm(directory, { recursive: true, force: true });
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Output:
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
first term: 1n
|
|
47
|
+
a second candidate wins: undefined
|
|
48
|
+
next term: 2n
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
- `campaign({ timeout, signal })` resolves with a `Term`, or with `undefined` when another candidate still leads after `timeout` milliseconds. Without a `timeout`, it tries once. When `signal` aborts, it rejects with `signal.reason`, and a claim that it won meanwhile is given up.
|
|
52
|
+
- `claimFile` and `epochFile` are files in `directory`. Candidates with the same directory and the same file names are one group. Never delete `claimFile` while candidates run: a new file lets a second leader win.
|
|
53
|
+
- The directory must be on a local file system. On a network file system, `campaign` rejects with `NetworkDirectoryError`.
|
|
54
|
+
- `pollInterval` is the time between two tries, in milliseconds. It defaults to 10. A try never blocks the process.
|
|
55
|
+
|
|
56
|
+
## The term
|
|
57
|
+
|
|
58
|
+
A term is a lease on leadership. `Term` implements the `Lease` interface of [`@zukhruf/lease`](../lease/README.md), so a function that takes a `Lease` also takes a term. See the glossary of [leases](../lease/CONTEXT.md).
|
|
59
|
+
|
|
60
|
+
- `term.epoch` is the number of the term. Each epoch is below 2^31.
|
|
61
|
+
- `term.resign()` ends the term and gives the claim up. A second call waits for the first. `await using` resigns at the end of its scope.
|
|
62
|
+
- `term.signal` aborts with `LeaseLostError` when the term is lost while its leader still runs. The `subject` of the error is `'leadership'`, and its `cause` is the reason of the backend. The signal does not abort when the leader resigns.
|
|
63
|
+
|
|
64
|
+
A leader that acts for the group must stop when `term.signal` aborts, because another candidate can lead then. `SqliteElection` never loses a living term, so its signal never aborts. A backend that can take the claim away can lose one.
|
|
65
|
+
|
|
66
|
+
A term has no fencing token. The signal warns the leader, but it does not protect a resource: a leader can write after another candidate won. To protect a resource, make fencing tokens from the epoch with `EpochTokenSource` of [`@zukhruf/fencing`](../fencing/README.md). Then a resource can refuse the writes of an older leader:
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
import { EpochTokenSource } from '@zukhruf/fencing';
|
|
70
|
+
|
|
71
|
+
const tokens = new EpochTokenSource(term.epoch);
|
|
72
|
+
const token = await tokens.next('orders');
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
[ADR 0002](./docs/adr/0002-a-term-is-a-lease-on-leadership-with-no-fencing.md) tells why.
|
|
76
|
+
|
|
77
|
+
## Write a backend
|
|
78
|
+
|
|
79
|
+
`LeaderElection` is an abstract class. It runs the campaign, and it owns the term. A backend is a subclass that implements five steps for its claim:
|
|
80
|
+
|
|
81
|
+
| Step | What it does |
|
|
82
|
+
| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
83
|
+
| `open(signal)` | Prepares one attempt, for example a connection. The campaign calls `tryClaim` on it again and again. |
|
|
84
|
+
| `tryClaim(claim, signal)` | Tries the claim once. Resolves with the epoch of the term it won, or `undefined` while another candidate holds the claim. |
|
|
85
|
+
| `watch(claim, lose)` | Watches a won claim for as long as its term lasts. Calls `lose` when the backend takes the claim away. Returns a `Disposable` that stops the watch. |
|
|
86
|
+
| `release(claim)` | Gives a won claim up when the leader resigns. |
|
|
87
|
+
| `close(claim)` | Frees the resources of an attempt, at each end. |
|
|
88
|
+
|
|
89
|
+
A backend must keep four rules:
|
|
90
|
+
|
|
91
|
+
1. `tryClaim` gives the epoch in the same step that wins the claim. Each epoch is higher than each earlier one, and below 2^31.
|
|
92
|
+
2. `watch` calls `lose` before the backend can give the claim to another candidate. For a claim that the backend keeps only for a time, renew it well before that time ends, and count the time on a monotonic clock. Then an old leader stops before a new one starts.
|
|
93
|
+
3. The campaign never calls `release` after a loss, because the claim can be another candidate's then.
|
|
94
|
+
4. The campaign calls `close` after each end: a won term, a lost term, a failed attempt, or an attempt that never won.
|
|
95
|
+
|
|
96
|
+
This backend elects among the candidates of one process. Its claim is an entry in a map:
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
import { LeaderElection } from '@zukhruf/election';
|
|
100
|
+
|
|
101
|
+
const holders = new Map<string, bigint>();
|
|
102
|
+
let lastEpoch = 0n;
|
|
103
|
+
|
|
104
|
+
class MapElection extends LeaderElection<string> {
|
|
105
|
+
readonly #name: string;
|
|
106
|
+
|
|
107
|
+
constructor(name: string) {
|
|
108
|
+
super(10);
|
|
109
|
+
this.#name = name;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
protected async open() {
|
|
113
|
+
return this.#name;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
protected async tryClaim(name: string) {
|
|
117
|
+
if (holders.has(name)) return undefined;
|
|
118
|
+
lastEpoch += 1n;
|
|
119
|
+
holders.set(name, lastEpoch);
|
|
120
|
+
return lastEpoch;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Nothing takes an entry of the map away, so there is nothing to watch.
|
|
124
|
+
protected watch() {
|
|
125
|
+
return { [Symbol.dispose]() {} };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
protected async release(name: string) {
|
|
129
|
+
holders.delete(name);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
protected async close() {}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const term = await new MapElection('jobs').campaign();
|
|
136
|
+
console.log('leader:', term?.epoch);
|
|
137
|
+
console.log('rival:', await new MapElection('jobs').campaign());
|
|
138
|
+
await term?.resign();
|
|
139
|
+
console.log(
|
|
140
|
+
'rival after the resign:',
|
|
141
|
+
(await new MapElection('jobs').campaign())?.epoch,
|
|
142
|
+
);
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Output:
|
|
146
|
+
|
|
147
|
+
```
|
|
148
|
+
leader: 1n
|
|
149
|
+
rival: undefined
|
|
150
|
+
rival after the resign: 2n
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
The socket lock store of `@zukhruf/mutex` uses `SqliteElection`, with the claim file `leader.lock` and the epoch file `leader.epoch`. Its leader serves the other candidates over a socket in the directory, so it works on one host only, also with another backend. Its server stops when `term.signal` aborts. `@zukhruf/single-flight` does not use this package yet, and keeps its own copy of the election ([copied code](./docs/copied-code.md)).
|
|
154
|
+
|
|
155
|
+
## Errors
|
|
156
|
+
|
|
157
|
+
| Error | When |
|
|
158
|
+
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
159
|
+
| `NetworkDirectoryError` | The directory of a `SqliteElection` is on a network file system. It is the class of `@zukhruf/fs`. |
|
|
160
|
+
| `LeaseLostError` | The reason of `term.signal` when the backend took the claim away. Its `subject` is `'leadership'`, and its `cause` is the backend's reason. It is the class of `@zukhruf/lease`, and this package exports it too. |
|
|
161
|
+
| `signal.reason` | A campaign rejects with it when its signal aborts. |
|
|
162
|
+
|
|
163
|
+
## Documentation
|
|
164
|
+
|
|
165
|
+
- [How SqliteElection elects a leader](./docs/concepts/leader-election.md)
|
|
166
|
+
- [Recipe: Run a job in only one process](./docs/recipes/singleton-job-with-leader-election.md)
|
|
167
|
+
- [ADR 0001: Leader election is a package, and each backend is a subclass of one campaign](./docs/adr/0001-leader-election-is-a-package-and-each-backend-is-a-subclass.md)
|
|
168
|
+
- [ADR 0002: A term is a lease on leadership, with no fencing](./docs/adr/0002-a-term-is-a-lease-on-leadership-with-no-fencing.md)
|
|
169
|
+
- [Code copied into this package](./docs/copied-code.md)
|
|
170
|
+
|
|
171
|
+
## Development
|
|
172
|
+
|
|
173
|
+
This package is part of the [zukhruf](../../README.md) workspace. Run the commands from the workspace root.
|
|
174
|
+
|
|
175
|
+
```sh
|
|
176
|
+
npm install
|
|
177
|
+
npx nx run election:test # builds, then runs the tests in src/
|
|
178
|
+
npx nx run election:typecheck # formats, lints, then type checks
|
|
179
|
+
npx nx run election:build # compiles src/ to dist/
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
```
|
|
183
|
+
src/
|
|
184
|
+
leader-election.ts LeaderElection: the campaign, and the steps of a backend
|
|
185
|
+
term.ts Term: the epoch, the lease on leadership, and resign
|
|
186
|
+
sqlite/ SqliteElection: the backend of one host
|
|
187
|
+
testing/ the helpers that start candidate processes in the tests
|
|
188
|
+
```
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { type CampaignOptions, LeaderElection } from './leader-election.ts';
|
|
2
|
+
export { Term, type TermSteps } from './term.ts';
|
|
3
|
+
export { SqliteElection, type SqliteElectionOptions, } from './sqlite/sqlite-election.ts';
|
|
4
|
+
export { NetworkDirectoryError } from '@zukhruf/fs';
|
|
5
|
+
export { LeaseLostError } from '@zukhruf/lease';
|
|
6
|
+
//# 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,KAAK,eAAe,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAC5E,OAAO,EAAE,IAAI,EAAE,KAAK,SAAS,EAAE,MAAM,WAAW,CAAC;AACjD,OAAO,EACL,cAAc,EACd,KAAK,qBAAqB,GAC3B,MAAM,6BAA6B,CAAC;AACrC,OAAO,EAAE,qBAAqB,EAAE,MAAM,aAAa,CAAC;AACpD,OAAO,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { LeaderElection } from "./leader-election.js";
|
|
2
|
+
export { Term } from "./term.js";
|
|
3
|
+
export { SqliteElection, } from "./sqlite/sqlite-election.js";
|
|
4
|
+
export { NetworkDirectoryError } from '@zukhruf/fs';
|
|
5
|
+
export { LeaseLostError } from '@zukhruf/lease';
|
|
6
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAwB,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAC5E,OAAO,EAAE,IAAI,EAAkB,MAAM,WAAW,CAAC;AACjD,OAAO,EACL,cAAc,GAEf,MAAM,6BAA6B,CAAC;AACrC,OAAO,EAAE,qBAAqB,EAAE,MAAM,aAAa,CAAC;AACpD,OAAO,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC"}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Term } from './term.ts';
|
|
2
|
+
export interface CampaignOptions {
|
|
3
|
+
/** Milliseconds to keep trying before conceding to the current leader. Defaults to one attempt. */
|
|
4
|
+
timeout?: number | undefined;
|
|
5
|
+
/** Stops the campaign, which then rejects with `signal.reason`. A term won after it aborted is given up. */
|
|
6
|
+
signal?: AbortSignal | undefined;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Elects one leader among candidates. The campaign is the same for each
|
|
10
|
+
* backend: open an attempt, try the claim until it is won or the time is
|
|
11
|
+
* up, watch the won claim, and hand it to a term. Each backend is a
|
|
12
|
+
* subclass that implements the steps for its claim.
|
|
13
|
+
*
|
|
14
|
+
* A backend keeps four rules:
|
|
15
|
+
* - `tryClaim` returns the epoch of the term it won, in the same step, and
|
|
16
|
+
* each epoch is higher than every earlier one and below 2^31.
|
|
17
|
+
* - `watch` calls `lose` before the backend can grant the claim to another
|
|
18
|
+
* candidate, so an old leader stops before a new one starts.
|
|
19
|
+
* - `release` is never called after a loss: the claim may be another
|
|
20
|
+
* candidate's by then.
|
|
21
|
+
* - `close` frees the attempt's resources, after each end.
|
|
22
|
+
*/
|
|
23
|
+
export declare abstract class LeaderElection<Claim> {
|
|
24
|
+
#private;
|
|
25
|
+
protected constructor(pollInterval: number);
|
|
26
|
+
/** Resolves with the new term, or `undefined` when another candidate still leads after `timeout`. */
|
|
27
|
+
campaign({ timeout, signal }?: CampaignOptions): Promise<Term | undefined>;
|
|
28
|
+
/** Prepares one attempt: the resources that `tryClaim` uses again and again. */
|
|
29
|
+
protected abstract open(signal: AbortSignal | undefined): Promise<Claim>;
|
|
30
|
+
/** Tries the claim once. Resolves with the epoch of the term it won, or `undefined` while another candidate holds it. */
|
|
31
|
+
protected abstract tryClaim(claim: Claim, signal: AbortSignal | undefined): Promise<bigint | undefined>;
|
|
32
|
+
/** Watches a won claim for as long as its term lasts, and calls `lose` when the backend takes it away. */
|
|
33
|
+
protected abstract watch(claim: Claim, lose: (reason: Error) => void): Disposable;
|
|
34
|
+
/** Gives a won claim up. Never called after a loss. */
|
|
35
|
+
protected abstract release(claim: Claim): Promise<void>;
|
|
36
|
+
/** Frees the resources of an attempt, whether it won, lost or failed. */
|
|
37
|
+
protected abstract close(claim: Claim): Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
//# sourceMappingURL=leader-election.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"leader-election.d.ts","sourceRoot":"","sources":["../src/leader-election.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAEjC,MAAM,WAAW,eAAe;IAC9B,mGAAmG;IACnG,OAAO,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC7B,4GAA4G;IAC5G,MAAM,CAAC,EAAE,WAAW,GAAG,SAAS,CAAC;CAClC;AAED;;;;;;;;;;;;;;GAcG;AACH,8BAAsB,cAAc,CAAC,KAAK;;IAGxC,SAAS,aAAa,YAAY,EAAE,MAAM;IAI1C,qGAAqG;IAC/F,QAAQ,CAAC,EAAE,OAAW,EAAE,MAAM,EAAE,GAAE,eAAoB,GAAG,OAAO,CACpE,IAAI,GAAG,SAAS,CACjB;IAUD,gFAAgF;IAChF,SAAS,CAAC,QAAQ,CAAC,IAAI,CAAC,MAAM,EAAE,WAAW,GAAG,SAAS,GAAG,OAAO,CAAC,KAAK,CAAC;IAExE,yHAAyH;IACzH,SAAS,CAAC,QAAQ,CAAC,QAAQ,CACzB,KAAK,EAAE,KAAK,EACZ,MAAM,EAAE,WAAW,GAAG,SAAS,GAC9B,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC;IAE9B,0GAA0G;IAC1G,SAAS,CAAC,QAAQ,CAAC,KAAK,CACtB,KAAK,EAAE,KAAK,EACZ,IAAI,EAAE,CAAC,MAAM,EAAE,KAAK,KAAK,IAAI,GAC5B,UAAU;IAEb,uDAAuD;IACvD,SAAS,CAAC,QAAQ,CAAC,OAAO,CAAC,KAAK,EAAE,KAAK,GAAG,OAAO,CAAC,IAAI,CAAC;IAEvD,yEAAyE;IACzE,SAAS,CAAC,QAAQ,CAAC,KAAK,CAAC,KAAK,EAAE,KAAK,GAAG,OAAO,CAAC,IAAI,CAAC;CAkDtD"}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
2
|
+
import { untilAborted } from '@zukhruf/async';
|
|
3
|
+
import { Term } from "./term.js";
|
|
4
|
+
/**
|
|
5
|
+
* Elects one leader among candidates. The campaign is the same for each
|
|
6
|
+
* backend: open an attempt, try the claim until it is won or the time is
|
|
7
|
+
* up, watch the won claim, and hand it to a term. Each backend is a
|
|
8
|
+
* subclass that implements the steps for its claim.
|
|
9
|
+
*
|
|
10
|
+
* A backend keeps four rules:
|
|
11
|
+
* - `tryClaim` returns the epoch of the term it won, in the same step, and
|
|
12
|
+
* each epoch is higher than every earlier one and below 2^31.
|
|
13
|
+
* - `watch` calls `lose` before the backend can grant the claim to another
|
|
14
|
+
* candidate, so an old leader stops before a new one starts.
|
|
15
|
+
* - `release` is never called after a loss: the claim may be another
|
|
16
|
+
* candidate's by then.
|
|
17
|
+
* - `close` frees the attempt's resources, after each end.
|
|
18
|
+
*/
|
|
19
|
+
export class LeaderElection {
|
|
20
|
+
#pollInterval;
|
|
21
|
+
constructor(pollInterval) {
|
|
22
|
+
this.#pollInterval = pollInterval;
|
|
23
|
+
}
|
|
24
|
+
/** Resolves with the new term, or `undefined` when another candidate still leads after `timeout`. */
|
|
25
|
+
async campaign({ timeout = 0, signal } = {}) {
|
|
26
|
+
signal?.throwIfAborted();
|
|
27
|
+
const deadline = performance.now() + timeout;
|
|
28
|
+
for (;;) {
|
|
29
|
+
const outcome = await this.#attempt(deadline, signal);
|
|
30
|
+
// A claim lost before its term began is no term: try again with a new attempt.
|
|
31
|
+
if (outcome !== 'lost')
|
|
32
|
+
return outcome;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
async #attempt(deadline, signal) {
|
|
36
|
+
const claim = await this.open(signal);
|
|
37
|
+
let epoch;
|
|
38
|
+
try {
|
|
39
|
+
for (;;) {
|
|
40
|
+
epoch = await this.tryClaim(claim, signal);
|
|
41
|
+
if (epoch !== undefined)
|
|
42
|
+
break;
|
|
43
|
+
if (performance.now() >= deadline)
|
|
44
|
+
break;
|
|
45
|
+
await untilAborted(delay(this.#pollInterval, undefined, { signal }), signal);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
await this.close(claim).catch(() => { });
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
if (epoch === undefined) {
|
|
53
|
+
await this.close(claim);
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
return this.#begin(claim, epoch, signal);
|
|
57
|
+
}
|
|
58
|
+
/** Hands a won claim to its term, or gives it up when the caller left meanwhile. */
|
|
59
|
+
async #begin(claim, epoch, signal) {
|
|
60
|
+
let term;
|
|
61
|
+
try {
|
|
62
|
+
signal?.throwIfAborted();
|
|
63
|
+
term = new Term(epoch, {
|
|
64
|
+
watch: (lose) => this.watch(claim, lose),
|
|
65
|
+
release: () => this.release(claim),
|
|
66
|
+
close: () => this.close(claim),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
await this.release(claim).catch(() => { });
|
|
71
|
+
await this.close(claim).catch(() => { });
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
return term.signal.aborted ? 'lost' : term;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
//# sourceMappingURL=leader-election.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"leader-election.js","sourceRoot":"","sources":["../src/leader-election.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { FileLock } from '@zukhruf/fs';
|
|
2
|
+
import { LeaderElection } from '../leader-election.ts';
|
|
3
|
+
export interface SqliteElectionOptions {
|
|
4
|
+
/** The local folder where the candidates meet. */
|
|
5
|
+
directory: string;
|
|
6
|
+
/**
|
|
7
|
+
* The file in `directory` whose file lock is the claim.
|
|
8
|
+
* Never delete it while candidates run: a new file would let a second
|
|
9
|
+
* leader win.
|
|
10
|
+
*/
|
|
11
|
+
claimFile: string;
|
|
12
|
+
/** The file in `directory` that records the epoch of the last term, as decimal text. */
|
|
13
|
+
epochFile: string;
|
|
14
|
+
/** Milliseconds between two tries while another process leads. Defaults to 10. */
|
|
15
|
+
pollInterval?: number | undefined;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Elects one leader among the processes of one host that share `directory`.
|
|
19
|
+
* The claim is the file lock of `claimFile`, held for the whole term. The
|
|
20
|
+
* kernel keeps that lock until the leader's process dies, so a living leader
|
|
21
|
+
* never loses its term, and a dead one frees it at once.
|
|
22
|
+
*/
|
|
23
|
+
export declare class SqliteElection extends LeaderElection<FileLock> {
|
|
24
|
+
#private;
|
|
25
|
+
constructor({ directory, claimFile, epochFile, pollInterval, }: SqliteElectionOptions);
|
|
26
|
+
protected open(): Promise<FileLock>;
|
|
27
|
+
protected tryClaim(claim: FileLock): Promise<bigint | undefined>;
|
|
28
|
+
/** The kernel holds the claim for as long as the process lives, so there is nothing to watch. */
|
|
29
|
+
protected watch(): Disposable;
|
|
30
|
+
protected release(claim: FileLock): Promise<void>;
|
|
31
|
+
protected close(claim: FileLock): Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
//# sourceMappingURL=sqlite-election.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sqlite-election.d.ts","sourceRoot":"","sources":["../../src/sqlite/sqlite-election.ts"],"names":[],"mappings":"AAGA,OAAO,EACL,QAAQ,EAIT,MAAM,aAAa,CAAC;AAErB,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAEvD,MAAM,WAAW,qBAAqB;IACpC,kDAAkD;IAClD,SAAS,EAAE,MAAM,CAAC;IAClB;;;;OAIG;IACH,SAAS,EAAE,MAAM,CAAC;IAClB,wFAAwF;IACxF,SAAS,EAAE,MAAM,CAAC;IAClB,kFAAkF;IAClF,YAAY,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CACnC;AAED;;;;;GAKG;AACH,qBAAa,cAAe,SAAQ,cAAc,CAAC,QAAQ,CAAC;;gBAK9C,EACV,SAAS,EACT,SAAS,EACT,SAAS,EACT,YAAiB,GAClB,EAAE,qBAAqB;cAOR,IAAI,IAAI,OAAO,CAAC,QAAQ,CAAC;cAMzB,QAAQ,CAAC,KAAK,EAAE,QAAQ,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC;IAQtE,iGAAiG;IACjG,SAAS,CAAC,KAAK,IAAI,UAAU;cAIb,OAAO,CAAC,KAAK,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC;cAIvC,KAAK,CAAC,KAAK,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC;CAGtD"}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { mkdir, readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { FileLock, assertLocalDirectory, durableWrite, isErrno, } from '@zukhruf/fs';
|
|
4
|
+
import { LeaderElection } from "../leader-election.js";
|
|
5
|
+
/**
|
|
6
|
+
* Elects one leader among the processes of one host that share `directory`.
|
|
7
|
+
* The claim is the file lock of `claimFile`, held for the whole term. The
|
|
8
|
+
* kernel keeps that lock until the leader's process dies, so a living leader
|
|
9
|
+
* never loses its term, and a dead one frees it at once.
|
|
10
|
+
*/
|
|
11
|
+
export class SqliteElection extends LeaderElection {
|
|
12
|
+
#directory;
|
|
13
|
+
#claimPath;
|
|
14
|
+
#epochPath;
|
|
15
|
+
constructor({ directory, claimFile, epochFile, pollInterval = 10, }) {
|
|
16
|
+
super(pollInterval);
|
|
17
|
+
this.#directory = directory;
|
|
18
|
+
this.#claimPath = join(directory, claimFile);
|
|
19
|
+
this.#epochPath = join(directory, epochFile);
|
|
20
|
+
}
|
|
21
|
+
async open() {
|
|
22
|
+
await assertLocalDirectory(this.#directory);
|
|
23
|
+
await mkdir(this.#directory, { recursive: true });
|
|
24
|
+
return FileLock.open(this.#claimPath);
|
|
25
|
+
}
|
|
26
|
+
async tryClaim(claim) {
|
|
27
|
+
if (!claim.tryLock())
|
|
28
|
+
return undefined;
|
|
29
|
+
// Safe without further locking: only the holder of the claim gets here.
|
|
30
|
+
const epoch = (await readEpoch(this.#epochPath)) + 1n;
|
|
31
|
+
await durableWrite(this.#epochPath, epoch.toString());
|
|
32
|
+
return epoch;
|
|
33
|
+
}
|
|
34
|
+
/** The kernel holds the claim for as long as the process lives, so there is nothing to watch. */
|
|
35
|
+
watch() {
|
|
36
|
+
return { [Symbol.dispose]() { } };
|
|
37
|
+
}
|
|
38
|
+
async release(claim) {
|
|
39
|
+
claim.unlock();
|
|
40
|
+
}
|
|
41
|
+
async close(claim) {
|
|
42
|
+
claim[Symbol.dispose]();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async function readEpoch(path) {
|
|
46
|
+
try {
|
|
47
|
+
return BigInt(await readFile(path, 'utf8'));
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
if (isErrno(error, 'ENOENT'))
|
|
51
|
+
return 0n;
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
//# sourceMappingURL=sqlite-election.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sqlite-election.js","sourceRoot":"","sources":["../../src/sqlite/sqlite-election.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AACnD,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAEjC,OAAO,EACL,QAAQ,EACR,oBAAoB,EACpB,YAAY,EACZ,OAAO,GACR,MAAM,aAAa,CAAC;AAErB,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAiBvD;;;;;GAKG;AACH,MAAM,OAAO,cAAe,SAAQ,cAAwB;IACjD,UAAU,CAAS;IACnB,UAAU,CAAS;IACnB,UAAU,CAAS;IAE5B,YAAY,EACV,SAAS,EACT,SAAS,EACT,SAAS,EACT,YAAY,GAAG,EAAE,GACK;QACtB,KAAK,CAAC,YAAY,CAAC,CAAC;QACpB,IAAI,CAAC,UAAU,GAAG,SAAS,CAAC;QAC5B,IAAI,CAAC,UAAU,GAAG,IAAI,CAAC,SAAS,EAAE,SAAS,CAAC,CAAC;QAC7C,IAAI,CAAC,UAAU,GAAG,IAAI,CAAC,SAAS,EAAE,SAAS,CAAC,CAAC;IAC/C,CAAC;IAES,KAAK,CAAC,IAAI;QAClB,MAAM,oBAAoB,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;QAC5C,MAAM,KAAK,CAAC,IAAI,CAAC,UAAU,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;QAClD,OAAO,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;IACxC,CAAC;IAES,KAAK,CAAC,QAAQ,CAAC,KAAe;QACtC,IAAI,CAAC,KAAK,CAAC,OAAO,EAAE;YAAE,OAAO,SAAS,CAAC;QACvC,wEAAwE;QACxE,MAAM,KAAK,GAAG,CAAC,MAAM,SAAS,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC,GAAG,EAAE,CAAC;QACtD,MAAM,YAAY,CAAC,IAAI,CAAC,UAAU,EAAE,KAAK,CAAC,QAAQ,EAAE,CAAC,CAAC;QACtD,OAAO,KAAK,CAAC;IACf,CAAC;IAED,iGAAiG;IACvF,KAAK;QACb,OAAO,EAAE,CAAC,MAAM,CAAC,OAAO,CAAC,KAAI,CAAC,EAAE,CAAC;IACnC,CAAC;IAES,KAAK,CAAC,OAAO,CAAC,KAAe;QACrC,KAAK,CAAC,MAAM,EAAE,CAAC;IACjB,CAAC;IAES,KAAK,CAAC,KAAK,CAAC,KAAe;QACnC,KAAK,CAAC,MAAM,CAAC,OAAO,CAAC,EAAE,CAAC;IAC1B,CAAC;CACF;AAED,KAAK,UAAU,SAAS,CAAC,IAAY;IACnC,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"}
|
package/dist/term.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type Lease } from '@zukhruf/lease';
|
|
2
|
+
/** What a term needs from its election to end: the backend's steps for the claim it won. */
|
|
3
|
+
export interface TermSteps {
|
|
4
|
+
/** Starts watching the claim; `lose` reports that the backend took it away. */
|
|
5
|
+
watch(lose: (reason: Error) => void): Disposable;
|
|
6
|
+
/** Gives the claim up. Called only for a term that was not lost. */
|
|
7
|
+
release(): Promise<void>;
|
|
8
|
+
/** Frees the claim's resources. Called at each end. */
|
|
9
|
+
close(): Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* One term of the elected leader: a lease on leadership. It ends when the
|
|
13
|
+
* leader resigns, when its process dies, or when the backend takes the claim
|
|
14
|
+
* away. Only the last one can happen while the leader still runs, and then
|
|
15
|
+
* `signal` aborts with `LeaseLostError`.
|
|
16
|
+
*/
|
|
17
|
+
export declare class Term implements Lease, AsyncDisposable {
|
|
18
|
+
#private;
|
|
19
|
+
/** Higher than the epoch of each earlier term, so a newer leader can always outrank an older one. */
|
|
20
|
+
readonly epoch: bigint;
|
|
21
|
+
constructor(epoch: bigint, steps: TermSteps);
|
|
22
|
+
/** Aborts with `LeaseLostError` when the term ends while its leader still runs. */
|
|
23
|
+
get signal(): AbortSignal;
|
|
24
|
+
/**
|
|
25
|
+
* Ends the term and gives the claim up. A second call waits for the first.
|
|
26
|
+
* After a loss, it only waits until the claim's resources are free, and it
|
|
27
|
+
* never rejects.
|
|
28
|
+
*/
|
|
29
|
+
resign(): Promise<void>;
|
|
30
|
+
[Symbol.asyncDispose](): Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
//# sourceMappingURL=term.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"term.d.ts","sourceRoot":"","sources":["../src/term.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,KAAK,KAAK,EAAmB,MAAM,gBAAgB,CAAC;AAE7D,4FAA4F;AAC5F,MAAM,WAAW,SAAS;IACxB,+EAA+E;IAC/E,KAAK,CAAC,IAAI,EAAE,CAAC,MAAM,EAAE,KAAK,KAAK,IAAI,GAAG,UAAU,CAAC;IACjD,oEAAoE;IACpE,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACzB,uDAAuD;IACvD,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAKD;;;;;GAKG;AACH,qBAAa,IAAK,YAAW,KAAK,EAAE,eAAe;;IACjD,qGAAqG;IACrG,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;gBAQX,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS;IAM3C,mFAAmF;IACnF,IAAI,MAAM,IAAI,WAAW,CAExB;IAED;;;;OAIG;IACG,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC;IAU7B,CAAC,MAAM,CAAC,YAAY,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC;CA+CvC"}
|
package/dist/term.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { Latch } from '@zukhruf/async';
|
|
2
|
+
import { LeaseController } from '@zukhruf/lease';
|
|
3
|
+
/**
|
|
4
|
+
* One term of the elected leader: a lease on leadership. It ends when the
|
|
5
|
+
* leader resigns, when its process dies, or when the backend takes the claim
|
|
6
|
+
* away. Only the last one can happen while the leader still runs, and then
|
|
7
|
+
* `signal` aborts with `LeaseLostError`.
|
|
8
|
+
*/
|
|
9
|
+
export class Term {
|
|
10
|
+
/** Higher than the epoch of each earlier term, so a newer leader can always outrank an older one. */
|
|
11
|
+
epoch;
|
|
12
|
+
#steps;
|
|
13
|
+
#lease = new LeaseController('leadership');
|
|
14
|
+
/** Aborts once the term starts to end, by resign or by loss. */
|
|
15
|
+
#ending = new AbortController();
|
|
16
|
+
#ended = new Latch();
|
|
17
|
+
#watching = new DisposableStack();
|
|
18
|
+
constructor(epoch, steps) {
|
|
19
|
+
this.epoch = epoch;
|
|
20
|
+
this.#steps = steps;
|
|
21
|
+
this.#watching.use(steps.watch((reason) => this.#lose(reason)));
|
|
22
|
+
}
|
|
23
|
+
/** Aborts with `LeaseLostError` when the term ends while its leader still runs. */
|
|
24
|
+
get signal() {
|
|
25
|
+
return this.#lease.signal;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Ends the term and gives the claim up. A second call waits for the first.
|
|
29
|
+
* After a loss, it only waits until the claim's resources are free, and it
|
|
30
|
+
* never rejects.
|
|
31
|
+
*/
|
|
32
|
+
async resign() {
|
|
33
|
+
if (!this.#ending.signal.aborted) {
|
|
34
|
+
this.#ending.abort();
|
|
35
|
+
this.#lease.end();
|
|
36
|
+
this.#ended.open(await this.#giveUp());
|
|
37
|
+
}
|
|
38
|
+
const ending = await this.#ended.wait();
|
|
39
|
+
if (ending.failed)
|
|
40
|
+
throw ending.error;
|
|
41
|
+
}
|
|
42
|
+
[Symbol.asyncDispose]() {
|
|
43
|
+
return this.resign();
|
|
44
|
+
}
|
|
45
|
+
#lose(reason) {
|
|
46
|
+
// After a resign began, the lease ignores the loss: the leader gave the
|
|
47
|
+
// claim up on purpose, so it is not told.
|
|
48
|
+
this.#lease.lose(reason);
|
|
49
|
+
// The claim is freed once, by the resign or by the first loss.
|
|
50
|
+
if (this.#ending.signal.aborted)
|
|
51
|
+
return;
|
|
52
|
+
this.#ending.abort();
|
|
53
|
+
// Freed one step later: a watch that reports a loss before it returns is
|
|
54
|
+
// held by then, so it stops before its claim is freed.
|
|
55
|
+
void Promise.resolve()
|
|
56
|
+
.then(() => this.#freeLost())
|
|
57
|
+
.then((ending) => this.#ended.open(ending));
|
|
58
|
+
}
|
|
59
|
+
async #giveUp() {
|
|
60
|
+
try {
|
|
61
|
+
this.#watching.dispose();
|
|
62
|
+
await this.#steps.release();
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
await this.#steps.close().catch(() => { });
|
|
66
|
+
return { failed: true, error };
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
await this.#steps.close();
|
|
70
|
+
return { failed: false };
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
return { failed: true, error };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* A lost claim is no longer this term's to give up: a release could undo
|
|
78
|
+
* the next leader's claim. Only its resources are freed, and a loss has no
|
|
79
|
+
* caller to report a failure to.
|
|
80
|
+
*/
|
|
81
|
+
async #freeLost() {
|
|
82
|
+
try {
|
|
83
|
+
this.#watching.dispose();
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
await this.#steps.close().catch(() => { });
|
|
87
|
+
}
|
|
88
|
+
return { failed: false };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
//# sourceMappingURL=term.js.map
|
package/dist/term.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"term.js","sourceRoot":"","sources":["../src/term.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,48 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zukhruf/election",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.3.17",
|
|
4
|
+
"description": "Elect one leader among the processes that share a directory, with a term that ends when the leader dies and an epoch that grows with each term. Backends are subclasses of one campaign.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/JanuaryLabs/zukhruf",
|
|
9
|
+
"directory": "packages/election"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/JanuaryLabs/zukhruf/issues"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/JanuaryLabs/zukhruf/tree/main/packages/election#readme",
|
|
15
|
+
"keywords": [
|
|
16
|
+
"leader-election",
|
|
17
|
+
"election",
|
|
18
|
+
"leader",
|
|
19
|
+
"epoch",
|
|
20
|
+
"inter-process",
|
|
21
|
+
"sqlite"
|
|
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/async": "^0.3.13",
|
|
45
|
+
"@zukhruf/fs": "^0.3.16",
|
|
46
|
+
"@zukhruf/lease": "^0.3.16"
|
|
47
|
+
}
|
|
48
|
+
}
|