@platless/axom-cli 0.3.13 → 0.6.36
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 +18 -5
- package/dist/axom-patch/cli/README.md +119 -9
- package/dist/axom-patch/cli/account-limits/README.md +27 -0
- package/dist/axom-patch/cli/account-limits/antigravity-proxy.mjs +47 -0
- package/dist/axom-patch/cli/account-limits/antigravity-read.mjs +90 -0
- package/dist/axom-patch/cli/account-limits/antigravity.mjs +46 -0
- package/dist/axom-patch/cli/account-limits/generate.py +123 -0
- package/dist/axom-patch/cli/account-limits/kimi-proxy.mjs +81 -0
- package/dist/axom-patch/cli/account-limits/normalize.mjs +151 -0
- package/dist/axom-patch/cli/claude-account-limits.json +37 -0
- package/dist/axom-patch/cli/codex-account-limits.json +37 -0
- package/dist/axom-patch/cli/codex-terminal-output.json +16 -0
- package/dist/axom-patch/cli/commandcode-account-limits.json +30 -0
- package/dist/axom-patch/cli/kimi-quota.mjs +12 -0
- package/dist/axom-patch/cli/kimi-subagents.mjs +173 -0
- package/dist/axom-patch/cli/nightly-task.md +194 -0
- package/dist/axom-patch/cli/pi-subagent-subwork.json +23 -0
- package/dist/axom-patch/cli/probes/claude-late-async-terminal.mjs +5 -1
- package/dist/axom-patch/cli/probes/codex-terminal-output.mjs +33 -0
- package/dist/axom-patch/cli/probes/kimi-subagent-subwork.mjs +82 -0
- package/dist/axom-patch/cli/probes/pi-subagent-subwork.mjs +211 -0
- package/dist/axom-patch/cli/probes/zcode-subagent-subwork.mjs +257 -0
- package/dist/axom-patch/cli/qoder/dist/axom-acp-compat.mjs +1591 -0
- package/dist/axom-patch/cli/qoder/releases/1.1.75.json +360 -0
- package/dist/axom-patch/cli/qoder-background-terminal.md +13 -5
- package/dist/axom-patch/cli/qoder-foreground-output.md +14 -7
- package/dist/axom-patch/cli/qoder-native-acp.json +227 -0
- package/dist/axom-patch/cli/qoder-steering.md +20 -25
- package/dist/axom-patch/cli/qoder-subagent-subwork.md +17 -68
- package/dist/axom-patch/cli/runtime.json +39 -15
- package/dist/axom-patch/cli/zcode-account-limits.json +30 -0
- package/dist/axom-patch/cli/zcode-models.json +37 -0
- package/dist/axom-patch/cli/zcode-subagent-subwork.json +72 -0
- package/dist/cli.js +4 -9
- package/dist/commands/knowledge.js +47 -0
- package/dist/commands/update.js +65 -3
- package/dist/commands/workstation.js +73 -7
- package/dist/install/claude/commands.js +13 -13
- package/dist/keepers/acp/keeper.mjs +3083 -0
- package/dist/keepers/acp/manifest.json +9 -0
- package/dist/keepers/terminal/keeper.mjs +8469 -0
- package/dist/keepers/terminal/manifest.json +9 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/conpty_console_list_agent.js +25 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/eventEmitter2.js +47 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/index.js +52 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/interfaces.js +7 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/shared/conout.js +10 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/terminal.js +190 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/types.js +7 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/unixTerminal.js +351 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/utils.js +38 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/windowsConoutConnection.js +131 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/windowsPtyAgent.js +344 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/windowsTerminal.js +209 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/worker/conoutSocketWorker.js +22 -0
- package/dist/keepers/terminal/node_modules/node-pty/package.json +66 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-arm64/pty.node +0 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-arm64/spawn-helper +0 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-x64/pty.node +0 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-x64/spawn-helper +0 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/linux-arm64/pty.node +0 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/linux-x64/pty.node +0 -0
- package/dist/mcp.js +1 -1
- package/dist/native/darwin-arm64/axom-quic +0 -0
- package/dist/test/keeperKit.js +67 -0
- package/dist/workstation/acp/acpAgents.js +2192 -636
- package/dist/workstation/acp/acpClient.js +84 -495
- package/dist/workstation/acp/adoption.js +10 -396
- package/dist/workstation/acp/binaryInstall.js +100 -27
- package/dist/workstation/acp/builds/gc.js +245 -0
- package/dist/workstation/acp/builds/install/binary.js +28 -0
- package/dist/workstation/acp/builds/install/npm.js +324 -0
- package/dist/workstation/acp/builds/install/uv.js +254 -0
- package/dist/workstation/acp/builds/migrate.js +195 -0
- package/dist/workstation/acp/builds/model.js +128 -0
- package/dist/workstation/acp/builds/nodeGate.js +100 -0
- package/dist/workstation/acp/builds/store.js +352 -0
- package/dist/workstation/acp/commandTableProbe.js +68 -0
- package/dist/workstation/acp/configOptions.js +103 -1
- package/dist/workstation/acp/envSecrets.js +27 -0
- package/dist/workstation/acp/initializeResponse.js +290 -0
- package/dist/workstation/acp/installErrors.js +12 -2
- package/dist/workstation/acp/installProbe.js +1 -1
- package/dist/workstation/acp/managedPatches.js +239 -159
- package/dist/workstation/acp/managedRuntime.js +314 -0
- package/dist/workstation/acp/outbox.js +330 -0
- package/dist/workstation/acp/patchAssets.js +60 -3
- package/dist/workstation/acp/permission.js +42 -10
- package/dist/workstation/acp/processor/checkpoint.js +78 -0
- package/dist/workstation/acp/processor/commands.js +35 -0
- package/dist/workstation/acp/processor/control.js +282 -0
- package/dist/workstation/acp/processor/emit.js +26 -0
- package/dist/workstation/acp/processor/jsonrpc.js +172 -0
- package/dist/workstation/acp/processor/memoryLog.js +49 -0
- package/dist/workstation/acp/processor/origins.js +48 -0
- package/dist/workstation/acp/processor/output.js +67 -0
- package/dist/workstation/acp/processor/projection.js +42 -0
- package/dist/workstation/acp/processor/record.js +128 -0
- package/dist/workstation/acp/processor/runtime.js +105 -0
- package/dist/workstation/acp/processor/sdkSchemas.js +61 -0
- package/dist/workstation/acp/processor/session.js +496 -0
- package/dist/workstation/acp/processor/state.js +67 -0
- package/dist/workstation/acp/processor/step.js +442 -0
- package/dist/workstation/acp/processor/test/fixtureLog.js +58 -0
- package/dist/workstation/acp/processor/test/harness.js +120 -0
- package/dist/workstation/acp/processor/test/largeIdEcho.js +50 -0
- package/dist/workstation/acp/processor/test/rawAgent.js +70 -0
- package/dist/workstation/acp/processor/transport/generations.js +76 -0
- package/dist/workstation/acp/processor/transport/keeper.js +130 -0
- package/dist/workstation/acp/processor/transport/local.js +269 -0
- package/dist/workstation/acp/processor/transport/types.js +78 -0
- package/dist/workstation/acp/processor/updates.js +390 -0
- package/dist/workstation/acp/processorSession.js +1348 -0
- package/dist/workstation/acp/registry/catalog.js +189 -41
- package/dist/workstation/acp/registry/launch.js +76 -119
- package/dist/workstation/acp/sessionUpdateMapper.js +18 -7
- package/dist/workstation/acp/steering.js +58 -0
- package/dist/workstation/acp/switching.js +111 -0
- package/dist/workstation/acp/terminalLogin.js +16 -42
- package/dist/workstation/acp/terminalLoginSpec.js +41 -0
- package/dist/workstation/acp/test/adapterFixtures.js +256 -0
- package/dist/workstation/acp/test/daemonLife.js +106 -0
- package/dist/workstation/acp/test/fakeAgent.js +13 -0
- package/dist/workstation/acp/test/keeperWorld.js +295 -0
- package/dist/workstation/acp/test/memoryOutbox.js +55 -0
- package/dist/workstation/acp/test/raw/fixtures.js +196 -0
- package/dist/workstation/acp/test/raw/sanitize.js +344 -0
- package/dist/workstation/acp/test/raw/sources.js +135 -0
- package/dist/workstation/acp/test/realAdapter.js +331 -0
- package/dist/workstation/acp/test/scriptAgent.js +214 -0
- package/dist/workstation/acp/test/sessionFrames.js +42 -0
- package/dist/workstation/acp/timedCommand.js +4 -2
- package/dist/workstation/acp/uvxInstall.js +72 -122
- package/dist/workstation/agentFlow.js +103 -0
- package/dist/workstation/agentSessions.js +1289 -544
- package/dist/workstation/channels/agent.js +7 -5
- package/dist/workstation/channels/computer.js +69 -21
- package/dist/workstation/channels/computerExecs.js +200 -0
- package/dist/workstation/channels/management.js +172 -8
- package/dist/workstation/channels/url.js +1 -1
- package/dist/workstation/config.js +16 -4
- package/dist/workstation/connection/encryptedChannel.js +4 -1
- package/dist/workstation/control/bindingStore.js +200 -0
- package/dist/workstation/control/commands.js +124 -0
- package/dist/workstation/control/daemonIdentity.js +53 -0
- package/dist/workstation/control/enrollment.js +106 -0
- package/dist/workstation/control/inspect.js +71 -0
- package/dist/workstation/control/locks.js +63 -0
- package/dist/workstation/control/protocol.js +40 -0
- package/dist/workstation/control/restartGuard.js +46 -0
- package/dist/workstation/control/runtimeClient.js +97 -0
- package/dist/workstation/control/runtimeHost.js +148 -0
- package/dist/workstation/control/serverIdentity.js +35 -0
- package/dist/workstation/daemon.js +393 -12
- package/dist/workstation/daemonctl.js +103 -14
- package/dist/workstation/desktopControl/audio.js +99 -0
- package/dist/workstation/desktopControl/capabilities.js +92 -0
- package/dist/workstation/desktopControl/captureModes.js +9 -0
- package/dist/workstation/desktopControl/captureTargetLabel.js +9 -0
- package/dist/workstation/desktopControl/deviceSockets.js +246 -0
- package/dist/workstation/desktopControl/helperProcess.js +353 -0
- package/dist/workstation/desktopControl/lobby.js +129 -0
- package/dist/workstation/desktopControl/macProvider.js +155 -0
- package/dist/workstation/desktopControl/media.js +331 -0
- package/dist/workstation/desktopControl/nativeProvider.js +1018 -0
- package/dist/workstation/desktopControl/networkEvidence.js +43 -0
- package/dist/workstation/desktopControl/overview.js +58 -0
- package/dist/workstation/desktopControl/protocol.js +302 -0
- package/dist/workstation/desktopControl/quicMedia.js +240 -0
- package/dist/workstation/desktopControl/rateController.js +226 -0
- package/dist/workstation/desktopControl/supervisor.js +1681 -0
- package/dist/workstation/desktopControl/transport.js +27 -0
- package/dist/workstation/desktopControl/wallpaper.js +78 -0
- package/dist/workstation/exec/attach.js +126 -0
- package/dist/workstation/exec/command.js +40 -0
- package/dist/workstation/exec/durable.js +517 -0
- package/dist/workstation/exec/fallback.js +106 -0
- package/dist/workstation/exec/policy.js +837 -0
- package/dist/workstation/exec/shaping.js +211 -0
- package/dist/workstation/exec.js +3 -112
- package/dist/workstation/files/adapters/computerFileFrames.js +51 -0
- package/dist/workstation/files/adapters/toolRequests.js +121 -0
- package/dist/workstation/files/client.js +455 -0
- package/dist/workstation/files/credentials.js +142 -0
- package/dist/workstation/files/errors.js +28 -0
- package/dist/workstation/files/fence.js +350 -0
- package/dist/workstation/files/find/fileIndex.js +153 -0
- package/dist/workstation/files/find/fuzzy.js +155 -0
- package/dist/workstation/files/host.js +122 -0
- package/dist/workstation/files/hostProtocol.js +2 -0
- package/dist/workstation/files/media/cache.js +620 -0
- package/dist/workstation/files/media/encoding.js +308 -0
- package/dist/workstation/files/media/estimator.js +100 -0
- package/dist/workstation/files/media/media.js +325 -0
- package/dist/workstation/files/media/packedAudio.js +23 -0
- package/dist/workstation/files/media/scheduler.js +551 -0
- package/dist/workstation/files/media/types.js +5 -0
- package/dist/workstation/files/scope.js +45 -0
- package/dist/workstation/files/structured.js +284 -0
- package/dist/workstation/{fileOps.js → files/text.js} +152 -39
- package/dist/workstation/files/uploads.js +351 -0
- package/dist/workstation/journal.js +65 -236
- package/dist/workstation/keepers/acp/client.js +101 -0
- package/dist/workstation/keepers/acp/envelope.js +62 -0
- package/dist/workstation/keepers/acp/host.js +419 -0
- package/dist/workstation/keepers/acp/main.js +18 -0
- package/dist/workstation/keepers/acp/memoryStream.js +121 -0
- package/dist/workstation/keepers/acp/params.js +137 -0
- package/dist/workstation/keepers/acp/protocol.js +66 -0
- package/dist/workstation/keepers/acp/recover.js +120 -0
- package/dist/workstation/keepers/acp/session.js +584 -0
- package/dist/workstation/keepers/acp/stderr.js +154 -0
- package/dist/workstation/keepers/kit/activity.js +103 -0
- package/dist/workstation/keepers/kit/bootstrap.js +164 -0
- package/dist/workstation/keepers/kit/buildVersion.js +51 -0
- package/dist/workstation/keepers/kit/bundle.js +124 -0
- package/dist/workstation/keepers/kit/client.js +432 -0
- package/dist/workstation/keepers/kit/identity.js +21 -0
- package/dist/workstation/keepers/kit/ipc.js +112 -0
- package/dist/workstation/keepers/kit/launchConfig.js +8 -0
- package/dist/workstation/keepers/kit/launcher.js +289 -0
- package/dist/workstation/keepers/kit/lock.js +123 -0
- package/dist/workstation/keepers/kit/orphanSweep.js +86 -0
- package/dist/workstation/keepers/kit/paths.js +76 -0
- package/dist/workstation/keepers/kit/protocol.js +43 -0
- package/dist/workstation/keepers/kit/server.js +518 -0
- package/dist/workstation/keepers/kit/streamLog.js +697 -0
- package/dist/workstation/keepers/legacy.js +296 -0
- package/dist/workstation/keepers/policy.js +576 -0
- package/dist/workstation/keepers/stop.js +34 -0
- package/dist/workstation/keepers/terminal/client.js +213 -0
- package/dist/workstation/keepers/terminal/exec.js +981 -0
- package/dist/workstation/keepers/terminal/generations.js +129 -0
- package/dist/workstation/keepers/terminal/keeper.js +62 -0
- package/dist/workstation/keepers/terminal/main.js +43 -0
- package/dist/workstation/keepers/terminal/orphanSweep.js +3 -0
- package/dist/workstation/keepers/terminal/params.js +27 -0
- package/dist/workstation/keepers/terminal/protocol.js +56 -0
- package/dist/workstation/keepers/terminal/pty.js +330 -0
- package/dist/workstation/managedInstall.js +57 -0
- package/dist/workstation/protocol/accountLimits.js +47 -0
- package/dist/workstation/protocol/agent.js +50 -19
- package/dist/workstation/protocol/common.js +30 -0
- package/dist/workstation/protocol/computer.js +15 -4
- package/dist/workstation/protocol/management.js +141 -4
- package/dist/workstation/protocol/runtimeDiagnostics.js +15 -0
- package/dist/workstation/rd/artifact.js +119 -0
- package/dist/workstation/rd/control.js +119 -0
- package/dist/workstation/rd/display.js +29 -0
- package/dist/workstation/rd/provider.js +222 -0
- package/dist/workstation/rd/sessions.js +391 -0
- package/dist/workstation/rd/state.js +45 -0
- package/dist/workstation/rd/update.js +171 -0
- package/dist/workstation/rd/upkeep.js +38 -0
- package/dist/workstation/register.js +27 -37
- package/dist/workstation/service/installer.js +104 -13
- package/dist/workstation/service/launchd.js +1 -0
- package/dist/workstation/storageMetrics.js +118 -0
- package/dist/workstation/systemMetrics.js +154 -93
- package/dist/workstation/toolchain.js +94 -0
- package/dist/workstation/tools/browser.js +28 -4
- package/dist/workstation/tools/client.js +9 -1
- package/dist/workstation/tools/info.js +50 -0
- package/dist/workstation/tools/keeperProcess.js +15 -5
- package/dist/workstation/tools/router.js +55 -0
- package/dist/workstation/tools/terminalRouter.js +222 -0
- package/dist/workstation/tools/validation.js +37 -1
- package/dist/workstation/update/artifact.js +2 -1
- package/dist/workstation/update/coordinator.js +3 -0
- package/dist/workstation/update/globalInstall.js +116 -0
- package/dist/workstation/update/npmInstaller.js +49 -23
- package/dist/workstation/update/ptyPreflight.js +21 -0
- package/dist/workstation/update/rdRollback.js +145 -0
- package/dist/workstation/update/runUpdate.js +3 -2
- package/dist/workstation/update/taskRegistry.js +105 -11
- package/dist/workstation/update/watchdog.js +16790 -334
- package/package.json +11 -8
- package/skills/axom/SKILL.md +11 -11
- package/skills/axom-definition/SKILL.md +3 -3
- package/skills/axom-platform/SKILL.md +3 -3
- package/dist/axom-patch/cli/probes/qoder-steering.mjs +0 -89
- package/dist/axom-patch/cli/probes/qoder-subagent-origin.mjs +0 -49
- package/dist/axom-patch/cli/qoder-steering.json +0 -52
- package/dist/axom-patch/cli/qoder-subagent-origin.json +0 -73
- package/dist/commands/guide.js +0 -38
- package/dist/workstation/acp/acpSession.js +0 -1475
- package/dist/workstation/acp/contract.js +0 -499
- package/dist/workstation/acp/delegationMcp.js +0 -24
- package/dist/workstation/acp/draftInterceptor.js +0 -163
- package/dist/workstation/acp/qoderStartup.js +0 -45
- package/dist/workstation/acp/sessionScope.js +0 -19
- package/dist/workstation/filePaths.js +0 -174
- package/dist/workstation/fileWorker.js +0 -81
- package/dist/workstation/fileWorkerClient.js +0 -121
- package/dist/workstation/qoderAdapter/backgroundProcess.js +0 -106
- package/dist/workstation/qoderAdapter/backgroundTerminal.js +0 -262
- package/dist/workstation/qoderAdapter/completedContinuation.js +0 -139
- package/dist/workstation/qoderAdapter/contract.js +0 -78
- package/dist/workstation/qoderAdapter/foregroundOutput.js +0 -30
- package/dist/workstation/qoderAdapter/history.js +0 -87
- package/dist/workstation/qoderAdapter/identityIndex.js +0 -191
- package/dist/workstation/qoderAdapter/inputQueue.js +0 -42
- package/dist/workstation/qoderAdapter/main.js +0 -93
- package/dist/workstation/qoderAdapter/mapper.js +0 -117
- package/dist/workstation/qoderAdapter/observerPlugin.js +0 -245
- package/dist/workstation/qoderAdapter/observerReceiver.js +0 -187
- package/dist/workstation/qoderAdapter/permissions.js +0 -61
- package/dist/workstation/qoderAdapter/profile.js +0 -93
- package/dist/workstation/qoderAdapter/sdkDriver.js +0 -49
- package/dist/workstation/qoderAdapter/session.js +0 -319
- package/dist/workstation/qoderAdapter/subagentMeta.js +0 -28
- package/dist/workstation/qoderAdapter/subwork.js +0 -77
- package/dist/workstation/qoderAdapter/taskControl.js +0 -38
- package/dist/workstation/qoderAdapter/taskEvent.js +0 -26
- package/dist/workstation/qoderAdapter/taskLedger.js +0 -163
- package/dist/workstation/qoderAdapter/usage.js +0 -37
- package/dist/workstation/tools/core.js +0 -50
- package/dist/workstation/tools/files.js +0 -525
- package/dist/workstation/tools/terminal.js +0 -214
- /package/dist/workstation/{qoderAdapter/types.js → desktopControl/provider.js} +0 -0
|
@@ -0,0 +1,697 @@
|
|
|
1
|
+
import { chmodSync, closeSync, fstatSync, fsync, fsyncSync, ftruncateSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, unlinkSync, writeSync, } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
export const SEGMENT_BYTES = 8 * 1024 * 1024;
|
|
4
|
+
export const SOFT_QUOTA_BYTES = 256 * 1024 * 1024;
|
|
5
|
+
export const FSYNC_INTERVAL_MS = 100;
|
|
6
|
+
export const FSYNC_BYTES = 64 * 1024;
|
|
7
|
+
export const IMMEDIATE_KINDS = ['spawn', 'exit', 'checkpoint', 'commit'];
|
|
8
|
+
const INDEX_STRIDE = 64;
|
|
9
|
+
const CHUNK_BYTES = 1024 * 1024;
|
|
10
|
+
const SUBSCRIBE_BATCH = 256;
|
|
11
|
+
const SEGMENT_NAME = /^seg-(\d+)\.log$/;
|
|
12
|
+
const CHECKPOINT_NAME = /^checkpoint-(\d+)\.json$/;
|
|
13
|
+
const META_FILE = 'meta.json';
|
|
14
|
+
const KEPT_CHECKPOINTS = 2;
|
|
15
|
+
export class StreamCorruptError extends Error {
|
|
16
|
+
constructor(message) { super(message); this.name = 'StreamCorruptError'; }
|
|
17
|
+
}
|
|
18
|
+
const openStreams = new Set();
|
|
19
|
+
function fsyncDirectory(dir) {
|
|
20
|
+
let fd;
|
|
21
|
+
try {
|
|
22
|
+
fd = openSync(dir, 'r');
|
|
23
|
+
fsyncSync(fd);
|
|
24
|
+
}
|
|
25
|
+
catch { /* best effort: some filesystems cannot fsync a directory */ }
|
|
26
|
+
finally {
|
|
27
|
+
if (fd !== undefined)
|
|
28
|
+
try {
|
|
29
|
+
closeSync(fd);
|
|
30
|
+
}
|
|
31
|
+
catch { /* ignore */ }
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function writeAll(fd, data) {
|
|
35
|
+
let written = 0;
|
|
36
|
+
while (written < data.length)
|
|
37
|
+
written += writeSync(fd, data, written, data.length - written);
|
|
38
|
+
}
|
|
39
|
+
/** Atomically replaces `file` (tmp + fsync + rename + directory fsync), mode 0600. */
|
|
40
|
+
function atomicWrite(file, content) {
|
|
41
|
+
const temp = `${file}.tmp`;
|
|
42
|
+
const fd = openSync(temp, 'w', 0o600);
|
|
43
|
+
try {
|
|
44
|
+
writeAll(fd, Buffer.from(content));
|
|
45
|
+
fsyncSync(fd);
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
closeSync(fd);
|
|
49
|
+
}
|
|
50
|
+
renameSync(temp, file);
|
|
51
|
+
fsyncDirectory(path.dirname(file));
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Calls `onLine(line, pos)` for every complete (newline-terminated) line in `[start, end)` of `fd`; `line` is only valid
|
|
55
|
+
* during the call. Stops when it returns false. Returns the position just after the last complete line seen.
|
|
56
|
+
*/
|
|
57
|
+
function scanLines(fd, start, end, onLine) {
|
|
58
|
+
const chunk = Buffer.allocUnsafe(Math.min(CHUNK_BYTES, Math.max(1, end - start)));
|
|
59
|
+
let pos = start; // file position of chunk[0]
|
|
60
|
+
let lineStart = start;
|
|
61
|
+
let carry = [];
|
|
62
|
+
while (pos < end) {
|
|
63
|
+
const n = readSync(fd, chunk, 0, Math.min(chunk.length, end - pos), pos);
|
|
64
|
+
if (n <= 0)
|
|
65
|
+
break;
|
|
66
|
+
const view = chunk.subarray(0, n);
|
|
67
|
+
let from = 0;
|
|
68
|
+
for (;;) {
|
|
69
|
+
const nl = view.indexOf(10, from);
|
|
70
|
+
if (nl < 0)
|
|
71
|
+
break;
|
|
72
|
+
let line;
|
|
73
|
+
if (carry.length > 0) {
|
|
74
|
+
carry.push(chunk.subarray(from, nl));
|
|
75
|
+
line = Buffer.concat(carry);
|
|
76
|
+
carry = [];
|
|
77
|
+
}
|
|
78
|
+
else
|
|
79
|
+
line = chunk.subarray(from, nl);
|
|
80
|
+
if (onLine(line, lineStart) === false)
|
|
81
|
+
return lineStart;
|
|
82
|
+
lineStart = pos + nl + 1;
|
|
83
|
+
from = nl + 1;
|
|
84
|
+
}
|
|
85
|
+
if (from < n)
|
|
86
|
+
carry.push(Buffer.from(chunk.subarray(from, n)));
|
|
87
|
+
pos += n;
|
|
88
|
+
}
|
|
89
|
+
return lineStart;
|
|
90
|
+
}
|
|
91
|
+
function parseRecord(line) {
|
|
92
|
+
try {
|
|
93
|
+
const value = JSON.parse(line.toString('utf8'));
|
|
94
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
95
|
+
const r = value;
|
|
96
|
+
if (Number.isSafeInteger(r.o) && typeof r.k === 'string' && typeof r.t === 'number')
|
|
97
|
+
return r;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
catch { /* torn or garbage */ }
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
export class StreamLog {
|
|
104
|
+
/** Bytes dropped from the newest segment's end when the log was opened (a torn tail); 0 for a clean open. */
|
|
105
|
+
repairedBytes;
|
|
106
|
+
dir;
|
|
107
|
+
segmentBytes;
|
|
108
|
+
softQuota;
|
|
109
|
+
fsyncIntervalMs;
|
|
110
|
+
fsyncBytes;
|
|
111
|
+
immediate;
|
|
112
|
+
now;
|
|
113
|
+
syncFile;
|
|
114
|
+
onError;
|
|
115
|
+
segments = [];
|
|
116
|
+
active;
|
|
117
|
+
nextOffset = 0;
|
|
118
|
+
committedOffset = -1;
|
|
119
|
+
tail;
|
|
120
|
+
checkpointSeq = 0;
|
|
121
|
+
checkpointOffset = -1;
|
|
122
|
+
totalBytes = 0;
|
|
123
|
+
/** Bytes ever appended / bytes known durable (by a completed fsync). The difference is what a crash could still lose. */
|
|
124
|
+
appendedBytes = 0;
|
|
125
|
+
syncedBytes = 0;
|
|
126
|
+
syncTimer;
|
|
127
|
+
syncCount = 0;
|
|
128
|
+
syncErrors = 0;
|
|
129
|
+
lastSyncError;
|
|
130
|
+
/** The one background fsync (batched appends never fsync on the event loop); `again` = more bytes arrived meanwhile. */
|
|
131
|
+
inflight;
|
|
132
|
+
closed = false;
|
|
133
|
+
notifyPending = false;
|
|
134
|
+
watchers = new Set();
|
|
135
|
+
static open(options) { return new StreamLog(options); }
|
|
136
|
+
constructor(options) {
|
|
137
|
+
this.dir = path.resolve(options.dir);
|
|
138
|
+
this.segmentBytes = options.segmentBytes ?? SEGMENT_BYTES;
|
|
139
|
+
this.softQuota = options.softQuotaBytes ?? SOFT_QUOTA_BYTES;
|
|
140
|
+
this.fsyncIntervalMs = options.fsyncIntervalMs ?? FSYNC_INTERVAL_MS;
|
|
141
|
+
this.fsyncBytes = options.fsyncBytes ?? FSYNC_BYTES;
|
|
142
|
+
this.immediate = new Set(options.immediateKinds ?? IMMEDIATE_KINDS);
|
|
143
|
+
this.now = options.now ?? Date.now;
|
|
144
|
+
this.onError = options.onError;
|
|
145
|
+
this.syncFile = options.syncFile ?? fsyncSync;
|
|
146
|
+
if (options.baseOffset !== undefined && (!Number.isSafeInteger(options.baseOffset) || options.baseOffset < 0))
|
|
147
|
+
throw new RangeError(`Invalid baseOffset ${options.baseOffset}`);
|
|
148
|
+
if (openStreams.has(this.dir))
|
|
149
|
+
throw new Error(`Stream log already open in this process: ${this.dir}`);
|
|
150
|
+
mkdirSync(this.dir, { recursive: true, mode: 0o700 });
|
|
151
|
+
chmodSync(this.dir, 0o700);
|
|
152
|
+
openStreams.add(this.dir);
|
|
153
|
+
let repaired = 0;
|
|
154
|
+
try {
|
|
155
|
+
repaired = this.recover(options.baseOffset);
|
|
156
|
+
if (repaired > 0)
|
|
157
|
+
this.append({ k: 'discontinuity', reason: 'torn-tail', droppedBytes: repaired }, { sync: true });
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
// Release everything: the directory stays openable and no descriptor leaks.
|
|
161
|
+
this.closed = true;
|
|
162
|
+
this.closeActive();
|
|
163
|
+
if (this.syncTimer)
|
|
164
|
+
clearTimeout(this.syncTimer);
|
|
165
|
+
openStreams.delete(this.dir);
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
this.repairedBytes = repaired;
|
|
169
|
+
}
|
|
170
|
+
// ---- recovery ----------------------------------------------------------------------------------------------
|
|
171
|
+
recover(baseOffset) {
|
|
172
|
+
const names = readdirSync(this.dir);
|
|
173
|
+
if (baseOffset !== undefined && baseOffset > 0 && !names.some((n) => SEGMENT_NAME.test(n) || n === META_FILE)) {
|
|
174
|
+
// A fresh log continuing another one's offsets: the watermark is durable before anything is appended.
|
|
175
|
+
atomicWrite(path.join(this.dir, META_FILE), JSON.stringify({ v: 1, committed: baseOffset - 1, t: this.now() }));
|
|
176
|
+
names.push(META_FILE);
|
|
177
|
+
}
|
|
178
|
+
for (const name of names)
|
|
179
|
+
if (name.endsWith('.tmp'))
|
|
180
|
+
try {
|
|
181
|
+
unlinkSync(path.join(this.dir, name));
|
|
182
|
+
}
|
|
183
|
+
catch { /* ignore */ }
|
|
184
|
+
const firsts = names.map((n) => SEGMENT_NAME.exec(n)).filter((m) => !!m).map((m) => Number(m[1])).sort((a, b) => a - b);
|
|
185
|
+
for (let i = 0; i < firsts.length; i++) {
|
|
186
|
+
const file = path.join(this.dir, `seg-${firsts[i]}.log`);
|
|
187
|
+
const bytes = fstatSizeOf(file);
|
|
188
|
+
const count = i + 1 < firsts.length ? firsts[i + 1] - firsts[i] : 0; // the newest is counted by the repair scan
|
|
189
|
+
this.segments.push({ first: firsts[i], file, bytes, count, idx: [], indexed: false });
|
|
190
|
+
}
|
|
191
|
+
let meta = {};
|
|
192
|
+
try {
|
|
193
|
+
meta = JSON.parse(readFileSync(path.join(this.dir, META_FILE), 'utf8'));
|
|
194
|
+
}
|
|
195
|
+
catch { /* absent or unreadable: derived below */ }
|
|
196
|
+
this.committedOffset = Number.isSafeInteger(meta.committed) ? meta.committed : -1;
|
|
197
|
+
this.tail = meta.tail;
|
|
198
|
+
let repaired = 0;
|
|
199
|
+
const newest = this.segments[this.segments.length - 1];
|
|
200
|
+
if (newest)
|
|
201
|
+
repaired = this.repairNewest(newest);
|
|
202
|
+
// Everything before the oldest retained segment was committed, even if meta.json cannot say so.
|
|
203
|
+
const firstRetained = this.segments[0]?.first;
|
|
204
|
+
if (firstRetained !== undefined)
|
|
205
|
+
this.committedOffset = Math.max(this.committedOffset, firstRetained - 1);
|
|
206
|
+
this.nextOffset = newest ? newest.first + newest.count : 0;
|
|
207
|
+
this.nextOffset = Math.max(this.nextOffset, this.committedOffset + 1);
|
|
208
|
+
this.totalBytes = this.segments.reduce((sum, s) => sum + s.bytes, 0);
|
|
209
|
+
// A crash between writing meta.json and deleting the covered segments: finish the deletion.
|
|
210
|
+
this.deleteCommittedSegments();
|
|
211
|
+
// Resume appending to the newest segment if any remains (reopen it for append).
|
|
212
|
+
const last = this.segments[this.segments.length - 1];
|
|
213
|
+
if (last && last.first + last.count === this.nextOffset) {
|
|
214
|
+
const fd = openSync(last.file, 'a', 0o600);
|
|
215
|
+
this.active = { seg: last, fd };
|
|
216
|
+
}
|
|
217
|
+
const checkpoints = this.checkpointFiles();
|
|
218
|
+
if (checkpoints.length > 0)
|
|
219
|
+
this.checkpointSeq = checkpoints[checkpoints.length - 1].seq;
|
|
220
|
+
this.checkpointOffset = this.latestCheckpoint()?.offset ?? -1;
|
|
221
|
+
return repaired;
|
|
222
|
+
}
|
|
223
|
+
/** Scans the newest segment: counts and indexes its records and truncates anything after the last intact one. */
|
|
224
|
+
repairNewest(seg) {
|
|
225
|
+
const fd = openSync(seg.file, 'r+', 0o600);
|
|
226
|
+
try {
|
|
227
|
+
let expected = seg.first;
|
|
228
|
+
let good = 0;
|
|
229
|
+
const idx = [];
|
|
230
|
+
const size = fstatSync(fd).size;
|
|
231
|
+
scanLines(fd, 0, size, (line, pos) => {
|
|
232
|
+
const record = parseRecord(line);
|
|
233
|
+
if (!record || record.o !== expected)
|
|
234
|
+
return false;
|
|
235
|
+
if ((expected - seg.first) % INDEX_STRIDE === 0)
|
|
236
|
+
idx.push(pos);
|
|
237
|
+
expected++;
|
|
238
|
+
good = pos + line.length + 1;
|
|
239
|
+
return true;
|
|
240
|
+
});
|
|
241
|
+
let dropped = 0;
|
|
242
|
+
if (good < size) {
|
|
243
|
+
dropped = size - good;
|
|
244
|
+
ftruncateSync(fd, good);
|
|
245
|
+
fsyncSync(fd);
|
|
246
|
+
}
|
|
247
|
+
seg.bytes = good;
|
|
248
|
+
seg.count = expected - seg.first;
|
|
249
|
+
seg.idx = idx;
|
|
250
|
+
seg.indexed = true;
|
|
251
|
+
return dropped;
|
|
252
|
+
}
|
|
253
|
+
finally {
|
|
254
|
+
closeSync(fd);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
deleteCommittedSegments() {
|
|
258
|
+
let removed = false;
|
|
259
|
+
while (this.segments.length > 0) {
|
|
260
|
+
const seg = this.segments[0];
|
|
261
|
+
if (seg.first + seg.count - 1 > this.committedOffset)
|
|
262
|
+
break;
|
|
263
|
+
if (this.active?.seg === seg) {
|
|
264
|
+
this.retireFd(this.active.fd);
|
|
265
|
+
this.active = undefined;
|
|
266
|
+
}
|
|
267
|
+
try {
|
|
268
|
+
unlinkSync(seg.file);
|
|
269
|
+
}
|
|
270
|
+
catch (error) {
|
|
271
|
+
if (error.code !== 'ENOENT')
|
|
272
|
+
throw error;
|
|
273
|
+
}
|
|
274
|
+
this.totalBytes -= seg.bytes;
|
|
275
|
+
this.segments.shift();
|
|
276
|
+
removed = true;
|
|
277
|
+
}
|
|
278
|
+
if (removed)
|
|
279
|
+
fsyncDirectory(this.dir);
|
|
280
|
+
}
|
|
281
|
+
// ---- append ------------------------------------------------------------------------------------------------
|
|
282
|
+
/**
|
|
283
|
+
* Appends one record (stamping `o` and `t`) and returns it.
|
|
284
|
+
*
|
|
285
|
+
* Durability: ordinary records are fsynced in the background (at most one fsync in flight; started when 64 KiB are
|
|
286
|
+
* unsynced or 100 ms after the first unsynced append) and never block the caller. Records of an immediate kind
|
|
287
|
+
* (spawn/exit/checkpoint/commit by default) or appended with `sync: true` are fsynced synchronously before returning.
|
|
288
|
+
* Once the line is written the append has succeeded: if that synchronous fsync then fails, the record IS in the log
|
|
289
|
+
* (a retry would duplicate it), so the error is reported through `onError` and `stats().syncErrors`/`lastSyncError`
|
|
290
|
+
* instead of being thrown, and the record stays counted as unsynced. Only a failure to write the line itself throws.
|
|
291
|
+
*/
|
|
292
|
+
append(input, options = {}) {
|
|
293
|
+
this.assertOpen();
|
|
294
|
+
if (typeof input.k !== 'string' || input.k === '')
|
|
295
|
+
throw new TypeError('A stream record needs a non-empty kind `k`');
|
|
296
|
+
if ('o' in input || 't' in input)
|
|
297
|
+
throw new TypeError('`o` and `t` are stamped by the stream log');
|
|
298
|
+
const { k, ...rest } = input;
|
|
299
|
+
const record = { o: this.nextOffset, t: this.now(), k, ...rest };
|
|
300
|
+
const line = Buffer.from(`${JSON.stringify(record)}\n`);
|
|
301
|
+
let target = this.active;
|
|
302
|
+
if (target && target.seg.bytes > 0 && target.seg.bytes + line.length > this.segmentBytes) {
|
|
303
|
+
this.sealActive();
|
|
304
|
+
target = undefined;
|
|
305
|
+
}
|
|
306
|
+
if (!target)
|
|
307
|
+
target = this.startSegment(record.o);
|
|
308
|
+
try {
|
|
309
|
+
writeAll(target.fd, line);
|
|
310
|
+
}
|
|
311
|
+
catch (error) {
|
|
312
|
+
try {
|
|
313
|
+
ftruncateSync(target.fd, target.seg.bytes);
|
|
314
|
+
}
|
|
315
|
+
catch { /* the next open repairs it */ }
|
|
316
|
+
throw error;
|
|
317
|
+
}
|
|
318
|
+
const seg = target.seg;
|
|
319
|
+
if (seg.count % INDEX_STRIDE === 0)
|
|
320
|
+
seg.idx.push(seg.bytes);
|
|
321
|
+
seg.bytes += line.length;
|
|
322
|
+
seg.count++;
|
|
323
|
+
this.totalBytes += line.length;
|
|
324
|
+
this.nextOffset++;
|
|
325
|
+
this.appendedBytes += line.length;
|
|
326
|
+
this.scheduleNotify();
|
|
327
|
+
if (options.sync || this.immediate.has(k)) {
|
|
328
|
+
try {
|
|
329
|
+
this.sync();
|
|
330
|
+
}
|
|
331
|
+
catch (error) {
|
|
332
|
+
this.reportSyncError(error);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
else if (this.appendedBytes - this.syncedBytes >= this.fsyncBytes)
|
|
336
|
+
this.startBackgroundSync();
|
|
337
|
+
else if (!this.syncTimer && !this.inflight) {
|
|
338
|
+
this.syncTimer = setTimeout(() => { this.syncTimer = undefined; this.startBackgroundSync(); }, this.fsyncIntervalMs);
|
|
339
|
+
this.syncTimer.unref();
|
|
340
|
+
}
|
|
341
|
+
return record;
|
|
342
|
+
}
|
|
343
|
+
reportSyncError(error) {
|
|
344
|
+
this.syncErrors++;
|
|
345
|
+
this.lastSyncError = error instanceof Error ? error : new Error(String(error));
|
|
346
|
+
this.onError?.(this.lastSyncError);
|
|
347
|
+
}
|
|
348
|
+
/** Starts the single background fsync of the active segment, or asks the running one to go again when it finishes. */
|
|
349
|
+
startBackgroundSync() {
|
|
350
|
+
if (this.closed || !this.active)
|
|
351
|
+
return;
|
|
352
|
+
if (this.inflight) {
|
|
353
|
+
this.inflight.again = true;
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
if (this.appendedBytes === this.syncedBytes)
|
|
357
|
+
return;
|
|
358
|
+
const flight = { fd: this.active.fd, covers: this.appendedBytes, again: false, closeAfter: false };
|
|
359
|
+
this.inflight = flight;
|
|
360
|
+
fsync(flight.fd, (error) => {
|
|
361
|
+
this.inflight = undefined;
|
|
362
|
+
if (flight.closeAfter) {
|
|
363
|
+
try {
|
|
364
|
+
closeSync(flight.fd);
|
|
365
|
+
}
|
|
366
|
+
catch { /* ignore */ }
|
|
367
|
+
}
|
|
368
|
+
if (error) {
|
|
369
|
+
if (!flight.closeAfter)
|
|
370
|
+
this.reportSyncError(error);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
this.syncCount++;
|
|
374
|
+
this.syncedBytes = Math.max(this.syncedBytes, flight.covers);
|
|
375
|
+
if (this.closed)
|
|
376
|
+
return;
|
|
377
|
+
if (flight.again || this.appendedBytes - this.syncedBytes >= this.fsyncBytes)
|
|
378
|
+
this.startBackgroundSync();
|
|
379
|
+
else if (this.appendedBytes > this.syncedBytes && !this.syncTimer) {
|
|
380
|
+
this.syncTimer = setTimeout(() => { this.syncTimer = undefined; this.startBackgroundSync(); }, this.fsyncIntervalMs);
|
|
381
|
+
this.syncTimer.unref();
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
/** Closes `fd`, or hands it to the in-flight fsync to close when that completes (never close under a running fsync). */
|
|
386
|
+
retireFd(fd) {
|
|
387
|
+
if (this.inflight?.fd === fd) {
|
|
388
|
+
this.inflight.closeAfter = true;
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
try {
|
|
392
|
+
closeSync(fd);
|
|
393
|
+
}
|
|
394
|
+
catch { /* ignore */ }
|
|
395
|
+
}
|
|
396
|
+
/** Appends an explicit `discontinuity` record: the caller (or the keeper) knows data before this point may be missing. */
|
|
397
|
+
markDiscontinuity(reason, fields = {}) {
|
|
398
|
+
return this.append({ ...fields, k: 'discontinuity', reason }, { sync: true });
|
|
399
|
+
}
|
|
400
|
+
/** Synchronously fsyncs everything appended so far (throws if the fsync fails). */
|
|
401
|
+
sync() {
|
|
402
|
+
if (this.syncTimer) {
|
|
403
|
+
clearTimeout(this.syncTimer);
|
|
404
|
+
this.syncTimer = undefined;
|
|
405
|
+
}
|
|
406
|
+
if (this.active && this.appendedBytes > this.syncedBytes) {
|
|
407
|
+
const covers = this.appendedBytes;
|
|
408
|
+
this.syncFile(this.active.fd);
|
|
409
|
+
this.syncCount++;
|
|
410
|
+
this.syncedBytes = Math.max(this.syncedBytes, covers);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
startSegment(first) {
|
|
414
|
+
const file = path.join(this.dir, `seg-${first}.log`);
|
|
415
|
+
const fd = openSync(file, 'a', 0o600);
|
|
416
|
+
const seg = { first, file, bytes: 0, count: 0, idx: [], indexed: true };
|
|
417
|
+
this.segments.push(seg);
|
|
418
|
+
this.active = { seg, fd };
|
|
419
|
+
fsyncDirectory(this.dir);
|
|
420
|
+
return this.active;
|
|
421
|
+
}
|
|
422
|
+
sealActive() {
|
|
423
|
+
if (!this.active)
|
|
424
|
+
return;
|
|
425
|
+
this.sync();
|
|
426
|
+
this.retireFd(this.active.fd);
|
|
427
|
+
this.active = undefined;
|
|
428
|
+
}
|
|
429
|
+
// ---- read / subscribe --------------------------------------------------------------------------------------
|
|
430
|
+
/**
|
|
431
|
+
* Up to `limit` records with `o >= from`, in order. If `from` was already truncated away the result starts at the first
|
|
432
|
+
* retained record (the caller can tell from `records[0].o`; use `readFrom` or `subscribe`'s `onTruncated` to be told).
|
|
433
|
+
*/
|
|
434
|
+
read(from, limit, options = {}) { return this.readFrom(from, limit, options).records; }
|
|
435
|
+
/** Like `read`, plus `first` (first retained offset) and `truncated` (true when `from < first`: requested records are gone). */
|
|
436
|
+
readFrom(from, limit, options = {}) {
|
|
437
|
+
this.assertOpen();
|
|
438
|
+
const maxBytes = options.maxBytes ?? Number.POSITIVE_INFINITY;
|
|
439
|
+
let bytes = 0;
|
|
440
|
+
const first = this.segments[0]?.first ?? this.nextOffset;
|
|
441
|
+
const truncated = from < first;
|
|
442
|
+
const out = [];
|
|
443
|
+
if (!(limit > 0) || this.segments.length === 0)
|
|
444
|
+
return { records: out, first, truncated };
|
|
445
|
+
from = Math.max(from, first);
|
|
446
|
+
if (from >= this.nextOffset)
|
|
447
|
+
return { records: out, first, truncated };
|
|
448
|
+
let si = this.segments.length - 1;
|
|
449
|
+
while (si > 0 && this.segments[si].first > from)
|
|
450
|
+
si--;
|
|
451
|
+
let full = false;
|
|
452
|
+
for (; si < this.segments.length && out.length < limit && !full; si++) {
|
|
453
|
+
const seg = this.segments[si];
|
|
454
|
+
if (seg.count === 0)
|
|
455
|
+
continue;
|
|
456
|
+
this.ensureIndexed(seg);
|
|
457
|
+
const rel = Math.max(0, from - seg.first);
|
|
458
|
+
const slot = Math.floor(rel / INDEX_STRIDE);
|
|
459
|
+
let skip = rel - slot * INDEX_STRIDE;
|
|
460
|
+
let expected = seg.first + slot * INDEX_STRIDE;
|
|
461
|
+
const fd = openSync(seg.file, 'r');
|
|
462
|
+
try {
|
|
463
|
+
scanLines(fd, seg.idx[slot], seg.bytes, (line) => {
|
|
464
|
+
if (skip > 0) {
|
|
465
|
+
skip--;
|
|
466
|
+
expected++;
|
|
467
|
+
return true;
|
|
468
|
+
}
|
|
469
|
+
if (out.length > 0 && bytes + line.length + 1 > maxBytes) {
|
|
470
|
+
full = true;
|
|
471
|
+
return false;
|
|
472
|
+
}
|
|
473
|
+
const record = parseRecord(line);
|
|
474
|
+
if (!record || record.o !== expected)
|
|
475
|
+
throw new StreamCorruptError(`Corrupt record at offset ${expected} in ${seg.file}`);
|
|
476
|
+
expected++;
|
|
477
|
+
out.push(record);
|
|
478
|
+
bytes += line.length + 1;
|
|
479
|
+
return out.length < limit;
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
finally {
|
|
483
|
+
closeSync(fd);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
return { records: out, first, truncated };
|
|
487
|
+
}
|
|
488
|
+
ensureIndexed(seg) {
|
|
489
|
+
if (seg.indexed)
|
|
490
|
+
return;
|
|
491
|
+
const fd = openSync(seg.file, 'r');
|
|
492
|
+
try {
|
|
493
|
+
let n = 0;
|
|
494
|
+
const idx = [];
|
|
495
|
+
scanLines(fd, 0, seg.bytes, (_line, pos) => { if (n % INDEX_STRIDE === 0)
|
|
496
|
+
idx.push(pos); n++; });
|
|
497
|
+
if (n !== seg.count)
|
|
498
|
+
throw new StreamCorruptError(`Segment ${seg.file} holds ${n} records, expected ${seg.count}`);
|
|
499
|
+
seg.idx = idx;
|
|
500
|
+
seg.indexed = true;
|
|
501
|
+
}
|
|
502
|
+
finally {
|
|
503
|
+
closeSync(fd);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* Pushes records with `o >= from` to `on`, in batches, now and whenever more are appended. Returns the unsubscribe.
|
|
508
|
+
* Delivery is asynchronous (never from inside `append`). A throwing `on` ends that subscription and goes to `onError`.
|
|
509
|
+
* If `from` is below the first retained record, delivery starts at that record and `onTruncated({from, first})` is
|
|
510
|
+
* called once before the first batch, so the loss of the committed range is observable.
|
|
511
|
+
*/
|
|
512
|
+
subscribe(from, on, options = {}) {
|
|
513
|
+
let next = from;
|
|
514
|
+
let live = true;
|
|
515
|
+
let reported = false;
|
|
516
|
+
const pump = () => {
|
|
517
|
+
if (!live || this.closed)
|
|
518
|
+
return;
|
|
519
|
+
try {
|
|
520
|
+
for (;;) {
|
|
521
|
+
const { records: batch, first, truncated } = this.readFrom(next, SUBSCRIBE_BATCH);
|
|
522
|
+
if (truncated && !reported) {
|
|
523
|
+
reported = true;
|
|
524
|
+
options.onTruncated?.({ from: next, first });
|
|
525
|
+
}
|
|
526
|
+
if (batch.length === 0)
|
|
527
|
+
return;
|
|
528
|
+
next = batch[batch.length - 1].o + 1;
|
|
529
|
+
on(batch);
|
|
530
|
+
if (!live)
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
catch (error) {
|
|
535
|
+
live = false;
|
|
536
|
+
unwatch();
|
|
537
|
+
this.onError?.(error);
|
|
538
|
+
}
|
|
539
|
+
};
|
|
540
|
+
const unwatch = this.watch(pump);
|
|
541
|
+
this.scheduleNotify(pump);
|
|
542
|
+
return () => { live = false; unwatch(); };
|
|
543
|
+
}
|
|
544
|
+
/** Calls `notify` (asynchronously, coalesced) after appends. Returns the unwatch. */
|
|
545
|
+
watch(notify) {
|
|
546
|
+
this.watchers.add(notify);
|
|
547
|
+
return () => { this.watchers.delete(notify); };
|
|
548
|
+
}
|
|
549
|
+
/** The adapter the kit server streams from (`keeper.subscribe`, credit counted in records). */
|
|
550
|
+
asEventSource(options = {}) {
|
|
551
|
+
return {
|
|
552
|
+
read: (from, limit, perRead) => {
|
|
553
|
+
const caps = [options.maxBytes, perRead?.maxBytes].filter((cap) => cap !== undefined);
|
|
554
|
+
const result = this.readFrom(from, limit, { maxBytes: caps.length > 0 ? Math.min(...caps) : undefined });
|
|
555
|
+
if (result.truncated)
|
|
556
|
+
options.onTruncated?.({ from, first: result.first });
|
|
557
|
+
return result.records;
|
|
558
|
+
},
|
|
559
|
+
watch: (notify) => this.watch(notify),
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
scheduleNotify(only) {
|
|
563
|
+
if (only) {
|
|
564
|
+
queueMicrotask(() => { if (!this.closed)
|
|
565
|
+
only(); });
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
if (this.notifyPending || this.watchers.size === 0)
|
|
569
|
+
return;
|
|
570
|
+
this.notifyPending = true;
|
|
571
|
+
queueMicrotask(() => {
|
|
572
|
+
this.notifyPending = false;
|
|
573
|
+
if (this.closed)
|
|
574
|
+
return;
|
|
575
|
+
for (const watcher of [...this.watchers]) {
|
|
576
|
+
try {
|
|
577
|
+
watcher();
|
|
578
|
+
}
|
|
579
|
+
catch (error) {
|
|
580
|
+
this.onError?.(error);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
// ---- commit / checkpoint -----------------------------------------------------------------------------------
|
|
586
|
+
/**
|
|
587
|
+
* Marks records through `through` (inclusive) as committed and deletes the segments that hold nothing else — whole
|
|
588
|
+
* segments only, files are never rewritten. `tail` is the caller's opaque watermark; it is persisted atomically with
|
|
589
|
+
* the truncation and stays readable (`state().committedTail`) after the records are gone. Omitting `tail` keeps the
|
|
590
|
+
* previous one. A `through` at or below the current watermark changes nothing (but a given `tail` is still stored).
|
|
591
|
+
*/
|
|
592
|
+
commit(through, tail) {
|
|
593
|
+
this.assertOpen();
|
|
594
|
+
if (!Number.isSafeInteger(through) || through < -1)
|
|
595
|
+
throw new RangeError(`Invalid commit offset ${through}`);
|
|
596
|
+
if (through > this.nextOffset - 1)
|
|
597
|
+
throw new RangeError(`Commit offset ${through} is beyond the head ${this.nextOffset - 1}`);
|
|
598
|
+
const advance = through > this.committedOffset;
|
|
599
|
+
if (!advance && tail === undefined)
|
|
600
|
+
return;
|
|
601
|
+
this.sync(); // the watermark must never point at records that are not durable
|
|
602
|
+
const committed = advance ? through : this.committedOffset;
|
|
603
|
+
const newTail = tail === undefined ? this.tail : tail;
|
|
604
|
+
atomicWrite(path.join(this.dir, META_FILE), JSON.stringify({ v: 1, committed, tail: newTail, t: this.now() }));
|
|
605
|
+
this.committedOffset = committed;
|
|
606
|
+
this.tail = newTail;
|
|
607
|
+
if (advance)
|
|
608
|
+
this.deleteCommittedSegments();
|
|
609
|
+
}
|
|
610
|
+
/** Atomically stores a checkpoint slot (tmp + rename, fsynced), keeping the latest two. `blob` is opaque. */
|
|
611
|
+
writeCheckpoint(offset, blob) {
|
|
612
|
+
this.assertOpen();
|
|
613
|
+
if (!Number.isSafeInteger(offset) || offset < -1)
|
|
614
|
+
throw new RangeError(`Invalid checkpoint offset ${offset}`);
|
|
615
|
+
const data = typeof blob === 'string' ? Buffer.from(blob, 'utf8') : Buffer.from(blob);
|
|
616
|
+
this.sync(); // a durable checkpoint must never point beyond records that could still be lost
|
|
617
|
+
const seq = this.checkpointSeq + 1;
|
|
618
|
+
const t = this.now();
|
|
619
|
+
atomicWrite(path.join(this.dir, `checkpoint-${String(seq).padStart(12, '0')}.json`), JSON.stringify({ v: 1, seq, offset, t, blob: data.toString('base64') }));
|
|
620
|
+
this.checkpointSeq = seq;
|
|
621
|
+
this.checkpointOffset = offset;
|
|
622
|
+
const files = this.checkpointFiles();
|
|
623
|
+
for (const old of files.slice(0, Math.max(0, files.length - KEPT_CHECKPOINTS)))
|
|
624
|
+
try {
|
|
625
|
+
unlinkSync(old.file);
|
|
626
|
+
}
|
|
627
|
+
catch { /* ignore */ }
|
|
628
|
+
return { seq, offset, t, blob: data };
|
|
629
|
+
}
|
|
630
|
+
/** The newest readable checkpoint (falls back to the previous slot when the newest cannot be parsed). */
|
|
631
|
+
latestCheckpoint() {
|
|
632
|
+
const files = this.checkpointFiles();
|
|
633
|
+
for (let i = files.length - 1; i >= 0; i--) {
|
|
634
|
+
try {
|
|
635
|
+
const value = JSON.parse(readFileSync(files[i].file, 'utf8'));
|
|
636
|
+
if (Number.isSafeInteger(value.seq) && Number.isSafeInteger(value.offset) && typeof value.t === 'number' && typeof value.blob === 'string') {
|
|
637
|
+
return { seq: value.seq, offset: value.offset, t: value.t, blob: Buffer.from(value.blob, 'base64') };
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
catch { /* try the older slot */ }
|
|
641
|
+
}
|
|
642
|
+
return undefined;
|
|
643
|
+
}
|
|
644
|
+
checkpointFiles() {
|
|
645
|
+
return readdirSync(this.dir).map((n) => CHECKPOINT_NAME.exec(n)).filter((m) => !!m)
|
|
646
|
+
.map((m) => ({ seq: Number(m[1]), file: path.join(this.dir, m[0]) })).sort((a, b) => a.seq - b.seq);
|
|
647
|
+
}
|
|
648
|
+
// ---- state / lifecycle -------------------------------------------------------------------------------------
|
|
649
|
+
state() {
|
|
650
|
+
return {
|
|
651
|
+
head: this.nextOffset - 1,
|
|
652
|
+
committed: this.committedOffset,
|
|
653
|
+
committedTail: this.tail,
|
|
654
|
+
overQuota: this.totalBytes > this.softQuota,
|
|
655
|
+
checkpointOffset: this.checkpointOffset,
|
|
656
|
+
first: this.segments[0]?.first ?? this.nextOffset,
|
|
657
|
+
next: this.nextOffset,
|
|
658
|
+
bytes: this.totalBytes,
|
|
659
|
+
segments: this.segments.length,
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
/** Counters for tests and diagnostics. */
|
|
663
|
+
stats() {
|
|
664
|
+
return { fsyncs: this.syncCount, unsyncedBytes: this.appendedBytes - this.syncedBytes, syncErrors: this.syncErrors, lastSyncError: this.lastSyncError };
|
|
665
|
+
}
|
|
666
|
+
/** Flushes, closes the files and releases the directory. Idempotent. */
|
|
667
|
+
close() {
|
|
668
|
+
if (this.closed)
|
|
669
|
+
return;
|
|
670
|
+
try {
|
|
671
|
+
this.sync();
|
|
672
|
+
}
|
|
673
|
+
finally {
|
|
674
|
+
this.closed = true;
|
|
675
|
+
this.closeActive();
|
|
676
|
+
this.watchers.clear();
|
|
677
|
+
openStreams.delete(this.dir);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
closeActive() {
|
|
681
|
+
if (this.active) {
|
|
682
|
+
this.retireFd(this.active.fd);
|
|
683
|
+
this.active = undefined;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
assertOpen() { if (this.closed)
|
|
687
|
+
throw new Error('Stream log is closed'); }
|
|
688
|
+
}
|
|
689
|
+
function fstatSizeOf(file) {
|
|
690
|
+
const fd = openSync(file, 'r');
|
|
691
|
+
try {
|
|
692
|
+
return fstatSync(fd).size;
|
|
693
|
+
}
|
|
694
|
+
finally {
|
|
695
|
+
closeSync(fd);
|
|
696
|
+
}
|
|
697
|
+
}
|