@dimina-kit/fs-core 0.2.0-dev.20260710085051 → 0.3.0-dev.6-dev.20260711133419
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/README.md +205 -18
- package/dist/client.js +23 -11
- package/dist/disk-mirror.js +2 -2
- package/dist/fs-core.worker.js +101 -21
- package/dist/sync/binary-sidecar.js +147 -0
- package/dist/sync/sync-engine.js +119 -169
- package/dist/sync/watch-expander.js +202 -0
- package/dist/worker-files.cjs +32 -0
- package/dist/worker-files.js +35 -0
- package/dist/worker-lib/.tsbuildinfo +1 -0
- package/dist/worker-lib/engine-shared.d.ts +79 -0
- package/dist/worker-lib/engine-shared.d.ts.map +1 -0
- package/dist/worker-lib/engine-shared.js +6 -3
- package/dist/worker-lib/paths.d.ts +4 -0
- package/dist/worker-lib/paths.d.ts.map +1 -0
- package/dist/worker-lib/protocol.d.ts +119 -0
- package/dist/worker-lib/protocol.d.ts.map +1 -0
- package/dist/worker-lib/protocol.js +73 -0
- package/dist/worker-lib/rpc-types.d.ts +68 -0
- package/dist/worker-lib/rpc-types.d.ts.map +1 -0
- package/dist/worker-lib/wal-codec.d.ts +58 -0
- package/dist/worker-lib/wal-codec.d.ts.map +1 -0
- package/dist/worker-lib/wal-codec.js +8 -5
- package/dist/zip.js +1 -1
- package/package.json +25 -2
- package/src/__checks__/types-smoke.ts +61 -0
- package/src/agent-tools.ts +3 -3
- package/src/client-retry.test.ts +76 -0
- package/src/client.ts +112 -45
- package/src/disk-mirror.ts +2 -2
- package/src/fs-core-handover.test.ts +215 -0
- package/src/fs-core-opid-replay.test.ts +158 -0
- package/src/fs-core-recovery-lock.test.ts +225 -0
- package/src/fs-core-recovery.ts +115 -13
- package/src/fs-core-write-ops.ts +14 -11
- package/src/fs-core.worker.ts +26 -11
- package/src/worker-files.test.ts +39 -0
- package/src/worker-files.ts +43 -0
- package/src/worker-lib/engine-shared.ts +19 -5
- package/src/worker-lib/protocol.test.ts +37 -0
- package/src/worker-lib/protocol.ts +184 -0
- package/src/worker-lib/wal-codec.ts +8 -5
- package/src/zip.ts +1 -1
- package/sync/binary-sidecar.test.ts +150 -0
- package/sync/binary-sidecar.ts +187 -0
- package/sync/sync-engine-degraded.test.ts +309 -0
- package/sync/sync-engine.test.ts +20 -91
- package/sync/sync-engine.ts +134 -181
- package/sync/truth-port.ts +3 -3
- package/sync/watch-expander.test.ts +96 -0
- package/sync/watch-expander.ts +236 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The authoritative statement of fs-core's worker-artifact distribution
|
|
3
|
+
* contract, for Node-side consumers that copy/serve the worker files
|
|
4
|
+
* (dimina-kit workbench's vite-preset, qdmp-web-workbench's server.cjs):
|
|
5
|
+
*
|
|
6
|
+
* dist/fs-core.worker.js and dist/fs-query.worker.js are single-file,
|
|
7
|
+
* self-contained ESM (no import statements) living in the SAME directory
|
|
8
|
+
* as dist/client.js, under exactly these file names.
|
|
9
|
+
*
|
|
10
|
+
* build-workers.js asserts this at build time; consumers should derive the
|
|
11
|
+
* names/locations from here instead of re-deriving them with hardcoded
|
|
12
|
+
* string joins. Pure string manipulation (no node:path) so the module loads
|
|
13
|
+
* in any runtime and ships as both ESM (dist/worker-files.js) and CJS
|
|
14
|
+
* (dist/worker-files.cjs — see the exports map's `require` condition, for
|
|
15
|
+
* CommonJS hosts like server.cjs).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** The worker artifacts' literal file names, siblings of `client.js`. */
|
|
19
|
+
export const FS_CORE_WORKER_FILES = ['fs-core.worker.js', 'fs-query.worker.js'] as const
|
|
20
|
+
|
|
21
|
+
export interface ResolvedWorkerFiles {
|
|
22
|
+
/** Directory holding client.js and both worker files. */
|
|
23
|
+
dir: string
|
|
24
|
+
/** Absolute path of each worker file, in {@link FS_CORE_WORKER_FILES} order. */
|
|
25
|
+
files: string[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Resolve the on-disk worker file paths from the resolved path of the
|
|
30
|
+
* `./client` entry — the one path every consumer can obtain:
|
|
31
|
+
*
|
|
32
|
+
* resolveWorkerFiles(require.resolve('@dimina-kit/fs-core/client'))
|
|
33
|
+
*
|
|
34
|
+
* Preserves whichever path separator the input uses (POSIX or Windows), so
|
|
35
|
+
* the result is joinable/copyable as-is on the host platform.
|
|
36
|
+
*/
|
|
37
|
+
export function resolveWorkerFiles(clientEntryPath: string): ResolvedWorkerFiles {
|
|
38
|
+
const cut = Math.max(clientEntryPath.lastIndexOf('/'), clientEntryPath.lastIndexOf('\\'))
|
|
39
|
+
if (cut < 0) throw new Error(`resolveWorkerFiles: not a path to client.js: ${clientEntryPath}`)
|
|
40
|
+
const sep = clientEntryPath[cut]!
|
|
41
|
+
const dir = clientEntryPath.slice(0, cut)
|
|
42
|
+
return { dir, files: FS_CORE_WORKER_FILES.map((name) => dir + sep + name) }
|
|
43
|
+
}
|
|
@@ -6,20 +6,21 @@
|
|
|
6
6
|
* (WebWorker lib)两个 program 同时编译。
|
|
7
7
|
*/
|
|
8
8
|
import type { WalRecord } from './wal-codec.js'
|
|
9
|
+
import type { FsCoreErrorCode } from './protocol.js'
|
|
9
10
|
|
|
10
11
|
export const OP = { WRITE: 1, RM: 2, MV: 3, MKDIR: 4, CHECKPOINT: 5, RESTORE: 6 } as const
|
|
11
12
|
export const OP_NAME: Record<number, string> = { 1: 'write', 2: 'rm', 3: 'mv', 4: 'mkdir', 5: 'checkpoint', 6: 'restore' }
|
|
12
|
-
//
|
|
13
|
+
// restore 冲突检查只关心"写类"操作(改变文件内容/存在性),checkpoint 本身不算
|
|
13
14
|
export const WRITE_OPCODES = new Set<number>([OP.WRITE, OP.RM, OP.MV, OP.RESTORE])
|
|
14
15
|
export const INLINE_MAX = 4096 // payload ≤4KB 内联进 WAL 记录
|
|
15
16
|
export const GROUP_WINDOW_MS = 50 // 人类写组提交窗口
|
|
16
17
|
export const SEGMENT_ROTATE_BYTES = 4 * 1024 * 1024
|
|
17
18
|
export const OPID_WINDOW = 1024
|
|
18
|
-
//
|
|
19
|
+
// turn 能力:agent 写必须在有效 turn 内(fs-core 侧执法,不信任调用方透传)
|
|
19
20
|
export const TURN_DEFAULT_TTL_MS = 120000
|
|
20
21
|
export const TURN_MAX_OPS = 1000 // per-turn 限额(跑飞的 agent 刹车)
|
|
21
22
|
export const AUDIT_CAP = 4096 // 内存审计环(fs_diff 的数据源;重启由 WAL 回放重建)
|
|
22
|
-
//
|
|
23
|
+
// checkpoint LRU:保留最近 N 个;被淘汰者的 blob 在下次 compaction GC 回收
|
|
23
24
|
export const CHECKPOINT_KEEP = 20
|
|
24
25
|
|
|
25
26
|
export interface MirrorEntry {
|
|
@@ -54,14 +55,27 @@ export interface WindowOp {
|
|
|
54
55
|
extra?: Record<string, unknown>
|
|
55
56
|
}
|
|
56
57
|
|
|
58
|
+
/** An opId's cached RPC result, replayed verbatim (+`idempotent: true`) when
|
|
59
|
+
* the same opId is re-sent after a response timeout. The live write path
|
|
60
|
+
* caches the FULL respond payload (`{gen, rev, ...extra}` / restore's
|
|
61
|
+
* `{gen, restored}`) so a replay carries the same fields the first response
|
|
62
|
+
* did — `cpId`/`turnId`/`expiresAt`/`restored` included. Entries rebuilt by
|
|
63
|
+
* WAL replay after a worker restart only carry `{gen}` (the WAL does not
|
|
64
|
+
* record memory-only extras); those entries serve cross-session dedup, which
|
|
65
|
+
* a same-session retry never hits. */
|
|
66
|
+
export type OpIdResult = { gen: number; rev?: number } & Record<string, unknown>
|
|
67
|
+
|
|
57
68
|
export type Respond = (r: Record<string, unknown>) => void
|
|
58
69
|
|
|
59
70
|
export interface WorkerError extends Error {
|
|
60
|
-
code?:
|
|
71
|
+
code?: FsCoreErrorCode
|
|
61
72
|
extra?: Record<string, unknown>
|
|
62
73
|
}
|
|
63
74
|
|
|
64
|
-
|
|
75
|
+
/** `code` is typed against the exported wire contract (worker-lib/protocol.ts),
|
|
76
|
+
* so the set of codes this worker can throw and the set consumers can match on
|
|
77
|
+
* are the same list by construction. */
|
|
78
|
+
export function rpcErr(code: FsCoreErrorCode, message: string, extra?: Record<string, unknown>): WorkerError {
|
|
65
79
|
const e = new Error(message) as WorkerError
|
|
66
80
|
e.code = code
|
|
67
81
|
if (extra) e.extra = extra
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guards the exported wire contract: consumers match rejections via
|
|
3
|
+
* `isFsCoreErrorCode`/`getFsCoreErrorCode` against the SAME code list the
|
|
4
|
+
* worker's `rpcErr` factory is typed against — so a code the worker can
|
|
5
|
+
* throw and a code a consumer can match are one set by construction.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, it } from 'vitest'
|
|
8
|
+
import { FS_CORE_ERROR_CODES, getFsCoreErrorCode, isFsCoreErrorCode } from './protocol.js'
|
|
9
|
+
import { rpcErr } from './engine-shared.js'
|
|
10
|
+
|
|
11
|
+
describe('fs-core error-code contract', () => {
|
|
12
|
+
it('recognizes an error produced by the worker-side rpcErr factory', () => {
|
|
13
|
+
const e = rpcErr('turn-closed', 'turn expired: t-1')
|
|
14
|
+
expect(getFsCoreErrorCode(e)).toBe('turn-closed')
|
|
15
|
+
expect(isFsCoreErrorCode(e, 'turn-closed')).toBe(true)
|
|
16
|
+
expect(isFsCoreErrorCode(e, 'readonly')).toBe(false)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('recognizes the plain-object shape a structured-clone/client rejection carries', () => {
|
|
20
|
+
// client.ts materializes rejections as Object.assign(new Error(msg), {code})
|
|
21
|
+
const clientShaped = Object.assign(new Error('fs-core is readonly'), { code: 'readonly' })
|
|
22
|
+
expect(getFsCoreErrorCode(clientShaped)).toBe('readonly')
|
|
23
|
+
expect(isFsCoreErrorCode(clientShaped, 'readonly')).toBe(true)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('returns undefined for non-fs-core errors, unknown codes, and non-objects', () => {
|
|
27
|
+
expect(getFsCoreErrorCode(new Error('plain'))).toBeUndefined()
|
|
28
|
+
expect(getFsCoreErrorCode(Object.assign(new Error('x'), { code: 'ENOENT' }))).toBeUndefined()
|
|
29
|
+
expect(getFsCoreErrorCode(null)).toBeUndefined()
|
|
30
|
+
expect(getFsCoreErrorCode('readonly')).toBeUndefined()
|
|
31
|
+
expect(isFsCoreErrorCode(undefined, 'readonly')).toBe(false)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('lists every code exactly once', () => {
|
|
35
|
+
expect(new Set(FS_CORE_ERROR_CODES).size).toBe(FS_CORE_ERROR_CODES.length)
|
|
36
|
+
})
|
|
37
|
+
})
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fs-core wire contract — the single authoritative enumeration of the error
|
|
3
|
+
* codes, event names, mode values, and message shapes that cross the worker
|
|
4
|
+
* boundary. Both ends compile against this module (worker side:
|
|
5
|
+
* engine-shared.ts's `rpcErr` signature; main-thread side: client.ts), and
|
|
6
|
+
* consumers should match on these exported symbols instead of quoting string
|
|
7
|
+
* literals from worker source or citing dist line numbers — those references
|
|
8
|
+
* rot on every version bump while the wire strings silently stay load-bearing.
|
|
9
|
+
*
|
|
10
|
+
* Lib-neutral (types + pure predicates only) so it stays compilable by both
|
|
11
|
+
* the DOM and the WebWorker tsc programs — see this directory's convention in
|
|
12
|
+
* ../../tsconfig.json.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Single-writer lifecycle of one fs-core worker, as observed by a client:
|
|
17
|
+
* starting (HELLO sent, WELCOME not yet received) | writer (holds the Web
|
|
18
|
+
* Locks writer lease) | readonly (queued for the lease, or handed it over) |
|
|
19
|
+
* draining (worker-side handover transient — a client never observes it for
|
|
20
|
+
* long; see fs-core-recovery.ts, which converges to readonly before
|
|
21
|
+
* broadcasting) | dead (FATAL received, the worker is unusable).
|
|
22
|
+
*/
|
|
23
|
+
export type FsCoreMode = 'starting' | 'writer' | 'readonly' | 'draining' | 'dead'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Every `code` a rejected fs-core RPC can carry (client-side: `error.code` on
|
|
27
|
+
* the rejection; wire-side: the `code` field of a `{ok: false}` reply). The
|
|
28
|
+
* worker's `rpcErr` factory is typed against this list, so a code appearing
|
|
29
|
+
* here and a code the worker can actually throw are the same set by
|
|
30
|
+
* construction.
|
|
31
|
+
*/
|
|
32
|
+
export const FS_CORE_ERROR_CODES = [
|
|
33
|
+
/** Write attempted while this worker does not hold the writer lease (another tab owns it). */
|
|
34
|
+
'readonly',
|
|
35
|
+
/** Write attempted while the writer lease handover is in progress. */
|
|
36
|
+
'draining',
|
|
37
|
+
/** Agent write without an active matching turn, or the turn has expired. */
|
|
38
|
+
'turn-closed',
|
|
39
|
+
/** `turnBegin` while another turn is already active. */
|
|
40
|
+
'turn-active',
|
|
41
|
+
/** Per-turn op quota exceeded. */
|
|
42
|
+
'turn-quota',
|
|
43
|
+
/** Agent write without the armed agent token (see `armAgentTokenGate`). */
|
|
44
|
+
'agent-token-required',
|
|
45
|
+
/** `armAgentTokenGate` re-armed with a DIFFERENT token (same token replays are ok+idempotent). */
|
|
46
|
+
'agent-token-gate-armed',
|
|
47
|
+
/** `restore` refused: the audit window no longer covers `baseGen`, or human edits landed since (carries `humanPaths`/`auditGap` extras). */
|
|
48
|
+
'restore-conflict',
|
|
49
|
+
/** Optimistic-concurrency failure: `ifMatch` mismatch, or the target path already exists. */
|
|
50
|
+
'cas-mismatch',
|
|
51
|
+
/** `edit`'s old string not found in the file. */
|
|
52
|
+
'edit-no-match',
|
|
53
|
+
/** `edit`'s old string matches more than once. */
|
|
54
|
+
'edit-ambiguous',
|
|
55
|
+
/** Path (or checkpoint id) does not exist. */
|
|
56
|
+
'not-found',
|
|
57
|
+
/** Path failed normalization (absolute, `..`, empty, ...). */
|
|
58
|
+
'bad-path',
|
|
59
|
+
/** Malformed argument (non-string content, missing turnId, ...). */
|
|
60
|
+
'bad-args',
|
|
61
|
+
/** Unknown RPC op name. */
|
|
62
|
+
'bad-op',
|
|
63
|
+
/** Write into a derived (read-only) area. */
|
|
64
|
+
'derived-readonly',
|
|
65
|
+
/** Internal write-path signal (WAL segment rotation needed); not expected to surface to clients. */
|
|
66
|
+
'rotate-needed',
|
|
67
|
+
/** Fallback for a worker-side error that carried no code of its own. */
|
|
68
|
+
'internal',
|
|
69
|
+
] as const
|
|
70
|
+
|
|
71
|
+
export type FsCoreErrorCode = (typeof FS_CORE_ERROR_CODES)[number]
|
|
72
|
+
|
|
73
|
+
/** Extra fields a `restore-conflict` rejection carries (see `rpcErr`'s `extra`). */
|
|
74
|
+
export interface FsCoreErrorExtras {
|
|
75
|
+
humanPaths?: string[]
|
|
76
|
+
auditGap?: boolean
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* `error.code` of `error` when it looks like an fs-core RPC rejection,
|
|
81
|
+
* else `undefined`. Purely structural — works on the plain `Error` the client
|
|
82
|
+
* materializes as well as on a structured-clone of it.
|
|
83
|
+
*/
|
|
84
|
+
export function getFsCoreErrorCode(error: unknown): FsCoreErrorCode | undefined {
|
|
85
|
+
if (!error || typeof error !== 'object' || !('code' in error)) return undefined
|
|
86
|
+
const code = (error as { code?: unknown }).code
|
|
87
|
+
return (FS_CORE_ERROR_CODES as readonly unknown[]).includes(code) ? (code as FsCoreErrorCode) : undefined
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** True when `error` is an fs-core RPC rejection carrying exactly `code`. */
|
|
91
|
+
export function isFsCoreErrorCode(error: unknown, code: FsCoreErrorCode): boolean {
|
|
92
|
+
return getFsCoreErrorCode(error) === code
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Names of the unsolicited events the core worker pushes to its client. */
|
|
96
|
+
export type FsCoreEventName = 'writer-granted' | 'writer-lost' | 'fs-change'
|
|
97
|
+
|
|
98
|
+
// ── Wire message shapes (worker → client main port) ──
|
|
99
|
+
|
|
100
|
+
/** First message after HELLO; resolves `ProjectFsClient.connect`. */
|
|
101
|
+
export interface CoreWelcomeMessage {
|
|
102
|
+
type: 'WELCOME'
|
|
103
|
+
epoch: number
|
|
104
|
+
memGen: number
|
|
105
|
+
readonly: boolean
|
|
106
|
+
mode: FsCoreMode
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Unrecoverable worker failure — the worker is unusable afterwards. */
|
|
110
|
+
export interface CoreFatalMessage {
|
|
111
|
+
type: 'FATAL'
|
|
112
|
+
error: string
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Liveness reply to the client's PING. */
|
|
116
|
+
export interface CorePongMessage {
|
|
117
|
+
type: 'PONG'
|
|
118
|
+
t?: number
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Unsolicited event push. `writer-granted`/`writer-lost` drive the client's
|
|
123
|
+
* `mode`; `fs-change` reports a committed write batch (`paths` when ≤ 32,
|
|
124
|
+
* else just `count`; `restore` carries the checkpoint id a restore replayed).
|
|
125
|
+
*/
|
|
126
|
+
export interface CoreEventMessage {
|
|
127
|
+
evt: FsCoreEventName
|
|
128
|
+
gen?: number
|
|
129
|
+
actor?: string
|
|
130
|
+
paths?: string[]
|
|
131
|
+
count?: number
|
|
132
|
+
restore?: string
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** RPC reply, success. */
|
|
136
|
+
export interface CoreReplyOkMessage {
|
|
137
|
+
id: number
|
|
138
|
+
ok: true
|
|
139
|
+
result: unknown
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** RPC reply, failure — `code` is one of {@link FS_CORE_ERROR_CODES}. */
|
|
143
|
+
export interface CoreReplyErrMessage extends FsCoreErrorExtras {
|
|
144
|
+
id: number
|
|
145
|
+
ok: false
|
|
146
|
+
code: FsCoreErrorCode
|
|
147
|
+
error: string
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Everything the core worker's main port can deliver, as a discriminated union. */
|
|
151
|
+
export type CoreWireMessage =
|
|
152
|
+
| CoreWelcomeMessage
|
|
153
|
+
| CoreFatalMessage
|
|
154
|
+
| CorePongMessage
|
|
155
|
+
| CoreEventMessage
|
|
156
|
+
| CoreReplyOkMessage
|
|
157
|
+
| CoreReplyErrMessage
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Loosely-shaped dynamic view of {@link CoreWireMessage}: every field
|
|
161
|
+
* optional, distinguished at runtime by which ones are present. This matches
|
|
162
|
+
* how `ProjectFsClient` actually reads the port (each field independently
|
|
163
|
+
* checked), and is the type its `onChange` callbacks receive; the union above
|
|
164
|
+
* is the authoritative shape each individual message satisfies.
|
|
165
|
+
*/
|
|
166
|
+
export interface CoreMessage {
|
|
167
|
+
type?: string
|
|
168
|
+
evt?: string
|
|
169
|
+
error?: string
|
|
170
|
+
mode?: FsCoreMode
|
|
171
|
+
readonly?: boolean
|
|
172
|
+
gen?: number
|
|
173
|
+
id?: number
|
|
174
|
+
ok?: boolean
|
|
175
|
+
result?: unknown
|
|
176
|
+
code?: string
|
|
177
|
+
humanPaths?: string[]
|
|
178
|
+
auditGap?: boolean
|
|
179
|
+
actor?: string
|
|
180
|
+
paths?: string[]
|
|
181
|
+
count?: number
|
|
182
|
+
restore?: string
|
|
183
|
+
t?: number
|
|
184
|
+
}
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* WAL 编解码(纯函数,lib 中立)—— crc32、superblock 槽、WAL
|
|
3
|
-
*
|
|
4
|
-
* (DOM lib)与 tsconfig.worker.json(WebWorker lib)两个 program
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* WAL 编解码(纯函数,lib 中立)—— crc32、superblock 槽、WAL 记录成帧/解析、
|
|
3
|
+
* sha256hex。从 fs-core.worker.ts 抽出:不含任何 OPFS/Worker 专属 API,可被
|
|
4
|
+
* 主 tsconfig(DOM lib)与 tsconfig.worker.json(WebWorker lib)两个 program
|
|
5
|
+
* 同时编译,因此 zip.ts(主 program 侧)与 fs-core.worker.ts(worker program
|
|
6
|
+
* 侧)都能 import 同一份 crc32/CRC_TABLE 实现(消除重复代码)。sync/ 侧
|
|
7
|
+
* (独立 rootDir 的第三个 tsc program)经 package.json 的 `imports` 自引用
|
|
8
|
+
* (`#worker-lib/wal-codec.js`,见 tsconfig.worker-lib.build.json)复用同一份
|
|
9
|
+
* sha256hex,同样消除重复代码。
|
|
7
10
|
*
|
|
8
11
|
* WAL 记录成帧:
|
|
9
12
|
* [u32 len][u64 gen][u32 epoch][u8 opcode][u16 metaLen][meta JSON][u32 crc32][u8 0xC1]
|
package/src/zip.ts
CHANGED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contract tests for the binary side-car: classification, the size+sha256
|
|
3
|
+
* index, echo judgement, bytes retention, overlay precedence, and change
|
|
4
|
+
* events — the one authority every host's "binary never enters the string
|
|
5
|
+
* ledger" handling builds on.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
8
|
+
import { createBinarySidecar, looksBinary } from './binary-sidecar.js'
|
|
9
|
+
|
|
10
|
+
const png = (...bytes: number[]) => new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0, ...bytes])
|
|
11
|
+
|
|
12
|
+
describe('looksBinary', () => {
|
|
13
|
+
it('classifies by a NUL byte in the first 8192 bytes', () => {
|
|
14
|
+
expect(looksBinary(png())).toBe(true)
|
|
15
|
+
expect(looksBinary(new TextEncoder().encode('plain text'))).toBe(false)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('does not scan past the sniff window', () => {
|
|
19
|
+
const big = new Uint8Array(10000).fill(0x61)
|
|
20
|
+
big[9500] = 0 // NUL beyond the 8192-byte window
|
|
21
|
+
expect(looksBinary(big)).toBe(false)
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe('createBinarySidecar — index + echo judgement', () => {
|
|
26
|
+
it('put() records a new entry and reports changed', async () => {
|
|
27
|
+
const sc = createBinarySidecar()
|
|
28
|
+
expect(await sc.put('a.png', png(1))).toBe(true)
|
|
29
|
+
expect(sc.has('a.png')).toBe(true)
|
|
30
|
+
const entry = sc.entry('a.png')
|
|
31
|
+
expect(entry?.size).toBe(6)
|
|
32
|
+
expect(entry?.sha256).toMatch(/^[0-9a-f]{64}$/)
|
|
33
|
+
expect(sc.size).toBe(1)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('put() of identical bytes is an echo: unchanged, no event', async () => {
|
|
37
|
+
const sc = createBinarySidecar()
|
|
38
|
+
const onChange = vi.fn()
|
|
39
|
+
await sc.put('a.png', png(1))
|
|
40
|
+
sc.onChange(onChange)
|
|
41
|
+
expect(await sc.put('a.png', png(1))).toBe(false)
|
|
42
|
+
expect(onChange).not.toHaveBeenCalled()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('put() of different same-length bytes IS a change (sha decides, not size)', async () => {
|
|
46
|
+
const sc = createBinarySidecar()
|
|
47
|
+
await sc.put('a.png', png(1))
|
|
48
|
+
expect(await sc.put('a.png', png(2))).toBe(true)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('remove() reports whether an entry existed and emits null once', async () => {
|
|
52
|
+
const sc = createBinarySidecar()
|
|
53
|
+
const onChange = vi.fn()
|
|
54
|
+
sc.onChange(onChange)
|
|
55
|
+
await expect(sc.remove('ghost.png')).resolves.toBe(false)
|
|
56
|
+
expect(onChange).not.toHaveBeenCalled()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('index-only sidecar retains no bytes and refuses overlay()', async () => {
|
|
60
|
+
const sc = createBinarySidecar()
|
|
61
|
+
await sc.put('a.png', png(1))
|
|
62
|
+
expect(sc.bytes('a.png')).toBeUndefined()
|
|
63
|
+
expect(sc.toRecord()).toEqual({})
|
|
64
|
+
expect(() => sc.overlay({})).toThrow(/retainBytes/)
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
describe('createBinarySidecar — retainBytes host shape', () => {
|
|
69
|
+
it('retains bytes and overlays them UNDER the ledger files (ledger wins)', async () => {
|
|
70
|
+
const sc = createBinarySidecar({ retainBytes: true })
|
|
71
|
+
await sc.put('img/a.png', png(1))
|
|
72
|
+
await sc.put('both.txt', png(9))
|
|
73
|
+
const merged = sc.overlay({ 'app.json': '{}', 'both.txt': 'ledger text' })
|
|
74
|
+
expect(merged['img/a.png']).toEqual(png(1))
|
|
75
|
+
expect(merged['app.json']).toBe('{}')
|
|
76
|
+
expect(merged['both.txt']).toBe('ledger text')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('reset() reseeds silently-cleared state with the new entries', async () => {
|
|
80
|
+
const sc = createBinarySidecar({ retainBytes: true })
|
|
81
|
+
await sc.put('old.png', png(1))
|
|
82
|
+
await sc.reset({ 'new.png': png(2) })
|
|
83
|
+
expect(sc.has('old.png')).toBe(false)
|
|
84
|
+
expect(sc.keys()).toEqual(['new.png'])
|
|
85
|
+
expect(sc.bytes('new.png')).toEqual(png(2))
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('onChange fires for effective put/remove, unsubscribes cleanly, and a throwing subscriber cannot break the mutation', async () => {
|
|
89
|
+
const sc = createBinarySidecar({ retainBytes: true })
|
|
90
|
+
const seen: Array<[string, Uint8Array | null]> = []
|
|
91
|
+
sc.onChange(() => {
|
|
92
|
+
throw new Error('subscriber bug')
|
|
93
|
+
})
|
|
94
|
+
const off = sc.onChange((rel, bytes) => seen.push([rel, bytes]))
|
|
95
|
+
await sc.put('a.png', png(1))
|
|
96
|
+
await sc.remove('a.png')
|
|
97
|
+
expect(seen).toEqual([
|
|
98
|
+
['a.png', png(1)],
|
|
99
|
+
['a.png', null],
|
|
100
|
+
])
|
|
101
|
+
expect(sc.has('a.png')).toBe(false)
|
|
102
|
+
off()
|
|
103
|
+
await sc.put('b.png', png(2))
|
|
104
|
+
expect(seen).toHaveLength(2)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('reset() is atomic to readers and silent: a mid-reset read sees the OLD set, and no events fire', async () => {
|
|
108
|
+
const sc = createBinarySidecar({ retainBytes: true })
|
|
109
|
+
await sc.put('old.png', png(1))
|
|
110
|
+
const onChange = vi.fn()
|
|
111
|
+
sc.onChange(onChange)
|
|
112
|
+
const resetting = sc.reset({ 'new.png': png(2) })
|
|
113
|
+
// Hashing has suspended reset(); a concurrent reader (compile/export
|
|
114
|
+
// overlaying the sidecar) must still see the complete old set — never an
|
|
115
|
+
// empty or half-built one.
|
|
116
|
+
expect(sc.keys()).toEqual(['old.png'])
|
|
117
|
+
expect(sc.toRecord()).toEqual({ 'old.png': png(1) })
|
|
118
|
+
await resetting
|
|
119
|
+
expect(sc.keys()).toEqual(['new.png'])
|
|
120
|
+
expect(onChange).not.toHaveBeenCalled()
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('a put issued AFTER reset() queues behind it and lands in the NEW session (never silently swallowed by the swap)', async () => {
|
|
124
|
+
const sc = createBinarySidecar({ retainBytes: true })
|
|
125
|
+
const resetting = sc.reset({ 'seed.png': png(1) })
|
|
126
|
+
const putting = sc.put('live.png', png(2))
|
|
127
|
+
await Promise.all([resetting, putting])
|
|
128
|
+
expect(sc.keys().sort()).toEqual(['live.png', 'seed.png'])
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('overlapping resets resolve in CALLER order: the later caller wins even when the earlier one hashes longer', async () => {
|
|
132
|
+
const sc = createBinarySidecar({ retainBytes: true })
|
|
133
|
+
const slow = Object.fromEntries(Array.from({ length: 40 }, (_, i) => [`slow${i}.png`, png(i)]))
|
|
134
|
+
const r1 = sc.reset(slow)
|
|
135
|
+
const r2 = sc.reset({ 'winner.png': png(9) })
|
|
136
|
+
await Promise.all([r1, r2])
|
|
137
|
+
expect(sc.keys()).toEqual(['winner.png'])
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('clear() is a silent session reseed: no per-entry removal events', async () => {
|
|
141
|
+
const sc = createBinarySidecar({ retainBytes: true })
|
|
142
|
+
await sc.put('a.png', png(1))
|
|
143
|
+
const onChange = vi.fn()
|
|
144
|
+
sc.onChange(onChange)
|
|
145
|
+
await sc.clear()
|
|
146
|
+
expect(sc.size).toBe(0)
|
|
147
|
+
expect(sc.toRecord()).toEqual({})
|
|
148
|
+
expect(onChange).not.toHaveBeenCalled()
|
|
149
|
+
})
|
|
150
|
+
})
|