@nimbus-sh/worker 0.1.0
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 +219 -0
- package/dist/_shared/bytes.d.ts +18 -0
- package/dist/_shared/bytes.d.ts.map +1 -0
- package/dist/_shared/bytes.js +17 -0
- package/dist/_shared/cache-stats.d.ts +146 -0
- package/dist/_shared/cache-stats.d.ts.map +1 -0
- package/dist/_shared/cache-stats.js +162 -0
- package/dist/_shared/exports-resolver.d.ts +76 -0
- package/dist/_shared/exports-resolver.d.ts.map +1 -0
- package/dist/_shared/exports-resolver.js +284 -0
- package/dist/_shared/install-phase.d.ts +47 -0
- package/dist/_shared/install-phase.d.ts.map +1 -0
- package/dist/_shared/install-phase.js +1 -0
- package/dist/_shared/real-node-imports.d.ts +38 -0
- package/dist/_shared/real-node-imports.d.ts.map +1 -0
- package/dist/_shared/real-node-imports.js +46 -0
- package/dist/_shared/retry.d.ts +93 -0
- package/dist/_shared/retry.d.ts.map +1 -0
- package/dist/_shared/retry.js +161 -0
- package/dist/_shared/session-id.d.ts +39 -0
- package/dist/_shared/session-id.d.ts.map +1 -0
- package/dist/_shared/session-id.js +127 -0
- package/dist/_shared/session-router.d.ts +104 -0
- package/dist/_shared/session-router.d.ts.map +1 -0
- package/dist/_shared/session-router.js +165 -0
- package/dist/_shared/w7-frame.d.ts +93 -0
- package/dist/_shared/w7-frame.d.ts.map +1 -0
- package/dist/_shared/w7-frame.js +402 -0
- package/dist/auth/index.d.ts +16 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +15 -0
- package/dist/auth/middleware.d.ts +85 -0
- package/dist/auth/middleware.d.ts.map +1 -0
- package/dist/auth/middleware.js +150 -0
- package/dist/auth/token.d.ts +96 -0
- package/dist/auth/token.d.ts.map +1 -0
- package/dist/auth/token.js +226 -0
- package/dist/auth/types.d.ts +141 -0
- package/dist/auth/types.d.ts.map +1 -0
- package/dist/auth/types.js +114 -0
- package/dist/bindings/d1.d.ts +102 -0
- package/dist/bindings/d1.d.ts.map +1 -0
- package/dist/bindings/d1.js +549 -0
- package/dist/bindings/kv.d.ts +82 -0
- package/dist/bindings/kv.d.ts.map +1 -0
- package/dist/bindings/kv.js +290 -0
- package/dist/bindings/r2.d.ts +137 -0
- package/dist/bindings/r2.d.ts.map +1 -0
- package/dist/bindings/r2.js +379 -0
- package/dist/cirrus-npm-cjs.generated.d.ts +17 -0
- package/dist/cirrus-npm-cjs.generated.d.ts.map +1 -0
- package/dist/cirrus-npm-cjs.generated.js +41 -0
- package/dist/cirrus-plugin-react.generated.d.ts +34 -0
- package/dist/cirrus-plugin-react.generated.d.ts.map +1 -0
- package/dist/cirrus-plugin-react.generated.js +33 -0
- package/dist/constants.d.ts +34 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +116 -0
- package/dist/esbuild-wasm-bundle.generated.d.ts +41 -0
- package/dist/esbuild-wasm-bundle.generated.d.ts.map +1 -0
- package/dist/esbuild-wasm-bundle.generated.js +40 -0
- package/dist/facets/cirrus-real.d.ts +147 -0
- package/dist/facets/cirrus-real.d.ts.map +1 -0
- package/dist/facets/cirrus-real.js +981 -0
- package/dist/facets/inner-do-registry.d.ts +41 -0
- package/dist/facets/inner-do-registry.d.ts.map +1 -0
- package/dist/facets/inner-do-registry.js +51 -0
- package/dist/facets/manager.d.ts +314 -0
- package/dist/facets/manager.d.ts.map +1 -0
- package/dist/facets/manager.js +2681 -0
- package/dist/facets/process.d.ts +318 -0
- package/dist/facets/process.d.ts.map +1 -0
- package/dist/facets/process.js +567 -0
- package/dist/facets/real-vite-fs-shim.d.ts +151 -0
- package/dist/facets/real-vite-fs-shim.d.ts.map +1 -0
- package/dist/facets/real-vite-fs-shim.js +1279 -0
- package/dist/facets/real-vite-hmr.d.ts +142 -0
- package/dist/facets/real-vite-hmr.d.ts.map +1 -0
- package/dist/facets/real-vite-hmr.js +476 -0
- package/dist/facets/vite-dev-server.d.ts +293 -0
- package/dist/facets/vite-dev-server.d.ts.map +1 -0
- package/dist/facets/vite-dev-server.js +2580 -0
- package/dist/facets/wasm-swap-registry.d.ts +261 -0
- package/dist/facets/wasm-swap-registry.d.ts.map +1 -0
- package/dist/facets/wasm-swap-registry.js +551 -0
- package/dist/facets/ws-terminal.d.ts +107 -0
- package/dist/facets/ws-terminal.d.ts.map +1 -0
- package/dist/facets/ws-terminal.js +207 -0
- package/dist/git/commands.d.ts +13 -0
- package/dist/git/commands.d.ts.map +1 -0
- package/dist/git/commands.js +633 -0
- package/dist/git/network-facet.d.ts +61 -0
- package/dist/git/network-facet.d.ts.map +1 -0
- package/dist/git/network-facet.js +700 -0
- package/dist/git-bundle.generated.d.ts +16 -0
- package/dist/git-bundle.generated.d.ts.map +1 -0
- package/dist/git-bundle.generated.js +15 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +73 -0
- package/dist/loaders/child-process/spawn-facet.d.ts +90 -0
- package/dist/loaders/child-process/spawn-facet.d.ts.map +1 -0
- package/dist/loaders/child-process/spawn-facet.js +103 -0
- package/dist/loaders/child-process/spawn-pool.d.ts +110 -0
- package/dist/loaders/child-process/spawn-pool.d.ts.map +1 -0
- package/dist/loaders/child-process/spawn-pool.js +161 -0
- package/dist/loaders/fanout-pool.d.ts +195 -0
- package/dist/loaders/fanout-pool.d.ts.map +1 -0
- package/dist/loaders/fanout-pool.js +326 -0
- package/dist/loaders/generated-workers.d.ts +26 -0
- package/dist/loaders/generated-workers.d.ts.map +1 -0
- package/dist/loaders/generated-workers.js +25 -0
- package/dist/loaders/index.d.ts +15 -0
- package/dist/loaders/index.d.ts.map +1 -0
- package/dist/loaders/index.js +13 -0
- package/dist/loaders/loader-pool.d.ts +273 -0
- package/dist/loaders/loader-pool.d.ts.map +1 -0
- package/dist/loaders/loader-pool.js +624 -0
- package/dist/loaders/npm-resolve-preamble.d.ts +34 -0
- package/dist/loaders/npm-resolve-preamble.d.ts.map +1 -0
- package/dist/loaders/npm-resolve-preamble.js +237 -0
- package/dist/loaders/pre-bundle-preamble.d.ts +41 -0
- package/dist/loaders/pre-bundle-preamble.d.ts.map +1 -0
- package/dist/loaders/pre-bundle-preamble.js +114 -0
- package/dist/loaders/vendor/codegen.d.ts +24 -0
- package/dist/loaders/vendor/codegen.d.ts.map +1 -0
- package/dist/loaders/vendor/codegen.js +44 -0
- package/dist/loaders/vendor/errors.d.ts +24 -0
- package/dist/loaders/vendor/errors.d.ts.map +1 -0
- package/dist/loaders/vendor/errors.js +46 -0
- package/dist/loaders/vendor/index.d.ts +16 -0
- package/dist/loaders/vendor/index.d.ts.map +1 -0
- package/dist/loaders/vendor/index.js +11 -0
- package/dist/loaders/vendor/pool.d.ts +88 -0
- package/dist/loaders/vendor/pool.d.ts.map +1 -0
- package/dist/loaders/vendor/pool.js +353 -0
- package/dist/loaders/vendor/primitives.d.ts +11 -0
- package/dist/loaders/vendor/primitives.d.ts.map +1 -0
- package/dist/loaders/vendor/primitives.js +33 -0
- package/dist/loaders/vendor/serialize.d.ts +3 -0
- package/dist/loaders/vendor/serialize.d.ts.map +1 -0
- package/dist/loaders/vendor/serialize.js +25 -0
- package/dist/loaders/vendor/types.d.ts +60 -0
- package/dist/loaders/vendor/types.d.ts.map +1 -0
- package/dist/loaders/vendor/types.js +4 -0
- package/dist/npm/cache.d.ts +136 -0
- package/dist/npm/cache.d.ts.map +1 -0
- package/dist/npm/cache.js +429 -0
- package/dist/npm/install-batch-facet.d.ts +129 -0
- package/dist/npm/install-batch-facet.d.ts.map +1 -0
- package/dist/npm/install-batch-facet.js +598 -0
- package/dist/npm/install-facet.d.ts +29 -0
- package/dist/npm/install-facet.d.ts.map +1 -0
- package/dist/npm/install-facet.js +9 -0
- package/dist/npm/installer.d.ts +239 -0
- package/dist/npm/installer.d.ts.map +1 -0
- package/dist/npm/installer.js +2022 -0
- package/dist/npm/npx-install.d.ts +74 -0
- package/dist/npm/npx-install.d.ts.map +1 -0
- package/dist/npm/npx-install.js +302 -0
- package/dist/npm/pre-bundle-facet.d.ts +168 -0
- package/dist/npm/pre-bundle-facet.d.ts.map +1 -0
- package/dist/npm/pre-bundle-facet.js +653 -0
- package/dist/npm/r2-cache.d.ts +286 -0
- package/dist/npm/r2-cache.d.ts.map +1 -0
- package/dist/npm/r2-cache.js +506 -0
- package/dist/npm/resolve-facet.d.ts +202 -0
- package/dist/npm/resolve-facet.d.ts.map +1 -0
- package/dist/npm/resolve-facet.js +833 -0
- package/dist/npm/resolve-one-facet.d.ts +204 -0
- package/dist/npm/resolve-one-facet.d.ts.map +1 -0
- package/dist/npm/resolve-one-facet.js +437 -0
- package/dist/npm/resolver.d.ts +109 -0
- package/dist/npm/resolver.d.ts.map +1 -0
- package/dist/npm/resolver.js +899 -0
- package/dist/npm/tarball-stream.d.ts +85 -0
- package/dist/npm/tarball-stream.d.ts.map +1 -0
- package/dist/npm/tarball-stream.js +205 -0
- package/dist/npm/tarball.d.ts +51 -0
- package/dist/npm/tarball.d.ts.map +1 -0
- package/dist/npm/tarball.js +185 -0
- package/dist/observability/diag-counters.d.ts +292 -0
- package/dist/observability/diag-counters.d.ts.map +1 -0
- package/dist/observability/diag-counters.js +302 -0
- package/dist/observability/heap-estimate.d.ts +166 -0
- package/dist/observability/heap-estimate.d.ts.map +1 -0
- package/dist/observability/heap-estimate.js +192 -0
- package/dist/observability/heavy-alloc-coord.d.ts +95 -0
- package/dist/observability/heavy-alloc-coord.d.ts.map +1 -0
- package/dist/observability/heavy-alloc-coord.js +149 -0
- package/dist/observability/oom-classify.d.ts +56 -0
- package/dist/observability/oom-classify.d.ts.map +1 -0
- package/dist/observability/oom-classify.js +118 -0
- package/dist/observability/oom-discriminator.d.ts +192 -0
- package/dist/observability/oom-discriminator.d.ts.map +1 -0
- package/dist/observability/oom-discriminator.js +233 -0
- package/dist/real-vite-bundle.generated.d.ts +46 -0
- package/dist/real-vite-bundle.generated.d.ts.map +1 -0
- package/dist/real-vite-bundle.generated.js +53 -0
- package/dist/replica/routing.d.ts +171 -0
- package/dist/replica/routing.d.ts.map +1 -0
- package/dist/replica/routing.js +288 -0
- package/dist/replica/suspension.d.ts +36 -0
- package/dist/replica/suspension.d.ts.map +1 -0
- package/dist/replica/suspension.js +49 -0
- package/dist/router/index.d.ts +156 -0
- package/dist/router/index.d.ts.map +1 -0
- package/dist/router/index.js +220 -0
- package/dist/runtime/assets-loader.d.ts +71 -0
- package/dist/runtime/assets-loader.d.ts.map +1 -0
- package/dist/runtime/assets-loader.js +128 -0
- package/dist/runtime/barrel-detect.d.ts +46 -0
- package/dist/runtime/barrel-detect.d.ts.map +1 -0
- package/dist/runtime/barrel-detect.js +73 -0
- package/dist/runtime/barrel-synthesizer.d.ts +181 -0
- package/dist/runtime/barrel-synthesizer.d.ts.map +1 -0
- package/dist/runtime/barrel-synthesizer.js +541 -0
- package/dist/runtime/bun-repl.d.ts +46 -0
- package/dist/runtime/bun-repl.d.ts.map +1 -0
- package/dist/runtime/bun-repl.js +326 -0
- package/dist/runtime/bun-runner.d.ts +66 -0
- package/dist/runtime/bun-runner.d.ts.map +1 -0
- package/dist/runtime/bun-runner.js +219 -0
- package/dist/runtime/clang-runner.d.ts +38 -0
- package/dist/runtime/clang-runner.d.ts.map +1 -0
- package/dist/runtime/clang-runner.js +1033 -0
- package/dist/runtime/comment-strip.d.ts +37 -0
- package/dist/runtime/comment-strip.d.ts.map +1 -0
- package/dist/runtime/comment-strip.js +74 -0
- package/dist/runtime/esbuild-service.d.ts +201 -0
- package/dist/runtime/esbuild-service.d.ts.map +1 -0
- package/dist/runtime/esbuild-service.js +1444 -0
- package/dist/runtime/esbuild-wasm-bytes.d.ts +87 -0
- package/dist/runtime/esbuild-wasm-bytes.d.ts.map +1 -0
- package/dist/runtime/esbuild-wasm-bytes.js +131 -0
- package/dist/runtime/framework-detect.d.ts +71 -0
- package/dist/runtime/framework-detect.d.ts.map +1 -0
- package/dist/runtime/framework-detect.js +178 -0
- package/dist/runtime/long-running-handle.d.ts +133 -0
- package/dist/runtime/long-running-handle.d.ts.map +1 -0
- package/dist/runtime/long-running-handle.js +192 -0
- package/dist/runtime/node-repl.d.ts +36 -0
- package/dist/runtime/node-repl.d.ts.map +1 -0
- package/dist/runtime/node-repl.js +293 -0
- package/dist/runtime/node-runner.d.ts +111 -0
- package/dist/runtime/node-runner.d.ts.map +1 -0
- package/dist/runtime/node-runner.js +194 -0
- package/dist/runtime/node-shims.d.ts +24 -0
- package/dist/runtime/node-shims.d.ts.map +1 -0
- package/dist/runtime/node-shims.js +3019 -0
- package/dist/runtime/package-manager.d.ts +118 -0
- package/dist/runtime/package-manager.d.ts.map +1 -0
- package/dist/runtime/package-manager.js +420 -0
- package/dist/runtime/port-registry.d.ts +80 -0
- package/dist/runtime/port-registry.d.ts.map +1 -0
- package/dist/runtime/port-registry.js +166 -0
- package/dist/runtime/process-logs-api.d.ts +102 -0
- package/dist/runtime/process-logs-api.d.ts.map +1 -0
- package/dist/runtime/process-logs-api.js +253 -0
- package/dist/runtime/process-logs.d.ts +311 -0
- package/dist/runtime/process-logs.d.ts.map +1 -0
- package/dist/runtime/process-logs.js +656 -0
- package/dist/runtime/process-table.d.ts +65 -0
- package/dist/runtime/process-table.d.ts.map +1 -0
- package/dist/runtime/process-table.js +107 -0
- package/dist/runtime/project-detect.d.ts +21 -0
- package/dist/runtime/project-detect.d.ts.map +1 -0
- package/dist/runtime/project-detect.js +46 -0
- package/dist/runtime/python-repl.d.ts +66 -0
- package/dist/runtime/python-repl.d.ts.map +1 -0
- package/dist/runtime/python-repl.js +563 -0
- package/dist/runtime/python-runner.d.ts +50 -0
- package/dist/runtime/python-runner.d.ts.map +1 -0
- package/dist/runtime/python-runner.js +870 -0
- package/dist/runtime/repl-session.d.ts +160 -0
- package/dist/runtime/repl-session.d.ts.map +1 -0
- package/dist/runtime/repl-session.js +393 -0
- package/dist/runtime/require-resolver.d.ts +52 -0
- package/dist/runtime/require-resolver.d.ts.map +1 -0
- package/dist/runtime/require-resolver.js +690 -0
- package/dist/runtime/router-basename.d.ts +36 -0
- package/dist/runtime/router-basename.d.ts.map +1 -0
- package/dist/runtime/router-basename.js +310 -0
- package/dist/runtime/ruby-repl.d.ts +43 -0
- package/dist/runtime/ruby-repl.d.ts.map +1 -0
- package/dist/runtime/ruby-repl.js +229 -0
- package/dist/runtime/ruby-runner.d.ts +62 -0
- package/dist/runtime/ruby-runner.d.ts.map +1 -0
- package/dist/runtime/ruby-runner.js +737 -0
- package/dist/runtime/runtime-catalog.d.ts +99 -0
- package/dist/runtime/runtime-catalog.d.ts.map +1 -0
- package/dist/runtime/runtime-catalog.js +183 -0
- package/dist/runtime/runtime-registry.d.ts +136 -0
- package/dist/runtime/runtime-registry.d.ts.map +1 -0
- package/dist/runtime/runtime-registry.js +318 -0
- package/dist/runtime/streams.d.ts +13 -0
- package/dist/runtime/streams.d.ts.map +1 -0
- package/dist/runtime/streams.js +402 -0
- package/dist/runtime/wasi-instance.d.ts +190 -0
- package/dist/runtime/wasi-instance.d.ts.map +1 -0
- package/dist/runtime/wasi-instance.js +2044 -0
- package/dist/runtime/wasm-runner.d.ts +117 -0
- package/dist/runtime/wasm-runner.d.ts.map +1 -0
- package/dist/runtime/wasm-runner.js +705 -0
- package/dist/session/bindings.d.ts +168 -0
- package/dist/session/bindings.d.ts.map +1 -0
- package/dist/session/bindings.js +524 -0
- package/dist/session/ctx-exports.d.ts +16 -0
- package/dist/session/ctx-exports.d.ts.map +1 -0
- package/dist/session/ctx-exports.js +22 -0
- package/dist/session/diag.d.ts +62 -0
- package/dist/session/diag.d.ts.map +1 -0
- package/dist/session/diag.js +118 -0
- package/dist/session/fs-watch.d.ts +132 -0
- package/dist/session/fs-watch.d.ts.map +1 -0
- package/dist/session/fs-watch.js +255 -0
- package/dist/session/helpers.d.ts +150 -0
- package/dist/session/helpers.d.ts.map +1 -0
- package/dist/session/helpers.js +472 -0
- package/dist/session/hibernation.d.ts +145 -0
- package/dist/session/hibernation.d.ts.map +1 -0
- package/dist/session/hibernation.js +385 -0
- package/dist/session/init-phases.d.ts +87 -0
- package/dist/session/init-phases.d.ts.map +1 -0
- package/dist/session/init-phases.js +137 -0
- package/dist/session/init.d.ts +46 -0
- package/dist/session/init.d.ts.map +1 -0
- package/dist/session/init.js +3579 -0
- package/dist/session/keys.d.ts +56 -0
- package/dist/session/keys.d.ts.map +1 -0
- package/dist/session/keys.js +55 -0
- package/dist/session/nimbus-session.d.ts +434 -0
- package/dist/session/nimbus-session.d.ts.map +1 -0
- package/dist/session/nimbus-session.js +1084 -0
- package/dist/session/programmatic.d.ts +105 -0
- package/dist/session/programmatic.d.ts.map +1 -0
- package/dist/session/programmatic.js +326 -0
- package/dist/session/replica-routes.d.ts +68 -0
- package/dist/session/replica-routes.d.ts.map +1 -0
- package/dist/session/replica-routes.js +77 -0
- package/dist/session/routes.d.ts +29 -0
- package/dist/session/routes.d.ts.map +1 -0
- package/dist/session/routes.js +1331 -0
- package/dist/session/rpc.d.ts +323 -0
- package/dist/session/rpc.d.ts.map +1 -0
- package/dist/session/rpc.js +808 -0
- package/dist/session/state-store.d.ts +210 -0
- package/dist/session/state-store.d.ts.map +1 -0
- package/dist/session/state-store.js +459 -0
- package/dist/session/supervisor-rpc.d.ts +269 -0
- package/dist/session/supervisor-rpc.d.ts.map +1 -0
- package/dist/session/supervisor-rpc.js +437 -0
- package/dist/session/ws-hibernation-config.d.ts +64 -0
- package/dist/session/ws-hibernation-config.d.ts.map +1 -0
- package/dist/session/ws-hibernation-config.js +89 -0
- package/dist/session/ws.d.ts +95 -0
- package/dist/session/ws.d.ts.map +1 -0
- package/dist/session/ws.js +395 -0
- package/dist/shell/features.d.ts +410 -0
- package/dist/shell/features.d.ts.map +1 -0
- package/dist/shell/features.js +1885 -0
- package/dist/shell/unix-commands.d.ts +15 -0
- package/dist/shell/unix-commands.d.ts.map +1 -0
- package/dist/shell/unix-commands.js +3703 -0
- package/dist/stubs/lifo-ui.d.ts +10 -0
- package/dist/stubs/lifo-ui.d.ts.map +1 -0
- package/dist/stubs/lifo-ui.js +1 -0
- package/dist/tailwind-play.generated.d.ts +19 -0
- package/dist/tailwind-play.generated.d.ts.map +1 -0
- package/dist/tailwind-play.generated.js +18 -0
- package/dist/vfs/dev-provider.d.ts +64 -0
- package/dist/vfs/dev-provider.d.ts.map +1 -0
- package/dist/vfs/dev-provider.js +191 -0
- package/dist/vfs/events.d.ts +51 -0
- package/dist/vfs/events.d.ts.map +1 -0
- package/dist/vfs/events.js +127 -0
- package/dist/vfs/path.d.ts +30 -0
- package/dist/vfs/path.d.ts.map +1 -0
- package/dist/vfs/path.js +41 -0
- package/dist/vfs/seed-project.d.ts +62 -0
- package/dist/vfs/seed-project.d.ts.map +1 -0
- package/dist/vfs/seed-project.js +1045 -0
- package/dist/vfs/sqlite-vfs.d.ts +435 -0
- package/dist/vfs/sqlite-vfs.d.ts.map +1 -0
- package/dist/vfs/sqlite-vfs.js +1670 -0
- package/dist/vfs/symlink-registry.d.ts +64 -0
- package/dist/vfs/symlink-registry.d.ts.map +1 -0
- package/dist/vfs/symlink-registry.js +137 -0
- package/dist/wrangler/nimbus-wrangler.d.ts +157 -0
- package/dist/wrangler/nimbus-wrangler.d.ts.map +1 -0
- package/dist/wrangler/nimbus-wrangler.js +931 -0
- package/package.json +77 -0
- package/public/_assets/cirrus-plugin-react.bundle.js +70740 -0
- package/public/_assets/esbuild-0.24.2.wasm +0 -0
- package/public/_assets/real-vite-bundle.js +101478 -0
- package/public/_assets/rollup.wasm +0 -0
- package/public/favicon.svg +8 -0
- package/public/index.html +565 -0
- package/public/s/index.html +2379 -0
- package/scripts/bundle-esbuild-wasm.mjs +244 -0
- package/scripts/bundle-facet-workers.mjs +137 -0
- package/scripts/bundle-git.mjs +118 -0
- package/scripts/bundle-npm-cjs.mjs +363 -0
- package/scripts/bundle-plugin-react.mjs +480 -0
- package/scripts/bundle-real-vite.mjs +959 -0
- package/scripts/bundle-runtime.mjs +600 -0
- package/scripts/bundle-tailwind-play.mjs +130 -0
- package/scripts/patch-lifo-ui.mjs +131 -0
|
@@ -0,0 +1,1670 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SqliteVFS — Demand-paged virtual filesystem on DO SQLite.
|
|
3
|
+
*
|
|
4
|
+
* Architecture (from webcontainer-v2-feasibility.md §4):
|
|
5
|
+
*
|
|
6
|
+
* ┌─────────────────────────────────────────┐
|
|
7
|
+
* │ Nimbus VFS (in-memory) │
|
|
8
|
+
* │ INode tree: always-resident metadata │ ~10-20 MB for 50K files
|
|
9
|
+
* │ ContentCache: LRU file content cache │ ~32 MB (512 × 64KB)
|
|
10
|
+
* │ ─────────────────────────────────────── │
|
|
11
|
+
* │ On cache miss → SQLite read │
|
|
12
|
+
* │ On eviction → SQLite write (if dirty) │
|
|
13
|
+
* │ On npm install → batch SQLite writes │
|
|
14
|
+
* └─────────────────────────────────────────┘
|
|
15
|
+
* │ │
|
|
16
|
+
* ▼ ▼
|
|
17
|
+
* ┌─────────────────┐ ┌─────────────────────┐
|
|
18
|
+
* │ file_chunks │ │ inodes │
|
|
19
|
+
* │ (path, chunk_id,│ │ (path, type, mode, │
|
|
20
|
+
* │ data BLOB) │ │ size, mtime, ...) │
|
|
21
|
+
* │ 64KB chunks │ │ │
|
|
22
|
+
* └─────────────────┘ └─────────────────────┘
|
|
23
|
+
* DO SQLite (10 GB)
|
|
24
|
+
*
|
|
25
|
+
* Key design from do86's SqlPageStore:
|
|
26
|
+
* - LRU eviction with dirty-write-back
|
|
27
|
+
* - Microtask-deferred batch writes (64 rows per INSERT)
|
|
28
|
+
* - All operations SYNCHRONOUS (DO sql.exec() is sync)
|
|
29
|
+
*
|
|
30
|
+
* Durability (audit C1):
|
|
31
|
+
* - writeFile() returns void (sync) — preserved to match LIFO's
|
|
32
|
+
* MountProvider.writeFile(subpath, content): void contract.
|
|
33
|
+
* - Deferred-flush failures (from transactionSync or individual row
|
|
34
|
+
* inserts) are retried ONCE without a transaction wrapper. Entries
|
|
35
|
+
* that fail both attempts land in failedWrites and are surfaced to
|
|
36
|
+
* subscribers via onWriteError(). flushAll() throws if any failed
|
|
37
|
+
* writes accumulated since last clearWriteFailures().
|
|
38
|
+
* - Callers that need a hard guarantee can use flushAndWait() (async)
|
|
39
|
+
* at explicit persistence boundaries.
|
|
40
|
+
*
|
|
41
|
+
* Key design decisions:
|
|
42
|
+
* - 64KB chunks (not 4KB): file access is sequential, fewer rows
|
|
43
|
+
* - INode metadata always in memory (small: ~200B per file)
|
|
44
|
+
* - File content demand-paged through LRU cache
|
|
45
|
+
*/
|
|
46
|
+
import { VfsEventEmitter } from './events.js';
|
|
47
|
+
import { CHUNK_SIZE, LRU_MAX_ENTRIES } from '../constants.js';
|
|
48
|
+
import { recordFailure } from '../observability/oom-discriminator.js';
|
|
49
|
+
import { classifyError } from '../observability/oom-classify.js';
|
|
50
|
+
import { enc, dec } from '../_shared/bytes.js';
|
|
51
|
+
// ── SqliteVFS ───────────────────────────────────────────────────────────────
|
|
52
|
+
export class SqliteVFS {
|
|
53
|
+
sql;
|
|
54
|
+
ctx;
|
|
55
|
+
events;
|
|
56
|
+
// ── INode tree (always resident) ──────────────────────────────────────
|
|
57
|
+
inodes = new Map();
|
|
58
|
+
/** Children index: parentPath → Set of child paths. O(1) readdir. */
|
|
59
|
+
children = new Map();
|
|
60
|
+
// ── Content cache (LRU, 512 × 64KB = 32MB) ───────────────────────────
|
|
61
|
+
// Map iteration order = insertion order. Delete+re-insert to move to MRU.
|
|
62
|
+
cache = new Map();
|
|
63
|
+
/** Actual bytes in cache (not all chunks are full 64KB) */
|
|
64
|
+
_cacheBytes = 0;
|
|
65
|
+
// ── W5 Lever 8: runtime-mutable LRU cap + shrink refcount ─────────
|
|
66
|
+
// Default seeded from LRU_MAX_ENTRIES (32 MiB). Heavy-alloc owners
|
|
67
|
+
// (npm install, git clone, pre-bundle) call shrinkForInstall() to
|
|
68
|
+
// drop the cap to ~8 MiB and free heap headroom for the in-flight
|
|
69
|
+
// RPC payloads + pending-writes queue. Refcount-based: nested
|
|
70
|
+
// acquires stack; only the OUTERMOST restoreAfterInstall() actually
|
|
71
|
+
// raises the cap back to the default.
|
|
72
|
+
//
|
|
73
|
+
// Why instance-level (not module-level):
|
|
74
|
+
// - Tests need an in-memory VFS without polluting the constant.
|
|
75
|
+
// - Future per-DO tuning (e.g. set higher cap on a session running
|
|
76
|
+
// `vite build` vs `npm install`) becomes a one-call change.
|
|
77
|
+
//
|
|
78
|
+
// The eviction trigger at cacheSet() reads this field. Counter
|
|
79
|
+
// accounting is unchanged.
|
|
80
|
+
_lruMaxEntries = LRU_MAX_ENTRIES;
|
|
81
|
+
_lruShrinkRefcount = 0;
|
|
82
|
+
// ── Running counters for O(1) getStats() (B3 / AUDIT M10 / M-S8) ──
|
|
83
|
+
// Replaces the triple scan of this.inodes on every /api/stats poll.
|
|
84
|
+
// Bootstrapped in loadInodes(); maintained at every mutator entry:
|
|
85
|
+
// mkdir/rmdir/writeFile/unlink/writeBatch (rename is a no-op —
|
|
86
|
+
// same inode, new path). Invariant: these match a fresh O(N)
|
|
87
|
+
// walk of this.inodes. Unit-tested in the A5/B3 runtime tests.
|
|
88
|
+
_totalFiles = 0;
|
|
89
|
+
_totalDirs = 0;
|
|
90
|
+
_usedBytes = 0;
|
|
91
|
+
// ── Deferred write queue (do86 pattern) ───────────────────────────────
|
|
92
|
+
pendingWrites = new Map();
|
|
93
|
+
/**
|
|
94
|
+
* Sum of `data.length` across pendingWrites entries. Maintained
|
|
95
|
+
* in lockstep with the Map by every code path that mutates it
|
|
96
|
+
* (deferWrite/clearPendingWritesForPath/flushPendingWrites/
|
|
97
|
+
* cleanupAfterDelete). Read by getStats() so /api/_diag/memory's
|
|
98
|
+
* `heap.breakdown.vfsInFlightBytes` is no longer a hardcoded 0.
|
|
99
|
+
* (N3, heap-correctness wave.)
|
|
100
|
+
*/
|
|
101
|
+
_pendingWriteBytes = 0;
|
|
102
|
+
/**
|
|
103
|
+
* N2 (heap-correctness wave). Sum of bytes currently held in the
|
|
104
|
+
* `chunks: []` spool inside writeStream(). Maintained per-chunk so
|
|
105
|
+
* a long-running drain shows live, not the steady-state 0.
|
|
106
|
+
* Reset (or decremented to the drained amount) inside writeStream's
|
|
107
|
+
* finally block, after transactionSync has either consumed the
|
|
108
|
+
* bytes or thrown.
|
|
109
|
+
*
|
|
110
|
+
* Sums into `heap.breakdown.vfsInFlightBytes` alongside
|
|
111
|
+
* _pendingWriteBytes so a single value reflects ALL transient
|
|
112
|
+
* write-bytes the supervisor is holding.
|
|
113
|
+
*/
|
|
114
|
+
_writeStreamSpoolBytes = 0;
|
|
115
|
+
writeFlushScheduled = false;
|
|
116
|
+
// ── Write-failure tracking (audit C1) ─────────────────────────────────
|
|
117
|
+
// When a deferred flush fails, the entry lands here so (a) flushAll()
|
|
118
|
+
// can throw with accurate context on the next forced flush, (b) the
|
|
119
|
+
// supervisor can surface the error to the user's terminal via the
|
|
120
|
+
// onWriteError subscription, and (c) flushAndWait() can report.
|
|
121
|
+
//
|
|
122
|
+
// Keys use cacheKey(path, chunkId). Retry count bounds backoff to one
|
|
123
|
+
// additional attempt (per audit recommendation: "re-queue on transient
|
|
124
|
+
// SQL errors once"); entries that fail twice are considered lost.
|
|
125
|
+
//
|
|
126
|
+
// NOTE: we intentionally do NOT keep the chunk bytes on this record.
|
|
127
|
+
// The retry happens inline inside flushPendingWrites — nothing
|
|
128
|
+
// re-reads the bytes after that. Storing them would turn a bad
|
|
129
|
+
// session into a multi-MB leak (64 KB per chunk) inside a DO with
|
|
130
|
+
// a ~128 MB isolate cap.
|
|
131
|
+
failedWrites = new Map();
|
|
132
|
+
writeErrorHandlers = new Set();
|
|
133
|
+
_writeFailures = 0;
|
|
134
|
+
// ── Stats ─────────────────────────────────────────────────────────────
|
|
135
|
+
_cacheHits = 0;
|
|
136
|
+
_cacheMisses = 0;
|
|
137
|
+
_evictions = 0;
|
|
138
|
+
_sqlReads = 0;
|
|
139
|
+
_sqlWrites = 0;
|
|
140
|
+
_batchWrites = 0;
|
|
141
|
+
_batchWriteRows = 0;
|
|
142
|
+
constructor(sql, ctx) {
|
|
143
|
+
this.sql = sql;
|
|
144
|
+
this.ctx = ctx;
|
|
145
|
+
this.events = new VfsEventEmitter();
|
|
146
|
+
this.initSchema();
|
|
147
|
+
this.loadInodes();
|
|
148
|
+
}
|
|
149
|
+
// ── Schema ────────────────────────────────────────────────────────────
|
|
150
|
+
initSchema() {
|
|
151
|
+
// Migrate from legacy fs_objects table FIRST (before creating new tables)
|
|
152
|
+
this.migrateFromLegacy();
|
|
153
|
+
this.sql.exec(`CREATE TABLE IF NOT EXISTS inodes (
|
|
154
|
+
path TEXT PRIMARY KEY,
|
|
155
|
+
parent_path TEXT NOT NULL DEFAULT '',
|
|
156
|
+
is_dir INTEGER NOT NULL DEFAULT 0,
|
|
157
|
+
size INTEGER NOT NULL DEFAULT 0,
|
|
158
|
+
mtime INTEGER NOT NULL DEFAULT 0,
|
|
159
|
+
mode INTEGER NOT NULL DEFAULT 0,
|
|
160
|
+
chunk_count INTEGER NOT NULL DEFAULT 0
|
|
161
|
+
)`);
|
|
162
|
+
this.sql.exec(`CREATE INDEX IF NOT EXISTS idx_inodes_parent ON inodes(parent_path)`);
|
|
163
|
+
this.sql.exec(`CREATE TABLE IF NOT EXISTS file_chunks (
|
|
164
|
+
path TEXT NOT NULL,
|
|
165
|
+
chunk_id INTEGER NOT NULL,
|
|
166
|
+
data BLOB NOT NULL,
|
|
167
|
+
PRIMARY KEY (path, chunk_id)
|
|
168
|
+
)`);
|
|
169
|
+
// Ensure chunk_count column exists (handles upgrade from older schema)
|
|
170
|
+
try {
|
|
171
|
+
this.sql.exec("SELECT chunk_count FROM inodes LIMIT 0");
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
try {
|
|
175
|
+
this.sql.exec("ALTER TABLE inodes ADD COLUMN chunk_count INTEGER NOT NULL DEFAULT 0");
|
|
176
|
+
}
|
|
177
|
+
catch { }
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
migrateFromLegacy() {
|
|
181
|
+
// Check if old fs_objects table exists
|
|
182
|
+
const rows = [...this.sql.exec("SELECT name FROM sqlite_master WHERE type='table' AND name='fs_objects'")];
|
|
183
|
+
if (rows.length === 0)
|
|
184
|
+
return;
|
|
185
|
+
console.log('[sqlite-vfs] Migrating from legacy fs_objects table...');
|
|
186
|
+
const oldRows = [...this.sql.exec("SELECT path, chunk_index, parent_path, data, is_dir, size, mtime, mode FROM fs_objects ORDER BY path, chunk_index")];
|
|
187
|
+
// Group by path, extract inodes and chunks
|
|
188
|
+
const seenPaths = new Set();
|
|
189
|
+
for (const row of oldRows) {
|
|
190
|
+
const path = String(row.path);
|
|
191
|
+
const chunkIndex = Number(row.chunk_index);
|
|
192
|
+
const parentPath = String(row.parent_path);
|
|
193
|
+
const isDir = Number(row.is_dir) === 1;
|
|
194
|
+
const size = Number(row.size);
|
|
195
|
+
const mtime = Number(row.mtime);
|
|
196
|
+
const mode = Number(row.mode);
|
|
197
|
+
if (!seenPaths.has(path)) {
|
|
198
|
+
seenPaths.add(path);
|
|
199
|
+
const chunkCount = isDir ? 0 : Math.max(1, Math.ceil(size / CHUNK_SIZE));
|
|
200
|
+
this.sql.exec("INSERT OR REPLACE INTO inodes (path, parent_path, is_dir, size, mtime, mode, chunk_count) VALUES (?, ?, ?, ?, ?, ?, ?)", path, parentPath, isDir ? 1 : 0, size, mtime, mode, chunkCount);
|
|
201
|
+
}
|
|
202
|
+
if (!isDir && row.data != null) {
|
|
203
|
+
const data = this.blobToUint8Array(row.data);
|
|
204
|
+
if (data.length > 0) {
|
|
205
|
+
// Re-chunk: old CHUNK_SIZE was 1.8MB, new is 64KB
|
|
206
|
+
if (chunkIndex === 0 && data.length <= CHUNK_SIZE) {
|
|
207
|
+
// Small file, single chunk — direct insert
|
|
208
|
+
this.sql.exec("INSERT OR REPLACE INTO file_chunks (path, chunk_id, data) VALUES (?, ?, ?)", path, 0, data);
|
|
209
|
+
this.sql.exec("UPDATE inodes SET chunk_count = 1 WHERE path = ?", path);
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
// Large file: re-chunk with 64KB chunks
|
|
213
|
+
// For multi-chunk old files, accumulate data then re-chunk
|
|
214
|
+
// This is simplified: we re-chunk the data we have
|
|
215
|
+
const numNewChunks = Math.ceil(data.length / CHUNK_SIZE);
|
|
216
|
+
for (let i = 0; i < numNewChunks; i++) {
|
|
217
|
+
// H10: subarray view; SQLite copies the bytes through
|
|
218
|
+
// its blob-bind boundary so retention ends at exec().
|
|
219
|
+
const chunk = data.subarray(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE);
|
|
220
|
+
// Offset chunk IDs by the old chunk's position in the new scheme
|
|
221
|
+
const oldStart = chunkIndex * 1_800_000; // old chunk size
|
|
222
|
+
const newBaseChunk = Math.floor(oldStart / CHUNK_SIZE);
|
|
223
|
+
this.sql.exec("INSERT OR REPLACE INTO file_chunks (path, chunk_id, data) VALUES (?, ?, ?)", path, newBaseChunk + i, chunk);
|
|
224
|
+
}
|
|
225
|
+
// Update chunk count based on total file size
|
|
226
|
+
const totalChunks = Math.ceil(size / CHUNK_SIZE);
|
|
227
|
+
this.sql.exec("UPDATE inodes SET chunk_count = ? WHERE path = ?", totalChunks, path);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
// Drop old table
|
|
233
|
+
this.sql.exec("DROP TABLE IF EXISTS fs_objects");
|
|
234
|
+
console.log(`[sqlite-vfs] Migration complete: ${seenPaths.size} entries migrated.`);
|
|
235
|
+
}
|
|
236
|
+
// ── INode loading ─────────────────────────────────────────────────────
|
|
237
|
+
loadInodes() {
|
|
238
|
+
this.inodes.clear();
|
|
239
|
+
this.children.clear();
|
|
240
|
+
// Reset counters before rescanning (B3).
|
|
241
|
+
this._totalFiles = 0;
|
|
242
|
+
this._totalDirs = 0;
|
|
243
|
+
this._usedBytes = 0;
|
|
244
|
+
const rows = [...this.sql.exec("SELECT path, parent_path, is_dir, size, mtime, mode, chunk_count FROM inodes")];
|
|
245
|
+
for (const row of rows) {
|
|
246
|
+
const inode = {
|
|
247
|
+
path: String(row.path),
|
|
248
|
+
parentPath: String(row.parent_path),
|
|
249
|
+
isDir: Number(row.is_dir) === 1,
|
|
250
|
+
size: Number(row.size),
|
|
251
|
+
mtime: Number(row.mtime),
|
|
252
|
+
mode: Number(row.mode),
|
|
253
|
+
chunkCount: Number(row.chunk_count),
|
|
254
|
+
};
|
|
255
|
+
this.inodes.set(inode.path, inode);
|
|
256
|
+
this._addToChildrenIndex(inode.parentPath, inode.path);
|
|
257
|
+
// Bootstrap the counters (B3).
|
|
258
|
+
if (inode.isDir)
|
|
259
|
+
this._totalDirs++;
|
|
260
|
+
else {
|
|
261
|
+
this._totalFiles++;
|
|
262
|
+
this._usedBytes += inode.size;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
_addToChildrenIndex(parentPath, childPath) {
|
|
267
|
+
let set = this.children.get(parentPath);
|
|
268
|
+
if (!set) {
|
|
269
|
+
set = new Set();
|
|
270
|
+
this.children.set(parentPath, set);
|
|
271
|
+
}
|
|
272
|
+
set.add(childPath);
|
|
273
|
+
}
|
|
274
|
+
_removeFromChildrenIndex(parentPath, childPath) {
|
|
275
|
+
const set = this.children.get(parentPath);
|
|
276
|
+
if (set) {
|
|
277
|
+
set.delete(childPath);
|
|
278
|
+
if (set.size === 0)
|
|
279
|
+
this.children.delete(parentPath);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
// ── Cache key ─────────────────────────────────────────────────────────
|
|
283
|
+
cacheKey(path, chunkId) {
|
|
284
|
+
return `${path}\0${chunkId}`;
|
|
285
|
+
}
|
|
286
|
+
// ── LRU Content Cache ─────────────────────────────────────────────────
|
|
287
|
+
cacheGet(path, chunkId) {
|
|
288
|
+
const key = this.cacheKey(path, chunkId);
|
|
289
|
+
const entry = this.cache.get(key);
|
|
290
|
+
if (entry) {
|
|
291
|
+
this._cacheHits++;
|
|
292
|
+
// Move to MRU position
|
|
293
|
+
this.cache.delete(key);
|
|
294
|
+
this.cache.set(key, entry);
|
|
295
|
+
return entry.data;
|
|
296
|
+
}
|
|
297
|
+
this._cacheMisses++;
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
cacheSet(path, chunkId, data, dirty) {
|
|
301
|
+
const key = this.cacheKey(path, chunkId);
|
|
302
|
+
// If already cached, update
|
|
303
|
+
const existing = this.cache.get(key);
|
|
304
|
+
if (existing) {
|
|
305
|
+
this._cacheBytes -= existing.data.length;
|
|
306
|
+
existing.data = data;
|
|
307
|
+
existing.dirty = existing.dirty || dirty;
|
|
308
|
+
this._cacheBytes += data.length;
|
|
309
|
+
// Move to MRU
|
|
310
|
+
this.cache.delete(key);
|
|
311
|
+
this.cache.set(key, existing);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
// Evict if at capacity. W5 Lever 8: read the runtime-mutable
|
|
315
|
+
// _lruMaxEntries instead of the constant so shrinkForInstall()
|
|
316
|
+
// takes effect for in-flight writes too.
|
|
317
|
+
while (this.cache.size >= this._lruMaxEntries) {
|
|
318
|
+
this.evictOne();
|
|
319
|
+
}
|
|
320
|
+
this._cacheBytes += data.length;
|
|
321
|
+
this.cache.set(key, { path, chunkId, data, dirty });
|
|
322
|
+
}
|
|
323
|
+
evictOne() {
|
|
324
|
+
// Evict the LRU entry (first in Map iteration order)
|
|
325
|
+
const firstKey = this.cache.keys().next().value;
|
|
326
|
+
if (firstKey === undefined)
|
|
327
|
+
return;
|
|
328
|
+
const entry = this.cache.get(firstKey);
|
|
329
|
+
this.cache.delete(firstKey);
|
|
330
|
+
this._cacheBytes -= entry.data.length;
|
|
331
|
+
this._evictions++;
|
|
332
|
+
if (entry.dirty) {
|
|
333
|
+
this.deferWrite(entry.path, entry.chunkId, entry.data);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
// ── W5 Lever 8: public LRU shrink / restore + evictAll ───────────────
|
|
337
|
+
//
|
|
338
|
+
// shrinkForInstall(targetEntries): tighten the cap so heavy-alloc
|
|
339
|
+
// owners (npm install / git clone / pre-bundle) free heap headroom
|
|
340
|
+
// for in-flight RPC payloads + pending-writes queue. Refcount-based
|
|
341
|
+
// so nested heavy-alloc owners (e.g. concurrent install + clone)
|
|
342
|
+
// don't race; only the OUTERMOST restoreAfterInstall() raises the
|
|
343
|
+
// cap back to LRU_MAX_ENTRIES.
|
|
344
|
+
//
|
|
345
|
+
// Default target 128 entries × 64 KB = 8 MiB. Matches
|
|
346
|
+
// CF-INTERNAL-OPTIMIZATION-RESEARCH.md J.1.2.
|
|
347
|
+
//
|
|
348
|
+
// Eviction during shrink flows through deferWrite → flushPendingWrites
|
|
349
|
+
// (existing path), so no data loss. Cold-cache bounce for the next
|
|
350
|
+
// reads of evicted pages is acceptable since install workloads
|
|
351
|
+
// write-once-and-rarely-reread.
|
|
352
|
+
shrinkForInstall(targetEntries = 128) {
|
|
353
|
+
const target = Math.max(1, Math.min(LRU_MAX_ENTRIES, targetEntries | 0));
|
|
354
|
+
// Refcount: nested acquires stack. Take the smallest target across
|
|
355
|
+
// owners — most aggressive shrinker wins.
|
|
356
|
+
if (this._lruShrinkRefcount > 0) {
|
|
357
|
+
if (target < this._lruMaxEntries)
|
|
358
|
+
this._lruMaxEntries = target;
|
|
359
|
+
this._lruShrinkRefcount++;
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
this._lruShrinkRefcount = 1;
|
|
363
|
+
this._lruMaxEntries = target;
|
|
364
|
+
// Evict down to the new cap. Each evictOne() flushes the dirty
|
|
365
|
+
// entry (if any) via deferWrite; queueMicrotask schedules the
|
|
366
|
+
// flush. Stays sync — preserves the sqlite-vfs invariant.
|
|
367
|
+
while (this.cache.size > this._lruMaxEntries) {
|
|
368
|
+
this.evictOne();
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
/** Decrement the heavy-alloc refcount. When the count returns to
|
|
372
|
+
* zero, restore the cap to LRU_MAX_ENTRIES. No re-population —
|
|
373
|
+
* the cache warms naturally on next reads. */
|
|
374
|
+
restoreAfterInstall() {
|
|
375
|
+
if (this._lruShrinkRefcount <= 0)
|
|
376
|
+
return;
|
|
377
|
+
this._lruShrinkRefcount--;
|
|
378
|
+
if (this._lruShrinkRefcount === 0) {
|
|
379
|
+
this._lruMaxEntries = LRU_MAX_ENTRIES;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Drop EVERY cache entry, flushing dirty ones via deferWrite. Used
|
|
384
|
+
* by the W5 Lever 9 SQLITE_NOMEM retry path to free pages owned by
|
|
385
|
+
* us before retrying a smaller batch. Sync; safe inside the input
|
|
386
|
+
* gate.
|
|
387
|
+
*/
|
|
388
|
+
evictAll() {
|
|
389
|
+
// Iterate a snapshot so concurrent mutation through deferWrite
|
|
390
|
+
// doesn't disturb iteration.
|
|
391
|
+
const keys = Array.from(this.cache.keys());
|
|
392
|
+
for (const key of keys) {
|
|
393
|
+
const entry = this.cache.get(key);
|
|
394
|
+
if (!entry)
|
|
395
|
+
continue;
|
|
396
|
+
this.cache.delete(key);
|
|
397
|
+
this._cacheBytes -= entry.data.length;
|
|
398
|
+
this._evictions++;
|
|
399
|
+
if (entry.dirty) {
|
|
400
|
+
this.deferWrite(entry.path, entry.chunkId, entry.data);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Invalidate all cache entries for a path.
|
|
406
|
+
* @param discard If true, dirty entries are discarded (not flushed).
|
|
407
|
+
* Use discard=true when the file is about to be overwritten or deleted.
|
|
408
|
+
*/
|
|
409
|
+
cacheInvalidate(path, discard = false) {
|
|
410
|
+
const toDelete = [];
|
|
411
|
+
for (const [key, entry] of this.cache) {
|
|
412
|
+
if (entry.path === path) {
|
|
413
|
+
if (entry.dirty && !discard) {
|
|
414
|
+
this.deferWrite(entry.path, entry.chunkId, entry.data);
|
|
415
|
+
}
|
|
416
|
+
this._cacheBytes -= entry.data.length;
|
|
417
|
+
toDelete.push(key);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
for (const key of toDelete) {
|
|
421
|
+
this.cache.delete(key);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
/** Remove all pending writes for a path (prevents orphan chunks). */
|
|
425
|
+
clearPendingWritesForPath(path) {
|
|
426
|
+
const toRemove = [];
|
|
427
|
+
for (const [key, entry] of this.pendingWrites) {
|
|
428
|
+
if (entry.path === path)
|
|
429
|
+
toRemove.push(key);
|
|
430
|
+
}
|
|
431
|
+
for (const key of toRemove) {
|
|
432
|
+
const e = this.pendingWrites.get(key);
|
|
433
|
+
if (e)
|
|
434
|
+
this._pendingWriteBytes -= e.data.length;
|
|
435
|
+
this.pendingWrites.delete(key);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Batch version of cacheInvalidate — invalidate every cache entry
|
|
440
|
+
* whose path is in `paths`. One pass over the cache instead of one
|
|
441
|
+
* pass per path (audit R2: writeBatch was O(P × C) before this).
|
|
442
|
+
*
|
|
443
|
+
* `discard` semantics match cacheInvalidate(path, discard): when
|
|
444
|
+
* false, dirty entries are re-queued for persistence before being
|
|
445
|
+
* dropped from the cache; when true (the writeBatch case — the row
|
|
446
|
+
* is about to be overwritten), dirty data is abandoned.
|
|
447
|
+
*/
|
|
448
|
+
cacheInvalidateBatch(paths, discard = false) {
|
|
449
|
+
if (paths.size === 0)
|
|
450
|
+
return;
|
|
451
|
+
const toDelete = [];
|
|
452
|
+
for (const [key, entry] of this.cache) {
|
|
453
|
+
if (!paths.has(entry.path))
|
|
454
|
+
continue;
|
|
455
|
+
if (entry.dirty && !discard) {
|
|
456
|
+
this.deferWrite(entry.path, entry.chunkId, entry.data);
|
|
457
|
+
}
|
|
458
|
+
this._cacheBytes -= entry.data.length;
|
|
459
|
+
toDelete.push(key);
|
|
460
|
+
}
|
|
461
|
+
for (const key of toDelete) {
|
|
462
|
+
this.cache.delete(key);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
/** Batch version of clearPendingWritesForPath — one pass for N paths. */
|
|
466
|
+
clearPendingWritesForPaths(paths) {
|
|
467
|
+
if (paths.size === 0)
|
|
468
|
+
return;
|
|
469
|
+
const toRemove = [];
|
|
470
|
+
for (const [key, entry] of this.pendingWrites) {
|
|
471
|
+
if (paths.has(entry.path))
|
|
472
|
+
toRemove.push(key);
|
|
473
|
+
}
|
|
474
|
+
for (const key of toRemove) {
|
|
475
|
+
const e = this.pendingWrites.get(key);
|
|
476
|
+
if (e)
|
|
477
|
+
this._pendingWriteBytes -= e.data.length;
|
|
478
|
+
this.pendingWrites.delete(key);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
// ── Deferred batch writes (do86 pattern) ──────────────────────────────
|
|
482
|
+
deferWrite(path, chunkId, data) {
|
|
483
|
+
const key = this.cacheKey(path, chunkId);
|
|
484
|
+
// SINGLE-OWNERSHIP BOUNDARY (H10 audit, heap-correctness wave).
|
|
485
|
+
//
|
|
486
|
+
// `data` is one of:
|
|
487
|
+
// - a `subarray` view into the caller's source ArrayBuffer
|
|
488
|
+
// (writeFile post-H10), or
|
|
489
|
+
// - a fresh Uint8Array (writeBatch chunks come pre-decoded), or
|
|
490
|
+
// - any caller-supplied buffer.
|
|
491
|
+
//
|
|
492
|
+
// The pendingWrites entry MUST own its bytes outright — until
|
|
493
|
+
// flush, the supervisor must be free to drop the caller's ref
|
|
494
|
+
// without losing the not-yet-persisted data. So we materialise a
|
|
495
|
+
// fresh ArrayBuffer here. This is the ONE copy on the writeFile
|
|
496
|
+
// path; before the H10 fix it was the second of THREE copies
|
|
497
|
+
// (slice + this) per chunk.
|
|
498
|
+
//
|
|
499
|
+
// _pendingWriteBytes counter is the source-of-truth for N3
|
|
500
|
+
// (heap-correctness wave): the `vfsInFlightBytes` heap-estimator
|
|
501
|
+
// contributor reads from it.
|
|
502
|
+
const copy = new Uint8Array(data.length);
|
|
503
|
+
copy.set(data);
|
|
504
|
+
// Overwrite-correctness for the byte counter: if a prior entry
|
|
505
|
+
// exists at this key (re-write of the same chunk before flush),
|
|
506
|
+
// subtract its bytes BEFORE replacing.
|
|
507
|
+
const prior = this.pendingWrites.get(key);
|
|
508
|
+
if (prior)
|
|
509
|
+
this._pendingWriteBytes -= prior.data.length;
|
|
510
|
+
this.pendingWrites.set(key, { path, chunkId, data: copy });
|
|
511
|
+
this._pendingWriteBytes += copy.length;
|
|
512
|
+
// Throttle: if too many writes pending, flush synchronously.
|
|
513
|
+
// 500 threshold balances memory (500 × ~64KB = ~32MB max pending) vs throughput
|
|
514
|
+
// (fewer flushes = faster git clone / npm install).
|
|
515
|
+
if (this.pendingWrites.size >= 500) {
|
|
516
|
+
this.flushPendingWrites();
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
if (!this.writeFlushScheduled) {
|
|
520
|
+
this.writeFlushScheduled = true;
|
|
521
|
+
queueMicrotask(() => this.flushPendingWrites());
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
flushPendingWrites() {
|
|
525
|
+
this.writeFlushScheduled = false;
|
|
526
|
+
if (this.pendingWrites.size === 0)
|
|
527
|
+
return;
|
|
528
|
+
const entries = Array.from(this.pendingWrites.values());
|
|
529
|
+
// The bytes are about to leave pendingWrites (either land in SQL
|
|
530
|
+
// on the transactionSync below, or move to failedWrites without
|
|
531
|
+
// their data — see line 171 comment). Either way, _pendingWriteBytes
|
|
532
|
+
// drops to 0 here. We do NOT subtract per-entry; clearing in one
|
|
533
|
+
// shot is the cheapest correct accounting (and matches the .clear
|
|
534
|
+
// below).
|
|
535
|
+
this._pendingWriteBytes = 0;
|
|
536
|
+
this.pendingWrites.clear();
|
|
537
|
+
this._batchWrites++;
|
|
538
|
+
// First attempt: batch inside transactionSync. Inner per-row
|
|
539
|
+
// try/catch captures row-level failures without aborting the
|
|
540
|
+
// transaction — those rows go to retry.
|
|
541
|
+
//
|
|
542
|
+
// Counter accounting: we do NOT increment _sqlWrites / _batchWriteRows
|
|
543
|
+
// inside the transaction block. If transactionSync later throws
|
|
544
|
+
// (rollback), every row it "wrote" is reverted — but the counter
|
|
545
|
+
// would already have been bumped. Instead, we count confirmed
|
|
546
|
+
// writes after each path completes.
|
|
547
|
+
const rowFailures = [];
|
|
548
|
+
let transactionFailed = null;
|
|
549
|
+
let confirmedRows = 0;
|
|
550
|
+
try {
|
|
551
|
+
if (this.ctx?.storage?.transactionSync) {
|
|
552
|
+
this.ctx.storage.transactionSync(() => {
|
|
553
|
+
for (const entry of entries) {
|
|
554
|
+
try {
|
|
555
|
+
this.sql.exec("INSERT OR REPLACE INTO file_chunks (path, chunk_id, data) VALUES (?, ?, ?)", entry.path, entry.chunkId, entry.data);
|
|
556
|
+
}
|
|
557
|
+
catch (e) {
|
|
558
|
+
rowFailures.push({ entry, error: e?.message || String(e) });
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
});
|
|
562
|
+
// Transaction committed. Rows that didn't hit the inner catch
|
|
563
|
+
// landed on disk.
|
|
564
|
+
confirmedRows += entries.length - rowFailures.length;
|
|
565
|
+
}
|
|
566
|
+
else {
|
|
567
|
+
// Fallback: individual writes without transaction (slower but safe).
|
|
568
|
+
// Each successful exec() is its own commit, so counters are
|
|
569
|
+
// safe to bump inline here.
|
|
570
|
+
for (const entry of entries) {
|
|
571
|
+
try {
|
|
572
|
+
this.sql.exec("INSERT OR REPLACE INTO file_chunks (path, chunk_id, data) VALUES (?, ?, ?)", entry.path, entry.chunkId, entry.data);
|
|
573
|
+
confirmedRows++;
|
|
574
|
+
}
|
|
575
|
+
catch (e) {
|
|
576
|
+
rowFailures.push({ entry, error: e?.message || String(e) });
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
catch (e) {
|
|
582
|
+
// transactionSync itself threw (rollback). Every entry in the
|
|
583
|
+
// batch is now un-written — retry them individually so one bad
|
|
584
|
+
// row doesn't poison the whole batch. rowFailures entries from
|
|
585
|
+
// before the rollback are discarded; the retry covers them too.
|
|
586
|
+
transactionFailed = e?.message || String(e);
|
|
587
|
+
rowFailures.length = 0;
|
|
588
|
+
}
|
|
589
|
+
// Retry path (audit C1: "re-queue on transient SQL errors once").
|
|
590
|
+
// If the transaction aborted, retry every entry individually without
|
|
591
|
+
// a transaction wrapper. Row-level failures from the successful-
|
|
592
|
+
// transaction path also get one retry here in case the failure was
|
|
593
|
+
// transient (e.g. a transient constraint conflict resolved by a
|
|
594
|
+
// concurrent write that completed between our attempts).
|
|
595
|
+
if (transactionFailed !== null) {
|
|
596
|
+
for (const entry of entries) {
|
|
597
|
+
try {
|
|
598
|
+
this.sql.exec("INSERT OR REPLACE INTO file_chunks (path, chunk_id, data) VALUES (?, ?, ?)", entry.path, entry.chunkId, entry.data);
|
|
599
|
+
confirmedRows++;
|
|
600
|
+
}
|
|
601
|
+
catch (e) {
|
|
602
|
+
this._recordFailedWrite(entry, e?.message || String(e), 2);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
else if (rowFailures.length > 0) {
|
|
607
|
+
for (const { entry, error: firstError } of rowFailures) {
|
|
608
|
+
try {
|
|
609
|
+
this.sql.exec("INSERT OR REPLACE INTO file_chunks (path, chunk_id, data) VALUES (?, ?, ?)", entry.path, entry.chunkId, entry.data);
|
|
610
|
+
confirmedRows++;
|
|
611
|
+
}
|
|
612
|
+
catch (e) {
|
|
613
|
+
this._recordFailedWrite(entry, `${firstError}; retry: ${e?.message || String(e)}`, 2);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
// Single accounting update for the whole flush. Reflects rows that
|
|
618
|
+
// actually committed (via either the first attempt or the retry).
|
|
619
|
+
this._sqlWrites += confirmedRows;
|
|
620
|
+
this._batchWriteRows += confirmedRows;
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* Move an un-writable chunk into failedWrites and notify subscribers.
|
|
624
|
+
* Called from the retry path of flushPendingWrites(). Entries recorded
|
|
625
|
+
* here are the ones that failed BOTH the original attempt and the
|
|
626
|
+
* one-shot retry; they are considered lost (we do not re-queue a
|
|
627
|
+
* third time — the audit recommendation was a single retry). The
|
|
628
|
+
* chunk bytes are NOT retained — see failedWrites comment above.
|
|
629
|
+
*/
|
|
630
|
+
_recordFailedWrite(entry, error, attempts) {
|
|
631
|
+
const key = this.cacheKey(entry.path, entry.chunkId);
|
|
632
|
+
// Defensive cap: a session that writes to a broken SQLite could
|
|
633
|
+
// otherwise grow failedWrites without bound between flushAll calls.
|
|
634
|
+
// 1000 entries × ~120 B = ~120 KB ceiling. The cumulative
|
|
635
|
+
// _writeFailures counter still grows so observers can see drops.
|
|
636
|
+
if (!this.failedWrites.has(key) && this.failedWrites.size >= 1000) {
|
|
637
|
+
// Drop the oldest recorded failure to make room; a 1000-deep
|
|
638
|
+
// failure queue already signals a serious problem.
|
|
639
|
+
const oldest = this.failedWrites.keys().next().value;
|
|
640
|
+
if (oldest !== undefined)
|
|
641
|
+
this.failedWrites.delete(oldest);
|
|
642
|
+
}
|
|
643
|
+
this.failedWrites.set(key, {
|
|
644
|
+
path: entry.path, chunkId: entry.chunkId,
|
|
645
|
+
error, attempts,
|
|
646
|
+
});
|
|
647
|
+
this._writeFailures++;
|
|
648
|
+
// Surface to subscribers so the supervisor can, e.g., write to the
|
|
649
|
+
// user's terminal. Handler errors are swallowed — they mustn't
|
|
650
|
+
// affect the flush path (called from a microtask).
|
|
651
|
+
const payload = { path: entry.path, chunkId: entry.chunkId, error, attempts };
|
|
652
|
+
for (const handler of this.writeErrorHandlers) {
|
|
653
|
+
try {
|
|
654
|
+
handler(payload);
|
|
655
|
+
}
|
|
656
|
+
catch { /* handler is last line of defense */ }
|
|
657
|
+
}
|
|
658
|
+
console.error('[sqlite-vfs] write permanently failed for', entry.path, 'chunk', entry.chunkId, 'after', attempts, 'attempts:', error);
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* Subscribe to write failures. Fires once per chunk that failed both
|
|
662
|
+
* its first attempt AND the one-shot retry. Returns an unsubscribe
|
|
663
|
+
* function. Multiple subscribers are permitted.
|
|
664
|
+
*
|
|
665
|
+
* Handlers run synchronously inside the flush microtask. Keep them
|
|
666
|
+
* cheap and non-throwing; errors thrown by a handler are caught and
|
|
667
|
+
* discarded so one bad subscriber can't break the flush path.
|
|
668
|
+
*/
|
|
669
|
+
onWriteError(handler) {
|
|
670
|
+
this.writeErrorHandlers.add(handler);
|
|
671
|
+
return () => { this.writeErrorHandlers.delete(handler); };
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* Snapshot of currently-recorded write failures. Intended for
|
|
675
|
+
* diagnostics (e.g. /api/stats). The underlying Map is not exposed
|
|
676
|
+
* so external code can't accidentally mutate it.
|
|
677
|
+
*/
|
|
678
|
+
getWriteFailures() {
|
|
679
|
+
return Array.from(this.failedWrites.values()).map(f => ({
|
|
680
|
+
path: f.path, chunkId: f.chunkId, error: f.error, attempts: f.attempts,
|
|
681
|
+
}));
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Clear recorded failures. Callers that have recovered (e.g. retried
|
|
685
|
+
* the user-facing operation, or logged the error and decided to move
|
|
686
|
+
* on) can call this to reset the counter so flushAll() stops
|
|
687
|
+
* throwing. Without this, a single poisoned chunk would make every
|
|
688
|
+
* subsequent flushAll() throw forever.
|
|
689
|
+
*/
|
|
690
|
+
clearWriteFailures() {
|
|
691
|
+
const n = this.failedWrites.size;
|
|
692
|
+
this.failedWrites.clear();
|
|
693
|
+
return n;
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* Force flush all dirty cache entries and pending writes to SQLite.
|
|
697
|
+
*
|
|
698
|
+
* Throws if any chunk failed both its first attempt AND the one-shot
|
|
699
|
+
* retry during this or any previous flush in this DO's lifetime.
|
|
700
|
+
* Callers that invoke flushAll() on a critical boundary (e.g.
|
|
701
|
+
* webSocketClose) get a synchronous error signal; callers that don't
|
|
702
|
+
* want to assert cleanliness should use flushAndWait() instead.
|
|
703
|
+
*
|
|
704
|
+
* Staying synchronous preserves the sqlite-vfs invariant that all
|
|
705
|
+
* file ops are sync (documented at the top of this file) — required
|
|
706
|
+
* by the LIFO @lifo-sh/core MountProvider interface.
|
|
707
|
+
*/
|
|
708
|
+
flushAll() {
|
|
709
|
+
// Flush cache dirty entries
|
|
710
|
+
for (const [, entry] of this.cache) {
|
|
711
|
+
if (entry.dirty) {
|
|
712
|
+
this.deferWrite(entry.path, entry.chunkId, entry.data);
|
|
713
|
+
entry.dirty = false;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
// Flush pending writes synchronously
|
|
717
|
+
this.flushPendingWrites();
|
|
718
|
+
if (this.failedWrites.size > 0) {
|
|
719
|
+
// Build a concise error message that names the first few paths
|
|
720
|
+
// so the operator can triage. Full list is available via
|
|
721
|
+
// getWriteFailures().
|
|
722
|
+
const first = Array.from(this.failedWrites.values()).slice(0, 3);
|
|
723
|
+
const preview = first.map(f => `${f.path}#${f.chunkId} (${f.error})`).join('; ');
|
|
724
|
+
const remaining = this.failedWrites.size > first.length
|
|
725
|
+
? ` +${this.failedWrites.size - first.length} more`
|
|
726
|
+
: '';
|
|
727
|
+
throw new Error(`[sqlite-vfs] flushAll: ${this.failedWrites.size} write(s) failed permanently: ${preview}${remaining}`);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* Force-flush and resolve only after the flush completes with no
|
|
732
|
+
* recorded failures. Rejects with the same error shape as flushAll()
|
|
733
|
+
* when one or more chunks are un-writable after retry.
|
|
734
|
+
*
|
|
735
|
+
* Use this at explicit persistence boundaries (e.g. end of
|
|
736
|
+
* `npm install`, end of `git clone`, seed-filesystem completion) to
|
|
737
|
+
* guarantee data landed. Synchronous `writeFile()` callers that
|
|
738
|
+
* don't opt in continue to get best-effort semantics — the audit's
|
|
739
|
+
* alternative fix path (keep void API, surface errors through
|
|
740
|
+
* onWriteError + throwing flushAll).
|
|
741
|
+
*/
|
|
742
|
+
async flushAndWait() {
|
|
743
|
+
// queueMicrotask-scheduled flush may be in flight. Await a microtask
|
|
744
|
+
// turn before checking + forcing a flush so we don't double-run.
|
|
745
|
+
await Promise.resolve();
|
|
746
|
+
this.flushAll(); // throws on failures
|
|
747
|
+
}
|
|
748
|
+
// ── Helpers ───────────────────────────────────────────────────────────
|
|
749
|
+
now() { return Date.now(); }
|
|
750
|
+
parentPath(path) {
|
|
751
|
+
return path.includes('/') ? path.substring(0, path.lastIndexOf('/')) : '';
|
|
752
|
+
}
|
|
753
|
+
blobToUint8Array(blob) {
|
|
754
|
+
if (blob instanceof Uint8Array)
|
|
755
|
+
return blob;
|
|
756
|
+
if (blob instanceof ArrayBuffer)
|
|
757
|
+
return new Uint8Array(blob);
|
|
758
|
+
if (ArrayBuffer.isView(blob))
|
|
759
|
+
return new Uint8Array(blob.buffer, blob.byteOffset, blob.byteLength);
|
|
760
|
+
return new Uint8Array(0);
|
|
761
|
+
}
|
|
762
|
+
readChunkFromSql(path, chunkId) {
|
|
763
|
+
// Check pending writes first (do86 pattern: avoid stale reads)
|
|
764
|
+
const key = this.cacheKey(path, chunkId);
|
|
765
|
+
const pending = this.pendingWrites.get(key);
|
|
766
|
+
if (pending)
|
|
767
|
+
return pending.data;
|
|
768
|
+
this._sqlReads++;
|
|
769
|
+
const rows = [...this.sql.exec("SELECT data FROM file_chunks WHERE path = ? AND chunk_id = ?", path, chunkId)];
|
|
770
|
+
if (rows.length === 0)
|
|
771
|
+
return null;
|
|
772
|
+
return this.blobToUint8Array(rows[0].data);
|
|
773
|
+
}
|
|
774
|
+
// ── Filesystem operations ─────────────────────────────────────────────
|
|
775
|
+
exists(path) {
|
|
776
|
+
return this.inodes.has(path);
|
|
777
|
+
}
|
|
778
|
+
isDirectory(path) {
|
|
779
|
+
const inode = this.inodes.get(path);
|
|
780
|
+
return inode !== undefined && inode.isDir;
|
|
781
|
+
}
|
|
782
|
+
isFile(path) {
|
|
783
|
+
const inode = this.inodes.get(path);
|
|
784
|
+
return inode !== undefined && !inode.isDir;
|
|
785
|
+
}
|
|
786
|
+
mkdir(path, options) {
|
|
787
|
+
if (this.exists(path))
|
|
788
|
+
return;
|
|
789
|
+
if (options?.recursive) {
|
|
790
|
+
const parts = path.split('/').filter(Boolean);
|
|
791
|
+
let current = '';
|
|
792
|
+
for (const part of parts) {
|
|
793
|
+
current = current ? current + '/' + part : part;
|
|
794
|
+
if (!this.exists(current)) {
|
|
795
|
+
this._mkdirSingle(current);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
else {
|
|
800
|
+
this._mkdirSingle(path);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
_mkdirSingle(path) {
|
|
804
|
+
const pp = this.parentPath(path);
|
|
805
|
+
const mtime = this.now();
|
|
806
|
+
this.sql.exec("INSERT OR REPLACE INTO inodes (path, parent_path, is_dir, size, mtime, mode, chunk_count) VALUES (?, ?, 1, 0, ?, ?, 0)", path, pp, mtime, 0o755);
|
|
807
|
+
const inode = { path, parentPath: pp, isDir: true, size: 0, mtime, mode: 0o755, chunkCount: 0 };
|
|
808
|
+
this.inodes.set(path, inode);
|
|
809
|
+
this._addToChildrenIndex(pp, path);
|
|
810
|
+
this._totalDirs++; // B3
|
|
811
|
+
this.events.emit('addDir', path);
|
|
812
|
+
}
|
|
813
|
+
writeFile(path, content) {
|
|
814
|
+
const data = typeof content === 'string' ? enc.encode(content) : content;
|
|
815
|
+
const pp = this.parentPath(path);
|
|
816
|
+
const mtime = this.now();
|
|
817
|
+
const chunkCount = data.length === 0 ? 0 : Math.ceil(data.length / CHUNK_SIZE);
|
|
818
|
+
// Capture prior state BEFORE mutating this.inodes (B3 delta tracking).
|
|
819
|
+
const prior = this.inodes.get(path);
|
|
820
|
+
const isNew = prior === undefined;
|
|
821
|
+
// Invalidate old cache entries (discard=true: old data is being replaced)
|
|
822
|
+
if (!isNew) {
|
|
823
|
+
this.cacheInvalidate(path, true);
|
|
824
|
+
this.clearPendingWritesForPath(path);
|
|
825
|
+
// Delete old chunks from SQL (chunk count may differ)
|
|
826
|
+
this.sql.exec("DELETE FROM file_chunks WHERE path = ?", path);
|
|
827
|
+
}
|
|
828
|
+
// Write inode
|
|
829
|
+
this.sql.exec("INSERT OR REPLACE INTO inodes (path, parent_path, is_dir, size, mtime, mode, chunk_count) VALUES (?, ?, 0, ?, ?, ?, ?)", path, pp, data.length, mtime, 0o644, chunkCount);
|
|
830
|
+
const newInode = { path, parentPath: pp, isDir: false, size: data.length, mtime, mode: 0o644, chunkCount };
|
|
831
|
+
this.inodes.set(path, newInode);
|
|
832
|
+
if (isNew)
|
|
833
|
+
this._addToChildrenIndex(pp, path);
|
|
834
|
+
// B3: update running counters. Handles new file, replace file,
|
|
835
|
+
// and the edge case of writing a file path that previously
|
|
836
|
+
// belonged to a directory (writeFile replacing an old dir — rare
|
|
837
|
+
// but `INSERT OR REPLACE` + `this.inodes.set` would silently
|
|
838
|
+
// convert it; handle the count flip defensively).
|
|
839
|
+
if (isNew) {
|
|
840
|
+
this._totalFiles++;
|
|
841
|
+
this._usedBytes += data.length;
|
|
842
|
+
}
|
|
843
|
+
else if (prior.isDir) {
|
|
844
|
+
// Dir → file conversion.
|
|
845
|
+
this._totalDirs--;
|
|
846
|
+
this._totalFiles++;
|
|
847
|
+
this._usedBytes += data.length; // prior size was 0
|
|
848
|
+
}
|
|
849
|
+
else {
|
|
850
|
+
// File → file replace: delta on size, no count change.
|
|
851
|
+
this._usedBytes += data.length - prior.size;
|
|
852
|
+
}
|
|
853
|
+
// Write chunks to cache (clean, not dirty) and defer SQL write.
|
|
854
|
+
// The deferred write handles persistence; cache entry stays clean
|
|
855
|
+
// to avoid double-writing when the entry is eventually evicted.
|
|
856
|
+
//
|
|
857
|
+
// H10 (heap-correctness wave) — `data.slice(...)` here used to
|
|
858
|
+
// allocate a brand-new chunk-sized ArrayBuffer per chunk. Combined
|
|
859
|
+
// with `deferWrite`'s defensive copy at the persistence boundary
|
|
860
|
+
// (sqlite-vfs.ts:_deferWriteCopyForOwnership), the loop transient
|
|
861
|
+
// peaked at 3× the source size:
|
|
862
|
+
//
|
|
863
|
+
// source AB (caller's content) 1 × N
|
|
864
|
+
// + per-chunk slice copies 1 × N
|
|
865
|
+
// + per-chunk deferWrite copies 1 × N
|
|
866
|
+
// = 3 × N
|
|
867
|
+
//
|
|
868
|
+
// `subarray` returns a VIEW into the source AB — zero allocation,
|
|
869
|
+
// zero copy. The subarray pins the source AB until the last view
|
|
870
|
+
// (in cache + in pendingWrites) is dropped, but that is exactly
|
|
871
|
+
// the same retention the caller already had via their `content`
|
|
872
|
+
// reference. We don't INCREASE pinning; we just stop creating
|
|
873
|
+
// redundant copies that the GC immediately has to reclaim.
|
|
874
|
+
//
|
|
875
|
+
// Single-ownership boundary moved to `deferWrite`: it still does
|
|
876
|
+
// ONE copy into a fresh ArrayBuffer at the SQLite persistence
|
|
877
|
+
// boundary so pendingWrites entries don't accidentally extend
|
|
878
|
+
// the lifetime of the caller's source.
|
|
879
|
+
if (data.length === 0) {
|
|
880
|
+
// Empty file: no chunks to write
|
|
881
|
+
}
|
|
882
|
+
else if (data.length <= CHUNK_SIZE) {
|
|
883
|
+
this.cacheSet(path, 0, data, false);
|
|
884
|
+
this.deferWrite(path, 0, data);
|
|
885
|
+
}
|
|
886
|
+
else {
|
|
887
|
+
for (let i = 0; i < chunkCount; i++) {
|
|
888
|
+
const chunk = data.subarray(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE);
|
|
889
|
+
this.cacheSet(path, i, chunk, false);
|
|
890
|
+
this.deferWrite(path, i, chunk);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
this.events.emit(isNew ? 'add' : 'change', path);
|
|
894
|
+
}
|
|
895
|
+
readFile(path) {
|
|
896
|
+
const inode = this.inodes.get(path);
|
|
897
|
+
if (!inode)
|
|
898
|
+
throw new Error("ENOENT: " + path);
|
|
899
|
+
if (inode.isDir)
|
|
900
|
+
throw new Error("EISDIR: " + path);
|
|
901
|
+
if (inode.size === 0 || inode.chunkCount === 0)
|
|
902
|
+
return new Uint8Array(0);
|
|
903
|
+
if (inode.chunkCount === 1) {
|
|
904
|
+
// Single chunk
|
|
905
|
+
const cached = this.cacheGet(path, 0);
|
|
906
|
+
if (cached)
|
|
907
|
+
return cached;
|
|
908
|
+
const data = this.readChunkFromSql(path, 0);
|
|
909
|
+
if (!data)
|
|
910
|
+
return new Uint8Array(0);
|
|
911
|
+
this.cacheSet(path, 0, data, false);
|
|
912
|
+
return data;
|
|
913
|
+
}
|
|
914
|
+
// Multi-chunk: reassemble
|
|
915
|
+
const chunks = [];
|
|
916
|
+
let totalRead = 0;
|
|
917
|
+
for (let i = 0; i < inode.chunkCount; i++) {
|
|
918
|
+
let chunk = this.cacheGet(path, i);
|
|
919
|
+
if (!chunk) {
|
|
920
|
+
chunk = this.readChunkFromSql(path, i);
|
|
921
|
+
if (chunk) {
|
|
922
|
+
this.cacheSet(path, i, chunk, false);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
if (chunk) {
|
|
926
|
+
chunks.push(chunk);
|
|
927
|
+
totalRead += chunk.length;
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
if (chunks.length === 1)
|
|
931
|
+
return chunks[0];
|
|
932
|
+
const result = new Uint8Array(totalRead);
|
|
933
|
+
let offset = 0;
|
|
934
|
+
for (const c of chunks) {
|
|
935
|
+
result.set(c, offset);
|
|
936
|
+
offset += c.length;
|
|
937
|
+
}
|
|
938
|
+
return result;
|
|
939
|
+
}
|
|
940
|
+
readFileString(path) {
|
|
941
|
+
return dec.decode(this.readFile(path));
|
|
942
|
+
}
|
|
943
|
+
stat(path) {
|
|
944
|
+
const inode = this.inodes.get(path);
|
|
945
|
+
if (!inode)
|
|
946
|
+
throw new Error("ENOENT: " + path);
|
|
947
|
+
return {
|
|
948
|
+
type: inode.isDir ? 'directory' : 'file',
|
|
949
|
+
size: inode.size,
|
|
950
|
+
ctime: inode.mtime,
|
|
951
|
+
mtime: inode.mtime,
|
|
952
|
+
mode: inode.mode,
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
readdir(path) {
|
|
956
|
+
const np = path.replace(/^\/+/, '').replace(/\/+$/, '');
|
|
957
|
+
const kids = this.children.get(np);
|
|
958
|
+
if (!kids) {
|
|
959
|
+
// W2.5b diagnostic: empty children-set for a directory we expected
|
|
960
|
+
// to be populated.
|
|
961
|
+
if (globalThis.process?.env?.NIMBUS_DIAG_INSTALL_PIPELINE === '1') {
|
|
962
|
+
// eslint-disable-next-line no-console
|
|
963
|
+
console.warn('[sqlite-vfs/W2.5b] readdir miss path=' + np +
|
|
964
|
+
' kidsUndefined=true inodeExists=' + this.inodes.has(np));
|
|
965
|
+
}
|
|
966
|
+
return [];
|
|
967
|
+
}
|
|
968
|
+
const results = [];
|
|
969
|
+
for (const childPath of kids) {
|
|
970
|
+
const inode = this.inodes.get(childPath);
|
|
971
|
+
if (inode) {
|
|
972
|
+
const name = inode.path.split('/').pop();
|
|
973
|
+
results.push({ name, type: inode.isDir ? 'directory' : 'file' });
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
// W2.5b diagnostic: if children-set has entries but readdir returns
|
|
977
|
+
// fewer (some entries' inodes are missing from this.inodes), log it.
|
|
978
|
+
// This distinguishes (a) "children index broken" from (b) "inodes
|
|
979
|
+
// map lost entries".
|
|
980
|
+
if (globalThis.process?.env?.NIMBUS_DIAG_INSTALL_PIPELINE === '1' &&
|
|
981
|
+
kids.size !== results.length) {
|
|
982
|
+
// eslint-disable-next-line no-console
|
|
983
|
+
console.warn('[sqlite-vfs/W2.5b] readdir size mismatch path=' + np +
|
|
984
|
+
' kidsSize=' + kids.size +
|
|
985
|
+
' resultsLength=' + results.length +
|
|
986
|
+
' missingInodes=' + (kids.size - results.length));
|
|
987
|
+
}
|
|
988
|
+
// W2.6a: sort lexicographically. Set-insertion order tracks
|
|
989
|
+
// writeBatch arrival order, which under concurrent npm install
|
|
990
|
+
// (pLimit=3) is non-deterministic. Sorting here removes a class
|
|
991
|
+
// of "works on Tuesday" bugs in any consumer that walks readdir
|
|
992
|
+
// results — buildPrefetchBundle, buildManifest, the kernel-VFS
|
|
993
|
+
// mount layer, etc. Cost is O(n log n) on dirs that already cost
|
|
994
|
+
// O(n) to assemble; negligible for typical npm package depths.
|
|
995
|
+
results.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
996
|
+
return results;
|
|
997
|
+
}
|
|
998
|
+
unlink(path) {
|
|
999
|
+
const inode = this.inodes.get(path);
|
|
1000
|
+
this.cacheInvalidate(path, true);
|
|
1001
|
+
this.clearPendingWritesForPath(path);
|
|
1002
|
+
this.sql.exec("DELETE FROM file_chunks WHERE path = ?", path);
|
|
1003
|
+
this.sql.exec("DELETE FROM inodes WHERE path = ?", path);
|
|
1004
|
+
if (inode) {
|
|
1005
|
+
this._removeFromChildrenIndex(inode.parentPath, path);
|
|
1006
|
+
// B3: running counters. unlink of a dir shouldn't happen (that's
|
|
1007
|
+
// rmdir's job) but guard defensively against the count flipping.
|
|
1008
|
+
if (inode.isDir)
|
|
1009
|
+
this._totalDirs--;
|
|
1010
|
+
else {
|
|
1011
|
+
this._totalFiles--;
|
|
1012
|
+
this._usedBytes -= inode.size;
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
this.inodes.delete(path);
|
|
1016
|
+
this.events.emit('unlink', path);
|
|
1017
|
+
}
|
|
1018
|
+
rmdir(path) {
|
|
1019
|
+
const np = path.replace(/^\/+/, '').replace(/\/+$/, '');
|
|
1020
|
+
// Check if empty using children index (O(1) instead of O(N))
|
|
1021
|
+
const kids = this.children.get(np);
|
|
1022
|
+
if (kids && kids.size > 0) {
|
|
1023
|
+
throw new Error("ENOTEMPTY: " + path);
|
|
1024
|
+
}
|
|
1025
|
+
const inode = this.inodes.get(np);
|
|
1026
|
+
this.sql.exec("DELETE FROM inodes WHERE path = ?", np);
|
|
1027
|
+
if (inode) {
|
|
1028
|
+
this._removeFromChildrenIndex(inode.parentPath, np);
|
|
1029
|
+
// B3: running counters. Rmdir targets a dir; a non-dir entry here
|
|
1030
|
+
// would be a caller bug. Still, match the actual inode state.
|
|
1031
|
+
if (inode.isDir)
|
|
1032
|
+
this._totalDirs--;
|
|
1033
|
+
else {
|
|
1034
|
+
this._totalFiles--;
|
|
1035
|
+
this._usedBytes -= inode.size;
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
this.inodes.delete(np);
|
|
1039
|
+
this.children.delete(np); // Remove empty children set
|
|
1040
|
+
this.events.emit('unlinkDir', np);
|
|
1041
|
+
}
|
|
1042
|
+
rename(oldPath, newPath) {
|
|
1043
|
+
// Flush pending writes for old path first to avoid orphans
|
|
1044
|
+
this.cacheInvalidate(oldPath, false); // flush dirty, not discard
|
|
1045
|
+
this.flushPendingWrites(); // synchronously flush so SQL paths are current
|
|
1046
|
+
this.clearPendingWritesForPath(oldPath);
|
|
1047
|
+
const newPp = this.parentPath(newPath);
|
|
1048
|
+
const oldPp = this.parentPath(oldPath);
|
|
1049
|
+
// Update inode
|
|
1050
|
+
const inode = this.inodes.get(oldPath);
|
|
1051
|
+
if (!inode)
|
|
1052
|
+
throw new Error("ENOENT: " + oldPath);
|
|
1053
|
+
// W-3 (WASI Wave-2): if newPath already exists, unlink it first so the
|
|
1054
|
+
// SQL UPDATE doesn't conflict on inodes.path uniqueness. POSIX rename(2)
|
|
1055
|
+
// overwrites the destination atomically; clang's atomic-write pattern
|
|
1056
|
+
// (write tmp + rename → final) depends on this. Without this branch,
|
|
1057
|
+
// a 2nd `make` after a prior successful build throws on UPDATE
|
|
1058
|
+
// failure. Pre-unlink covers both file-over-file and file-over-dir
|
|
1059
|
+
// (the latter is rare but POSIX permits it for empty dirs).
|
|
1060
|
+
const destInode = this.inodes.get(newPath);
|
|
1061
|
+
if (destInode) {
|
|
1062
|
+
if (destInode.isDir) {
|
|
1063
|
+
// POSIX semantics: rename onto a non-empty dir is an error
|
|
1064
|
+
// (ENOTEMPTY); rename onto an empty dir is allowed. We surface
|
|
1065
|
+
// both as a thrown error since the WASI shim treats every
|
|
1066
|
+
// unexpected exception as ENOSYS → caller diagnostics.
|
|
1067
|
+
// Conservative: refuse dir overwrite entirely; the WASI fixture
|
|
1068
|
+
// we care about (file → file) is the high-value case.
|
|
1069
|
+
if (inode.isDir) {
|
|
1070
|
+
throw new Error("ENOTDIR-or-EISDIR: rename onto existing dir not supported");
|
|
1071
|
+
}
|
|
1072
|
+
throw new Error("EISDIR: cannot rename file onto existing directory");
|
|
1073
|
+
}
|
|
1074
|
+
// Existing file at newPath — unlink it (drops chunks + inode row).
|
|
1075
|
+
this.cacheInvalidate(newPath, true /* discard */);
|
|
1076
|
+
this.clearPendingWritesForPath(newPath);
|
|
1077
|
+
this.sql.exec("DELETE FROM file_chunks WHERE path=?", newPath);
|
|
1078
|
+
this.sql.exec("DELETE FROM inodes WHERE path=?", newPath);
|
|
1079
|
+
this._removeFromChildrenIndex(newPp, newPath);
|
|
1080
|
+
this.inodes.delete(newPath);
|
|
1081
|
+
// Update running counters mirror unlink's accounting.
|
|
1082
|
+
try {
|
|
1083
|
+
this._totalFiles--;
|
|
1084
|
+
}
|
|
1085
|
+
catch { }
|
|
1086
|
+
}
|
|
1087
|
+
this.sql.exec("UPDATE inodes SET path=?, parent_path=? WHERE path=?", newPath, newPp, oldPath);
|
|
1088
|
+
this.sql.exec("UPDATE file_chunks SET path=? WHERE path=?", newPath, oldPath);
|
|
1089
|
+
// Update in-memory state
|
|
1090
|
+
this._removeFromChildrenIndex(oldPp, oldPath);
|
|
1091
|
+
this.inodes.delete(oldPath);
|
|
1092
|
+
inode.path = newPath;
|
|
1093
|
+
inode.parentPath = newPp;
|
|
1094
|
+
this.inodes.set(newPath, inode);
|
|
1095
|
+
this._addToChildrenIndex(newPp, newPath);
|
|
1096
|
+
// Rename children if directory
|
|
1097
|
+
if (inode.isDir) {
|
|
1098
|
+
const childPaths = [];
|
|
1099
|
+
for (const [p] of this.inodes) {
|
|
1100
|
+
if (p.startsWith(oldPath + '/'))
|
|
1101
|
+
childPaths.push(p);
|
|
1102
|
+
}
|
|
1103
|
+
for (const cp of childPaths) {
|
|
1104
|
+
const ncp = newPath + cp.substring(oldPath.length);
|
|
1105
|
+
const ncpp = this.parentPath(ncp);
|
|
1106
|
+
this.cacheInvalidate(cp, false);
|
|
1107
|
+
this.clearPendingWritesForPath(cp);
|
|
1108
|
+
this.sql.exec("UPDATE inodes SET path=?, parent_path=? WHERE path=?", ncp, ncpp, cp);
|
|
1109
|
+
this.sql.exec("UPDATE file_chunks SET path=? WHERE path=?", ncp, cp);
|
|
1110
|
+
const childInode = this.inodes.get(cp);
|
|
1111
|
+
const oldCpp = childInode.parentPath;
|
|
1112
|
+
this._removeFromChildrenIndex(oldCpp, cp);
|
|
1113
|
+
this.inodes.delete(cp);
|
|
1114
|
+
childInode.path = ncp;
|
|
1115
|
+
childInode.parentPath = ncpp;
|
|
1116
|
+
this.inodes.set(ncp, childInode);
|
|
1117
|
+
this._addToChildrenIndex(ncpp, ncp);
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
this.events.emit('rename', newPath, oldPath);
|
|
1121
|
+
}
|
|
1122
|
+
copyFile(src, dest) {
|
|
1123
|
+
this.writeFile(dest, this.readFile(src));
|
|
1124
|
+
}
|
|
1125
|
+
// ── Batch write (npm install fast path) ───────────────────────────────
|
|
1126
|
+
/**
|
|
1127
|
+
* Atomic bulk write: ALL inodes + chunks in ONE transactionSync().
|
|
1128
|
+
*
|
|
1129
|
+
* Why this exists:
|
|
1130
|
+
* writeFile() does 1 DELETE + 1 INSERT per inode (each auto-committed)
|
|
1131
|
+
* plus deferWrite() which flushes at 500-threshold.
|
|
1132
|
+
* For 30K files: ~60K sync SQL ops → 30-60s, often crashes DO.
|
|
1133
|
+
*
|
|
1134
|
+
* writeBatch() does:
|
|
1135
|
+
* 1 transactionSync() containing:
|
|
1136
|
+
* - N DELETE for old paths (if any)
|
|
1137
|
+
* - Multi-row INSERT for inodes (up to 4000/statement)
|
|
1138
|
+
* - Multi-row INSERT for chunks (up to 200/statement, blob-heavy)
|
|
1139
|
+
* Total: 1 transactionSync() per wave of ~300-500 files.
|
|
1140
|
+
*
|
|
1141
|
+
* Speedup: 60K ops → ~60 ops (1000x fewer transaction commits).
|
|
1142
|
+
*/
|
|
1143
|
+
writeBatch(payload) {
|
|
1144
|
+
// W5 Lever 9: top-level entry. Inner _writeBatchOnce throws on
|
|
1145
|
+
// SQLITE_NOMEM; this wrapper catches, classifies, drops the LRU,
|
|
1146
|
+
// and retries by halving — bounded depth 4. Other errors propagate
|
|
1147
|
+
// unchanged (loud failure preserves the W2.5 error contract for
|
|
1148
|
+
// constraint conflicts etc.).
|
|
1149
|
+
return this._writeBatchWithRetry(payload, 0);
|
|
1150
|
+
}
|
|
1151
|
+
/**
|
|
1152
|
+
* W7 — streaming bulk-write. Same semantics as writeBatch() but
|
|
1153
|
+
* accepts the chunks list as an `AsyncIterable<BatchChunkEntry>`
|
|
1154
|
+
* rather than a fully-realised array.
|
|
1155
|
+
*
|
|
1156
|
+
* v1 (this wave) is "spool-then-commit": we drain the iterator into
|
|
1157
|
+
* an in-memory Array<BatchChunkEntry>, then delegate to writeBatch.
|
|
1158
|
+
* The HEAP-savings claim of W7 lives on the FACET side — by the
|
|
1159
|
+
* time chunks reach this method (post-RPC), they've already
|
|
1160
|
+
* traversed the byte-stream boundary without hitting the 32 MiB
|
|
1161
|
+
* structured-clone cap.
|
|
1162
|
+
*
|
|
1163
|
+
* Heap-correctness wave (N2): the spool is bounded by the peer's
|
|
1164
|
+
* SHARED_RPC_FLUSH_THRESHOLD (4 MiB; see install-batch-facet.ts:196)
|
|
1165
|
+
* — a peer never sends more than ~4 MiB of chunk-bytes per
|
|
1166
|
+
* writeBatchStream RPC, AND workerd's input gate serialises
|
|
1167
|
+
* concurrent RPCs on the same DO. So the supervisor's `chunks: []`
|
|
1168
|
+
* spool peak is ≤ 4 MiB + path-overhead per call.
|
|
1169
|
+
*
|
|
1170
|
+
* What this method DID lack: the spool bytes were invisible to the
|
|
1171
|
+
* heap estimator. We now maintain `_writeStreamSpoolBytes` that
|
|
1172
|
+
* tracks the live spool contents during the drain; the diag's
|
|
1173
|
+
* vfsInFlightBytes contributor now sums pendingWriteBytes +
|
|
1174
|
+
* writeStreamSpoolBytes. The N2 RED probe asserts on this sum.
|
|
1175
|
+
*
|
|
1176
|
+
* Throws on SQLITE_NOMEM (with halve-retry per writeBatch); any
|
|
1177
|
+
* iterator-source error propagates unchanged. Atomicity guarantee
|
|
1178
|
+
* matches writeBatch: either ALL inodes + chunks land in SQLite or
|
|
1179
|
+
* NONE do.
|
|
1180
|
+
*/
|
|
1181
|
+
async writeStream(payload) {
|
|
1182
|
+
// Drain the iterator. Iterator errors propagate unchanged.
|
|
1183
|
+
// We build the chunks array fully BEFORE entering transactionSync
|
|
1184
|
+
// because transactionSync is synchronous (cannot await mid-txn).
|
|
1185
|
+
//
|
|
1186
|
+
// N2 (heap-correctness wave): track spool bytes so the diag
|
|
1187
|
+
// estimator's vfsInFlightBytes is no longer a 0 lie. Increment
|
|
1188
|
+
// per chunk pushed; reset to 0 immediately before delegating to
|
|
1189
|
+
// writeBatch (the bytes are now `pendingWrites' problem if they
|
|
1190
|
+
// survive transactionSync).
|
|
1191
|
+
const chunks = [];
|
|
1192
|
+
try {
|
|
1193
|
+
for await (const c of payload.chunkIter) {
|
|
1194
|
+
chunks.push(c);
|
|
1195
|
+
this._writeStreamSpoolBytes += c.data.length;
|
|
1196
|
+
}
|
|
1197
|
+
const r = this.writeBatch({
|
|
1198
|
+
inodes: payload.inodes,
|
|
1199
|
+
chunks,
|
|
1200
|
+
deletePaths: payload.deletePaths,
|
|
1201
|
+
});
|
|
1202
|
+
return r;
|
|
1203
|
+
}
|
|
1204
|
+
finally {
|
|
1205
|
+
// The bytes are no longer in `chunks` — they've either been
|
|
1206
|
+
// copied into pendingWrites by writeBatch's inner deferWrite
|
|
1207
|
+
// path, or written directly inside transactionSync. Either
|
|
1208
|
+
// way, the spool counter resets here. Subtract by the actual
|
|
1209
|
+
// sum we tracked, NOT by chunks.length × CHUNK_SIZE — chunks
|
|
1210
|
+
// can be smaller than CHUNK_SIZE on the last entry of a file.
|
|
1211
|
+
let drained = 0;
|
|
1212
|
+
for (const c of chunks)
|
|
1213
|
+
drained += c.data.length;
|
|
1214
|
+
this._writeStreamSpoolBytes -= drained;
|
|
1215
|
+
if (this._writeStreamSpoolBytes < 0)
|
|
1216
|
+
this._writeStreamSpoolBytes = 0;
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
_writeBatchWithRetry(payload, depth) {
|
|
1220
|
+
try {
|
|
1221
|
+
return this._writeBatchOnce(payload);
|
|
1222
|
+
}
|
|
1223
|
+
catch (e) {
|
|
1224
|
+
const cause = classifyError(e);
|
|
1225
|
+
// Classify before deciding to retry. Only the SQLITE_NOMEM family
|
|
1226
|
+
// is retryable; constraint conflicts / disk-full / clone-refused
|
|
1227
|
+
// / unknown all surface to the caller (fail loud).
|
|
1228
|
+
const lru = this._cacheBytes;
|
|
1229
|
+
const inFlight = this._estimateBatchBytes(payload);
|
|
1230
|
+
recordFailure({
|
|
1231
|
+
at: Date.now(),
|
|
1232
|
+
phase: 'install',
|
|
1233
|
+
cause,
|
|
1234
|
+
rssEstimateBytes: 0,
|
|
1235
|
+
heapUsedBytes: this._safeHeapUsed(),
|
|
1236
|
+
lruBytes: lru,
|
|
1237
|
+
inFlightBytes: inFlight,
|
|
1238
|
+
lastRpcFrame: null,
|
|
1239
|
+
lastFacetId: null,
|
|
1240
|
+
message: e?.message || String(e),
|
|
1241
|
+
});
|
|
1242
|
+
if (cause !== 'sqlite_nomem')
|
|
1243
|
+
throw e;
|
|
1244
|
+
// Bounded retry depth. 500-row batch → 250 → 125 → ~63 → ~32.
|
|
1245
|
+
// Beyond depth=4, give up: persistent OOM at 32 rows means we
|
|
1246
|
+
// are not the bottleneck.
|
|
1247
|
+
if (depth >= 4)
|
|
1248
|
+
throw e;
|
|
1249
|
+
// Free pages owned by us before re-attempting. evictAll() flushes
|
|
1250
|
+
// dirty entries via deferWrite (existing path) so no data loss.
|
|
1251
|
+
this.evictAll();
|
|
1252
|
+
// Halve the payload by partitioning ALL three lists (inodes,
|
|
1253
|
+
// chunks, deletePaths) by path-set so each half operates on
|
|
1254
|
+
// disjoint paths. Halving is on inodes (the primary index);
|
|
1255
|
+
// chunks and deletePaths are partitioned to match.
|
|
1256
|
+
const halves = this._halveBatchPayload(payload);
|
|
1257
|
+
const r1 = this._writeBatchWithRetry(halves[0], depth + 1);
|
|
1258
|
+
const r2 = this._writeBatchWithRetry(halves[1], depth + 1);
|
|
1259
|
+
return {
|
|
1260
|
+
inodes: r1.inodes + r2.inodes,
|
|
1261
|
+
chunks: r1.chunks + r2.chunks,
|
|
1262
|
+
};
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
/**
|
|
1266
|
+
* Estimate the byte cost of a writeBatch payload. Used by the W5
|
|
1267
|
+
* recordFailure call so /api/_diag/memory can report inFlightBytes
|
|
1268
|
+
* at the moment of the SQLITE_NOMEM. Fast (no copy).
|
|
1269
|
+
*/
|
|
1270
|
+
_estimateBatchBytes(payload) {
|
|
1271
|
+
let n = 0;
|
|
1272
|
+
for (const c of payload.chunks)
|
|
1273
|
+
n += c.data.length;
|
|
1274
|
+
// Path strings + inode header overhead — rough estimate.
|
|
1275
|
+
for (const i of payload.inodes)
|
|
1276
|
+
n += 80 + i.path.length;
|
|
1277
|
+
return n;
|
|
1278
|
+
}
|
|
1279
|
+
/** Best-effort process.memoryUsage().heapUsed; 0 in DO contexts. */
|
|
1280
|
+
_safeHeapUsed() {
|
|
1281
|
+
try {
|
|
1282
|
+
const mu = globalThis.process?.memoryUsage?.();
|
|
1283
|
+
return Number(mu?.heapUsed) || 0;
|
|
1284
|
+
}
|
|
1285
|
+
catch {
|
|
1286
|
+
return 0;
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
/**
|
|
1290
|
+
* Partition a writeBatch payload into two halves with disjoint
|
|
1291
|
+
* path-sets. Preserves the W2.5 invariant: deletePaths and chunks
|
|
1292
|
+
* follow their owning inode into the same half. Used by the
|
|
1293
|
+
* SQLITE_NOMEM retry path.
|
|
1294
|
+
*/
|
|
1295
|
+
_halveBatchPayload(p) {
|
|
1296
|
+
// If there are inodes, halve by inode list and partition chunks +
|
|
1297
|
+
// deletePaths by path-set. If there are NO inodes (chunks-only
|
|
1298
|
+
// batch), halve chunks directly.
|
|
1299
|
+
const inodes = p.inodes ?? [];
|
|
1300
|
+
const chunks = p.chunks ?? [];
|
|
1301
|
+
const dels = p.deletePaths ?? [];
|
|
1302
|
+
if (inodes.length >= 2) {
|
|
1303
|
+
const mid = Math.ceil(inodes.length / 2);
|
|
1304
|
+
const i1 = inodes.slice(0, mid);
|
|
1305
|
+
const i2 = inodes.slice(mid);
|
|
1306
|
+
const set1 = new Set(i1.map(n => n.path));
|
|
1307
|
+
const set2 = new Set(i2.map(n => n.path));
|
|
1308
|
+
const c1 = [];
|
|
1309
|
+
const c2 = [];
|
|
1310
|
+
for (const c of chunks) {
|
|
1311
|
+
if (set1.has(c.path))
|
|
1312
|
+
c1.push(c);
|
|
1313
|
+
else if (set2.has(c.path))
|
|
1314
|
+
c2.push(c);
|
|
1315
|
+
// Chunks orphaned from inodes (defensive) go to half 1.
|
|
1316
|
+
else
|
|
1317
|
+
c1.push(c);
|
|
1318
|
+
}
|
|
1319
|
+
const d1 = [];
|
|
1320
|
+
const d2 = [];
|
|
1321
|
+
for (const d of dels) {
|
|
1322
|
+
if (set1.has(d))
|
|
1323
|
+
d1.push(d);
|
|
1324
|
+
else if (set2.has(d))
|
|
1325
|
+
d2.push(d);
|
|
1326
|
+
else
|
|
1327
|
+
d1.push(d);
|
|
1328
|
+
}
|
|
1329
|
+
return [
|
|
1330
|
+
{ inodes: i1, chunks: c1, deletePaths: d1 },
|
|
1331
|
+
{ inodes: i2, chunks: c2, deletePaths: d2 },
|
|
1332
|
+
];
|
|
1333
|
+
}
|
|
1334
|
+
// No inodes — chunks-only or delete-only payload. Halve directly.
|
|
1335
|
+
if (chunks.length >= 2) {
|
|
1336
|
+
const mid = Math.ceil(chunks.length / 2);
|
|
1337
|
+
return [
|
|
1338
|
+
{ inodes: [], chunks: chunks.slice(0, mid), deletePaths: dels },
|
|
1339
|
+
{ inodes: [], chunks: chunks.slice(mid), deletePaths: [] },
|
|
1340
|
+
];
|
|
1341
|
+
}
|
|
1342
|
+
if (dels.length >= 2) {
|
|
1343
|
+
const mid = Math.ceil(dels.length / 2);
|
|
1344
|
+
return [
|
|
1345
|
+
{ inodes: [], chunks: [], deletePaths: dels.slice(0, mid) },
|
|
1346
|
+
{ inodes: [], chunks: [], deletePaths: dels.slice(mid) },
|
|
1347
|
+
];
|
|
1348
|
+
}
|
|
1349
|
+
// Single-item payload — can't halve further; return original + empty.
|
|
1350
|
+
return [p, { inodes: [], chunks: [], deletePaths: [] }];
|
|
1351
|
+
}
|
|
1352
|
+
_writeBatchOnce(payload) {
|
|
1353
|
+
let inodeCount = 0;
|
|
1354
|
+
let chunkCount = 0;
|
|
1355
|
+
// Invalidate cache for all affected paths before writing.
|
|
1356
|
+
// Audit R2: the previous per-path loop was O(P × C) — each
|
|
1357
|
+
// cacheInvalidate/clearPendingWritesForPath did a full scan of
|
|
1358
|
+
// the 512-entry cache / pendingWrites map. For a 500-path wave
|
|
1359
|
+
// that was ~256K comparisons; a 30K-file git clone paid ~15M
|
|
1360
|
+
// total. The cacheInvalidateBatch / clearPendingWritesForPaths
|
|
1361
|
+
// helpers do a single pass each — O(P + C).
|
|
1362
|
+
const affectedPaths = new Set();
|
|
1363
|
+
for (const inode of payload.inodes) {
|
|
1364
|
+
if (!inode.isDir)
|
|
1365
|
+
affectedPaths.add(inode.path);
|
|
1366
|
+
}
|
|
1367
|
+
for (const chunk of payload.chunks) {
|
|
1368
|
+
affectedPaths.add(chunk.path);
|
|
1369
|
+
}
|
|
1370
|
+
if (payload.deletePaths) {
|
|
1371
|
+
for (const p of payload.deletePaths)
|
|
1372
|
+
affectedPaths.add(p);
|
|
1373
|
+
}
|
|
1374
|
+
this.cacheInvalidateBatch(affectedPaths, true);
|
|
1375
|
+
this.clearPendingWritesForPaths(affectedPaths);
|
|
1376
|
+
try {
|
|
1377
|
+
const doTransaction = (fn) => {
|
|
1378
|
+
if (this.ctx?.storage?.transactionSync) {
|
|
1379
|
+
this.ctx.storage.transactionSync(fn);
|
|
1380
|
+
}
|
|
1381
|
+
else {
|
|
1382
|
+
fn();
|
|
1383
|
+
}
|
|
1384
|
+
};
|
|
1385
|
+
doTransaction(() => {
|
|
1386
|
+
// 1. Delete old paths
|
|
1387
|
+
if (payload.deletePaths?.length) {
|
|
1388
|
+
for (const path of payload.deletePaths) {
|
|
1389
|
+
this.sql.exec("DELETE FROM file_chunks WHERE path = ?", path);
|
|
1390
|
+
this.sql.exec("DELETE FROM inodes WHERE path = ?", path);
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
// 2. Batch insert inodes — multi-row VALUES.
|
|
1394
|
+
// DO SQLite has a low bind-parameter limit (~100 variables).
|
|
1395
|
+
// 7 columns per inode → max 14 rows per statement (14×7=98).
|
|
1396
|
+
const INODE_BATCH = 14;
|
|
1397
|
+
for (let i = 0; i < payload.inodes.length; i += INODE_BATCH) {
|
|
1398
|
+
const batch = payload.inodes.slice(i, i + INODE_BATCH);
|
|
1399
|
+
const placeholders = batch.map(() => '(?,?,?,?,?,?,?)').join(',');
|
|
1400
|
+
const values = [];
|
|
1401
|
+
for (const n of batch) {
|
|
1402
|
+
values.push(n.path, n.parentPath, n.isDir ? 1 : 0, n.size, n.mtime, n.mode, n.chunkCount);
|
|
1403
|
+
}
|
|
1404
|
+
this.sql.exec(`INSERT OR REPLACE INTO inodes (path, parent_path, is_dir, size, mtime, mode, chunk_count) VALUES ${placeholders}`, ...values);
|
|
1405
|
+
inodeCount += batch.length;
|
|
1406
|
+
}
|
|
1407
|
+
// 3. Batch insert chunks — multi-row VALUES.
|
|
1408
|
+
// 3 columns per chunk → max 33 rows per statement (33×3=99).
|
|
1409
|
+
const CHUNK_BATCH = 33;
|
|
1410
|
+
for (let i = 0; i < payload.chunks.length; i += CHUNK_BATCH) {
|
|
1411
|
+
const batch = payload.chunks.slice(i, i + CHUNK_BATCH);
|
|
1412
|
+
const placeholders = batch.map(() => '(?,?,?)').join(',');
|
|
1413
|
+
const values = [];
|
|
1414
|
+
for (const c of batch) {
|
|
1415
|
+
values.push(c.path, c.chunkId, c.data);
|
|
1416
|
+
}
|
|
1417
|
+
this.sql.exec(`INSERT OR REPLACE INTO file_chunks (path, chunk_id, data) VALUES ${placeholders}`, ...values);
|
|
1418
|
+
chunkCount += batch.length;
|
|
1419
|
+
}
|
|
1420
|
+
});
|
|
1421
|
+
}
|
|
1422
|
+
catch (e) {
|
|
1423
|
+
console.error('[sqlite-vfs] writeBatch failed:', e?.message);
|
|
1424
|
+
throw e;
|
|
1425
|
+
}
|
|
1426
|
+
// 4. Update in-memory inode tree + children index (outside transaction — fast).
|
|
1427
|
+
// B3: also maintain running counters in sync. For each payload entry,
|
|
1428
|
+
// compute the delta against any pre-existing inode at that path.
|
|
1429
|
+
//
|
|
1430
|
+
// W2.5a (audit/sections/W2.5-plan.md): the children-index call is now
|
|
1431
|
+
// OUTSIDE the `prior === undefined` guard. Pre-W2.5a, ~37 of 46 packages
|
|
1432
|
+
// per `npm install fastify` accumulated inodes in SQL but never reached
|
|
1433
|
+
// the in-memory `this.children` index because some path's `prior` was
|
|
1434
|
+
// unexpectedly defined when the package's writeBatch arrived (root
|
|
1435
|
+
// cause unidentified — see §4.2 diagnostic plan in W2.5-plan.md).
|
|
1436
|
+
// `_addToChildrenIndex` uses Set.add so repeated calls are idempotent;
|
|
1437
|
+
// gating it on `prior === undefined` was the bug. Counters remain
|
|
1438
|
+
// gated correctly so they don't double-count.
|
|
1439
|
+
const __diag = (globalThis.process?.env?.NIMBUS_DIAG_INSTALL_PIPELINE === '1');
|
|
1440
|
+
for (const entry of payload.inodes) {
|
|
1441
|
+
const prior = this.inodes.get(entry.path);
|
|
1442
|
+
const node = {
|
|
1443
|
+
path: entry.path,
|
|
1444
|
+
parentPath: entry.parentPath,
|
|
1445
|
+
isDir: entry.isDir,
|
|
1446
|
+
size: entry.size,
|
|
1447
|
+
mtime: entry.mtime,
|
|
1448
|
+
mode: entry.mode,
|
|
1449
|
+
chunkCount: entry.chunkCount,
|
|
1450
|
+
};
|
|
1451
|
+
this.inodes.set(entry.path, node);
|
|
1452
|
+
// ALWAYS re-affirm the children-index entry. Idempotent.
|
|
1453
|
+
this._addToChildrenIndex(entry.parentPath, entry.path);
|
|
1454
|
+
// W2.5b diagnostic: log every "stale prior" case where we'd have
|
|
1455
|
+
// skipped the index call pre-W2.5a. Reveals which paths were
|
|
1456
|
+
// pre-populated in this.inodes by a code path other than the
|
|
1457
|
+
// current writeBatch — H5a / H8 candidate.
|
|
1458
|
+
if (__diag && prior !== undefined) {
|
|
1459
|
+
const indexed = this.children.get(entry.parentPath)?.has(entry.path) ?? false;
|
|
1460
|
+
// eslint-disable-next-line no-console
|
|
1461
|
+
console.warn('[sqlite-vfs/W2.5b] stale-prior path=' + entry.path +
|
|
1462
|
+
' parent=' + entry.parentPath +
|
|
1463
|
+
' priorParent=' + prior.parentPath +
|
|
1464
|
+
' priorIsDir=' + prior.isDir +
|
|
1465
|
+
' entryIsDir=' + entry.isDir +
|
|
1466
|
+
' indexedBefore=' + indexed);
|
|
1467
|
+
}
|
|
1468
|
+
// Counter delta — gated on prior so we don't double-count.
|
|
1469
|
+
if (prior === undefined) {
|
|
1470
|
+
if (entry.isDir)
|
|
1471
|
+
this._totalDirs++;
|
|
1472
|
+
else {
|
|
1473
|
+
this._totalFiles++;
|
|
1474
|
+
this._usedBytes += entry.size;
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
else {
|
|
1478
|
+
// Replace: handle dir↔file flip + size delta. (Identical to pre-W2.5a.)
|
|
1479
|
+
if (prior.isDir && !entry.isDir) {
|
|
1480
|
+
this._totalDirs--;
|
|
1481
|
+
this._totalFiles++;
|
|
1482
|
+
this._usedBytes += entry.size;
|
|
1483
|
+
}
|
|
1484
|
+
else if (!prior.isDir && entry.isDir) {
|
|
1485
|
+
this._totalFiles--;
|
|
1486
|
+
this._usedBytes -= prior.size;
|
|
1487
|
+
this._totalDirs++;
|
|
1488
|
+
}
|
|
1489
|
+
else if (!entry.isDir) {
|
|
1490
|
+
// File-replace: size delta only.
|
|
1491
|
+
this._usedBytes += entry.size - prior.size;
|
|
1492
|
+
}
|
|
1493
|
+
// Dir-replace (both dir): no counter change.
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
// Note: payload.deletePaths only removes from SQL, not from
|
|
1497
|
+
// this.inodes (pre-existing behaviour — see writeBatch SQL section
|
|
1498
|
+
// around line 1020). Counters therefore track in-memory state,
|
|
1499
|
+
// which matches what getStats() observes. If the in-memory cleanup
|
|
1500
|
+
// of deletePaths is ever added, update the counters there too.
|
|
1501
|
+
// 5. Fire events for VFS-aware subscribers (HMR etc.)
|
|
1502
|
+
for (const entry of payload.inodes) {
|
|
1503
|
+
if (entry.isDir) {
|
|
1504
|
+
this.events.emit('addDir', entry.path);
|
|
1505
|
+
}
|
|
1506
|
+
else {
|
|
1507
|
+
this.events.emit('add', entry.path);
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
this._sqlWrites += inodeCount + chunkCount;
|
|
1511
|
+
this._batchWrites++;
|
|
1512
|
+
this._batchWriteRows += inodeCount + chunkCount;
|
|
1513
|
+
return { inodes: inodeCount, chunks: chunkCount };
|
|
1514
|
+
}
|
|
1515
|
+
/**
|
|
1516
|
+
* Bulk mkdir: create all directories in a single transactionSync.
|
|
1517
|
+
* Pre-creates the full directory tree before file writes to avoid
|
|
1518
|
+
* per-file mkdir overhead.
|
|
1519
|
+
*/
|
|
1520
|
+
mkdirBatch(paths) {
|
|
1521
|
+
const mtime = Date.now();
|
|
1522
|
+
const toCreate = [];
|
|
1523
|
+
const seen = new Set();
|
|
1524
|
+
for (const path of paths) {
|
|
1525
|
+
const parts = path.split('/').filter(Boolean);
|
|
1526
|
+
let current = '';
|
|
1527
|
+
for (const part of parts) {
|
|
1528
|
+
current = current ? current + '/' + part : part;
|
|
1529
|
+
if (!seen.has(current) && !this.exists(current)) {
|
|
1530
|
+
seen.add(current);
|
|
1531
|
+
toCreate.push({
|
|
1532
|
+
path: current,
|
|
1533
|
+
parentPath: this.parentPath(current),
|
|
1534
|
+
isDir: true,
|
|
1535
|
+
size: 0,
|
|
1536
|
+
mtime,
|
|
1537
|
+
mode: 0o755,
|
|
1538
|
+
chunkCount: 0,
|
|
1539
|
+
});
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
if (toCreate.length === 0)
|
|
1544
|
+
return 0;
|
|
1545
|
+
this.writeBatch({ inodes: toCreate, chunks: [] });
|
|
1546
|
+
return toCreate.length;
|
|
1547
|
+
}
|
|
1548
|
+
// ── Stats ─────────────────────────────────────────────────────────────
|
|
1549
|
+
/**
|
|
1550
|
+
* Debug-only: recompute counters from scratch and return any drift
|
|
1551
|
+
* against the running counters. Returns null if consistent. Used by
|
|
1552
|
+
* the B3 runtime test; production paths should never call this
|
|
1553
|
+
* (the whole point of B3 is avoiding the O(N) walk).
|
|
1554
|
+
*/
|
|
1555
|
+
_verifyCounters() {
|
|
1556
|
+
let f = 0, d = 0, b = 0;
|
|
1557
|
+
for (const inode of this.inodes.values()) {
|
|
1558
|
+
if (inode.isDir)
|
|
1559
|
+
d++;
|
|
1560
|
+
else {
|
|
1561
|
+
f++;
|
|
1562
|
+
b += inode.size;
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
if (f === this._totalFiles && d === this._totalDirs && b === this._usedBytes)
|
|
1566
|
+
return null;
|
|
1567
|
+
return {
|
|
1568
|
+
expected: { files: f, dirs: d, bytes: b },
|
|
1569
|
+
actual: { files: this._totalFiles, dirs: this._totalDirs, bytes: this._usedBytes },
|
|
1570
|
+
};
|
|
1571
|
+
}
|
|
1572
|
+
getStats() {
|
|
1573
|
+
// B3: O(1) — read the running counters. Previously three passes
|
|
1574
|
+
// over this.inodes (two filter + one for-of); at 50K inodes that
|
|
1575
|
+
// was 150K iterations per poll, every 5 s, serialising on the
|
|
1576
|
+
// input gate alongside shell keystrokes (AUDIT M10 / M-S8).
|
|
1577
|
+
const totalFiles = this._totalFiles;
|
|
1578
|
+
const totalDirs = this._totalDirs;
|
|
1579
|
+
const usedBytes = this._usedBytes;
|
|
1580
|
+
const totalAccesses = this._cacheHits + this._cacheMisses;
|
|
1581
|
+
const hitRate = totalAccesses > 0 ? (this._cacheHits / totalAccesses * 100) : 0;
|
|
1582
|
+
return {
|
|
1583
|
+
// Legacy compat
|
|
1584
|
+
files: totalFiles,
|
|
1585
|
+
directories: totalDirs,
|
|
1586
|
+
usedBytes,
|
|
1587
|
+
capacityBytes: 10 * 1024 * 1024 * 1024, // 10 GB
|
|
1588
|
+
backend: 'DO SQLite (demand-paged VFS)',
|
|
1589
|
+
// Cache stats. maxEntries / maxBytes are now W5-runtime-mutable —
|
|
1590
|
+
// shrinkForInstall() drops them, restoreAfterInstall() restores.
|
|
1591
|
+
// lruShrunk is the at-a-glance signal for /api/_diag/memory.
|
|
1592
|
+
cache: {
|
|
1593
|
+
entries: this.cache.size,
|
|
1594
|
+
maxEntries: this._lruMaxEntries,
|
|
1595
|
+
chunkSize: CHUNK_SIZE,
|
|
1596
|
+
hotBytes: this._cacheBytes,
|
|
1597
|
+
maxBytes: this._lruMaxEntries * CHUNK_SIZE,
|
|
1598
|
+
hits: this._cacheHits,
|
|
1599
|
+
misses: this._cacheMisses,
|
|
1600
|
+
hitRate: Math.round(hitRate * 100) / 100,
|
|
1601
|
+
evictions: this._evictions,
|
|
1602
|
+
lruShrunk: this._lruMaxEntries < LRU_MAX_ENTRIES,
|
|
1603
|
+
},
|
|
1604
|
+
// SQL I/O stats
|
|
1605
|
+
sql: {
|
|
1606
|
+
reads: this._sqlReads,
|
|
1607
|
+
writes: this._sqlWrites,
|
|
1608
|
+
batchWrites: this._batchWrites,
|
|
1609
|
+
batchWriteRows: this._batchWriteRows,
|
|
1610
|
+
pendingWrites: this.pendingWrites.size,
|
|
1611
|
+
// N3 (heap-correctness wave): real byte sum of the
|
|
1612
|
+
// pendingWrites Map. Source-of-truth for the heap-estimator's
|
|
1613
|
+
// vfsInFlightBytes contributor (was hardcoded 0 at routes.ts:347
|
|
1614
|
+
// pre-fix). Adding this AS WELL AS the entry count above so
|
|
1615
|
+
// ops dashboards that already plot the count don't break, while
|
|
1616
|
+
// memory triage gets the actual byte number.
|
|
1617
|
+
pendingWriteBytes: this._pendingWriteBytes,
|
|
1618
|
+
// N2 (heap-correctness wave): live byte count inside the
|
|
1619
|
+
// writeStream() drain spool. Non-zero only during the brief
|
|
1620
|
+
// window between the first chunk arriving and writeBatch
|
|
1621
|
+
// returning — bounded by SHARED_RPC_FLUSH_THRESHOLD (4 MiB)
|
|
1622
|
+
// on the peer side.
|
|
1623
|
+
writeStreamSpoolBytes: this._writeStreamSpoolBytes,
|
|
1624
|
+
failedWrites: this.failedWrites.size,
|
|
1625
|
+
totalWriteFailures: this._writeFailures,
|
|
1626
|
+
},
|
|
1627
|
+
// Event stats
|
|
1628
|
+
events: this.events.stats,
|
|
1629
|
+
// INode stats
|
|
1630
|
+
inodes: {
|
|
1631
|
+
total: this.inodes.size,
|
|
1632
|
+
files: totalFiles,
|
|
1633
|
+
directories: totalDirs,
|
|
1634
|
+
memoryEstimate: this.inodes.size * 200, // ~200 bytes per entry
|
|
1635
|
+
},
|
|
1636
|
+
};
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
// ── SqliteVFSProvider (MountProvider for Nimbus Kernel VFS) ────────────────────
|
|
1640
|
+
export class SqliteVFSProvider {
|
|
1641
|
+
vfs;
|
|
1642
|
+
prefix;
|
|
1643
|
+
constructor(vfs, prefix) {
|
|
1644
|
+
this.vfs = vfs;
|
|
1645
|
+
this.prefix = prefix.replace(/^\/+/, '').replace(/\/+$/, '');
|
|
1646
|
+
}
|
|
1647
|
+
resolve(sub) {
|
|
1648
|
+
const c = sub.replace(/^\/+/, '').replace(/\/+$/, '');
|
|
1649
|
+
return c ? this.prefix + '/' + c : this.prefix;
|
|
1650
|
+
}
|
|
1651
|
+
readFile(sub) { return this.vfs.readFile(this.resolve(sub)); }
|
|
1652
|
+
readFileString(sub) { return this.vfs.readFileString(this.resolve(sub)); }
|
|
1653
|
+
writeFile(sub, content) {
|
|
1654
|
+
const fp = this.resolve(sub);
|
|
1655
|
+
const pp = fp.includes('/') ? fp.substring(0, fp.lastIndexOf('/')) : '';
|
|
1656
|
+
if (pp && !this.vfs.exists(pp))
|
|
1657
|
+
this.vfs.mkdir(pp, { recursive: true });
|
|
1658
|
+
this.vfs.writeFile(fp, content);
|
|
1659
|
+
}
|
|
1660
|
+
exists(sub) { return this.vfs.exists(this.resolve(sub)); }
|
|
1661
|
+
stat(sub) { return this.vfs.stat(this.resolve(sub)); }
|
|
1662
|
+
readdir(sub) { return this.vfs.readdir(this.resolve(sub)); }
|
|
1663
|
+
unlink(sub) { this.vfs.unlink(this.resolve(sub)); }
|
|
1664
|
+
mkdir(sub, opts) {
|
|
1665
|
+
this.vfs.mkdir(this.resolve(sub), opts);
|
|
1666
|
+
}
|
|
1667
|
+
rmdir(sub) { this.vfs.rmdir(this.resolve(sub)); }
|
|
1668
|
+
rename(o, n) { this.vfs.rename(this.resolve(o), this.resolve(n)); }
|
|
1669
|
+
copyFile(s, d) { this.vfs.copyFile(this.resolve(s), this.resolve(d)); }
|
|
1670
|
+
}
|