loqkit 0.0.0-stage → 0.0.2

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 Reggie Brooks
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,155 @@
1
- # Temporary Holding Version
1
+ # loqkit
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
+ Typed memoization and layered locking for JavaScript runtimes.
4
+
5
+ ## Locking
6
+
7
+ ```ts
8
+ import { withLock } from "loqkit";
9
+
10
+ const result = await withLock(
11
+ { resource: "cache" },
12
+ async ({ resource, lock }) => {
13
+ console.log(`locked ${resource} with ${lock.backends.length} layers`);
14
+ },
15
+ {
16
+ key: "cache-refresh",
17
+ predicate: async ({ resource, state }) => {
18
+ return state === "predicate-after"
19
+ ? await stillNeedsRefresh(resource)
20
+ : true;
21
+ },
22
+ onCheckFailed: async ({ resource, state }) => {
23
+ console.log(`${resource} skipped in ${state}`);
24
+ },
25
+ },
26
+ );
27
+
28
+ console.log(result.value, result.context.state, result.context.lock.waitedMs);
29
+ ```
30
+
31
+ By default, `withLock` acquires memory, worker-thread, and file locks for the same key. The argument after the action can be a predicate function or an options object containing `predicate`. The predicate runs once before waiting and again after all three layers are held. The action runs only when both checks pass.
32
+
33
+ Every callback receives `ctx.state`: `"predicate-before"` for the first check, `"predicate-after"` for the check after acquisition, and `"executed"` for the action. `ctx.acquired` is the corresponding convenience boolean. When either check fails, `onCheckFailed(ctx)` runs after any acquired layers have been released. It can inspect `ctx.state`, log, reread data, or start other lock work without holding this lock. For this hook, `acquired` describes the context snapshot; it does not mean the hook currently holds the lock.
34
+
35
+ `withLock` returns `{ value, context }`. `value` is the action's result, or `undefined` when the action did not run. The returned context has the same fields and state values as a callback context, with `"timeout"` added because a non-throwing timeout can only be observed in the result. Lock metadata, including wait time, is available on `result.context.lock`.
36
+
37
+ Omitting `timeout` waits indefinitely. A numeric timeout throws `LockTimeoutError` when it expires. Use the object form to return a timeout result instead. A zero timeout makes one non-blocking acquisition attempt and never sleeps or polls a second time:
38
+
39
+ ```ts
40
+ const attempt = await withLock(context, action, {
41
+ timeout: { ms: 0, error: false },
42
+ });
43
+
44
+ if (attempt.context.state === "timeout") {
45
+ console.log("Lock is busy");
46
+ }
47
+ ```
48
+
49
+ Use a narrower scope only when you deliberately need one boundary:
50
+
51
+ ```ts
52
+ await withLock(context, action, { scope: "memory" });
53
+ await withLock(context, action, { scope: ["memory", "file"] });
54
+ ```
55
+
56
+ `scope` accepts one backend or a non-empty array. Omit it to acquire all three. Selected layers always run in `memory`, `worker`, `file` order regardless of the array's input order.
57
+
58
+ ## Memoization
59
+
60
+ `memoize` turns a zero-argument factory into a getter that computes once and reuses its successful result. A synchronous factory stays synchronous. An asynchronous or thenable factory returns a native promise, and concurrent callers share the same in-flight work.
61
+
62
+ ```ts
63
+ import { memoize } from "loqkit";
64
+
65
+ const getConfig = memoize(() => ({ region: "us-east-1" }));
66
+ const config = getConfig();
67
+
68
+ const getCatalog = memoize(async () => {
69
+ const response = await fetch("https://example.com/catalog.json");
70
+ return response.json();
71
+ });
72
+
73
+ const catalog = await getCatalog();
74
+ ```
75
+
76
+ Successful results remain cached for the lifetime of the memoized getter. Thrown errors and rejected promises are not cached, so the next call retries. There is no TTL or expiration behavior.
77
+
78
+ ```mermaid
79
+ flowchart TD
80
+ call["Call memoized getter"] --> cached{"Successful result cached?"}
81
+ cached -- "Yes" --> reuse["Return cached value or promise"]
82
+ cached -- "No" --> factory["Run factory"]
83
+ factory --> kind{"Thenable result?"}
84
+ kind -- "No" --> storeValue["Cache and return value"]
85
+ kind -- "Yes" --> storePromise["Cache and return in-flight promise"]
86
+ storePromise --> outcome{"Promise outcome"}
87
+ outcome -- "Resolved" --> keep["Keep successful promise"]
88
+ outcome -- "Rejected" --> evict["Evict so the next call retries"]
89
+ ```
90
+
91
+ ## How locking works
92
+
93
+ `withLock` is a key-scoped lock. Calls with the same namespace and key run one at a time; calls with different keys can run concurrently. If no key is supplied, loqkit hashes the context into a stable key.
94
+
95
+ When `scope` is omitted, loqkit acquires these layers in order and releases them in reverse:
96
+
97
+ 1. An in-memory [`async-mutex`](https://github.com/DirtyHairy/async-mutex) lock serializes callers in the current JavaScript isolate.
98
+ 2. Node's worker lock manager coordinates worker threads when the runtime provides it. In the default layered mode, the file layer still preserves coordination when that API is unavailable; an explicit worker-only lock reports `LockUnavailableError` instead.
99
+ 3. A file lock coordinates processes on the same host. On macOS and Linux, `fileStrategy: "auto"` prefers the optional native [`fs-ext`](https://github.com/baudehlo/node-fs-ext) `flock` implementation. If native locking is unavailable or acquisition fails—for example, because a network filesystem does not support `flock`—loqkit transparently falls back to [`proper-lockfile`](https://github.com/moxystudio/node-proper-lockfile) polling.
100
+
101
+ `fileStrategy: "auto"` is the default and the only mode that falls back from native `flock` to portable polling. Set `fileStrategy: "flock"` to require native locking; it throws `LockUnavailableError` when native support is unavailable or acquisition fails. Set `fileStrategy: "poll"` to skip native locking entirely. A contention response stays on `flock`; switching protocols while another caller holds a native lock would break exclusivity. Filesystems that incorrectly report a successful native lock cannot be detected, so configure `"poll"` for a network mount known to have unreliable `flock` semantics. `lockDirectory` or `LOQKIT_LOCK_DIR` controls where file locks are stored.
102
+
103
+ ```mermaid
104
+ flowchart TD
105
+ call["Call withLock"] --> options["Validate options and choose a key"]
106
+ options --> checking["Create predicate-before context"]
107
+ checking --> before{"Predicate passes before waiting?"}
108
+ before -- "No" --> failedBefore["Run onCheckFailed without a lock"]
109
+ failedBefore --> skippedBefore["Return predicate-before context"]
110
+ before -- "Yes or absent" --> memory["Acquire memory lock"]
111
+ memory --> worker["Acquire worker-thread lock"]
112
+ worker --> file["Acquire native flock or polling file lock"]
113
+ memory -. "Timeout" .-> timeout{"timeout.error?"}
114
+ worker -. "Timeout" .-> timeout
115
+ file -. "Timeout" .-> timeout
116
+ timeout -- "Yes" --> timeoutError["Throw LockTimeoutError"]
117
+ timeout -- "No" --> timeoutResult["Return timeout context"]
118
+ file --> acquired["Create predicate-after context with all layers acquired"]
119
+ acquired --> after{"Predicate still passes?"}
120
+ after -- "No" --> releaseFailed["Release all layers"]
121
+ releaseFailed --> failedAfter["Run onCheckFailed without a lock"]
122
+ failedAfter --> skippedAfter["Return predicate-after context"]
123
+ after -- "Yes or absent" --> action["Run action with executed context"]
124
+ action --> release["Release file, worker, then memory"]
125
+ release --> result["Return value and executed context"]
126
+ ```
127
+
128
+ The second predicate check matters when another caller changes the underlying state while this caller is waiting:
129
+
130
+ ```mermaid
131
+ sequenceDiagram
132
+ participant A as Caller A
133
+ participant B as Caller B
134
+ participant L as Layered lock for one key
135
+
136
+ A->>L: Acquire memory, worker, file
137
+ L-->>A: Granted
138
+ B->>B: Check predicate
139
+ B->>L: Acquire same layers
140
+ Note over B,L: Caller B waits
141
+ A->>A: Run action
142
+ A-->>L: Release all layers
143
+ L-->>B: Granted
144
+ B->>B: Check predicate again
145
+ B->>B: Run action if still needed
146
+ B-->>L: Release all layers
147
+ ```
148
+
149
+ The default file directory is local to the host. Containers coordinate only when they share the configured lock directory, and separate hosts still need a shared lock service or database-backed lock.
150
+
151
+ ## Releasing
152
+
153
+ Run `bun run release` from a clean, synchronized `main` branch. Patch is the default increment; `minor`, `major`, and `--dry-run` are also supported. The existing untagged npm `0.0.1` requires the one-time bootstrap form `bun run release -- --initial`.
154
+
155
+ The local command verifies the Git identity, derives the next version from remote GitHub tags, runs the full suite, and audits the package archive before atomically pushing the release commit and tag. [The tag workflow](.github/workflows/publish.yml) repeats verification because release tags are not protected, then publishes the exact audited archive through npm Trusted Publishing with provenance. It does not use an npm token.
package/package.json CHANGED
@@ -1,6 +1,70 @@
1
1
  {
2
2
  "name": "loqkit",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.0.2",
4
+ "description": "Typed memoization and layered memory, worker-thread, and file locks for JavaScript runtimes.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/regbo/loqkit.git"
8
+ },
9
+ "bugs": {
10
+ "url": "https://github.com/regbo/loqkit/issues"
11
+ },
12
+ "homepage": "https://github.com/regbo/loqkit#readme",
13
+ "type": "module",
14
+ "packageManager": "bun@1.4.4",
15
+ "sideEffects": false,
16
+ "exports": {
17
+ ".": {
18
+ "types": "./release/index.d.ts",
19
+ "import": "./release/index.js"
20
+ }
21
+ },
22
+ "types": "./release/index.d.ts",
23
+ "files": [
24
+ "release",
25
+ "README.md",
26
+ "LICENSE"
27
+ ],
28
+ "scripts": {
29
+ "build": "tsc -p tsconfig.json",
30
+ "check": "bun run typecheck && bun test && bun run build",
31
+ "prepack": "bun scripts/prepack.ts",
32
+ "release": "bun scripts/release.ts",
33
+ "test": "bun test",
34
+ "typecheck": "tsc -p tsconfig.json --noEmit"
35
+ },
36
+ "keywords": [
37
+ "lock",
38
+ "mutex",
39
+ "concurrency",
40
+ "flock",
41
+ "lockfile",
42
+ "worker-threads",
43
+ "memoization",
44
+ "memoize",
45
+ "typescript"
46
+ ],
47
+ "license": "MIT",
48
+ "engines": {
49
+ "node": ">=20"
50
+ },
51
+ "dependencies": {
52
+ "async-mutex": "^0.5.0",
53
+ "ohash": "^2.0.12",
54
+ "proper-lockfile": "^4.1.2",
55
+ "read-package-up": "^12.0.0",
56
+ "zod": "^4.6.7"
57
+ },
58
+ "devDependencies": {
59
+ "@types/fs-ext": "^2.0.3",
60
+ "@types/node": "^26.6.6",
61
+ "@types/proper-lockfile": "^4.1.4",
62
+ "typescript": "^5.9.3"
63
+ },
64
+ "publishConfig": {
65
+ "access": "public"
66
+ },
67
+ "optionalDependencies": {
68
+ "fs-ext": "^2.1.1"
69
+ }
70
+ }
@@ -0,0 +1,11 @@
1
+ import type { ResolvedLockOptionsData } from "../schemas.js";
2
+ import type { LockInfo } from "../types.js";
3
+ import type { PreparedBackend } from "./types.js";
4
+ interface FsExt {
5
+ flock(fileDescriptor: number, flags: "exnb" | "un", callback: (error?: NodeJS.ErrnoException | null) => void): void;
6
+ }
7
+ interface FileBackendDependencies {
8
+ readonly loadFsExt: () => Promise<FsExt | undefined>;
9
+ }
10
+ export declare function prepareFileBackend(info: Readonly<LockInfo>, options: ResolvedLockOptionsData, fileBase: string, dependencies?: FileBackendDependencies): Promise<PreparedBackend>;
11
+ export {};
@@ -0,0 +1,142 @@
1
+ import { mkdir, open } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import lockfile from "proper-lockfile";
4
+ import { LockUnavailableError } from "../errors.js";
5
+ import { throwIfAborted, waitForRetry } from "../waiting.js";
6
+ async function loadFsExt() {
7
+ if (process.platform === "win32")
8
+ return undefined;
9
+ try {
10
+ const imported = (await import("fs-ext"));
11
+ const candidate = imported.flock
12
+ ? { flock: imported.flock }
13
+ : imported.default;
14
+ return candidate && typeof candidate.flock === "function"
15
+ ? candidate
16
+ : undefined;
17
+ }
18
+ catch {
19
+ return undefined;
20
+ }
21
+ }
22
+ function callFlock(fsExt, fileDescriptor, flags) {
23
+ return new Promise((resolve, reject) => {
24
+ fsExt.flock(fileDescriptor, flags, (error) => {
25
+ if (error)
26
+ reject(error);
27
+ else
28
+ resolve();
29
+ });
30
+ });
31
+ }
32
+ function isBusy(error) {
33
+ const code = error?.code;
34
+ return code === "EAGAIN" || code === "EWOULDBLOCK" || code === "EACCES";
35
+ }
36
+ function isPollingContention(error) {
37
+ return error?.code === "ELOCKED";
38
+ }
39
+ function preparePollingBackend(info, options, file, fallback) {
40
+ const target = `${file}.target`;
41
+ const details = {
42
+ scope: "file",
43
+ type: "poll",
44
+ file,
45
+ ...(fallback ? { fallback } : {}),
46
+ };
47
+ return {
48
+ initial: details,
49
+ async run(callback) {
50
+ await mkdir(dirname(file), { recursive: true });
51
+ const targetHandle = await open(target, "a");
52
+ await targetHandle.close();
53
+ const startedAt = Date.now();
54
+ while (true) {
55
+ throwIfAborted(options.signal, info);
56
+ let release;
57
+ try {
58
+ release = await lockfile.lock(target, {
59
+ lockfilePath: file,
60
+ realpath: false,
61
+ retries: 0,
62
+ stale: options.staleMs,
63
+ });
64
+ }
65
+ catch (error) {
66
+ if (!isPollingContention(error))
67
+ throw error;
68
+ await waitForRetry(startedAt, options.timeout?.ms, options.pollIntervalMs, options.signal, info);
69
+ continue;
70
+ }
71
+ try {
72
+ return await callback(details);
73
+ }
74
+ finally {
75
+ await release();
76
+ }
77
+ }
78
+ },
79
+ };
80
+ }
81
+ function prepareFlockBackend(fsExt, info, options, file, pollingFallback) {
82
+ const details = {
83
+ scope: "file",
84
+ type: "flock",
85
+ file,
86
+ };
87
+ return {
88
+ initial: details,
89
+ async run(callback) {
90
+ await mkdir(dirname(file), { recursive: true });
91
+ const handle = await open(file, "a+");
92
+ const startedAt = Date.now();
93
+ try {
94
+ while (true) {
95
+ throwIfAborted(options.signal, info);
96
+ try {
97
+ await callFlock(fsExt, handle.fd, "exnb");
98
+ break;
99
+ }
100
+ catch (error) {
101
+ if (isBusy(error)) {
102
+ await waitForRetry(startedAt, options.timeout?.ms, options.pollIntervalMs, options.signal, info);
103
+ continue;
104
+ }
105
+ // Never switch protocols for contention: a proper-lockfile lock
106
+ // would not see the flock already held by another caller.
107
+ if (pollingFallback) {
108
+ await handle.close();
109
+ return pollingFallback.run(callback);
110
+ }
111
+ throw new LockUnavailableError(info, "native flock acquisition failed", error);
112
+ }
113
+ }
114
+ try {
115
+ return await callback(details);
116
+ }
117
+ finally {
118
+ await callFlock(fsExt, handle.fd, "un");
119
+ }
120
+ }
121
+ finally {
122
+ await handle.close().catch(() => undefined);
123
+ }
124
+ },
125
+ };
126
+ }
127
+ export async function prepareFileBackend(info, options, fileBase, dependencies = { loadFsExt }) {
128
+ const pollFile = `${fileBase}.poll.lock`;
129
+ const polling = preparePollingBackend(info, options, pollFile);
130
+ if (options.fileStrategy === "poll")
131
+ return polling;
132
+ const fsExt = await dependencies.loadFsExt();
133
+ if (!fsExt) {
134
+ if (options.fileStrategy === "flock") {
135
+ throw new LockUnavailableError(info, "native flock is unavailable on this platform");
136
+ }
137
+ return preparePollingBackend(info, options, pollFile, "native-flock-unavailable");
138
+ }
139
+ const flockFile = `${fileBase}.flock`;
140
+ const fallback = preparePollingBackend(info, options, pollFile, "native-flock-failed");
141
+ return prepareFlockBackend(fsExt, info, options, flockFile, options.fileStrategy === "auto" ? fallback : undefined);
142
+ }
@@ -0,0 +1,4 @@
1
+ import type { ResolvedLockOptionsData } from "../schemas.js";
2
+ import type { LockBackendInfo, LockInfo } from "../types.js";
3
+ import type { PreparedBackend } from "./types.js";
4
+ export declare function prepareMemoryBackend(info: Readonly<LockInfo>, options: ResolvedLockOptionsData, details?: Readonly<LockBackendInfo>): PreparedBackend;
@@ -0,0 +1,45 @@
1
+ import { Mutex } from "async-mutex";
2
+ import { acquireWithLimits } from "../waiting.js";
3
+ const mutexes = new Map();
4
+ function retainMutex(id) {
5
+ let entry = mutexes.get(id);
6
+ if (!entry) {
7
+ entry = { mutex: new Mutex(), users: 0 };
8
+ mutexes.set(id, entry);
9
+ }
10
+ entry.users += 1;
11
+ return entry;
12
+ }
13
+ function releaseMutexReference(id, entry) {
14
+ entry.users -= 1;
15
+ deleteUnusedMutex(id, entry);
16
+ }
17
+ function deleteUnusedMutex(id, entry) {
18
+ if (entry.users === 0 && !entry.mutex.isLocked()) {
19
+ mutexes.delete(id);
20
+ }
21
+ }
22
+ export function prepareMemoryBackend(info, options, details = {
23
+ scope: "memory",
24
+ type: "memory",
25
+ }) {
26
+ return {
27
+ initial: details,
28
+ async run(callback) {
29
+ const entry = retainMutex(info.id);
30
+ const acquisition = entry.mutex.acquire();
31
+ let release;
32
+ try {
33
+ release = await acquireWithLimits(acquisition, options.timeout?.ms, options.signal, info, (lateRelease) => {
34
+ lateRelease();
35
+ deleteUnusedMutex(info.id, entry);
36
+ });
37
+ return await callback(details);
38
+ }
39
+ finally {
40
+ release?.();
41
+ releaseMutexReference(info.id, entry);
42
+ }
43
+ },
44
+ };
45
+ }
@@ -0,0 +1,5 @@
1
+ import type { LockBackendInfo } from "../types.js";
2
+ export interface PreparedBackend {
3
+ readonly initial: Readonly<LockBackendInfo>;
4
+ run<TResult>(callback: (details: Readonly<LockBackendInfo>) => Promise<TResult>): Promise<TResult>;
5
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,4 @@
1
+ import type { ResolvedLockOptionsData } from "../schemas.js";
2
+ import type { LockInfo } from "../types.js";
3
+ import type { PreparedBackend } from "./types.js";
4
+ export declare function prepareWorkerBackend(info: Readonly<LockInfo>, options: ResolvedLockOptionsData, fileLayerSelected?: boolean): PreparedBackend;
@@ -0,0 +1,87 @@
1
+ import * as workerThreads from "node:worker_threads";
2
+ import { LockAbortedError, LockTimeoutError, LockUnavailableError, } from "../errors.js";
3
+ function getWorkerLocks() {
4
+ const candidate = workerThreads
5
+ .locks;
6
+ return candidate && typeof candidate.request === "function"
7
+ ? candidate
8
+ : undefined;
9
+ }
10
+ function passThroughBackend(details) {
11
+ return {
12
+ initial: details,
13
+ async run(callback) {
14
+ return callback(details);
15
+ },
16
+ };
17
+ }
18
+ export function prepareWorkerBackend(info, options, fileLayerSelected = false) {
19
+ const locks = getWorkerLocks();
20
+ if (!locks) {
21
+ const fallback = {
22
+ scope: "worker",
23
+ type: "skipped",
24
+ fallback: "worker-locks-unavailable-file-layer-selected",
25
+ };
26
+ if (fileLayerSelected)
27
+ return passThroughBackend(fallback);
28
+ throw new LockUnavailableError(info, "the runtime does not provide worker_threads.locks");
29
+ }
30
+ const details = {
31
+ scope: "worker",
32
+ type: "worker",
33
+ };
34
+ return {
35
+ initial: details,
36
+ async run(callback) {
37
+ const controller = new AbortController();
38
+ let acquired = false;
39
+ let timedOut = false;
40
+ let timer;
41
+ const abortFromCaller = () => controller.abort(options.signal?.reason);
42
+ if (options.signal?.aborted) {
43
+ throw new LockAbortedError(info, options.signal.reason);
44
+ }
45
+ options.signal?.addEventListener("abort", abortFromCaller, { once: true });
46
+ const stopWaiting = () => {
47
+ if (timer) {
48
+ clearTimeout(timer);
49
+ timer = undefined;
50
+ }
51
+ options.signal?.removeEventListener("abort", abortFromCaller);
52
+ };
53
+ if (options.timeout && options.timeout.ms > 0) {
54
+ timer = setTimeout(() => {
55
+ timedOut = true;
56
+ controller.abort();
57
+ }, options.timeout.ms);
58
+ timer.unref?.();
59
+ }
60
+ try {
61
+ return await locks.request(info.id, {
62
+ mode: "exclusive",
63
+ ifAvailable: options.timeout?.ms === 0,
64
+ signal: controller.signal,
65
+ }, async (lock) => {
66
+ if (!lock)
67
+ throw new LockTimeoutError(info, options.timeout.ms);
68
+ acquired = true;
69
+ stopWaiting();
70
+ return callback(details);
71
+ });
72
+ }
73
+ catch (error) {
74
+ if (!acquired && timedOut) {
75
+ throw new LockTimeoutError(info, options.timeout.ms);
76
+ }
77
+ if (!acquired && options.signal?.aborted) {
78
+ throw new LockAbortedError(info, options.signal.reason);
79
+ }
80
+ throw error;
81
+ }
82
+ finally {
83
+ stopWaiting();
84
+ }
85
+ },
86
+ };
87
+ }
@@ -0,0 +1,19 @@
1
+ import type { LockInfo } from "./types.js";
2
+ declare abstract class LoqkitError extends Error {
3
+ readonly lock: Readonly<LockInfo>;
4
+ abstract readonly code: string;
5
+ constructor(message: string, lock: Readonly<LockInfo>, options?: ErrorOptions);
6
+ }
7
+ export declare class LockTimeoutError extends LoqkitError {
8
+ readonly code = "LOQKIT_TIMEOUT";
9
+ constructor(lock: Readonly<LockInfo>, timeoutMs: number);
10
+ }
11
+ export declare class LockAbortedError extends LoqkitError {
12
+ readonly code = "LOQKIT_ABORTED";
13
+ constructor(lock: Readonly<LockInfo>, cause?: unknown);
14
+ }
15
+ export declare class LockUnavailableError extends LoqkitError {
16
+ readonly code = "LOQKIT_UNAVAILABLE";
17
+ constructor(lock: Readonly<LockInfo>, detail: string, cause?: unknown);
18
+ }
19
+ export {};
@@ -0,0 +1,26 @@
1
+ class LoqkitError extends Error {
2
+ lock;
3
+ constructor(message, lock, options) {
4
+ super(message, options);
5
+ this.lock = lock;
6
+ this.name = new.target.name;
7
+ }
8
+ }
9
+ export class LockTimeoutError extends LoqkitError {
10
+ code = "LOQKIT_TIMEOUT";
11
+ constructor(lock, timeoutMs) {
12
+ super(`Timed out after ${timeoutMs}ms waiting for lock "${lock.name}"`, lock);
13
+ }
14
+ }
15
+ export class LockAbortedError extends LoqkitError {
16
+ code = "LOQKIT_ABORTED";
17
+ constructor(lock, cause) {
18
+ super(`Waiting for lock "${lock.name}" was aborted`, lock, { cause });
19
+ }
20
+ }
21
+ export class LockUnavailableError extends LoqkitError {
22
+ code = "LOQKIT_UNAVAILABLE";
23
+ constructor(lock, detail, cause) {
24
+ super(`Lock "${lock.name}" is unavailable: ${detail}`, lock, { cause });
25
+ }
26
+ }
@@ -0,0 +1,9 @@
1
+ import type { ResolvedLockOptionsData } from "./schemas.js";
2
+ export declare function resolveNamespace(options: ResolvedLockOptionsData): Promise<string>;
3
+ export declare function resolveLockDirectory(options: ResolvedLockOptionsData): string;
4
+ export declare function createLockIdentity(context: object, options: ResolvedLockOptionsData): Promise<{
5
+ id: string;
6
+ name: string;
7
+ namespace: string;
8
+ fileBase: string;
9
+ }>;
@@ -0,0 +1,41 @@
1
+ import { tmpdir } from "node:os";
2
+ import { basename, isAbsolute, join, resolve } from "node:path";
3
+ import { hash } from "ohash";
4
+ import { readPackageUp } from "read-package-up";
5
+ const namespaceCache = new Map();
6
+ async function discoverNamespace(cwd) {
7
+ const packageResult = await readPackageUp({ cwd });
8
+ const packageName = packageResult?.packageJson.name;
9
+ if (typeof packageName === "string" && packageName.length > 0) {
10
+ return packageName;
11
+ }
12
+ return basename(resolve(cwd)) || "default";
13
+ }
14
+ export async function resolveNamespace(options) {
15
+ if (options.namespace)
16
+ return options.namespace;
17
+ const environmentNamespace = process.env.LOQKIT_NAMESPACE?.trim();
18
+ if (environmentNamespace)
19
+ return environmentNamespace;
20
+ const cwd = resolve(options.cwd);
21
+ let pending = namespaceCache.get(cwd);
22
+ if (!pending) {
23
+ pending = discoverNamespace(cwd);
24
+ namespaceCache.set(cwd, pending);
25
+ }
26
+ return pending;
27
+ }
28
+ export function resolveLockDirectory(options) {
29
+ const configured = options.lockDirectory ??
30
+ process.env.LOQKIT_LOCK_DIR?.trim() ??
31
+ join(tmpdir(), "loqkit");
32
+ return isAbsolute(configured) ? configured : resolve(options.cwd, configured);
33
+ }
34
+ export async function createLockIdentity(context, options) {
35
+ const namespace = await resolveNamespace(options);
36
+ const id = hash({ namespace, key: options.key ?? context });
37
+ const name = options.name ?? id.slice(0, 12);
38
+ const namespaceDirectory = hash(namespace).slice(0, 12);
39
+ const fileBase = join(resolveLockDirectory(options), namespaceDirectory, id);
40
+ return { id, name, namespace, fileBase };
41
+ }
@@ -0,0 +1,16 @@
1
+ import type { WithLock } from "./types.js";
2
+ /**
3
+ * Runs an action with exclusive access to a deterministic lock.
4
+ *
5
+ * By default, memory, worker-thread, and cross-process file layers are held in
6
+ * that order. When supplied, the predicate runs before waiting and again after
7
+ * every requested layer has been acquired. Callbacks distinguish those phases
8
+ * through context.state; context.acquired is the corresponding convenience
9
+ * boolean.
10
+ */
11
+ export declare const withLock: WithLock;
12
+ export { LockAbortedError, LockTimeoutError, LockUnavailableError } from "./errors.js";
13
+ export { memoize } from "./memoize.js";
14
+ export { fileLockStrategySchema, lockBackendScopeSchema, lockOptionsSchema, lockScopeSchema, lockTimeoutSchema, } from "./schemas.js";
15
+ export type { FileLockStrategy, LockTimeout, LockScope, ResolvedLockScope, ResolvedLockTimeout, } from "./schemas.js";
16
+ export type { LockAction, LockBackendInfo, LockBackendScope, LockCallbackState, LockCheckFailedHandler, LockContext, LockInfo, LockOptions, LockPredicate, LockResult, LockResultContext, ResolvedLockOptions, LockState, LockType, ResolvedLockType, } from "./types.js";
@@ -0,0 +1,217 @@
1
+ import { performance } from "node:perf_hooks";
2
+ import { prepareFileBackend } from "./backends/file.js";
3
+ import { prepareMemoryBackend } from "./backends/memory.js";
4
+ import { prepareWorkerBackend } from "./backends/worker.js";
5
+ import { LockAbortedError, LockTimeoutError } from "./errors.js";
6
+ import { createLockIdentity } from "./identity.js";
7
+ import { lockOptionsSchema, } from "./schemas.js";
8
+ function createContext(context, lock, state) {
9
+ const acquired = state !== "predicate-before";
10
+ return Object.freeze({ ...context, state, acquired, lock });
11
+ }
12
+ function freezeBackends(backends) {
13
+ return Object.freeze(backends.map((backend) => Object.freeze({ ...backend })));
14
+ }
15
+ function createInfo(base, details, acquired = false, waitedMs = 0) {
16
+ const backends = freezeBackends(details);
17
+ return Object.freeze({
18
+ ...base,
19
+ type: backends.length === 1 ? backends[0].type : "composite",
20
+ backends,
21
+ waitedMs,
22
+ ...(acquired ? { acquiredAt: new Date().toISOString() } : {}),
23
+ });
24
+ }
25
+ function createResult(value, context) {
26
+ return Object.freeze({ value, context });
27
+ }
28
+ function createTimeoutContext(context, lock) {
29
+ return Object.freeze({
30
+ ...context,
31
+ state: "timeout",
32
+ acquired: false,
33
+ lock,
34
+ });
35
+ }
36
+ function singleBackend(backend) {
37
+ return {
38
+ initial: [backend.initial],
39
+ async run(callback) {
40
+ return backend.run((details) => callback([details]));
41
+ },
42
+ };
43
+ }
44
+ function layeredBackends(info, options, controller, backends) {
45
+ return {
46
+ initial: backends.map((backend) => backend.initial),
47
+ async run(callback) {
48
+ let timer;
49
+ let timedOut = false;
50
+ const abortFromCaller = () => controller.abort(options.signal?.reason);
51
+ const stopWaiting = () => {
52
+ if (timer) {
53
+ clearTimeout(timer);
54
+ timer = undefined;
55
+ }
56
+ options.signal?.removeEventListener("abort", abortFromCaller);
57
+ };
58
+ if (options.signal?.aborted) {
59
+ throw new LockAbortedError(info, options.signal.reason);
60
+ }
61
+ options.signal?.addEventListener("abort", abortFromCaller, { once: true });
62
+ if (options.timeout && options.timeout.ms > 0) {
63
+ timer = setTimeout(() => {
64
+ timedOut = true;
65
+ controller.abort();
66
+ }, options.timeout.ms);
67
+ timer.unref?.();
68
+ }
69
+ const acquired = [];
70
+ const acquireAt = async (index) => {
71
+ const backend = backends[index];
72
+ if (!backend) {
73
+ stopWaiting();
74
+ if (timedOut)
75
+ throw new LockTimeoutError(info, options.timeout.ms);
76
+ if (options.signal?.aborted) {
77
+ throw new LockAbortedError(info, options.signal.reason);
78
+ }
79
+ return callback(acquired);
80
+ }
81
+ return backend.run(async (details) => {
82
+ acquired.push(details);
83
+ return acquireAt(index + 1);
84
+ });
85
+ };
86
+ try {
87
+ return await acquireAt(0);
88
+ }
89
+ catch (error) {
90
+ if (timedOut)
91
+ throw new LockTimeoutError(info, options.timeout.ms);
92
+ throw error;
93
+ }
94
+ finally {
95
+ stopWaiting();
96
+ }
97
+ },
98
+ };
99
+ }
100
+ async function prepareLock(info, options, fileBase) {
101
+ const controller = new AbortController();
102
+ const isLayered = options.scope.length > 1;
103
+ const effectiveOptions = isLayered
104
+ ? { ...options, signal: controller.signal }
105
+ : options;
106
+ const fileLayerSelected = options.scope.includes("file");
107
+ const backends = [];
108
+ for (const scope of options.scope) {
109
+ switch (scope) {
110
+ case "memory":
111
+ backends.push(prepareMemoryBackend(info, effectiveOptions));
112
+ break;
113
+ case "worker":
114
+ backends.push(prepareWorkerBackend(info, effectiveOptions, fileLayerSelected));
115
+ break;
116
+ case "file":
117
+ backends.push(await prepareFileBackend(info, effectiveOptions, fileBase));
118
+ break;
119
+ }
120
+ }
121
+ if (backends.length === 1)
122
+ return singleBackend(backends[0]);
123
+ return layeredBackends(info, options, controller, backends);
124
+ }
125
+ function provisionalBackends(options) {
126
+ const fileType = options.fileStrategy === "poll" ? "poll" : "flock";
127
+ return options.scope.map((scope) => {
128
+ switch (scope) {
129
+ case "memory":
130
+ return { scope, type: "memory" };
131
+ case "worker":
132
+ return { scope, type: "worker" };
133
+ case "file":
134
+ return { scope, type: fileType };
135
+ }
136
+ });
137
+ }
138
+ const withLockImplementation = async (context, action, predicateOrOptions, suppliedOptions) => {
139
+ const options = lockOptionsSchema.parse(typeof predicateOrOptions === "function"
140
+ ? suppliedOptions
141
+ : predicateOrOptions);
142
+ const positionalPredicate = typeof predicateOrOptions === "function" ? predicateOrOptions : undefined;
143
+ const optionPredicate = options.predicate;
144
+ if (positionalPredicate && optionPredicate) {
145
+ throw new TypeError("Pass the predicate either positionally or as options.predicate, not both");
146
+ }
147
+ const predicate = positionalPredicate ?? optionPredicate;
148
+ const onCheckFailed = options.onCheckFailed;
149
+ const identity = await createLockIdentity(context, options);
150
+ const baseInfo = {
151
+ id: identity.id,
152
+ name: identity.name,
153
+ namespace: identity.namespace,
154
+ scope: options.scope,
155
+ ...(options.scope.includes("file")
156
+ ? { strategy: options.fileStrategy }
157
+ : {}),
158
+ };
159
+ const provisionalInfo = createInfo(baseInfo, provisionalBackends(options));
160
+ const prepared = await prepareLock(provisionalInfo, options, identity.fileBase);
161
+ const checkingInfo = createInfo(baseInfo, prepared.initial);
162
+ const beforeContext = createContext(context, checkingInfo, "predicate-before");
163
+ if (predicate && !(await predicate(beforeContext))) {
164
+ await onCheckFailed?.(beforeContext);
165
+ return createResult(undefined, beforeContext);
166
+ }
167
+ const startedAt = performance.now();
168
+ let fullyAcquired = false;
169
+ let result;
170
+ try {
171
+ result = await prepared.run(async (details) => {
172
+ fullyAcquired = true;
173
+ const acquiredInfo = createInfo(baseInfo, details, true, Math.max(0, performance.now() - startedAt));
174
+ const predicateContext = createContext(context, acquiredInfo, "predicate-after");
175
+ if (predicate && !(await predicate(predicateContext))) {
176
+ return {
177
+ state: "predicate-after",
178
+ context: predicateContext,
179
+ };
180
+ }
181
+ const actionContext = createContext(context, acquiredInfo, "executed");
182
+ const value = await action(actionContext);
183
+ return {
184
+ state: "executed",
185
+ value,
186
+ context: actionContext,
187
+ };
188
+ });
189
+ }
190
+ catch (error) {
191
+ if (error instanceof LockTimeoutError &&
192
+ !fullyAcquired &&
193
+ options.timeout?.error === false) {
194
+ const waitingInfo = createInfo(baseInfo, prepared.initial, false, Math.max(0, performance.now() - startedAt));
195
+ return createResult(undefined, createTimeoutContext(context, waitingInfo));
196
+ }
197
+ throw error;
198
+ }
199
+ if (result.state === "predicate-after") {
200
+ await onCheckFailed?.(result.context);
201
+ return createResult(undefined, result.context);
202
+ }
203
+ return createResult(result.value, result.context);
204
+ };
205
+ /**
206
+ * Runs an action with exclusive access to a deterministic lock.
207
+ *
208
+ * By default, memory, worker-thread, and cross-process file layers are held in
209
+ * that order. When supplied, the predicate runs before waiting and again after
210
+ * every requested layer has been acquired. Callbacks distinguish those phases
211
+ * through context.state; context.acquired is the corresponding convenience
212
+ * boolean.
213
+ */
214
+ export const withLock = withLockImplementation;
215
+ export { LockAbortedError, LockTimeoutError, LockUnavailableError } from "./errors.js";
216
+ export { memoize } from "./memoize.js";
217
+ export { fileLockStrategySchema, lockBackendScopeSchema, lockOptionsSchema, lockScopeSchema, lockTimeoutSchema, } from "./schemas.js";
@@ -0,0 +1,4 @@
1
+ /** Runs a zero-argument factory once and reuses its successful result. */
2
+ export declare function memoize<T>(factory: () => PromiseLike<T>): () => Promise<T>;
3
+ export declare function memoize<T>(factory: () => T): () => T;
4
+ export declare function memoize<T>(factory: () => T | PromiseLike<T>): () => T | Promise<T>;
@@ -0,0 +1,28 @@
1
+ export function memoize(factory) {
2
+ let cache;
3
+ return () => {
4
+ if (!cache) {
5
+ const result = factory();
6
+ if (isThenable(result)) {
7
+ const pending = Promise.resolve(result);
8
+ const entry = { value: pending };
9
+ cache = entry;
10
+ // A failed computation is retryable; only successful results stay memoized.
11
+ void pending.then(undefined, () => {
12
+ if (cache === entry)
13
+ cache = undefined;
14
+ });
15
+ }
16
+ else {
17
+ cache = { value: result };
18
+ }
19
+ }
20
+ return cache.value;
21
+ };
22
+ }
23
+ function isThenable(value) {
24
+ return Boolean(value !== null &&
25
+ typeof value === "object" &&
26
+ "then" in value &&
27
+ typeof value.then === "function");
28
+ }
@@ -0,0 +1,75 @@
1
+ import { z } from "zod";
2
+ export declare const lockBackendScopeSchema: z.ZodEnum<{
3
+ worker: "worker";
4
+ file: "file";
5
+ memory: "memory";
6
+ }>;
7
+ export declare const lockScopeSchema: z.ZodPipe<z.ZodDefault<z.ZodUnion<readonly [z.ZodEnum<{
8
+ worker: "worker";
9
+ file: "file";
10
+ memory: "memory";
11
+ }>, z.ZodArray<z.ZodEnum<{
12
+ worker: "worker";
13
+ file: "file";
14
+ memory: "memory";
15
+ }>>]>>, z.ZodTransform<readonly ("worker" | "file" | "memory")[], "worker" | "file" | "memory" | ("worker" | "file" | "memory")[]>>;
16
+ export declare const fileLockStrategySchema: z.ZodEnum<{
17
+ auto: "auto";
18
+ flock: "flock";
19
+ poll: "poll";
20
+ }>;
21
+ export declare const lockTimeoutSchema: z.ZodPipe<z.ZodUnion<readonly [z.ZodNumber, z.ZodObject<{
22
+ ms: z.ZodNumber;
23
+ error: z.ZodDefault<z.ZodBoolean>;
24
+ }, z.core.$strict>]>, z.ZodTransform<{
25
+ ms: number;
26
+ error: boolean;
27
+ }, number | {
28
+ ms: number;
29
+ error: boolean;
30
+ }>>;
31
+ /** Runtime-validated options accepted by {@link withLock}. */
32
+ export declare const lockOptionsSchema: z.ZodObject<{
33
+ key: z.ZodOptional<z.ZodUnknown>;
34
+ name: z.ZodOptional<z.ZodString>;
35
+ namespace: z.ZodOptional<z.ZodString>;
36
+ scope: z.ZodPipe<z.ZodDefault<z.ZodUnion<readonly [z.ZodEnum<{
37
+ worker: "worker";
38
+ file: "file";
39
+ memory: "memory";
40
+ }>, z.ZodArray<z.ZodEnum<{
41
+ worker: "worker";
42
+ file: "file";
43
+ memory: "memory";
44
+ }>>]>>, z.ZodTransform<readonly ("worker" | "file" | "memory")[], "worker" | "file" | "memory" | ("worker" | "file" | "memory")[]>>;
45
+ fileStrategy: z.ZodDefault<z.ZodEnum<{
46
+ auto: "auto";
47
+ flock: "flock";
48
+ poll: "poll";
49
+ }>>;
50
+ timeout: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodNumber, z.ZodObject<{
51
+ ms: z.ZodNumber;
52
+ error: z.ZodDefault<z.ZodBoolean>;
53
+ }, z.core.$strict>]>, z.ZodTransform<{
54
+ ms: number;
55
+ error: boolean;
56
+ }, number | {
57
+ ms: number;
58
+ error: boolean;
59
+ }>>>;
60
+ pollIntervalMs: z.ZodDefault<z.ZodNumber>;
61
+ staleMs: z.ZodDefault<z.ZodNumber>;
62
+ lockDirectory: z.ZodOptional<z.ZodString>;
63
+ cwd: z.ZodDefault<z.ZodString>;
64
+ signal: z.ZodOptional<z.ZodCustom<AbortSignal, AbortSignal>>;
65
+ predicate: z.ZodOptional<z.ZodCustom<(...args: never[]) => unknown, (...args: never[]) => unknown>>;
66
+ onCheckFailed: z.ZodOptional<z.ZodCustom<(...args: never[]) => unknown, (...args: never[]) => unknown>>;
67
+ }, z.core.$strict>;
68
+ export type LockBackendScope = z.infer<typeof lockBackendScopeSchema>;
69
+ export type LockScope = LockBackendScope | readonly LockBackendScope[];
70
+ export type ResolvedLockScope = z.output<typeof lockScopeSchema>;
71
+ export type FileLockStrategy = z.infer<typeof fileLockStrategySchema>;
72
+ export type LockTimeout = z.input<typeof lockTimeoutSchema>;
73
+ export type ResolvedLockTimeout = z.output<typeof lockTimeoutSchema>;
74
+ export type LockOptionsInput = z.input<typeof lockOptionsSchema>;
75
+ export type ResolvedLockOptionsData = z.output<typeof lockOptionsSchema>;
@@ -0,0 +1,67 @@
1
+ import { z } from "zod";
2
+ export const lockBackendScopeSchema = z.enum(["memory", "worker", "file"]);
3
+ const allBackendScopes = ["memory", "worker", "file"];
4
+ const lockScopeListSchema = z
5
+ .array(lockBackendScopeSchema)
6
+ .min(1)
7
+ .max(allBackendScopes.length)
8
+ .refine((scopes) => new Set(scopes).size === scopes.length, {
9
+ message: "Lock scopes must be unique",
10
+ });
11
+ export const lockScopeSchema = z
12
+ .union([lockBackendScopeSchema, lockScopeListSchema])
13
+ .default([...allBackendScopes])
14
+ .transform((scope) => {
15
+ const requested = new Set(Array.isArray(scope) ? scope : [scope]);
16
+ return Object.freeze(allBackendScopes.filter((item) => requested.has(item)));
17
+ });
18
+ export const fileLockStrategySchema = z.enum(["auto", "flock", "poll"]);
19
+ const timeoutMsSchema = z.number().int().min(0).max(2_147_483_647);
20
+ export const lockTimeoutSchema = z
21
+ .union([
22
+ timeoutMsSchema,
23
+ z
24
+ .object({
25
+ ms: timeoutMsSchema,
26
+ error: z.boolean().default(true),
27
+ })
28
+ .strict(),
29
+ ])
30
+ .transform((timeout) => typeof timeout === "number" ? { ms: timeout, error: true } : timeout);
31
+ const abortSignalSchema = z.custom((value) => typeof AbortSignal !== "undefined" && value instanceof AbortSignal, "Expected an AbortSignal");
32
+ const callbackSchema = z.custom((value) => typeof value === "function", "Expected a function");
33
+ /** Runtime-validated options accepted by {@link withLock}. */
34
+ export const lockOptionsSchema = z
35
+ .object({
36
+ /** Stable input used to derive the lock id. Defaults to the supplied context. */
37
+ key: z.unknown().optional(),
38
+ /** Human-readable label used in diagnostics. */
39
+ name: z.string().trim().min(1).max(200).optional(),
40
+ /** Overrides namespace discovery from the consuming package. */
41
+ namespace: z.string().trim().min(1).max(214).optional(),
42
+ /** One or more lock boundaries. Omission acquires every layer. */
43
+ scope: lockScopeSchema,
44
+ /** Auto falls back to polling; flock requires native support; poll skips it. */
45
+ fileStrategy: fileLockStrategySchema.default("auto"),
46
+ /** Optional wait limit. A number throws; object form can return a result. */
47
+ timeout: lockTimeoutSchema.optional(),
48
+ /** Delay between polling-file lock attempts. */
49
+ pollIntervalMs: z.number().int().min(1).max(60_000).default(100),
50
+ /** Age after which an abandoned polling lock can be reclaimed. */
51
+ staleMs: z.number().int().min(5_000).max(2_147_483_647).default(30_000),
52
+ /** Overrides LOQKIT_LOCK_DIR and the operating-system temporary directory. */
53
+ lockDirectory: z.string().trim().min(1).optional(),
54
+ /** Starting directory for consuming-package discovery. */
55
+ cwd: z
56
+ .string()
57
+ .trim()
58
+ .min(1)
59
+ .default(() => process.cwd()),
60
+ /** Cancels only while waiting; an acquired action is not interrupted. */
61
+ signal: abortSignalSchema.optional(),
62
+ /** Runs before waiting and again after acquisition. */
63
+ predicate: callbackSchema.optional(),
64
+ /** Runs without a held lock after either predicate check fails. */
65
+ onCheckFailed: callbackSchema.optional(),
66
+ })
67
+ .strict();
@@ -0,0 +1,63 @@
1
+ import type { FileLockStrategy, LockBackendScope as SchemaLockBackendScope, LockOptionsInput, LockScope, ResolvedLockScope, ResolvedLockOptionsData } from "./schemas.js";
2
+ export type LockBackendScope = SchemaLockBackendScope;
3
+ export type ResolvedLockType = "memory" | "worker" | "flock" | "poll" | "skipped";
4
+ export type LockType = ResolvedLockType | "composite";
5
+ export type LockCallbackState = "predicate-before" | "predicate-after" | "executed";
6
+ export type LockState = LockCallbackState | "timeout";
7
+ type LockAcquired<TState extends LockState> = TState extends "predicate-before" | "timeout" ? false : true;
8
+ /** One requested lock layer and the implementation that satisfied it. */
9
+ export interface LockBackendInfo {
10
+ readonly scope: LockBackendScope;
11
+ readonly type: ResolvedLockType;
12
+ readonly file?: string;
13
+ readonly fallback?: string;
14
+ }
15
+ /** Details about the resource and implementations used for a lock. */
16
+ export interface LockInfo {
17
+ readonly id: string;
18
+ readonly name: string;
19
+ readonly namespace: string;
20
+ readonly scope: ResolvedLockScope;
21
+ readonly type: LockType;
22
+ readonly backends: readonly Readonly<LockBackendInfo>[];
23
+ readonly strategy?: FileLockStrategy;
24
+ readonly waitedMs: number;
25
+ readonly acquiredAt?: string;
26
+ }
27
+ /** The caller context plus immutable information about the current lock. */
28
+ export type LockContext<TContext extends object, TState extends LockCallbackState = LockCallbackState> = Readonly<Omit<TContext, "acquired" | "lock" | "state"> & {
29
+ /** Which branch of the lock attempt is invoking this callback. */
30
+ readonly state: TState;
31
+ /** Whether this context snapshot was created after lock acquisition. */
32
+ readonly acquired: LockAcquired<TState>;
33
+ readonly lock: Readonly<LockInfo>;
34
+ }>;
35
+ export type LockAction<TContext extends object, TResult> = (context: LockContext<TContext, "executed">) => TResult | PromiseLike<TResult>;
36
+ export type LockPredicate<TContext extends object> = (context: LockContext<TContext, "predicate-before" | "predicate-after">) => boolean | PromiseLike<boolean>;
37
+ export type LockCheckFailedHandler<TContext extends object> = (context: LockContext<TContext, "predicate-before" | "predicate-after">) => void | PromiseLike<void>;
38
+ interface LockCallbacks<TContext extends object> {
39
+ readonly predicate?: LockPredicate<TContext>;
40
+ readonly onCheckFailed?: LockCheckFailedHandler<TContext>;
41
+ }
42
+ /** Lock configuration with callbacks typed to the supplied context. */
43
+ export type LockOptions<TContext extends object = object> = Omit<LockOptionsInput, "onCheckFailed" | "predicate" | "scope"> & {
44
+ readonly scope?: LockScope;
45
+ } & LockCallbacks<TContext>;
46
+ /** Runtime-resolved lock configuration with context-aware callbacks. */
47
+ export type ResolvedLockOptions<TContext extends object = object> = Omit<ResolvedLockOptionsData, "onCheckFailed" | "predicate" | "scope"> & {
48
+ readonly scope: ResolvedLockScope;
49
+ } & LockCallbacks<TContext>;
50
+ /** A callback context widened with the timeout state only results can observe. */
51
+ export type LockResultContext<TContext extends object> = Readonly<Omit<LockContext<TContext>, "state"> & {
52
+ readonly state: LockState;
53
+ }>;
54
+ /** The action value and terminal context for one withLock attempt. */
55
+ export interface LockResult<TContext extends object, TResult> {
56
+ readonly value: TResult | undefined;
57
+ readonly context: LockResultContext<TContext>;
58
+ }
59
+ export interface WithLock {
60
+ <TContext extends object, TResult>(context: TContext, action: LockAction<TContext, TResult>, options?: LockOptions<TContext>): Promise<LockResult<TContext, TResult>>;
61
+ <TContext extends object, TResult>(context: TContext, action: LockAction<TContext, TResult>, predicate: LockPredicate<TContext>, options?: LockOptions<TContext>): Promise<LockResult<TContext, TResult>>;
62
+ }
63
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,5 @@
1
+ import type { LockInfo } from "./types.js";
2
+ export declare function throwIfAborted(signal: AbortSignal | undefined, info: Readonly<LockInfo>): void;
3
+ export declare function throwIfTimedOut(startedAt: number, timeoutMs: number | undefined, info: Readonly<LockInfo>): void;
4
+ export declare function waitForRetry(startedAt: number, timeoutMs: number | undefined, pollIntervalMs: number, signal: AbortSignal | undefined, info: Readonly<LockInfo>): Promise<void>;
5
+ export declare function acquireWithLimits<T>(acquisition: Promise<T>, timeoutMs: number | undefined, signal: AbortSignal | undefined, info: Readonly<LockInfo>, releaseLateAcquisition: (value: T) => void): Promise<T>;
@@ -0,0 +1,60 @@
1
+ import { setTimeout as delay } from "node:timers/promises";
2
+ import { LockAbortedError, LockTimeoutError } from "./errors.js";
3
+ export function throwIfAborted(signal, info) {
4
+ if (signal?.aborted) {
5
+ throw new LockAbortedError(info, signal.reason);
6
+ }
7
+ }
8
+ export function throwIfTimedOut(startedAt, timeoutMs, info) {
9
+ if (timeoutMs !== undefined && Date.now() - startedAt >= timeoutMs) {
10
+ throw new LockTimeoutError(info, timeoutMs);
11
+ }
12
+ }
13
+ export async function waitForRetry(startedAt, timeoutMs, pollIntervalMs, signal, info) {
14
+ throwIfAborted(signal, info);
15
+ throwIfTimedOut(startedAt, timeoutMs, info);
16
+ const waitMs = timeoutMs === undefined
17
+ ? pollIntervalMs
18
+ : Math.max(1, Math.min(pollIntervalMs, timeoutMs - (Date.now() - startedAt)));
19
+ try {
20
+ await delay(waitMs, undefined, { signal });
21
+ }
22
+ catch (error) {
23
+ if (signal?.aborted) {
24
+ throw new LockAbortedError(info, signal.reason);
25
+ }
26
+ throw error;
27
+ }
28
+ }
29
+ export async function acquireWithLimits(acquisition, timeoutMs, signal, info, releaseLateAcquisition) {
30
+ throwIfAborted(signal, info);
31
+ let timer;
32
+ let abortListener;
33
+ const contenders = [acquisition];
34
+ if (timeoutMs !== undefined) {
35
+ contenders.push(new Promise((_resolve, reject) => {
36
+ timer = setTimeout(() => reject(new LockTimeoutError(info, timeoutMs)), timeoutMs);
37
+ timer.unref?.();
38
+ }));
39
+ }
40
+ if (signal) {
41
+ contenders.push(new Promise((_resolve, reject) => {
42
+ abortListener = () => reject(new LockAbortedError(info, signal.reason));
43
+ signal.addEventListener("abort", abortListener, { once: true });
44
+ }));
45
+ }
46
+ try {
47
+ return await Promise.race(contenders);
48
+ }
49
+ catch (error) {
50
+ void acquisition.then(releaseLateAcquisition, () => undefined);
51
+ throw error;
52
+ }
53
+ finally {
54
+ if (timer)
55
+ clearTimeout(timer);
56
+ if (signal && abortListener) {
57
+ signal.removeEventListener("abort", abortListener);
58
+ }
59
+ }
60
+ }