@opencode-ai/util 0.0.0-bootstrap.0 → 0.0.0-next-15994
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/dist/cross-spawn-spawner.d.ts +3 -0
- package/dist/cross-spawn-spawner.js +403 -0
- package/dist/effect/app-node-platform.d.ts +6 -0
- package/dist/effect/app-node-platform.js +8 -0
- package/dist/effect/app-node.d.ts +50 -0
- package/dist/effect/app-node.js +8 -0
- package/dist/effect/layer-node.d.ts +79 -0
- package/dist/effect/layer-node.js +181 -0
- package/dist/effect/memo-map.d.ts +2 -0
- package/dist/effect/memo-map.js +2 -0
- package/dist/effect/runtime.d.ts +8 -0
- package/dist/effect/runtime.js +16 -0
- package/dist/effect/service-use.d.ts +7 -0
- package/dist/effect/service-use.js +27 -0
- package/dist/effect-flock.d.ts +31 -0
- package/dist/effect-flock.js +185 -0
- package/dist/flock.d.ts +30 -0
- package/dist/flock.js +273 -0
- package/dist/fs-util.d.ts +139 -0
- package/dist/fs-util.js +224 -0
- package/dist/glob.d.ts +12 -0
- package/dist/glob.js +26 -0
- package/dist/global.d.ts +30 -0
- package/dist/global.js +57 -0
- package/dist/hash.d.ts +4 -0
- package/dist/hash.js +12 -0
- package/dist/npm-config.d.ts +4 -0
- package/dist/npm-config.js +32 -0
- package/dist/npm.d.ts +35 -0
- package/dist/npm.js +207 -0
- package/dist/observability/logging.d.ts +6 -0
- package/dist/observability/logging.js +67 -0
- package/dist/observability/otlp.d.ts +18 -0
- package/dist/observability/otlp.js +73 -0
- package/dist/observability/shared.d.ts +1 -0
- package/dist/observability/shared.js +1 -0
- package/dist/observability.d.ts +13 -0
- package/dist/observability.js +31 -0
- package/dist/patch.d.ts +42 -0
- package/dist/patch.js +206 -0
- package/dist/process.d.ts +54 -0
- package/dist/process.js +162 -0
- package/dist/runtime/import.bun.d.ts +2 -0
- package/dist/runtime/import.bun.js +6 -0
- package/dist/runtime/import.node.d.ts +2 -0
- package/dist/runtime/import.node.js +36 -0
- package/dist/runtime-import.d.ts +1 -0
- package/dist/runtime-import.js +1 -0
- package/package.json +56 -7
- package/index.js +0 -1
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { Brand, Context, Layer } from "effect";
|
|
2
|
+
const makeTag = Brand.nominal();
|
|
3
|
+
export function tags(config) {
|
|
4
|
+
const names = Object.keys(config);
|
|
5
|
+
const values = Object.fromEntries(names.map((name) => [name, makeTag(name)]));
|
|
6
|
+
return {
|
|
7
|
+
values,
|
|
8
|
+
make: ((name) => (input) => make({ ...input, tag: values[name] })),
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
export function make(input) {
|
|
12
|
+
return {
|
|
13
|
+
kind: "layer",
|
|
14
|
+
name: input.service !== undefined ? input.service.key : input.name,
|
|
15
|
+
service: input.service,
|
|
16
|
+
implementation: input.layer,
|
|
17
|
+
dependencies: input.deps,
|
|
18
|
+
tag: input.tag,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export function unbound(service, tag) {
|
|
22
|
+
return {
|
|
23
|
+
kind: "unbound",
|
|
24
|
+
name: service.key,
|
|
25
|
+
service,
|
|
26
|
+
dependencies: [],
|
|
27
|
+
tag,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export function group(dependencies) {
|
|
31
|
+
return { kind: "group", name: "group", dependencies };
|
|
32
|
+
}
|
|
33
|
+
function replacementNode(source, replacement) {
|
|
34
|
+
const replacementNode = isNode(replacement)
|
|
35
|
+
? replacement
|
|
36
|
+
: make({
|
|
37
|
+
...nodeMakeIdentity(source),
|
|
38
|
+
layer: replacement,
|
|
39
|
+
deps: [],
|
|
40
|
+
tag: source.tag,
|
|
41
|
+
});
|
|
42
|
+
if (source.name !== replacementNode.name) {
|
|
43
|
+
throw new Error(`Cannot replace ${source.name} with ${replacementNode.name}`);
|
|
44
|
+
}
|
|
45
|
+
if (source.tag !== replacementNode.tag) {
|
|
46
|
+
throw new Error(`Cannot replace ${source.name} across tags`);
|
|
47
|
+
}
|
|
48
|
+
return replacementNode;
|
|
49
|
+
}
|
|
50
|
+
function nodeMakeIdentity(node) {
|
|
51
|
+
if (node.service !== undefined)
|
|
52
|
+
return { service: node.service };
|
|
53
|
+
return { name: node.name };
|
|
54
|
+
}
|
|
55
|
+
function isNode(input) {
|
|
56
|
+
return "kind" in input && "dependencies" in input;
|
|
57
|
+
}
|
|
58
|
+
function walk(root, visit, options = {}) {
|
|
59
|
+
const cache = options.cache ?? new Map();
|
|
60
|
+
const visiting = new Set();
|
|
61
|
+
const stack = [];
|
|
62
|
+
const recur = (node) => {
|
|
63
|
+
const target = options.resolve?.(node) ?? node;
|
|
64
|
+
const cached = cache.get(target);
|
|
65
|
+
if (cached !== undefined || cache.has(target))
|
|
66
|
+
return cached;
|
|
67
|
+
if (options.detectCycles !== false && visiting.has(target)) {
|
|
68
|
+
const start = stack.indexOf(target);
|
|
69
|
+
throw new Error(`Cycle detected in layer tree: ${[...stack.slice(start), target].map((item) => item.name).join(" -> ")}`);
|
|
70
|
+
}
|
|
71
|
+
visiting.add(target);
|
|
72
|
+
stack.push(target);
|
|
73
|
+
try {
|
|
74
|
+
const result = visit(target, { cache, visit: recur });
|
|
75
|
+
if (!cache.has(target))
|
|
76
|
+
cache.set(target, result);
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
stack.pop();
|
|
81
|
+
visiting.delete(target);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
return recur(root);
|
|
85
|
+
}
|
|
86
|
+
export function hoist(root, tag, replacements) {
|
|
87
|
+
const hoisted = new Map();
|
|
88
|
+
const replacementMap = replacementMapFrom(replacements);
|
|
89
|
+
const node = walk(root, (node, context) => {
|
|
90
|
+
if (node.kind === "group") {
|
|
91
|
+
return { ...node, dependencies: node.dependencies.map(context.visit) };
|
|
92
|
+
}
|
|
93
|
+
if (node.tag === tag) {
|
|
94
|
+
const existing = hoisted.get(node.name);
|
|
95
|
+
if (existing && existing.implementation !== node.implementation) {
|
|
96
|
+
throw new Error(`Tag ${tag} has conflicting implementations for ${node.name}`);
|
|
97
|
+
}
|
|
98
|
+
hoisted.set(node.name, rewriteReplacementDependencies(node, replacementMap));
|
|
99
|
+
return group([]);
|
|
100
|
+
}
|
|
101
|
+
if (node.kind === "unbound") {
|
|
102
|
+
return node;
|
|
103
|
+
}
|
|
104
|
+
return { ...node, dependencies: node.dependencies.map(context.visit) };
|
|
105
|
+
}, { resolve: (node) => replacementMap.get(node.name) ?? node });
|
|
106
|
+
return {
|
|
107
|
+
node: node,
|
|
108
|
+
hoisted: group(Array.from(hoisted.values())),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
export function compile(root, replacements) {
|
|
112
|
+
const replacementMap = replacementMapFrom(replacements);
|
|
113
|
+
const cache = new Map();
|
|
114
|
+
const compileNode = (node) => walk(node, (node, context) => {
|
|
115
|
+
if (node.kind === "unbound")
|
|
116
|
+
throw new Error(`Unbound layer node: ${node.name}`);
|
|
117
|
+
const dependencies = node.dependencies.flatMap(flatten).map(context.visit);
|
|
118
|
+
const implementation = node.implementation;
|
|
119
|
+
return dependencies.length === 0
|
|
120
|
+
? implementation
|
|
121
|
+
: implementation.pipe(Layer.provide(dependencies));
|
|
122
|
+
}, { cache, resolve: (node) => replacementMap.get(node.name) ?? node });
|
|
123
|
+
const layers = flatten(root).map((node) => compileNode(node));
|
|
124
|
+
const layer = layers.reduce((result, layer) => layer.pipe(Layer.provideMerge(result)), Layer.empty);
|
|
125
|
+
return layer;
|
|
126
|
+
}
|
|
127
|
+
function replacementMapFrom(replacements) {
|
|
128
|
+
return (replacements?.reduce((map, [source, replacement]) => {
|
|
129
|
+
const normalized = rewriteReplacementDependencies(replacementNode(source, replacement), map);
|
|
130
|
+
const current = new Map([[source.name, normalized]]);
|
|
131
|
+
for (const [name, node] of map)
|
|
132
|
+
map.set(name, rewriteReplacementDependencies(node, current));
|
|
133
|
+
map.set(source.name, normalized);
|
|
134
|
+
return map;
|
|
135
|
+
}, new Map()) ?? new Map());
|
|
136
|
+
}
|
|
137
|
+
function rewriteReplacementDependencies(root, replacements) {
|
|
138
|
+
if (replacements.size === 0)
|
|
139
|
+
return root;
|
|
140
|
+
const cache = new Map();
|
|
141
|
+
const visiting = new Set();
|
|
142
|
+
const stack = [];
|
|
143
|
+
const recur = (node, isRoot = false) => {
|
|
144
|
+
const target = isRoot ? node : (replacements.get(node.name) ?? node);
|
|
145
|
+
const cached = cache.get(target);
|
|
146
|
+
if (cached !== undefined || cache.has(target))
|
|
147
|
+
return cached;
|
|
148
|
+
if (visiting.has(target)) {
|
|
149
|
+
const start = stack.indexOf(target);
|
|
150
|
+
throw new Error(`Cycle detected in layer tree: ${[...stack.slice(start), target].map((item) => item.name).join(" -> ")}`);
|
|
151
|
+
}
|
|
152
|
+
visiting.add(target);
|
|
153
|
+
stack.push(target);
|
|
154
|
+
try {
|
|
155
|
+
const dependencies = target.dependencies.map((dependency) => recur(dependency));
|
|
156
|
+
const result = dependencies.every((dependency, index) => dependency === target.dependencies[index])
|
|
157
|
+
? target
|
|
158
|
+
: { ...target, dependencies };
|
|
159
|
+
cache.set(target, result);
|
|
160
|
+
return result;
|
|
161
|
+
}
|
|
162
|
+
finally {
|
|
163
|
+
stack.pop();
|
|
164
|
+
visiting.delete(target);
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
return recur(root, true);
|
|
168
|
+
}
|
|
169
|
+
export function hasUnbound(root, source) {
|
|
170
|
+
if (source.kind !== "unbound")
|
|
171
|
+
throw new Error(`Cannot check non-unbound layer node: ${source.name}`);
|
|
172
|
+
return walk(root, (node, context) => {
|
|
173
|
+
if (node === source)
|
|
174
|
+
return true;
|
|
175
|
+
return node.dependencies.some(context.visit);
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
function flatten(node) {
|
|
179
|
+
return node.kind === "group" ? node.dependencies.flatMap(flatten) : [node];
|
|
180
|
+
}
|
|
181
|
+
export * as LayerNode from "./layer-node.js";
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Layer, type Context, type Effect } from "effect";
|
|
2
|
+
export declare function makeRuntime<I, S, E>(service: Context.Service<I, S>, layer: Layer.Layer<I, E>): {
|
|
3
|
+
runSync: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => A;
|
|
4
|
+
runPromiseExit: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) => Promise<import("effect/Exit").Exit<A, E | Err>>;
|
|
5
|
+
runPromise: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) => Promise<A>;
|
|
6
|
+
runFork: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => import("effect/Fiber").Fiber<A, E | Err>;
|
|
7
|
+
runCallback: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => (interruptor?: number | undefined) => void;
|
|
8
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Layer, ManagedRuntime } from "effect";
|
|
2
|
+
import { memoMap } from "./memo-map.js";
|
|
3
|
+
import { Observability } from "../observability.js";
|
|
4
|
+
export function makeRuntime(service, layer) {
|
|
5
|
+
let rt;
|
|
6
|
+
const getRuntime = () => (rt ??= ManagedRuntime.make(Layer.provideMerge(layer, Observability.layer()), {
|
|
7
|
+
memoMap,
|
|
8
|
+
}));
|
|
9
|
+
return {
|
|
10
|
+
runSync: (fn) => getRuntime().runSync(service.use(fn)),
|
|
11
|
+
runPromiseExit: (fn, options) => getRuntime().runPromiseExit(service.use(fn), options),
|
|
12
|
+
runPromise: (fn, options) => getRuntime().runPromise(service.use(fn), options),
|
|
13
|
+
runFork: (fn) => getRuntime().runFork(service.use(fn)),
|
|
14
|
+
runCallback: (fn) => getRuntime().runCallback(service.use(fn)),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { Context, Effect } from "effect";
|
|
2
|
+
type EffectMethod = (...args: ReadonlyArray<never>) => Effect.Effect<unknown, unknown, unknown>;
|
|
3
|
+
type ServiceUse<Identifier, Shape> = {
|
|
4
|
+
readonly [Key in keyof Shape as Shape[Key] extends EffectMethod ? Key : never]: Shape[Key] extends (...args: infer Args) => infer Return ? Args extends ReadonlyArray<unknown> ? Return extends Effect.Effect<infer A, infer E, infer R> ? (...args: Args) => Effect.Effect<A, E, R | Identifier> : never : never : never;
|
|
5
|
+
};
|
|
6
|
+
export declare const serviceUse: <Identifier, Shape>(tag: Context.Service<Identifier, Shape>) => ServiceUse<Identifier, Shape>;
|
|
7
|
+
export {};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Context, Effect } from "effect";
|
|
2
|
+
export const serviceUse = (tag) => {
|
|
3
|
+
const cache = new Map();
|
|
4
|
+
// This is the only dynamic boundary: TypeScript knows the accessor shape,
|
|
5
|
+
// but Proxy property names are runtime values.
|
|
6
|
+
const access = new Proxy({}, {
|
|
7
|
+
get: (_, key) => {
|
|
8
|
+
if (typeof key !== "string")
|
|
9
|
+
return undefined;
|
|
10
|
+
const cached = cache.get(key);
|
|
11
|
+
if (cached)
|
|
12
|
+
return cached;
|
|
13
|
+
const accessor = (...args) => tag.use((service) => {
|
|
14
|
+
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Proxy keys are checked at runtime.
|
|
15
|
+
const method = service[key];
|
|
16
|
+
if (typeof method !== "function")
|
|
17
|
+
return Effect.die(new Error(`Service method not found: ${key}`));
|
|
18
|
+
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- ServiceUse exposes only Effect-returning methods.
|
|
19
|
+
return method(...args);
|
|
20
|
+
});
|
|
21
|
+
cache.set(key, accessor);
|
|
22
|
+
return accessor;
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Proxy implements the mapped accessor surface lazily.
|
|
26
|
+
return access;
|
|
27
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Context, Effect, Schema } from "effect";
|
|
2
|
+
import type { Scope } from "effect";
|
|
3
|
+
export declare namespace EffectFlock {
|
|
4
|
+
const LockTimeoutError_base: Schema.Class<LockTimeoutError, Schema.TaggedStruct<"LockTimeoutError", {
|
|
5
|
+
readonly key: Schema.String;
|
|
6
|
+
}>, import("effect/Cause").YieldableError>;
|
|
7
|
+
export class LockTimeoutError extends LockTimeoutError_base {
|
|
8
|
+
}
|
|
9
|
+
const LockCompromisedError_base: Schema.Class<LockCompromisedError, Schema.TaggedStruct<"LockCompromisedError", {
|
|
10
|
+
readonly detail: Schema.String;
|
|
11
|
+
}>, import("effect/Cause").YieldableError>;
|
|
12
|
+
export class LockCompromisedError extends LockCompromisedError_base {
|
|
13
|
+
}
|
|
14
|
+
export type LockError = LockTimeoutError | LockCompromisedError;
|
|
15
|
+
export interface Options {
|
|
16
|
+
readonly staleMs?: number;
|
|
17
|
+
readonly timeoutMs?: number;
|
|
18
|
+
}
|
|
19
|
+
export interface Interface {
|
|
20
|
+
readonly acquire: (key: string, dir?: string, options?: Options) => Effect.Effect<void, LockError, Scope.Scope>;
|
|
21
|
+
readonly withLock: {
|
|
22
|
+
(key: string, dir?: string): <A, E, R>(body: Effect.Effect<A, E, R>) => Effect.Effect<A, E | LockError, R>;
|
|
23
|
+
<A, E, R>(body: Effect.Effect<A, E, R>, key: string, dir?: string): Effect.Effect<A, E | LockError, R>;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
const Service_base: Context.ServiceClass<Service, "EffectFlock", Interface>;
|
|
27
|
+
export class Service extends Service_base {
|
|
28
|
+
}
|
|
29
|
+
export const node: import("./effect/layer-node.js").Node<Service, never, import("./effect/layer-node.js").Tag<"global">>;
|
|
30
|
+
export {};
|
|
31
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import { randomUUID } from "crypto";
|
|
4
|
+
import { Context, Effect, Function, Layer, Option, Schedule, Schema } from "effect";
|
|
5
|
+
import { FSUtil } from "./fs-util.js";
|
|
6
|
+
import { Global } from "./global.js";
|
|
7
|
+
import { makeGlobalNode } from "./effect/app-node.js";
|
|
8
|
+
import { Hash } from "./hash.js";
|
|
9
|
+
export var EffectFlock;
|
|
10
|
+
(function (EffectFlock) {
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Errors
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
class LockTimeoutError extends Schema.TaggedErrorClass()("LockTimeoutError", {
|
|
15
|
+
key: Schema.String,
|
|
16
|
+
}) {
|
|
17
|
+
}
|
|
18
|
+
EffectFlock.LockTimeoutError = LockTimeoutError;
|
|
19
|
+
class LockCompromisedError extends Schema.TaggedErrorClass()("LockCompromisedError", {
|
|
20
|
+
detail: Schema.String,
|
|
21
|
+
}) {
|
|
22
|
+
}
|
|
23
|
+
EffectFlock.LockCompromisedError = LockCompromisedError;
|
|
24
|
+
class ReleaseError extends Schema.TaggedErrorClass()("ReleaseError", {
|
|
25
|
+
detail: Schema.String,
|
|
26
|
+
cause: Schema.optional(Schema.Defect()),
|
|
27
|
+
}) {
|
|
28
|
+
get message() {
|
|
29
|
+
return this.detail;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** Internal: signals "lock is held, retry later". Never leaks to callers. */
|
|
33
|
+
class NotAcquired extends Schema.TaggedErrorClass()("NotAcquired", {}) {
|
|
34
|
+
}
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Timing defaults
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
const DEFAULT_STALE_MS = 60_000;
|
|
39
|
+
const DEFAULT_TIMEOUT_MS = 5 * 60_000;
|
|
40
|
+
const BASE_DELAY_MS = 100;
|
|
41
|
+
const MAX_DELAY_MS = 2_000;
|
|
42
|
+
const retrySchedule = (timeoutMs) => Schedule.min([
|
|
43
|
+
Schedule.exponential(BASE_DELAY_MS, 1.7),
|
|
44
|
+
Schedule.spaced(Math.min(MAX_DELAY_MS, Math.max(BASE_DELAY_MS, Math.floor(timeoutMs / 10)))),
|
|
45
|
+
]).pipe(Schedule.jittered, Schedule.while((meta) => meta.elapsed < timeoutMs));
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Lock metadata schema
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
const LockMetaJson = Schema.fromJsonString(Schema.Struct({
|
|
50
|
+
token: Schema.String,
|
|
51
|
+
pid: Schema.Number,
|
|
52
|
+
hostname: Schema.String,
|
|
53
|
+
createdAt: Schema.String,
|
|
54
|
+
}));
|
|
55
|
+
const decodeMeta = Schema.decodeUnknownSync(LockMetaJson);
|
|
56
|
+
const encodeMeta = Schema.encodeSync(LockMetaJson);
|
|
57
|
+
class Service extends Context.Service()("EffectFlock") {
|
|
58
|
+
}
|
|
59
|
+
EffectFlock.Service = Service;
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Layer
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
function wall() {
|
|
64
|
+
return performance.timeOrigin + performance.now();
|
|
65
|
+
}
|
|
66
|
+
const mtimeMs = (info) => Option.getOrElse(info.mtime, () => new Date(0)).getTime();
|
|
67
|
+
const isPathGone = (e) => e.reason._tag === "NotFound" || e.reason._tag === "Unknown";
|
|
68
|
+
const layer = Layer.effect(Service, Effect.gen(function* () {
|
|
69
|
+
const global = yield* Global.Service;
|
|
70
|
+
const fs = yield* FSUtil.Service;
|
|
71
|
+
const lockRoot = path.join(global.state, "locks");
|
|
72
|
+
const hostname = os.hostname();
|
|
73
|
+
const ensuredDirs = new Set();
|
|
74
|
+
// -- helpers (close over fs) --
|
|
75
|
+
const safeStat = (file) => fs.stat(file).pipe(Effect.catchIf(isPathGone, () => Effect.void), Effect.orDie);
|
|
76
|
+
const forceRemove = (target) => fs.remove(target, { recursive: true }).pipe(Effect.ignore);
|
|
77
|
+
/** Atomic mkdir — returns true if created, false if already exists, dies on other errors. */
|
|
78
|
+
const atomicMkdir = (dir) => fs.makeDirectory(dir, { mode: 0o700 }).pipe(Effect.as(true), Effect.catchIf((e) => e.reason._tag === "AlreadyExists", () => Effect.succeed(false)), Effect.orDie);
|
|
79
|
+
/** Write with exclusive create — compromised error if file already exists. */
|
|
80
|
+
const exclusiveWrite = (filePath, content, lockDir, detail) => fs.writeFileString(filePath, content, { flag: "wx" }).pipe(Effect.catch(() => Effect.gen(function* () {
|
|
81
|
+
yield* forceRemove(lockDir);
|
|
82
|
+
return yield* new LockCompromisedError({ detail });
|
|
83
|
+
})));
|
|
84
|
+
const cleanStaleBreaker = Effect.fnUntraced(function* (breakerPath, staleMs) {
|
|
85
|
+
const bs = yield* safeStat(breakerPath);
|
|
86
|
+
if (bs && wall() - mtimeMs(bs) > staleMs)
|
|
87
|
+
yield* forceRemove(breakerPath);
|
|
88
|
+
return false;
|
|
89
|
+
});
|
|
90
|
+
const ensureDir = Effect.fnUntraced(function* (dir) {
|
|
91
|
+
if (ensuredDirs.has(dir))
|
|
92
|
+
return;
|
|
93
|
+
yield* fs.makeDirectory(dir, { recursive: true }).pipe(Effect.orDie);
|
|
94
|
+
ensuredDirs.add(dir);
|
|
95
|
+
});
|
|
96
|
+
const isStale = Effect.fnUntraced(function* (lockDir, heartbeatPath, metaPath, staleMs) {
|
|
97
|
+
const now = wall();
|
|
98
|
+
const hb = yield* safeStat(heartbeatPath);
|
|
99
|
+
if (hb)
|
|
100
|
+
return now - mtimeMs(hb) > staleMs;
|
|
101
|
+
const meta = yield* safeStat(metaPath);
|
|
102
|
+
if (meta)
|
|
103
|
+
return now - mtimeMs(meta) > staleMs;
|
|
104
|
+
const dir = yield* safeStat(lockDir);
|
|
105
|
+
if (!dir)
|
|
106
|
+
return false;
|
|
107
|
+
return now - mtimeMs(dir) > staleMs;
|
|
108
|
+
});
|
|
109
|
+
const tryAcquireLockDir = (lockDir, key, staleMs) => Effect.gen(function* () {
|
|
110
|
+
const token = randomUUID();
|
|
111
|
+
const metaPath = path.join(lockDir, "meta.json");
|
|
112
|
+
const heartbeatPath = path.join(lockDir, "heartbeat");
|
|
113
|
+
// Atomic mkdir — the POSIX lock primitive
|
|
114
|
+
const created = yield* atomicMkdir(lockDir);
|
|
115
|
+
if (!created) {
|
|
116
|
+
if (!(yield* isStale(lockDir, heartbeatPath, metaPath, staleMs)))
|
|
117
|
+
return yield* new NotAcquired();
|
|
118
|
+
// Stale — race for breaker ownership
|
|
119
|
+
const breakerPath = lockDir + ".breaker";
|
|
120
|
+
const claimed = yield* fs.makeDirectory(breakerPath, { mode: 0o700 }).pipe(Effect.as(true), Effect.catchIf((e) => e.reason._tag === "AlreadyExists", () => cleanStaleBreaker(breakerPath, staleMs)), Effect.catchIf(isPathGone, () => Effect.succeed(false)), Effect.orDie);
|
|
121
|
+
if (!claimed)
|
|
122
|
+
return yield* new NotAcquired();
|
|
123
|
+
// We own the breaker — double-check staleness, nuke, recreate
|
|
124
|
+
const recreated = yield* Effect.gen(function* () {
|
|
125
|
+
if (!(yield* isStale(lockDir, heartbeatPath, metaPath, staleMs)))
|
|
126
|
+
return false;
|
|
127
|
+
yield* forceRemove(lockDir);
|
|
128
|
+
return yield* atomicMkdir(lockDir);
|
|
129
|
+
}).pipe(Effect.ensuring(forceRemove(breakerPath)));
|
|
130
|
+
if (!recreated)
|
|
131
|
+
return yield* new NotAcquired();
|
|
132
|
+
}
|
|
133
|
+
// We own the lock dir — write heartbeat + meta with exclusive create
|
|
134
|
+
yield* exclusiveWrite(heartbeatPath, "", lockDir, "heartbeat already existed");
|
|
135
|
+
const metaJson = encodeMeta({ token, pid: process.pid, hostname, createdAt: new Date().toISOString() });
|
|
136
|
+
yield* exclusiveWrite(metaPath, metaJson, lockDir, "meta.json already existed");
|
|
137
|
+
return { token, metaPath, heartbeatPath, lockDir };
|
|
138
|
+
}).pipe(Effect.withSpan("EffectFlock.tryAcquire", {
|
|
139
|
+
attributes: { key },
|
|
140
|
+
}));
|
|
141
|
+
// -- retry wrapper (preserves Handle type) --
|
|
142
|
+
const acquireHandle = (lockfile, key, options) => tryAcquireLockDir(lockfile, key, options.staleMs).pipe(Effect.retry({
|
|
143
|
+
while: (err) => err._tag === "NotAcquired",
|
|
144
|
+
schedule: retrySchedule(options.timeoutMs),
|
|
145
|
+
}), Effect.catchTag("NotAcquired", () => Effect.fail(new LockTimeoutError({ key }))), Effect.timeoutOrElse({
|
|
146
|
+
duration: options.timeoutMs,
|
|
147
|
+
orElse: () => Effect.fail(new LockTimeoutError({ key })),
|
|
148
|
+
}));
|
|
149
|
+
// -- release --
|
|
150
|
+
const release = (handle) => Effect.gen(function* () {
|
|
151
|
+
const raw = yield* fs.readFileString(handle.metaPath).pipe(Effect.catch((err) => {
|
|
152
|
+
if (isPathGone(err))
|
|
153
|
+
return Effect.die(new ReleaseError({ detail: "metadata missing" }));
|
|
154
|
+
return Effect.die(err);
|
|
155
|
+
}));
|
|
156
|
+
const parsed = yield* Effect.try({
|
|
157
|
+
try: () => decodeMeta(raw),
|
|
158
|
+
catch: (cause) => new ReleaseError({ detail: "metadata invalid", cause }),
|
|
159
|
+
}).pipe(Effect.orDie);
|
|
160
|
+
if (parsed.token !== handle.token)
|
|
161
|
+
return yield* Effect.die(new ReleaseError({ detail: "token mismatch" }));
|
|
162
|
+
yield* forceRemove(handle.lockDir);
|
|
163
|
+
});
|
|
164
|
+
// -- build service --
|
|
165
|
+
const acquire = Effect.fn("EffectFlock.acquire")(function* (key, dir, options = {}) {
|
|
166
|
+
const lockDir = dir ?? lockRoot;
|
|
167
|
+
const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
|
|
168
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
169
|
+
yield* ensureDir(lockDir);
|
|
170
|
+
const lockfile = path.join(lockDir, Hash.fast(key) + ".lock");
|
|
171
|
+
// acquireRelease: acquire is uninterruptible, release is guaranteed
|
|
172
|
+
const handle = yield* Effect.acquireRelease(acquireHandle(lockfile, key, { staleMs, timeoutMs }), (handle) => release(handle));
|
|
173
|
+
// Heartbeat fiber — scoped, so it's interrupted before release runs
|
|
174
|
+
yield* fs
|
|
175
|
+
.utimes(handle.heartbeatPath, new Date(), new Date())
|
|
176
|
+
.pipe(Effect.ignore, Effect.repeat(Schedule.spaced(Math.max(100, Math.floor(staleMs / 3)))), Effect.forkScoped);
|
|
177
|
+
});
|
|
178
|
+
const withLock = Function.dual((args) => Effect.isEffect(args[0]), (body, key, dir) => Effect.scoped(Effect.gen(function* () {
|
|
179
|
+
yield* acquire(key, dir);
|
|
180
|
+
return yield* body;
|
|
181
|
+
})));
|
|
182
|
+
return Service.of({ acquire, withLock });
|
|
183
|
+
}));
|
|
184
|
+
EffectFlock.node = makeGlobalNode({ service: Service, layer: layer, deps: [Global.node, FSUtil.node] });
|
|
185
|
+
})(EffectFlock || (EffectFlock = {}));
|
package/dist/flock.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
export type FlockGlobal = {
|
|
3
|
+
state: string;
|
|
4
|
+
};
|
|
5
|
+
export declare namespace Flock {
|
|
6
|
+
function setGlobal(g: FlockGlobal): void;
|
|
7
|
+
interface WaitEvent {
|
|
8
|
+
key: string;
|
|
9
|
+
attempt: number;
|
|
10
|
+
delay: number;
|
|
11
|
+
waited: number;
|
|
12
|
+
}
|
|
13
|
+
type Wait = (input: WaitEvent) => void | Promise<void>;
|
|
14
|
+
interface Options {
|
|
15
|
+
dir?: string;
|
|
16
|
+
signal?: AbortSignal;
|
|
17
|
+
staleMs?: number;
|
|
18
|
+
timeoutMs?: number;
|
|
19
|
+
baseDelayMs?: number;
|
|
20
|
+
maxDelayMs?: number;
|
|
21
|
+
onWait?: Wait;
|
|
22
|
+
}
|
|
23
|
+
interface Lease {
|
|
24
|
+
release: () => Promise<void>;
|
|
25
|
+
[Symbol.asyncDispose]: () => Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
function acquire(key: string, input?: Options): Promise<Lease>;
|
|
28
|
+
function withLock<T>(key: string, fn: () => Promise<T>, input?: Options): Promise<T>;
|
|
29
|
+
const effect: (key: string, input?: Options | undefined) => Effect.Effect<void, never, import("effect/Scope").Scope>;
|
|
30
|
+
}
|