refactor(credentials,llm): remove speculative mutation and route lifecycle
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/util/README.md
|
||||
README.md: 46904aba70c7cf0f98bb75cce79d97bb12b950a9
|
||||
README.zh.md: 59a2dcf7926c12d7005446393cadfd8b0be88f77
|
||||
README.md: 605c3dd0beebc16109e8e6bc944ea722a60975c0
|
||||
README.zh.md: 5c66ded33a36079f80965cf466449843e07511f0
|
||||
|
||||
@@ -10,7 +10,6 @@ Zero-dependency primitives shared across the other groups. A package lands here
|
||||
| `paths/` | Canonical single-root `DSH_HOME` resolution plus shared filesystem path constants and helpers for harness user data (no harness deps) |
|
||||
| `timeout/` | The timing/classification half of a timeout — `clampTimeout`/`deadline`/`timeoutOf`/`TimeoutReason` (pure functions, no harness deps); termination stays in each capability |
|
||||
| `retention/` | Bounded model-facing output — `ItemRetainer`/`TextRetainer` + neutral notice helpers (pure, no harness deps); business semantics stay in each tool |
|
||||
| `atomic-write/` | Atomic file replacement — `writeFileAtomic` (exclusive-create temp + rename carrying the caller-stated mode); shared by the settings and credentials stores |
|
||||
| `native-command/` | No-shell `execFile` runner for host-native OS integrations — utf8 capture, abort propagation, Windows hide (no harness deps); command choice stays in each caller |
|
||||
|
||||
`dsh-brand` is the canonical case: it owns ONLY the `Branded<B>` helper, so a capability package can brand the ids it owns (`dsh-tasks`'s `TaskId`, `dsh-session`'s `SessionId`, …) by depending on `dsh-brand` alone, without pulling in an unrelated package just to reach `Branded`.
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
| `paths/` | 规范的单根 `DSH_HOME` 解析,以及 harness 用户数据的共享文件系统路径常量和辅助工具(无 harness 依赖) |
|
||||
| `timeout/` | 超时的时序/分类部分:`clampTimeout`/`deadline`/`timeoutOf`/`TimeoutReason`(纯函数,无 harness 依赖);终止机制保留在各个功能中 |
|
||||
| `retention/` | 有界的面向模型输出:`ItemRetainer`/`TextRetainer` 加上中性通知辅助工具(纯工具,无 harness 依赖);业务语义保留在各个工具中 |
|
||||
| `atomic-write/` | 原子文件替换:`writeFileAtomic`(独占创建临时文件 + 携带调用方所声明 mode 的 rename);由设置与凭据存储共用 |
|
||||
| `native-command/` | 宿主原生 OS 集成的免 shell `execFile` 运行器——utf8 捕获、abort 传播、Windows 窗口隐藏(无 harness 依赖);命令选择保留在各调用方 |
|
||||
|
||||
`dsh-brand` 是规范示例:它只负责 `Branded<B>` 辅助工具,因此功能包可以为自己拥有的 id 添加品牌(`dsh-tasks` 的 `TaskId`、`dsh-session` 的 `SessionId` 等),而只需依赖 `dsh-brand`,无需仅为使用 `Branded` 而引入不相关的包。
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/util/atomic-write/README.md
|
||||
README.md: be9f896eb24e28aedc2c04858da8b8da9da548dc
|
||||
README.zh.md: 19a067dc84f12d334e5c31dda58e7cf78dac51f9
|
||||
@@ -1,45 +0,0 @@
|
||||
# dsh-atomic-write
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Zero-dependency atomic file replacement shared by file-backed stores that must never leave partial, symlink-hijacked, or wider-than-intended content on disk — the user-settings document (`dsh-settings-local`) and the credentials store (`dsh-credentials-local`).
|
||||
|
||||
## Surface
|
||||
|
||||
```ts
|
||||
import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
|
||||
|
||||
declare const text: string
|
||||
declare const render: (previous: string) => string
|
||||
|
||||
await writeFileAtomic('/home/u/.dsh/settings.yaml', text, { mode: 0o600 })
|
||||
|
||||
// Read-modify-write against the same file from several processes.
|
||||
await withFileLock('/home/u/.dsh/settings.yaml', async () => {
|
||||
await writeFileAtomic('/home/u/.dsh/settings.yaml', render(text), { mode: 0o600 })
|
||||
})
|
||||
```
|
||||
|
||||
`writeFileAtomic` commits one already-rendered string. The contract, in the order failures would exploit it:
|
||||
|
||||
- **Exclusive-create temp** (`wx`, random suffix): the open refuses to follow a symlink planted at a guessable temp path.
|
||||
- **The fresh inode carries `mode` through the rename**: replacing a wider-permission file narrows it without a chmod race. `mode` is required so the permission decision stays visible at every call site (subject to the process umask, like every fresh inode).
|
||||
- **`rename` replaces a symlinked target itself**, never writing through to its referent.
|
||||
- **Same-directory sibling** keeps the rename on one filesystem, so the swap stays atomic.
|
||||
- Parent directories are created; on any failure the temp is removed and the failure rethrown; readers observe either the old or the new complete content.
|
||||
|
||||
`withFileLock` serializes the writers of one file across processes, for the read-render-commit cycles a bare atomic commit cannot make safe on its own. The lock is a `wx`-created `<filename>.lock` sibling, so readers never contend; waiters back off exponentially and fail with a timeout rather than block forever. A lock older than the stale age is treated as a crashed holder and broken — see [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) for what that costs.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as this is a pure filesystem primitive; nothing here reaches a model request.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; nothing here enters a request prefix.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Atomic, not durable** — no `fsync` of the file or its directory, so after a crash the rename may be observed unwound. The file-backed stores here re-read and republish on boot, keeping durability the caller's policy.
|
||||
- **String content only** — no `Buffer` or stream form until a consumer needs one.
|
||||
- **The lock takes over by age, not by ownership** (`TODO(settings-lock-ownership)`) — a holder slower than the stale age has its lock broken by a waiter, and release unlinks the path unconditionally, so a slow writer can remove a successor's lock. Two writers can then overlap and one cycle's result be lost. The stale age is set well above any write this repo performs, so the exposure is a paused or swapped-out process; ownership-safe acquisition and release is the fix.
|
||||
@@ -1,45 +0,0 @@
|
||||
# dsh-atomic-write
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
零依赖的原子文件替换,供绝不允许在磁盘上留下不完整、被符号链接劫持或权限过宽内容的文件型存储共用:用户设置文档(`dsh-settings-local`)与凭据存储(`dsh-credentials-local`)。
|
||||
|
||||
## 接口面
|
||||
|
||||
```ts
|
||||
import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
|
||||
|
||||
declare const text: string
|
||||
declare const render: (previous: string) => string
|
||||
|
||||
await writeFileAtomic('/home/u/.dsh/settings.yaml', text, { mode: 0o600 })
|
||||
|
||||
// Read-modify-write against the same file from several processes.
|
||||
await withFileLock('/home/u/.dsh/settings.yaml', async () => {
|
||||
await writeFileAtomic('/home/u/.dsh/settings.yaml', render(text), { mode: 0o600 })
|
||||
})
|
||||
```
|
||||
|
||||
`writeFileAtomic` 提交一份已经渲染好的字符串。契约按故障利用它的先后顺序列出:
|
||||
|
||||
- **独占创建临时文件**(`wx` + 随机后缀):open 拒绝跟随预先埋在可猜测临时路径上的符号链接。
|
||||
- **全新 inode 携带 `mode` 走完 rename**:替换权限过宽的旧文件时直接收窄,不存在 chmod 竞态。`mode` 为必填,让权限决策始终可见于每个调用点(与所有新建 inode 一样受进程 umask 影响)。
|
||||
- **`rename` 替换的是符号链接目标本身**,绝不写穿到其指向的文件。
|
||||
- **同目录兄弟文件**保证 rename 落在同一文件系统上,交换保持原子。
|
||||
- 自动创建父目录;任何失败都会移除临时文件并重新抛出该失败;读取方只会观察到旧内容或完整的新内容。
|
||||
|
||||
`withFileLock` 跨进程串行化同一文件的写入方,服务于单靠原子提交无法保证安全的读-渲染-提交循环。锁是以 `wx` 创建的同目录 `<filename>.lock`,因此读取方从不参与竞争;等待方按指数退避,超时即失败而非无限阻塞。超过陈旧时限的锁被视为持有者已崩溃并被打破——其代价见[Known Limitations and Deferred Work](#known-limitations-and-deferred-work)。
|
||||
|
||||
## Model Experience
|
||||
|
||||
无:本包是纯文件系统原语,此处没有任何内容会到达模型请求。
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
无;此处没有任何内容会进入请求前缀。
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **原子但不保证持久**——不对文件或其所在目录做 `fsync`,因此崩溃后可能观察到 rename 被回退。此处的文件型存储在启动时重新读取并重新发布,把持久性留作调用方的策略。
|
||||
- **仅支持字符串内容**——在有消费方需要之前,不提供 `Buffer` 或流式形态。
|
||||
- **锁按时长而非归属接管**(`TODO(settings-lock-ownership)`)——持有者若慢于陈旧时限,其锁会被等待方打破,而释放又无条件删除该路径,因此慢写入方可能删掉后继者的锁。两个写入方随之重叠,一轮循环的结果可能丢失。陈旧时限远高于本仓库的任何一次写入,因此暴露面是被暂停或被换出的进程;修法是按归属安全地获取与释放。
|
||||
@@ -1,37 +0,0 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-atomic-write",
|
||||
"description": "Zero-dependency atomic file replacement: exclusive-create random-suffix temp + rename carrying the caller-stated permissions (writeFileAtomic)",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
/**
|
||||
* Zero-dependency atomic file replacement and writer coordination.
|
||||
* `writeFileAtomic` writes a random-suffix sibling with exclusive create and
|
||||
* the caller's permission bits, then renames it over the target, so readers
|
||||
* observe either the old or the new complete content and a replaced file ends
|
||||
* up with exactly the stated mode. `withFileLock` serializes cross-process
|
||||
* writers of one file through a `wx`-created `<file>.lock` sibling, so a
|
||||
* read-modify-write cycle can never resurrect a state another writer just
|
||||
* replaced; readers stay lock-free because the rename commit is atomic.
|
||||
* @module @deepseek-ai/dsh-atomic-write
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { mkdir, rename, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { dirname } from 'node:path'
|
||||
|
||||
/**
|
||||
* Filesystem options for {@link writeFileAtomic}; `mode` is required so the
|
||||
* permission decision stays visible at every call site.
|
||||
*/
|
||||
export interface WriteFileAtomicOptions {
|
||||
/**
|
||||
* Permission bits stamped on the fresh temp inode and carried through the
|
||||
* rename (subject to the process umask, like every fresh inode).
|
||||
*/
|
||||
mode: number
|
||||
/**
|
||||
* Permission bits for parent directories this call creates (subject to the
|
||||
* umask; existing directories keep their mode). Omission uses the mkdir
|
||||
* default — pass `0o700` when the tree holds user-private data.
|
||||
*/
|
||||
dirMode?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace `filename` with `content` in one atomic step, creating parent
|
||||
* directories. The content is first written to a random-suffix sibling opened
|
||||
* with exclusive create (`wx`): the open refuses to follow a symlink planted
|
||||
* at the temp path, and the fresh inode carries `options.mode` through the
|
||||
* rename, so replacing a wider-permission file narrows it without a chmod
|
||||
* race. The rename also replaces a symlinked target itself instead of writing
|
||||
* through to its referent, and the same-directory sibling keeps the rename on
|
||||
* one filesystem. On any failure the temp file is removed and the failure
|
||||
* rethrown. Crash durability (fsync) is out of scope.
|
||||
* @param filename - final path receiving the content.
|
||||
* @param content - complete next file content.
|
||||
* @param options - permission bits for the replacement inode.
|
||||
*/
|
||||
export async function writeFileAtomic(filename: string, content: string, options: WriteFileAtomicOptions): Promise<void> {
|
||||
await mkdir(dirname(filename), {
|
||||
recursive: true,
|
||||
...options.dirMode === undefined ? {} : { mode: options.dirMode },
|
||||
})
|
||||
// TODO(settings-atomic-durability): Use a replacement that fsyncs the file
|
||||
// and parent directory and preserves owner-only permissions on Windows.
|
||||
const temp = `${filename}.${randomBytes(6).toString('hex')}.tmp`
|
||||
try {
|
||||
await writeFile(temp, content, { mode: options.mode, flag: 'wx' })
|
||||
await rename(temp, filename)
|
||||
} catch (error) {
|
||||
await rm(temp, { force: true })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether an exclusive create failed because the path already exists. */
|
||||
function isEEXIST(error: unknown): boolean {
|
||||
return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST'
|
||||
}
|
||||
|
||||
/** Whether a filesystem error means absence. */
|
||||
function isENOENT(error: unknown): boolean {
|
||||
return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'
|
||||
}
|
||||
|
||||
/**
|
||||
* Writer-lock protocol constants. These are robustness invariants of the
|
||||
* cross-process write protocol, not deployment tunables: a holder rewrites one
|
||||
* small file in milliseconds, so contention resolves well inside the retry
|
||||
* deadline, and a lock older than the stale age can only belong to a crashed
|
||||
* holder.
|
||||
*/
|
||||
const LOCK_RETRY_INITIAL_MS = 20
|
||||
const LOCK_RETRY_MAX_MS = 200
|
||||
const LOCK_TIMEOUT_MS = 2_000
|
||||
const LOCK_STALE_MS = 5_000
|
||||
|
||||
/** Options for {@link withFileLock}. */
|
||||
export interface WithFileLockOptions {
|
||||
/**
|
||||
* Called once each time a stale (crashed-holder) lock is broken, so the
|
||||
* caller can log the takeover in its own voice.
|
||||
*/
|
||||
onStaleBreak?: (lockPath: string) => void
|
||||
}
|
||||
|
||||
/** Age of the lock file, or `undefined` when it vanished after a failed create. */
|
||||
async function lockAgeMs(lockPath: string): Promise<number | undefined> {
|
||||
try {
|
||||
return Date.now() - (await stat(lockPath)).mtimeMs
|
||||
} catch (error) {
|
||||
if (!isENOENT(error)) throw error
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hold the cross-process writer lock for `filename` around one operation. The
|
||||
* lock is a `wx`-created sibling (`<filename>.lock`); paired with the
|
||||
* rename-based commit of {@link writeFileAtomic}, readers stay lock-free and
|
||||
* only writers contend. Contention backs off exponentially; a lock older than
|
||||
* the stale age is a crashed holder and is broken (see
|
||||
* {@link WithFileLockOptions.onStaleBreak}); a live holder past the deadline
|
||||
* fails the operation with a timed-out error. The parent directory must exist.
|
||||
* @param filename - the file whose writers this lock serializes.
|
||||
* @param operation - the read-render-commit cycle to run while holding the lock.
|
||||
* @param options - stale-takeover notification hook.
|
||||
* @returns the operation's result; the lock releases on both outcomes.
|
||||
*/
|
||||
export async function withFileLock<T>(
|
||||
filename: string,
|
||||
operation: () => Promise<T>,
|
||||
options?: WithFileLockOptions,
|
||||
): Promise<T> {
|
||||
const lockPath = `${filename}.lock`
|
||||
const deadline = Date.now() + LOCK_TIMEOUT_MS
|
||||
let delay = LOCK_RETRY_INITIAL_MS
|
||||
for (;;) {
|
||||
try {
|
||||
await writeFile(lockPath, `${process.pid}\n`, { mode: 0o600, flag: 'wx' })
|
||||
break
|
||||
} catch (error) {
|
||||
if (!isEEXIST(error)) throw error
|
||||
}
|
||||
const ageMs = await lockAgeMs(lockPath)
|
||||
// The holder released between the failed create and the stat: the lock is
|
||||
// free right now, so retry without burning backoff or deadline.
|
||||
if (ageMs === undefined) continue
|
||||
if (ageMs > LOCK_STALE_MS) {
|
||||
// TODO(settings-lock-ownership): Replace age-only takeover with ownership-safe
|
||||
// acquisition and release so a slow writer cannot remove a successor's lock.
|
||||
options?.onStaleBreak?.(lockPath)
|
||||
await rm(lockPath, { force: true })
|
||||
continue
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error(`atomic-write: timed out waiting for the writer lock at ${lockPath}`)
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, delay))
|
||||
delay = Math.min(delay * 2, LOCK_RETRY_MAX_MS)
|
||||
}
|
||||
try {
|
||||
return await operation()
|
||||
} finally {
|
||||
await rm(lockPath, { force: true })
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-atomic-write`.
|
||||
* @module @deepseek-ai/dsh-atomic-write/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-atomic-write'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'atomic-write-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this pure filesystem primitive owns no event stream or mutable runtime
|
||||
* data; its replacement contract is enforced by unit tests.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -1,48 +0,0 @@
|
||||
import { lstat, mkdir, mkdtemp, readFile, readdir, stat, symlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { writeFileAtomic } from '../src/index.ts'
|
||||
|
||||
async function scratch(): Promise<string> {
|
||||
return mkdtemp(join(tmpdir(), 'dsh-atomic-write-'))
|
||||
}
|
||||
|
||||
describe('writeFileAtomic', () => {
|
||||
it('creates the file and its parents with exactly the stated mode', async () => {
|
||||
const dir = await scratch()
|
||||
const target = join(dir, 'nested', 'deep', 'doc.yaml')
|
||||
await writeFileAtomic(target, 'a: 1\n', { mode: 0o600 })
|
||||
expect(await readFile(target, 'utf8')).toBe('a: 1\n')
|
||||
expect((await stat(target)).mode & 0o777).toBe(0o600)
|
||||
})
|
||||
|
||||
it('replaces existing content and narrows a wider-permission file to the stated mode', async () => {
|
||||
const dir = await scratch()
|
||||
const target = join(dir, 'doc.yaml')
|
||||
await writeFile(target, 'old', { mode: 0o644 })
|
||||
await writeFileAtomic(target, 'new', { mode: 0o600 })
|
||||
expect(await readFile(target, 'utf8')).toBe('new')
|
||||
expect((await stat(target)).mode & 0o777).toBe(0o600)
|
||||
})
|
||||
|
||||
it('replaces a symlinked target itself without writing through to the referent', async () => {
|
||||
const dir = await scratch()
|
||||
const victim = join(dir, 'victim')
|
||||
await writeFile(victim, 'victim-content')
|
||||
const target = join(dir, 'doc.yaml')
|
||||
await symlink(victim, target)
|
||||
await writeFileAtomic(target, 'replaced', { mode: 0o600 })
|
||||
expect((await lstat(target)).isSymbolicLink()).toBe(false)
|
||||
expect(await readFile(target, 'utf8')).toBe('replaced')
|
||||
expect(await readFile(victim, 'utf8')).toBe('victim-content')
|
||||
})
|
||||
|
||||
it('leaves no temp sibling and rethrows when the rename fails', async () => {
|
||||
const dir = await scratch()
|
||||
const target = join(dir, 'occupied')
|
||||
await mkdir(target)
|
||||
await expect(writeFileAtomic(target, 'content', { mode: 0o600 })).rejects.toThrow()
|
||||
expect((await readdir(dir)).filter(entry => entry.includes('.tmp'))).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,18 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import * as AtomicWriteInvariant from '../src/invariant.ts'
|
||||
|
||||
describe('atomic-write invariant companion', () => {
|
||||
it('registers its explained empty runtime invariant', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantService)
|
||||
const fiber = await ctx.plugin(AtomicWriteInvariant)
|
||||
|
||||
expect(() => {
|
||||
ctx.invariants.register('@deepseek-ai/dsh-atomic-write', () => {})
|
||||
}).toThrow(/already registered/)
|
||||
await fiber.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user