@saeeol/core 7.3.1
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/package.json +52 -0
- package/src/cross-spawn-process.ts +273 -0
- package/src/cross-spawn-spawner.ts +505 -0
- package/src/cross-spawn-utils.ts +74 -0
- package/src/effect/logger.ts +73 -0
- package/src/effect/memo-map.ts +3 -0
- package/src/effect/observability.ts +107 -0
- package/src/effect/runtime.ts +21 -0
- package/src/filesystem.ts +262 -0
- package/src/flag/flag.ts +107 -0
- package/src/global.ts +91 -0
- package/src/installation/version.ts +11 -0
- package/src/npm-config.ts +40 -0
- package/src/npm.ts +271 -0
- package/src/saeeol/global.ts +23 -0
- package/src/saeeol/kilocode/global.ts +23 -0
- package/src/saeeol/kilocode/spotlight.ts +23 -0
- package/src/saeeol/spotlight.ts +23 -0
- package/src/util/array.ts +10 -0
- package/src/util/binary.ts +41 -0
- package/src/util/effect-flock.ts +283 -0
- package/src/util/encode.ts +52 -0
- package/src/util/error.ts +60 -0
- package/src/util/flock.ts +358 -0
- package/src/util/glob.ts +34 -0
- package/src/util/hash.ts +7 -0
- package/src/util/identifier.ts +48 -0
- package/src/util/iife.ts +3 -0
- package/src/util/lazy.ts +11 -0
- package/src/util/log.ts +208 -0
- package/src/util/module.ts +10 -0
- package/src/util/path.ts +37 -0
- package/src/util/retry.ts +42 -0
- package/src/util/saeeol-process.ts +24 -0
- package/src/util/slug.ts +74 -0
- package/sst-env.d.ts +10 -0
- package/test/effect/cross-spawn-spawner.test.ts +423 -0
- package/test/effect/observability.test.ts +46 -0
- package/test/filesystem/filesystem.test.ts +338 -0
- package/test/fixture/effect-flock-worker.ts +60 -0
- package/test/fixture/flock-worker.ts +72 -0
- package/test/fixture/tmpdir.ts +13 -0
- package/test/global.test.ts +16 -0
- package/test/lib/effect.ts +53 -0
- package/test/npm-config.test.ts +51 -0
- package/test/npm.test.ts +91 -0
- package/test/saeeol/filesystem-containment.test.ts +13 -0
- package/test/saeeol/kilocode/filesystem-containment.test.ts +13 -0
- package/test/util/effect-flock.test.ts +386 -0
- package/test/util/flock.test.ts +426 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,283 @@
|
|
|
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 type { FileSystem, Scope } from "effect"
|
|
6
|
+
import type { PlatformError } from "effect/PlatformError"
|
|
7
|
+
import { AppFileSystem } from "../filesystem"
|
|
8
|
+
import { Global } from "../global"
|
|
9
|
+
import { Hash } from "./hash"
|
|
10
|
+
|
|
11
|
+
export namespace EffectFlock {
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Errors
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
export class LockTimeoutError extends Schema.TaggedErrorClass<LockTimeoutError>()("LockTimeoutError", {
|
|
17
|
+
key: Schema.String,
|
|
18
|
+
}) {}
|
|
19
|
+
|
|
20
|
+
export class LockCompromisedError extends Schema.TaggedErrorClass<LockCompromisedError>()("LockCompromisedError", {
|
|
21
|
+
detail: Schema.String,
|
|
22
|
+
}) {}
|
|
23
|
+
|
|
24
|
+
class ReleaseError extends Schema.TaggedErrorClass<ReleaseError>()("ReleaseError", {
|
|
25
|
+
detail: Schema.String,
|
|
26
|
+
cause: Schema.optional(Schema.Defect),
|
|
27
|
+
}) {
|
|
28
|
+
override get message() {
|
|
29
|
+
return this.detail
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Internal: signals "lock is held, retry later". Never leaks to callers. */
|
|
34
|
+
class NotAcquired extends Schema.TaggedErrorClass<NotAcquired>()("NotAcquired", {}) {}
|
|
35
|
+
|
|
36
|
+
export type LockError = LockTimeoutError | LockCompromisedError
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Timing (baked in — no caller ever overrides these)
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
const STALE_MS = 60_000
|
|
43
|
+
const TIMEOUT_MS = 5 * 60_000
|
|
44
|
+
const BASE_DELAY_MS = 100
|
|
45
|
+
const MAX_DELAY_MS = 2_000
|
|
46
|
+
const HEARTBEAT_MS = Math.max(100, Math.floor(STALE_MS / 3))
|
|
47
|
+
|
|
48
|
+
const retrySchedule = Schedule.exponential(BASE_DELAY_MS, 1.7).pipe(
|
|
49
|
+
Schedule.either(Schedule.spaced(MAX_DELAY_MS)),
|
|
50
|
+
Schedule.jittered,
|
|
51
|
+
Schedule.while((meta) => meta.elapsed < TIMEOUT_MS),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Lock metadata schema
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
const LockMetaJson = Schema.fromJsonString(
|
|
59
|
+
Schema.Struct({
|
|
60
|
+
token: Schema.String,
|
|
61
|
+
pid: Schema.Number,
|
|
62
|
+
hostname: Schema.String,
|
|
63
|
+
createdAt: Schema.String,
|
|
64
|
+
}),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
const decodeMeta = Schema.decodeUnknownSync(LockMetaJson)
|
|
68
|
+
const encodeMeta = Schema.encodeSync(LockMetaJson)
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// Service
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
export interface Interface {
|
|
75
|
+
readonly acquire: (key: string, dir?: string) => Effect.Effect<void, LockError, Scope.Scope>
|
|
76
|
+
readonly withLock: {
|
|
77
|
+
(key: string, dir?: string): <A, E, R>(body: Effect.Effect<A, E, R>) => Effect.Effect<A, E | LockError, R>
|
|
78
|
+
<A, E, R>(body: Effect.Effect<A, E, R>, key: string, dir?: string): Effect.Effect<A, E | LockError, R>
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export class Service extends Context.Service<Service, Interface>()("EffectFlock") {}
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Layer
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
function wall() {
|
|
89
|
+
return performance.timeOrigin + performance.now()
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const mtimeMs = (info: FileSystem.File.Info) => Option.getOrElse(info.mtime, () => new Date(0)).getTime()
|
|
93
|
+
|
|
94
|
+
const isPathGone = (e: PlatformError) => e.reason._tag === "NotFound" || e.reason._tag === "Unknown"
|
|
95
|
+
|
|
96
|
+
export const layer: Layer.Layer<Service, never, Global.Service | AppFileSystem.Service> = Layer.effect(
|
|
97
|
+
Service,
|
|
98
|
+
Effect.gen(function* () {
|
|
99
|
+
const global = yield* Global.Service
|
|
100
|
+
const fs = yield* AppFileSystem.Service
|
|
101
|
+
const lockRoot = path.join(global.state, "locks")
|
|
102
|
+
const hostname = os.hostname()
|
|
103
|
+
const ensuredDirs = new Set<string>()
|
|
104
|
+
|
|
105
|
+
// -- helpers (close over fs) --
|
|
106
|
+
|
|
107
|
+
const safeStat = (file: string) =>
|
|
108
|
+
fs.stat(file).pipe(
|
|
109
|
+
Effect.catchIf(isPathGone, () => Effect.void),
|
|
110
|
+
Effect.orDie,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
const forceRemove = (target: string) => fs.remove(target, { recursive: true }).pipe(Effect.ignore)
|
|
114
|
+
|
|
115
|
+
/** Atomic mkdir — returns true if created, false if already exists, dies on other errors. */
|
|
116
|
+
const atomicMkdir = (dir: string) =>
|
|
117
|
+
fs.makeDirectory(dir, { mode: 0o700 }).pipe(
|
|
118
|
+
Effect.as(true),
|
|
119
|
+
Effect.catchIf(
|
|
120
|
+
(e) => e.reason._tag === "AlreadyExists",
|
|
121
|
+
() => Effect.succeed(false),
|
|
122
|
+
),
|
|
123
|
+
Effect.orDie,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
/** Write with exclusive create — compromised error if file already exists. */
|
|
127
|
+
const exclusiveWrite = (filePath: string, content: string, lockDir: string, detail: string) =>
|
|
128
|
+
fs.writeFileString(filePath, content, { flag: "wx" }).pipe(
|
|
129
|
+
Effect.catch(() =>
|
|
130
|
+
Effect.gen(function* () {
|
|
131
|
+
yield* forceRemove(lockDir)
|
|
132
|
+
return yield* new LockCompromisedError({ detail })
|
|
133
|
+
}),
|
|
134
|
+
),
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
const cleanStaleBreaker = Effect.fnUntraced(function* (breakerPath: string) {
|
|
138
|
+
const bs = yield* safeStat(breakerPath)
|
|
139
|
+
if (bs && wall() - mtimeMs(bs) > STALE_MS) yield* forceRemove(breakerPath)
|
|
140
|
+
return false
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
const ensureDir = Effect.fnUntraced(function* (dir: string) {
|
|
144
|
+
if (ensuredDirs.has(dir)) return
|
|
145
|
+
yield* fs.makeDirectory(dir, { recursive: true }).pipe(Effect.orDie)
|
|
146
|
+
ensuredDirs.add(dir)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
const isStale = Effect.fnUntraced(function* (lockDir: string, heartbeatPath: string, metaPath: string) {
|
|
150
|
+
const now = wall()
|
|
151
|
+
|
|
152
|
+
const hb = yield* safeStat(heartbeatPath)
|
|
153
|
+
if (hb) return now - mtimeMs(hb) > STALE_MS
|
|
154
|
+
|
|
155
|
+
const meta = yield* safeStat(metaPath)
|
|
156
|
+
if (meta) return now - mtimeMs(meta) > STALE_MS
|
|
157
|
+
|
|
158
|
+
const dir = yield* safeStat(lockDir)
|
|
159
|
+
if (!dir) return false
|
|
160
|
+
|
|
161
|
+
return now - mtimeMs(dir) > STALE_MS
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
// -- single lock attempt --
|
|
165
|
+
|
|
166
|
+
type Handle = { token: string; metaPath: string; heartbeatPath: string; lockDir: string }
|
|
167
|
+
|
|
168
|
+
const tryAcquireLockDir = (lockDir: string, key: string) =>
|
|
169
|
+
Effect.gen(function* () {
|
|
170
|
+
const token = randomUUID()
|
|
171
|
+
const metaPath = path.join(lockDir, "meta.json")
|
|
172
|
+
const heartbeatPath = path.join(lockDir, "heartbeat")
|
|
173
|
+
|
|
174
|
+
// Atomic mkdir — the POSIX lock primitive
|
|
175
|
+
const created = yield* atomicMkdir(lockDir)
|
|
176
|
+
|
|
177
|
+
if (!created) {
|
|
178
|
+
if (!(yield* isStale(lockDir, heartbeatPath, metaPath))) return yield* new NotAcquired()
|
|
179
|
+
|
|
180
|
+
// Stale — race for breaker ownership
|
|
181
|
+
const breakerPath = lockDir + ".breaker"
|
|
182
|
+
|
|
183
|
+
const claimed = yield* fs.makeDirectory(breakerPath, { mode: 0o700 }).pipe(
|
|
184
|
+
Effect.as(true),
|
|
185
|
+
Effect.catchIf(
|
|
186
|
+
(e) => e.reason._tag === "AlreadyExists",
|
|
187
|
+
() => cleanStaleBreaker(breakerPath),
|
|
188
|
+
),
|
|
189
|
+
Effect.catchIf(isPathGone, () => Effect.succeed(false)),
|
|
190
|
+
Effect.orDie,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
if (!claimed) return yield* new NotAcquired()
|
|
194
|
+
|
|
195
|
+
// We own the breaker — double-check staleness, nuke, recreate
|
|
196
|
+
const recreated = yield* Effect.gen(function* () {
|
|
197
|
+
if (!(yield* isStale(lockDir, heartbeatPath, metaPath))) return false
|
|
198
|
+
yield* forceRemove(lockDir)
|
|
199
|
+
return yield* atomicMkdir(lockDir)
|
|
200
|
+
}).pipe(Effect.ensuring(forceRemove(breakerPath)))
|
|
201
|
+
|
|
202
|
+
if (!recreated) return yield* new NotAcquired()
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// We own the lock dir — write heartbeat + meta with exclusive create
|
|
206
|
+
yield* exclusiveWrite(heartbeatPath, "", lockDir, "heartbeat already existed")
|
|
207
|
+
|
|
208
|
+
const metaJson = encodeMeta({ token, pid: process.pid, hostname, createdAt: new Date().toISOString() })
|
|
209
|
+
yield* exclusiveWrite(metaPath, metaJson, lockDir, "meta.json already existed")
|
|
210
|
+
|
|
211
|
+
return { token, metaPath, heartbeatPath, lockDir } satisfies Handle
|
|
212
|
+
}).pipe(
|
|
213
|
+
Effect.withSpan("EffectFlock.tryAcquire", {
|
|
214
|
+
attributes: { key },
|
|
215
|
+
}),
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
// -- retry wrapper (preserves Handle type) --
|
|
219
|
+
|
|
220
|
+
const acquireHandle = (lockfile: string, key: string): Effect.Effect<Handle, LockError> =>
|
|
221
|
+
tryAcquireLockDir(lockfile, key).pipe(
|
|
222
|
+
Effect.retry({
|
|
223
|
+
while: (err) => err._tag === "NotAcquired",
|
|
224
|
+
schedule: retrySchedule,
|
|
225
|
+
}),
|
|
226
|
+
Effect.catchTag("NotAcquired", () => Effect.fail(new LockTimeoutError({ key }))),
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
// -- release --
|
|
230
|
+
|
|
231
|
+
const release = (handle: Handle) =>
|
|
232
|
+
Effect.gen(function* () {
|
|
233
|
+
const raw = yield* fs.readFileString(handle.metaPath).pipe(
|
|
234
|
+
Effect.catch((err) => {
|
|
235
|
+
if (isPathGone(err)) return Effect.die(new ReleaseError({ detail: "metadata missing" }))
|
|
236
|
+
return Effect.die(err)
|
|
237
|
+
}),
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
const parsed = yield* Effect.try({
|
|
241
|
+
try: () => decodeMeta(raw),
|
|
242
|
+
catch: (cause) => new ReleaseError({ detail: "metadata invalid", cause }),
|
|
243
|
+
}).pipe(Effect.orDie)
|
|
244
|
+
|
|
245
|
+
if (parsed.token !== handle.token) return yield* Effect.die(new ReleaseError({ detail: "token mismatch" }))
|
|
246
|
+
|
|
247
|
+
yield* forceRemove(handle.lockDir)
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
// -- build service --
|
|
251
|
+
|
|
252
|
+
const acquire = Effect.fn("EffectFlock.acquire")(function* (key: string, dir?: string) {
|
|
253
|
+
const lockDir = dir ?? lockRoot
|
|
254
|
+
yield* ensureDir(lockDir)
|
|
255
|
+
|
|
256
|
+
const lockfile = path.join(lockDir, Hash.fast(key) + ".lock")
|
|
257
|
+
|
|
258
|
+
// acquireRelease: acquire is uninterruptible, release is guaranteed
|
|
259
|
+
const handle = yield* Effect.acquireRelease(acquireHandle(lockfile, key), (handle) => release(handle))
|
|
260
|
+
|
|
261
|
+
// Heartbeat fiber — scoped, so it's interrupted before release runs
|
|
262
|
+
yield* fs
|
|
263
|
+
.utimes(handle.heartbeatPath, new Date(), new Date())
|
|
264
|
+
.pipe(Effect.ignore, Effect.repeat(Schedule.spaced(HEARTBEAT_MS)), Effect.forkScoped)
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
const withLock: Interface["withLock"] = Function.dual(
|
|
268
|
+
(args) => Effect.isEffect(args[0]),
|
|
269
|
+
<A, E, R>(body: Effect.Effect<A, E, R>, key: string, dir?: string): Effect.Effect<A, E | LockError, R> =>
|
|
270
|
+
Effect.scoped(
|
|
271
|
+
Effect.gen(function* () {
|
|
272
|
+
yield* acquire(key, dir)
|
|
273
|
+
return yield* body
|
|
274
|
+
}),
|
|
275
|
+
),
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
return Service.of({ acquire, withLock })
|
|
279
|
+
}),
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Global.layer))
|
|
283
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export function base64Encode(value: string) {
|
|
2
|
+
const normalized = value.replace(/\\/g, "/")
|
|
3
|
+
const bytes = new TextEncoder().encode(normalized)
|
|
4
|
+
const binary = Array.from(bytes, (b) => String.fromCharCode(b)).join("")
|
|
5
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function base64Decode(value: string) {
|
|
9
|
+
const binary = atob(value.replace(/-/g, "+").replace(/_/g, "/"))
|
|
10
|
+
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0))
|
|
11
|
+
return new TextDecoder().decode(bytes)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function hash(content: string, algorithm = "SHA-256"): Promise<string> {
|
|
15
|
+
const encoder = new TextEncoder()
|
|
16
|
+
const data = encoder.encode(content)
|
|
17
|
+
const hashBuffer = await crypto.subtle.digest(algorithm, data)
|
|
18
|
+
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
|
19
|
+
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("")
|
|
20
|
+
return hashHex
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function checksum(content: string): string | undefined {
|
|
24
|
+
if (!content) return undefined
|
|
25
|
+
let hash = 0x811c9dc5
|
|
26
|
+
for (let i = 0; i < content.length; i++) {
|
|
27
|
+
hash ^= content.charCodeAt(i)
|
|
28
|
+
hash = Math.imul(hash, 0x01000193)
|
|
29
|
+
}
|
|
30
|
+
return (hash >>> 0).toString(36)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function sampledChecksum(content: string, limit = 500_000): string | undefined {
|
|
34
|
+
if (!content) return undefined
|
|
35
|
+
if (content.length <= limit) return checksum(content)
|
|
36
|
+
|
|
37
|
+
const size = 4096
|
|
38
|
+
const points = [
|
|
39
|
+
0,
|
|
40
|
+
Math.floor(content.length * 0.25),
|
|
41
|
+
Math.floor(content.length * 0.5),
|
|
42
|
+
Math.floor(content.length * 0.75),
|
|
43
|
+
content.length - size,
|
|
44
|
+
]
|
|
45
|
+
const hashes = points
|
|
46
|
+
.map((point) => {
|
|
47
|
+
const start = Math.max(0, Math.min(content.length - size, point - Math.floor(size / 2)))
|
|
48
|
+
return checksum(content.slice(start, start + size)) ?? ""
|
|
49
|
+
})
|
|
50
|
+
.join(":")
|
|
51
|
+
return `${content.length}:${hashes}`
|
|
52
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import z from "zod"
|
|
2
|
+
|
|
3
|
+
export abstract class NamedError extends Error {
|
|
4
|
+
abstract schema(): z.core.$ZodType
|
|
5
|
+
abstract toObject(): { name: string; data: any }
|
|
6
|
+
|
|
7
|
+
static hasName(error: unknown, name: string): boolean {
|
|
8
|
+
return (
|
|
9
|
+
typeof error === "object" && error !== null && "name" in error && (error as Record<string, unknown>).name === name
|
|
10
|
+
)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
static create<Name extends string, Data extends z.core.$ZodType>(name: Name, data: Data) {
|
|
14
|
+
const schema = z
|
|
15
|
+
.object({
|
|
16
|
+
name: z.literal(name),
|
|
17
|
+
data,
|
|
18
|
+
})
|
|
19
|
+
.meta({
|
|
20
|
+
ref: name,
|
|
21
|
+
})
|
|
22
|
+
const result = class extends NamedError {
|
|
23
|
+
public static readonly Schema = schema
|
|
24
|
+
|
|
25
|
+
public override readonly name = name as Name
|
|
26
|
+
|
|
27
|
+
constructor(
|
|
28
|
+
public readonly data: z.input<Data>,
|
|
29
|
+
options?: ErrorOptions,
|
|
30
|
+
) {
|
|
31
|
+
super(name, options)
|
|
32
|
+
this.name = name
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
static isInstance(input: any): input is InstanceType<typeof result> {
|
|
36
|
+
return typeof input === "object" && "name" in input && input.name === name
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
schema() {
|
|
40
|
+
return schema
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
toObject() {
|
|
44
|
+
return {
|
|
45
|
+
name: name,
|
|
46
|
+
data: this.data,
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
Object.defineProperty(result, "name", { value: name })
|
|
51
|
+
return result
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
public static readonly Unknown = NamedError.create(
|
|
55
|
+
"UnknownError",
|
|
56
|
+
z.object({
|
|
57
|
+
message: z.string(),
|
|
58
|
+
}),
|
|
59
|
+
)
|
|
60
|
+
}
|