@mandujs/core 0.54.32 → 0.55.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +654 -654
- package/package.json +106 -210
- package/scripts/postinstall-lock.ts +153 -153
- package/src/a11y/__tests__/run-audit.test.ts +333 -333
- package/src/a11y/fix-hints.ts +76 -76
- package/src/a11y/index.ts +18 -18
- package/src/a11y/run-audit.ts +15 -15
- package/src/a11y/types.ts +125 -125
- package/src/agent/__tests__/apply.test.ts +224 -0
- package/src/agent/__tests__/context.test.ts +97 -98
- package/src/agent/apply.ts +1031 -0
- package/src/agent/context.ts +552 -552
- package/src/agent/index.ts +4 -3
- package/src/agent/plan.ts +161 -243
- package/src/agent/repair.ts +239 -162
- package/src/agent/sync.ts +197 -198
- package/src/agent/types.ts +242 -94
- package/src/agent/verify.ts +55 -55
- package/src/auth/__tests__/login.test.ts +1 -1
- package/src/auth/__tests__/password.test.ts +15 -5
- package/src/auth/__tests__/reset.test.ts +2 -2
- package/src/auth/__tests__/tokens.test.ts +274 -274
- package/src/auth/__tests__/verification.test.ts +274 -274
- package/src/auth/index.ts +76 -76
- package/src/auth/login.ts +225 -225
- package/src/auth/password.ts +14 -2
- package/src/auth/reset.ts +243 -243
- package/src/auth/tokens.ts +612 -612
- package/src/auth/verification.ts +253 -253
- package/src/brain/__tests__/redactor.test.ts +94 -94
- package/src/brain/adapters/__tests__/_helpers.ts +83 -83
- package/src/brain/adapters/__tests__/anthropic-oauth.test.ts +196 -196
- package/src/brain/adapters/__tests__/chatgpt-auth.test.ts +193 -193
- package/src/brain/adapters/__tests__/openai-oauth.test.ts +209 -209
- package/src/brain/adapters/__tests__/resolver.test.ts +143 -143
- package/src/brain/adapters/anthropic-oauth.ts +1 -1
- package/src/brain/adapters/chatgpt-auth.ts +300 -300
- package/src/brain/adapters/index.ts +319 -319
- package/src/brain/adapters/oauth-flow.ts +439 -439
- package/src/brain/consent.ts +240 -240
- package/src/brain/credentials.ts +396 -396
- package/src/brain/doctor/analyzer.ts +7 -7
- package/src/bundler/__snapshots__/build.test.ts.snap +5 -5
- package/src/bundler/__tests__/build-runner.ts +166 -166
- package/src/bundler/__tests__/client-boundary-transform.test.ts +524 -524
- package/src/bundler/__tests__/cold-start.test.ts +60 -60
- package/src/bundler/__tests__/css.test.ts +49 -20
- package/src/bundler/__tests__/dev-reliability.test.ts +619 -619
- package/src/bundler/__tests__/extended-watch.test.ts +711 -711
- package/src/bundler/__tests__/fast-refresh.test.ts +24 -24
- package/src/bundler/__tests__/generation.test.ts +447 -0
- package/src/bundler/__tests__/hdr.test.ts +24 -18
- package/src/bundler/__tests__/hmr-client.test.ts +62 -24
- package/src/bundler/__tests__/jsx-runtime-shim.test.ts +179 -140
- package/src/bundler/__tests__/manifest-schema.test.ts +305 -266
- package/src/bundler/__tests__/prod-smoke.test.ts +138 -138
- package/src/bundler/__tests__/reverse-import-graph.test.ts +42 -42
- package/src/bundler/__tests__/slot-dispatch.test.ts +573 -573
- package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -286
- package/src/bundler/__tests__/vendor-cache.test.ts +455 -455
- package/src/bundler/analyzer.ts +15 -15
- package/src/bundler/budget.ts +404 -404
- package/src/bundler/build.test.ts +898 -915
- package/src/bundler/build.ts +113 -24
- package/src/bundler/client-boundary-transform.ts +977 -977
- package/src/bundler/css.ts +65 -44
- package/src/bundler/dev.ts +105 -73
- package/src/bundler/fast-refresh-preamble.ts +47 -47
- package/src/bundler/generation.ts +602 -0
- package/src/bundler/index.ts +3 -3
- package/src/bundler/manifest-schema.ts +55 -40
- package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +13 -13
- package/src/bundler/plugins/__tests__/react-compiler-config.test.ts +83 -83
- package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -110
- package/src/bundler/plugins/block-generated-imports.ts +16 -15
- package/src/bundler/plugins/index.ts +83 -83
- package/src/bundler/plugins/react-compiler-config.ts +108 -108
- package/src/bundler/plugins/react-compiler-lint.ts +253 -253
- package/src/bundler/plugins/react-compiler.ts +162 -162
- package/src/bundler/prerender.ts +29 -9
- package/src/bundler/reverse-import-graph.ts +339 -339
- package/src/bundler/safe-build.test.ts +1 -1
- package/src/bundler/safe-build.ts +103 -103
- package/src/bundler/scenario-matrix.ts +229 -229
- package/src/bundler/types.ts +58 -56
- package/src/bundler/vendor-cache-types.ts +130 -130
- package/src/bundler/vendor-cache.ts +526 -526
- package/src/change/snapshot.ts +18 -18
- package/src/client/Form.tsx +105 -105
- package/src/client/Link.tsx +9 -9
- package/src/client/__tests__/props-serialization.test.ts +37 -37
- package/src/client/__tests__/use-sse.test.ts +153 -153
- package/src/client/globals.ts +1 -1
- package/src/client/hooks.ts +362 -362
- package/src/client/hydrate.ts +2 -2
- package/src/client/index.ts +1 -1
- package/src/client/island.ts +79 -79
- package/src/client/prefetch-helper.ts +55 -55
- package/src/client/props-serialization.ts +233 -233
- package/src/client/runtime-entry.ts +598 -598
- package/src/client/runtime.ts +1 -1
- package/src/client/serialize.ts +50 -50
- package/src/client/use-fetch.ts +6 -6
- package/src/client/use-head.ts +197 -197
- package/src/client/use-sse.ts +378 -378
- package/src/client/window-state.ts +101 -101
- package/src/components/Image-compat.ts +3 -0
- package/src/components/Image.tsx +162 -162
- package/src/config/validate.ts +1 -1
- package/src/config/watcher.ts +311 -311
- package/src/constants.ts +40 -40
- package/src/content/collection.ts +9 -9
- package/src/content/content-layer.ts +7 -7
- package/src/content/data-store.ts +245 -245
- package/src/content/frontmatter.ts +189 -189
- package/src/content/generate-types.ts +1 -1
- package/src/content/index.ts +2 -2
- package/src/content/loader-context.ts +171 -171
- package/src/content/loaders/api.ts +216 -216
- package/src/content/loaders/file.ts +172 -172
- package/src/content/loaders/glob.ts +253 -253
- package/src/content/loaders/index.ts +34 -34
- package/src/content/loaders/types.ts +137 -137
- package/src/content/meta-store.ts +209 -209
- package/src/content/prebuild.test.ts +571 -571
- package/src/content/prebuild.ts +636 -636
- package/src/content/schema.ts +20 -20
- package/src/content/sidebar.ts +630 -630
- package/src/content/slug.ts +110 -110
- package/src/content/types.ts +282 -282
- package/src/content/watcher.ts +135 -135
- package/src/contract/client-safe.test.ts +42 -42
- package/src/contract/client-safe.ts +114 -114
- package/src/contract/define.ts +11 -11
- package/src/contract/index.ts +1 -1
- package/src/contract/normalize.test.ts +276 -276
- package/src/contract/normalize.ts +410 -410
- package/src/contract/registry.test.ts +206 -206
- package/src/contract/route-helpers.ts +1 -1
- package/src/contract/rpc.ts +443 -443
- package/src/contract/types.ts +58 -58
- package/src/db/__tests__/db.test.ts +9 -9
- package/src/db/index.ts +2 -2
- package/src/db/migrations/history-table.ts +345 -345
- package/src/db/migrations/index.ts +3 -3
- package/src/db/migrations/lock.ts +324 -324
- package/src/db/migrations/runner.ts +650 -650
- package/src/deploy/cache.ts +140 -140
- package/src/deploy/compile/vercel.ts +344 -344
- package/src/deploy/index.ts +87 -87
- package/src/deploy/inference/brain.ts +268 -268
- package/src/deploy/inference/context.ts +82 -82
- package/src/deploy/inference/filling-extract.ts +245 -245
- package/src/deploy/inference/heuristic.ts +182 -182
- package/src/deploy/intent.ts +173 -173
- package/src/deploy/plan.ts +178 -178
- package/src/design/__tests__/agents-link.test.ts +109 -109
- package/src/design/__tests__/extract-patch-diff.test.ts +265 -265
- package/src/design/__tests__/lint.test.ts +110 -110
- package/src/design/__tests__/parser.test.ts +195 -195
- package/src/design/__tests__/tailwind-theme.test.ts +229 -229
- package/src/design/agents-link.ts +165 -165
- package/src/design/diff.ts +138 -138
- package/src/design/extract.ts +285 -285
- package/src/design/index.ts +102 -102
- package/src/design/lint.ts +209 -209
- package/src/design/parser.ts +555 -555
- package/src/design/patch.ts +241 -241
- package/src/design/scaffold.ts +147 -147
- package/src/design/tailwind-theme.ts +441 -441
- package/src/design/types.ts +210 -210
- package/src/desktop/__tests__/smoke.test.ts +2 -2
- package/src/desktop/__tests__/webview-fallback.test.ts +12 -32
- package/src/desktop/__tests__/window.test.ts +20 -99
- package/src/desktop/__tests__/worker.test.ts +266 -266
- package/src/desktop/index.ts +43 -43
- package/src/desktop/types.ts +158 -158
- package/src/desktop/webview-fallback.ts +17 -4
- package/src/desktop/window.ts +17 -8
- package/src/desktop/worker.ts +180 -180
- package/src/dev-error-overlay/__tests__/overlay-injector.test.ts +241 -241
- package/src/dev-error-overlay/index.ts +30 -30
- package/src/dev-error-overlay/overlay-injector.ts +243 -243
- package/src/dev-error-overlay/overlay-styles.ts +52 -52
- package/src/dev-error-overlay/types.ts +66 -66
- package/src/devtools/ai/context-builder.ts +375 -375
- package/src/devtools/ai/index.ts +25 -25
- package/src/devtools/ai/mcp-connector.ts +25 -25
- package/src/devtools/client/catchers/error-catcher.ts +344 -344
- package/src/devtools/client/catchers/index.ts +18 -18
- package/src/devtools/client/components/index.ts +39 -39
- package/src/devtools/client/components/mandu-character.tsx +331 -331
- package/src/devtools/client/components/panel/errors-panel.tsx +259 -259
- package/src/devtools/client/components/panel/guard-panel.tsx +30 -30
- package/src/devtools/client/components/panel/islands-panel.tsx +16 -16
- package/src/devtools/client/components/panel/network-panel.tsx +291 -291
- package/src/devtools/client/components/panel/panel-container.tsx +1 -1
- package/src/devtools/client/components/panel/preview-panel.tsx +46 -46
- package/src/devtools/client/filters/context-filters.ts +282 -282
- package/src/devtools/client/filters/index.ts +16 -16
- package/src/devtools/client/index.ts +63 -63
- package/src/devtools/hook/create-hook.ts +207 -207
- package/src/devtools/hook/index.ts +13 -13
- package/src/devtools/index.ts +439 -439
- package/src/devtools/init.ts +265 -265
- package/src/devtools/protocol.ts +237 -237
- package/src/devtools/server/index.ts +17 -17
- package/src/devtools/types.ts +35 -35
- package/src/devtools/worker/index.ts +25 -25
- package/src/devtools/worker/redaction-worker.ts +233 -233
- package/src/diagnose/__tests__/checks.test.ts +136 -136
- package/src/diagnose/checks.ts +185 -185
- package/src/diagnose/index.ts +17 -17
- package/src/diagnose/run.ts +10 -10
- package/src/diagnose/types.ts +53 -53
- package/src/email/__tests__/email.test.ts +355 -355
- package/src/email/index.ts +282 -282
- package/src/email/smtp.ts +64 -64
- package/src/error/domains.ts +265 -265
- package/src/error/types.ts +6 -6
- package/src/errors/extractor.ts +409 -409
- package/src/errors/index.ts +19 -19
- package/src/filling/__tests__/session-sqlite.test.ts +5 -1
- package/src/filling/auth.ts +308 -308
- package/src/filling/body-parse.test.ts +60 -60
- package/src/filling/cookie-codec.ts +5 -3
- package/src/filling/deps.ts +265 -265
- package/src/filling/head-method.test.ts +154 -154
- package/src/filling/session-sqlite.ts +617 -617
- package/src/filling/sse.ts +5 -5
- package/src/filling/ws.ts +78 -78
- package/src/generator/generate.ts +30 -30
- package/src/generator/index.ts +3 -3
- package/src/generator/templates.test.ts +48 -48
- package/src/generator/templates.ts +219 -219
- package/src/guard/__tests__/design-inline-class.test.ts +219 -219
- package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -347
- package/src/guard/analyzer.ts +360 -360
- package/src/guard/auto-correct.ts +1 -1
- package/src/guard/check.ts +39 -24
- package/src/guard/config-guard.ts +13 -13
- package/src/guard/contract-guard.ts +9 -9
- package/src/guard/define-rule.ts +243 -243
- package/src/guard/design-inline-class.ts +393 -393
- package/src/guard/file-type.test.ts +24 -24
- package/src/guard/fs-routes-policy.ts +51 -51
- package/src/guard/graph.ts +1 -1
- package/src/guard/healing.ts +36 -36
- package/src/guard/index.ts +11 -11
- package/src/guard/presets/atomic.ts +70 -70
- package/src/guard/presets/clean.ts +77 -77
- package/src/guard/presets/fsd.ts +79 -79
- package/src/guard/presets/hexagonal.ts +68 -68
- package/src/guard/reporter.ts +442 -442
- package/src/guard/rule-presets.ts +379 -379
- package/src/guard/semantic-slots.ts +1 -1
- package/src/guard/suggestions.ts +358 -358
- package/src/guard/tsgolint-bridge.ts +512 -512
- package/src/guard/types.ts +348 -348
- package/src/guard/watcher.ts +405 -405
- package/src/i18n/define.ts +126 -126
- package/src/i18n/index.ts +52 -52
- package/src/i18n/message-registry.ts +173 -173
- package/src/i18n/types.ts +112 -112
- package/src/id/__tests__/id.test.ts +3 -3
- package/src/id/index.ts +105 -105
- package/src/index.ts +9 -48
- package/src/internal/client-boundary.ts +266 -266
- package/src/internal/index.ts +2 -2
- package/src/kitchen/api/agent-devtools-api.ts +779 -779
- package/src/kitchen/api/bundle-inspector.ts +179 -0
- package/src/kitchen/api/errors-grouping.ts +126 -126
- package/src/kitchen/api/file-api.ts +11 -11
- package/src/kitchen/kitchen-handler.ts +36 -0
- package/src/kitchen/kitchen-ui.ts +456 -0
- package/src/logging/index.ts +22 -22
- package/src/logging/transports.ts +365 -365
- package/src/middleware/bridge.ts +147 -147
- package/src/middleware/compose.ts +134 -134
- package/src/middleware/compress.ts +62 -62
- package/src/middleware/cors.ts +47 -47
- package/src/middleware/csrf.ts +328 -328
- package/src/middleware/define.ts +132 -132
- package/src/middleware/jwt.ts +134 -134
- package/src/middleware/logger.ts +58 -58
- package/src/middleware/oauth/__tests__/oauth.test.ts +1 -1
- package/src/middleware/oauth/index.ts +505 -505
- package/src/middleware/oauth/providers.ts +115 -115
- package/src/middleware/rate-limit/index.ts +522 -522
- package/src/middleware/rate-limit/sqlite-store.ts +382 -382
- package/src/middleware/scheduler-cron.ts +96 -96
- package/src/middleware/secure/__tests__/secure.test.ts +360 -360
- package/src/middleware/secure/csp.ts +193 -193
- package/src/middleware/session.ts +174 -174
- package/src/middleware/timeout.ts +55 -55
- package/src/observability/logger-adapter.ts +36 -36
- package/src/observability/sqlite-store.ts +254 -254
- package/src/openapi/generator.ts +1 -1
- package/src/openapi/openapi.test.ts +43 -43
- package/src/perf/__tests__/user-marks.test.ts +354 -354
- package/src/perf/index.ts +133 -133
- package/src/perf/user-marks.ts +1 -1
- package/src/plugins/__tests__/lifecycle-integration.test.ts +272 -272
- package/src/plugins/__tests__/runner.test.ts +409 -409
- package/src/plugins/define.ts +124 -124
- package/src/plugins/examples/dep-check-plugin.ts +80 -80
- package/src/plugins/examples/prerender-cache-plugin.ts +111 -111
- package/src/plugins/examples/sitemap-plugin.ts +65 -65
- package/src/plugins/runner.ts +361 -361
- package/src/plugins/types.ts +368 -368
- package/src/report/index.ts +1 -1
- package/src/resource/__tests__/generator.test.ts +7 -7
- package/src/resource/__tests__/schema.test.ts +14 -14
- package/src/resource/ddl/__tests__/diff.test.ts +639 -639
- package/src/resource/ddl/__tests__/emit.test.ts +165 -165
- package/src/resource/ddl/__tests__/snapshot.test.ts +499 -499
- package/src/resource/ddl/emit.ts +146 -146
- package/src/resource/ddl/persistence-types.ts +218 -218
- package/src/resource/ddl/type-map.ts +223 -223
- package/src/resource/ddl/types.ts +232 -232
- package/src/resource/generator-repo.ts +630 -630
- package/src/resource/generator-schema.ts +11 -11
- package/src/resource/generators/slot.ts +72 -72
- package/src/resource/schema.ts +21 -21
- package/src/router/client-entry.test.ts +227 -227
- package/src/router/client-entry.ts +218 -218
- package/src/router/fs-patterns.test.ts +96 -96
- package/src/router/fs-routes.test.ts +532 -532
- package/src/router/fs-routes.ts +53 -53
- package/src/router/fs-scanner.ts +216 -216
- package/src/router/fs-types.ts +19 -19
- package/src/router/route-source-analyzer.ts +521 -521
- package/src/routes/index.ts +74 -74
- package/src/routes/metadata-routes.ts +427 -427
- package/src/routes/types.ts +341 -341
- package/src/runtime/__tests__/devtools-adapter.test.ts +68 -68
- package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -141
- package/src/runtime/__tests__/hdr-client.test.ts +223 -223
- package/src/runtime/__tests__/http-errors.test.ts +117 -117
- package/src/runtime/__tests__/inline-client-hydration.test.ts +237 -237
- package/src/runtime/__tests__/not-found.test.ts +152 -152
- package/src/runtime/__tests__/observability-lifecycle.test.ts +103 -103
- package/src/runtime/__tests__/page-render-response.test.ts +517 -517
- package/src/runtime/__tests__/request-middleware.test.ts +70 -70
- package/src/runtime/__tests__/searchparams-page-props.test.ts +81 -81
- package/src/runtime/adapter.ts +47 -47
- package/src/runtime/boundary.tsx +252 -252
- package/src/runtime/cache.ts +494 -494
- package/src/runtime/compose.ts +222 -222
- package/src/runtime/devtools-adapter.ts +68 -68
- package/src/runtime/escape.ts +34 -34
- package/src/runtime/fast-refresh-runtime.ts +322 -322
- package/src/runtime/fast-refresh-types.ts +1 -1
- package/src/runtime/handler.ts +65 -65
- package/src/runtime/handlers.ts +30 -11
- package/src/runtime/http-errors.ts +113 -113
- package/src/runtime/image-handler.ts +1 -1
- package/src/runtime/lifecycle.ts +381 -381
- package/src/runtime/logger.test.ts +345 -345
- package/src/runtime/middleware.ts +264 -264
- package/src/runtime/not-found.ts +93 -93
- package/src/runtime/observability-lifecycle.ts +290 -290
- package/src/runtime/openapi-endpoint.ts +236 -236
- package/src/runtime/page-render-response.ts +287 -274
- package/src/runtime/ppr.ts +74 -74
- package/src/runtime/rate-limit.ts +1 -1
- package/src/runtime/redirect.ts +1 -1
- package/src/runtime/registry.ts +171 -171
- package/src/runtime/request-middleware.ts +31 -31
- package/src/runtime/router.test.ts +4 -4
- package/src/runtime/router.ts +10 -10
- package/src/runtime/server.ts +127 -211
- package/src/runtime/shims.ts +48 -48
- package/src/runtime/ssr.ts +43 -17
- package/src/runtime/static-files.ts +369 -289
- package/src/runtime/streaming-ssr.ts +219 -180
- package/src/runtime/trace.ts +144 -144
- package/src/scheduler/__tests__/scheduler.test.ts +12 -11
- package/src/scheduler/index.ts +11 -5
- package/src/scheduler/validate.ts +169 -169
- package/src/seo/index.ts +219 -219
- package/src/seo/integration/ssr.ts +306 -306
- package/src/seo/render/basic.ts +435 -435
- package/src/seo/render/index.ts +143 -143
- package/src/seo/render/jsonld.ts +539 -539
- package/src/seo/render/opengraph.ts +197 -197
- package/src/seo/render/robots.ts +116 -116
- package/src/seo/render/sitemap.ts +137 -137
- package/src/seo/render/twitter.ts +127 -127
- package/src/seo/resolve/opengraph.ts +143 -143
- package/src/seo/resolve/robots.ts +73 -73
- package/src/seo/resolve/title.ts +94 -94
- package/src/seo/resolve/twitter.ts +73 -73
- package/src/seo/resolve/url.ts +104 -104
- package/src/seo/routes/index.ts +290 -290
- package/src/seo/types.ts +588 -588
- package/src/slot/validator.ts +39 -39
- package/src/spec/schema.ts +35 -35
- package/src/storage/s3/__tests__/s3.test.ts +479 -479
- package/src/storage/s3/index.ts +412 -412
- package/src/testing/__tests__/assertions.test.ts +632 -632
- package/src/testing/__tests__/reporter.test.ts +454 -454
- package/src/testing/assertions.ts +986 -986
- package/src/testing/db.ts +157 -157
- package/src/testing/index.ts +9 -3
- package/src/testing/lcov.ts +192 -0
- package/src/testing/mocks.ts +203 -203
- package/src/testing/session.ts +190 -190
- package/src/types/branded.ts +56 -56
- package/src/types/index.ts +1 -1
- package/src/utils/safe-io.ts +188 -188
- package/src/utils/string-safe.ts +298 -298
- package/src/watcher/__tests__/watcher.test.ts +59 -59
- package/src/watcher/watcher.ts +61 -61
|
@@ -1,382 +1,382 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @mandujs/core/middleware/rate-limit/sqlite-store
|
|
3
|
-
*
|
|
4
|
-
* SQLite-backed {@link RateLimitStore} for same-host multi-process rate
|
|
5
|
-
* limiting. Follows the Appendix D normative layer for Phase 4a:
|
|
6
|
-
*
|
|
7
|
-
* - Goes through {@link createDb} (D.5) — never `new Bun.SQL` directly.
|
|
8
|
-
* - Enables WAL journaling at init (D.4) so concurrent writers don't
|
|
9
|
-
* block readers.
|
|
10
|
-
* - Runs the per-key `SELECT → compute → INSERT OR REPLACE` inside a
|
|
11
|
-
* single transaction so two processes racing on the same key observe
|
|
12
|
-
* a strictly-increasing count (no lost updates).
|
|
13
|
-
*
|
|
14
|
-
* GC cron is OPTIONAL and OPT-IN. Callers who don't want a cron (or who run
|
|
15
|
-
* on pre-Bun-1.3.12 where `Bun.cron` is absent) can set `gcSchedule: false`
|
|
16
|
-
* and call `gcNow()` manually — identical pattern to
|
|
17
|
-
* `filling/session-sqlite.ts`.
|
|
18
|
-
*
|
|
19
|
-
* Not a distributed limiter: the limiter state is in the SQLite file, which
|
|
20
|
-
* only reaches processes that share that file. Multi-host deployments need
|
|
21
|
-
* a network-accessible store (future Redis backend).
|
|
22
|
-
*
|
|
23
|
-
* @module middleware/rate-limit/sqlite-store
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
|
-
import { createDb, type Db } from "../../db";
|
|
27
|
-
import { defineCron, type CronRegistration } from "../../scheduler";
|
|
28
|
-
import type { RateLimitResult, RateLimitStore } from "./index";
|
|
29
|
-
|
|
30
|
-
// ─── Public options ─────────────────────────────────────────────────────────
|
|
31
|
-
|
|
32
|
-
export interface SqliteRateLimitStoreOptions {
|
|
33
|
-
/**
|
|
34
|
-
* SQLite database path. Accepts `":memory:"` for transient tests or a
|
|
35
|
-
* filesystem path. Default: `".mandu/rate-limits.db"`.
|
|
36
|
-
*/
|
|
37
|
-
dbPath?: string;
|
|
38
|
-
/**
|
|
39
|
-
* Table name. Must match `[A-Za-z_][A-Za-z0-9_]*` — SQLite does not bind
|
|
40
|
-
* identifiers, so the name is interpolated into DDL/DML. Default:
|
|
41
|
-
* `"mandu_rate_limits"`.
|
|
42
|
-
*/
|
|
43
|
-
table?: string;
|
|
44
|
-
/**
|
|
45
|
-
* Cron schedule for background GC of stale rows. The sweep deletes rows
|
|
46
|
-
* whose window is older than ~2 × the largest window used — we don't know
|
|
47
|
-
* per-key windows at GC time, so the cron caller passes the threshold
|
|
48
|
-
* explicitly via `gcNow(olderThanMs)`. Set to `false` to disable the cron
|
|
49
|
-
* entirely; callers can still invoke `gcNow()` manually.
|
|
50
|
-
*
|
|
51
|
-
* Default: `"0 * * * *"` (hourly).
|
|
52
|
-
*/
|
|
53
|
-
gcSchedule?: string | false;
|
|
54
|
-
/**
|
|
55
|
-
* `olderThanMs` passed to the cron sweep. Defaults to 24 h — entries
|
|
56
|
-
* untouched for a full day are certainly safe to drop regardless of the
|
|
57
|
-
* actual window size. Callers with huge windows should override.
|
|
58
|
-
*/
|
|
59
|
-
gcOlderThanMs?: number;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// ─── Internal constants ────────────────────────────────────────────────────
|
|
63
|
-
|
|
64
|
-
const DEFAULT_DB_PATH = ".mandu/rate-limits.db";
|
|
65
|
-
const DEFAULT_TABLE = "mandu_rate_limits";
|
|
66
|
-
const DEFAULT_GC_SCHEDULE = "0 * * * *";
|
|
67
|
-
const DEFAULT_GC_OLDER_THAN_MS = 24 * 60 * 60 * 1000; // 24 h
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Safe identifier pattern — same validation shape as `session-sqlite.ts`.
|
|
71
|
-
* SQLite doesn't bind identifiers, so the name is string-interpolated into
|
|
72
|
-
* DDL/DML; we constrain it to eliminate any injection surface.
|
|
73
|
-
*/
|
|
74
|
-
const SAFE_IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
75
|
-
|
|
76
|
-
// ─── Row shape ──────────────────────────────────────────────────────────────
|
|
77
|
-
|
|
78
|
-
interface RateLimitRow {
|
|
79
|
-
key: string;
|
|
80
|
-
window_start: number;
|
|
81
|
-
count: number;
|
|
82
|
-
[column: string]: unknown;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// ─── Factory ────────────────────────────────────────────────────────────────
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Build a SQLite-backed rate-limit store. Initialisation is lazy — the DB
|
|
89
|
-
* handle is created up-front but no connection is opened until the first
|
|
90
|
-
* `hit()` / `gcNow()` call, matching the laziness contract of
|
|
91
|
-
* `@mandujs/core/db`.
|
|
92
|
-
*
|
|
93
|
-
* @throws {Error} Synchronously when `table` fails the safe-identifier
|
|
94
|
-
* check. Bad schedules surface at cron-registration time via the
|
|
95
|
-
* scheduler's own validator.
|
|
96
|
-
*/
|
|
97
|
-
export function createSqliteStore(
|
|
98
|
-
options: SqliteRateLimitStoreOptions = {},
|
|
99
|
-
): RateLimitStore {
|
|
100
|
-
const {
|
|
101
|
-
dbPath = DEFAULT_DB_PATH,
|
|
102
|
-
table = DEFAULT_TABLE,
|
|
103
|
-
gcSchedule = DEFAULT_GC_SCHEDULE,
|
|
104
|
-
gcOlderThanMs = DEFAULT_GC_OLDER_THAN_MS,
|
|
105
|
-
} = options;
|
|
106
|
-
|
|
107
|
-
if (!SAFE_IDENT_RE.test(table)) {
|
|
108
|
-
throw new Error(
|
|
109
|
-
`[@mandujs/core/middleware/rate-limit] Invalid table name ${JSON.stringify(
|
|
110
|
-
table,
|
|
111
|
-
)}. Must match ${SAFE_IDENT_RE}.`,
|
|
112
|
-
);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
const db: Db = createDb({ url: `sqlite:${dbPath}` });
|
|
116
|
-
|
|
117
|
-
// At-most-once init. Each public method awaits this promise to guarantee
|
|
118
|
-
// the schema + PRAGMAs are in place before any other query.
|
|
119
|
-
let initPromise: Promise<void> | null = null;
|
|
120
|
-
let closed = false;
|
|
121
|
-
|
|
122
|
-
// ─── Transaction mutex ────────────────────────────────────────────────────
|
|
123
|
-
//
|
|
124
|
-
// Bun.SQL's SQLite adapter (as of 1.3.12) does NOT queue concurrent
|
|
125
|
-
// `begin()` calls on a single-connection pool — two parallel transactions
|
|
126
|
-
// surface "cannot start a transaction within a transaction" from the
|
|
127
|
-
// native driver. We serialise tx calls in-process via a Promise chain so
|
|
128
|
-
// that two `hit()` callers racing on the same key (or on different keys)
|
|
129
|
-
// still produce correct counts.
|
|
130
|
-
//
|
|
131
|
-
// Within a single process this mutex is sufficient: the event loop can't
|
|
132
|
-
// preempt synchronous code, and every yield point inside the critical
|
|
133
|
-
// section is `await tx(...)` which keeps the chain intact.
|
|
134
|
-
//
|
|
135
|
-
// Across processes (same host, shared SQLite file), SQLite's own
|
|
136
|
-
// file-level locking under WAL handles serialisation — the mutex only
|
|
137
|
-
// prevents the in-process race.
|
|
138
|
-
let txChain: Promise<unknown> = Promise.resolve();
|
|
139
|
-
async function serialise<R>(fn: () => Promise<R>): Promise<R> {
|
|
140
|
-
// Chain a new link; each link awaits the previous to settle (regardless
|
|
141
|
-
// of rejection) before running. We capture the new link's result
|
|
142
|
-
// separately so a rejection here doesn't poison the chain for the
|
|
143
|
-
// next caller.
|
|
144
|
-
const next = txChain.then(fn, fn);
|
|
145
|
-
txChain = next.then(
|
|
146
|
-
() => undefined,
|
|
147
|
-
() => undefined,
|
|
148
|
-
);
|
|
149
|
-
return await next;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
function ensureInit(): Promise<void> {
|
|
153
|
-
if (initPromise) return initPromise;
|
|
154
|
-
initPromise = (async () => {
|
|
155
|
-
// D.4: enable WAL. Safe on `:memory:` (silently stays in memory
|
|
156
|
-
// journal mode) — we don't assert the return value so tests with
|
|
157
|
-
// `:memory:` still pass.
|
|
158
|
-
await db`PRAGMA journal_mode = WAL`;
|
|
159
|
-
|
|
160
|
-
// Identifier validated above — safe to interpolate.
|
|
161
|
-
await execRaw(
|
|
162
|
-
db,
|
|
163
|
-
`CREATE TABLE IF NOT EXISTS ${table} (
|
|
164
|
-
key TEXT PRIMARY KEY,
|
|
165
|
-
window_start INTEGER NOT NULL,
|
|
166
|
-
count INTEGER NOT NULL
|
|
167
|
-
)`,
|
|
168
|
-
);
|
|
169
|
-
await execRaw(
|
|
170
|
-
db,
|
|
171
|
-
`CREATE INDEX IF NOT EXISTS ${table}_window_start ON ${table}(window_start)`,
|
|
172
|
-
);
|
|
173
|
-
})();
|
|
174
|
-
return initPromise;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// ─── Cron (optional) ──────────────────────────────────────────────────────
|
|
178
|
-
|
|
179
|
-
let cronReg: CronRegistration | null = null;
|
|
180
|
-
function startCronIfEnabled(): void {
|
|
181
|
-
if (gcSchedule === false) return;
|
|
182
|
-
if (cronReg) return;
|
|
183
|
-
try {
|
|
184
|
-
const reg = defineCron({
|
|
185
|
-
[`${table}:gc`]: {
|
|
186
|
-
schedule: gcSchedule,
|
|
187
|
-
run: async () => {
|
|
188
|
-
await gcNow(gcOlderThanMs);
|
|
189
|
-
},
|
|
190
|
-
},
|
|
191
|
-
});
|
|
192
|
-
reg.start();
|
|
193
|
-
cronReg = reg;
|
|
194
|
-
} catch (err) {
|
|
195
|
-
// Bun < 1.3.12 or a malformed schedule. Warn once — manual gcNow()
|
|
196
|
-
// is still available — then keep serving traffic.
|
|
197
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
198
|
-
console.warn(
|
|
199
|
-
`[@mandujs/core/middleware/rate-limit] GC cron disabled: ${msg}. ` +
|
|
200
|
-
`Call store.gcNow() manually.`,
|
|
201
|
-
);
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
// Schedule cron after first init succeeds — same pattern as session-sqlite.
|
|
206
|
-
void ensureInit().then(startCronIfEnabled);
|
|
207
|
-
|
|
208
|
-
// ─── Store methods ────────────────────────────────────────────────────────
|
|
209
|
-
|
|
210
|
-
/**
|
|
211
|
-
* Atomic `SELECT → compute → INSERT OR REPLACE` inside a transaction.
|
|
212
|
-
* Two concurrent callers on the same key will serialise on the row's
|
|
213
|
-
* write lock — SQLite under WAL guarantees no lost updates.
|
|
214
|
-
*/
|
|
215
|
-
async function hit(
|
|
216
|
-
key: string,
|
|
217
|
-
limit: number,
|
|
218
|
-
windowMs: number,
|
|
219
|
-
): Promise<RateLimitResult> {
|
|
220
|
-
if (closed) {
|
|
221
|
-
throw new Error(
|
|
222
|
-
"[@mandujs/core/middleware/rate-limit] SQLite store is closed.",
|
|
223
|
-
);
|
|
224
|
-
}
|
|
225
|
-
if (typeof key !== "string" || key.length === 0) {
|
|
226
|
-
throw new TypeError(
|
|
227
|
-
"[@mandujs/core/middleware/rate-limit] hit: key must be a non-empty string.",
|
|
228
|
-
);
|
|
229
|
-
}
|
|
230
|
-
await ensureInit();
|
|
231
|
-
|
|
232
|
-
const now = Date.now();
|
|
233
|
-
|
|
234
|
-
const result = await serialise(() =>
|
|
235
|
-
db.transaction(async (tx) => {
|
|
236
|
-
const row = await queryOne<RateLimitRow>(
|
|
237
|
-
tx,
|
|
238
|
-
`SELECT key, window_start, count FROM ${table} WHERE key = $1`,
|
|
239
|
-
[key],
|
|
240
|
-
);
|
|
241
|
-
|
|
242
|
-
let windowStart: number;
|
|
243
|
-
let count: number;
|
|
244
|
-
if (!row || now - Number(row.window_start) >= windowMs) {
|
|
245
|
-
// Fresh window.
|
|
246
|
-
windowStart = now;
|
|
247
|
-
count = 1;
|
|
248
|
-
} else {
|
|
249
|
-
windowStart = Number(row.window_start);
|
|
250
|
-
count = Number(row.count) + 1;
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
await execWithParams(
|
|
254
|
-
tx,
|
|
255
|
-
`INSERT OR REPLACE INTO ${table} (key, window_start, count) VALUES ($1, $2, $3)`,
|
|
256
|
-
[key, windowStart, count],
|
|
257
|
-
);
|
|
258
|
-
|
|
259
|
-
return { windowStart, count };
|
|
260
|
-
}),
|
|
261
|
-
);
|
|
262
|
-
|
|
263
|
-
const resetAt = result.windowStart + windowMs;
|
|
264
|
-
const allowed = result.count <= limit;
|
|
265
|
-
const remaining = Math.max(0, limit - result.count);
|
|
266
|
-
const retryAfterSeconds = allowed
|
|
267
|
-
? 0
|
|
268
|
-
: Math.max(1, Math.ceil((resetAt - now) / 1000));
|
|
269
|
-
return { allowed, remaining, resetAt, retryAfterSeconds };
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
async function gcNow(olderThanMs: number): Promise<number> {
|
|
273
|
-
if (closed) {
|
|
274
|
-
throw new Error(
|
|
275
|
-
"[@mandujs/core/middleware/rate-limit] SQLite store is closed.",
|
|
276
|
-
);
|
|
277
|
-
}
|
|
278
|
-
if (typeof olderThanMs !== "number" || olderThanMs < 0) {
|
|
279
|
-
throw new TypeError(
|
|
280
|
-
"[@mandujs/core/middleware/rate-limit] gcNow: olderThanMs must be a non-negative number.",
|
|
281
|
-
);
|
|
282
|
-
}
|
|
283
|
-
await ensureInit();
|
|
284
|
-
|
|
285
|
-
const cutoff = Date.now() - olderThanMs;
|
|
286
|
-
|
|
287
|
-
// Count + delete inside one transaction so the returned number reflects
|
|
288
|
-
// what THIS call deleted (concurrent writers can't inflate it). Routed
|
|
289
|
-
// through the serialise mutex so it doesn't race with in-flight hits.
|
|
290
|
-
let deleted = 0;
|
|
291
|
-
await serialise(() =>
|
|
292
|
-
db.transaction(async (tx) => {
|
|
293
|
-
const row = await queryOne<{ n: number | bigint }>(
|
|
294
|
-
tx,
|
|
295
|
-
`SELECT COUNT(*) AS n FROM ${table} WHERE window_start < $1`,
|
|
296
|
-
[cutoff],
|
|
297
|
-
);
|
|
298
|
-
deleted = row ? Number(row.n) : 0;
|
|
299
|
-
await execWithParams(
|
|
300
|
-
tx,
|
|
301
|
-
`DELETE FROM ${table} WHERE window_start < $1`,
|
|
302
|
-
[cutoff],
|
|
303
|
-
);
|
|
304
|
-
}),
|
|
305
|
-
);
|
|
306
|
-
return deleted;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
async function close(): Promise<void> {
|
|
310
|
-
if (closed) return;
|
|
311
|
-
closed = true;
|
|
312
|
-
if (cronReg) {
|
|
313
|
-
try {
|
|
314
|
-
await cronReg.stop();
|
|
315
|
-
} catch {
|
|
316
|
-
// Best-effort shutdown — don't mask the caller's shutdown flow.
|
|
317
|
-
}
|
|
318
|
-
cronReg = null;
|
|
319
|
-
}
|
|
320
|
-
await db.close();
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
return { hit, gcNow, close };
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
// ─── DB helpers ─────────────────────────────────────────────────────────────
|
|
327
|
-
//
|
|
328
|
-
// Same `$N`-placeholder-to-TemplateStringsArray trick used in
|
|
329
|
-
// `filling/session-sqlite.ts`. We need dynamic SQL (the table name is
|
|
330
|
-
// interpolated) and Bun.SQL's only public API is tagged-template, so we
|
|
331
|
-
// synthesise a TSA at call time.
|
|
332
|
-
|
|
333
|
-
async function execWithParams(
|
|
334
|
-
dbOrTx: Db,
|
|
335
|
-
sql: string,
|
|
336
|
-
params: unknown[],
|
|
337
|
-
): Promise<void> {
|
|
338
|
-
const parts = splitPlaceholders(sql, params.length);
|
|
339
|
-
const strings = Object.assign(parts.slice(), {
|
|
340
|
-
raw: parts.slice(),
|
|
341
|
-
}) as unknown as TemplateStringsArray;
|
|
342
|
-
await dbOrTx(strings, ...params);
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
async function queryOne<T extends Record<string, unknown>>(
|
|
346
|
-
dbOrTx: Db,
|
|
347
|
-
sql: string,
|
|
348
|
-
params: unknown[],
|
|
349
|
-
): Promise<T | null> {
|
|
350
|
-
const parts = splitPlaceholders(sql, params.length);
|
|
351
|
-
const strings = Object.assign(parts.slice(), {
|
|
352
|
-
raw: parts.slice(),
|
|
353
|
-
}) as unknown as TemplateStringsArray;
|
|
354
|
-
const rows = await dbOrTx<T>(strings, ...params);
|
|
355
|
-
if (!rows || rows.length === 0) return null;
|
|
356
|
-
return rows[0] as T;
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
async function execRaw(dbOrTx: Db, sql: string): Promise<void> {
|
|
360
|
-
const strings = Object.assign([sql], {
|
|
361
|
-
raw: [sql],
|
|
362
|
-
}) as unknown as TemplateStringsArray;
|
|
363
|
-
await dbOrTx(strings);
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
function splitPlaceholders(sql: string, expected: number): string[] {
|
|
367
|
-
const parts: string[] = [];
|
|
368
|
-
let rest = sql;
|
|
369
|
-
for (let i = 1; i <= expected; i++) {
|
|
370
|
-
const marker = `$${i}`;
|
|
371
|
-
const idx = rest.indexOf(marker);
|
|
372
|
-
if (idx === -1) {
|
|
373
|
-
throw new Error(
|
|
374
|
-
`[@mandujs/core/middleware/rate-limit] placeholder ${marker} missing in SQL: ${sql}`,
|
|
375
|
-
);
|
|
376
|
-
}
|
|
377
|
-
parts.push(rest.slice(0, idx));
|
|
378
|
-
rest = rest.slice(idx + marker.length);
|
|
379
|
-
}
|
|
380
|
-
parts.push(rest);
|
|
381
|
-
return parts;
|
|
382
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* @mandujs/core/middleware/rate-limit/sqlite-store
|
|
3
|
+
*
|
|
4
|
+
* SQLite-backed {@link RateLimitStore} for same-host multi-process rate
|
|
5
|
+
* limiting. Follows the Appendix D normative layer for Phase 4a:
|
|
6
|
+
*
|
|
7
|
+
* - Goes through {@link createDb} (D.5) — never `new Bun.SQL` directly.
|
|
8
|
+
* - Enables WAL journaling at init (D.4) so concurrent writers don't
|
|
9
|
+
* block readers.
|
|
10
|
+
* - Runs the per-key `SELECT → compute → INSERT OR REPLACE` inside a
|
|
11
|
+
* single transaction so two processes racing on the same key observe
|
|
12
|
+
* a strictly-increasing count (no lost updates).
|
|
13
|
+
*
|
|
14
|
+
* GC cron is OPTIONAL and OPT-IN. Callers who don't want a cron (or who run
|
|
15
|
+
* on pre-Bun-1.3.12 where `Bun.cron` is absent) can set `gcSchedule: false`
|
|
16
|
+
* and call `gcNow()` manually — identical pattern to
|
|
17
|
+
* `filling/session-sqlite.ts`.
|
|
18
|
+
*
|
|
19
|
+
* Not a distributed limiter: the limiter state is in the SQLite file, which
|
|
20
|
+
* only reaches processes that share that file. Multi-host deployments need
|
|
21
|
+
* a network-accessible store (future Redis backend).
|
|
22
|
+
*
|
|
23
|
+
* @module middleware/rate-limit/sqlite-store
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { createDb, type Db } from "../../db";
|
|
27
|
+
import { defineCron, type CronRegistration } from "../../scheduler";
|
|
28
|
+
import type { RateLimitResult, RateLimitStore } from "./index";
|
|
29
|
+
|
|
30
|
+
// ─── Public options ─────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
export interface SqliteRateLimitStoreOptions {
|
|
33
|
+
/**
|
|
34
|
+
* SQLite database path. Accepts `":memory:"` for transient tests or a
|
|
35
|
+
* filesystem path. Default: `".mandu/rate-limits.db"`.
|
|
36
|
+
*/
|
|
37
|
+
dbPath?: string;
|
|
38
|
+
/**
|
|
39
|
+
* Table name. Must match `[A-Za-z_][A-Za-z0-9_]*` — SQLite does not bind
|
|
40
|
+
* identifiers, so the name is interpolated into DDL/DML. Default:
|
|
41
|
+
* `"mandu_rate_limits"`.
|
|
42
|
+
*/
|
|
43
|
+
table?: string;
|
|
44
|
+
/**
|
|
45
|
+
* Cron schedule for background GC of stale rows. The sweep deletes rows
|
|
46
|
+
* whose window is older than ~2 × the largest window used — we don't know
|
|
47
|
+
* per-key windows at GC time, so the cron caller passes the threshold
|
|
48
|
+
* explicitly via `gcNow(olderThanMs)`. Set to `false` to disable the cron
|
|
49
|
+
* entirely; callers can still invoke `gcNow()` manually.
|
|
50
|
+
*
|
|
51
|
+
* Default: `"0 * * * *"` (hourly).
|
|
52
|
+
*/
|
|
53
|
+
gcSchedule?: string | false;
|
|
54
|
+
/**
|
|
55
|
+
* `olderThanMs` passed to the cron sweep. Defaults to 24 h — entries
|
|
56
|
+
* untouched for a full day are certainly safe to drop regardless of the
|
|
57
|
+
* actual window size. Callers with huge windows should override.
|
|
58
|
+
*/
|
|
59
|
+
gcOlderThanMs?: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ─── Internal constants ────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
const DEFAULT_DB_PATH = ".mandu/rate-limits.db";
|
|
65
|
+
const DEFAULT_TABLE = "mandu_rate_limits";
|
|
66
|
+
const DEFAULT_GC_SCHEDULE = "0 * * * *";
|
|
67
|
+
const DEFAULT_GC_OLDER_THAN_MS = 24 * 60 * 60 * 1000; // 24 h
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Safe identifier pattern — same validation shape as `session-sqlite.ts`.
|
|
71
|
+
* SQLite doesn't bind identifiers, so the name is string-interpolated into
|
|
72
|
+
* DDL/DML; we constrain it to eliminate any injection surface.
|
|
73
|
+
*/
|
|
74
|
+
const SAFE_IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
75
|
+
|
|
76
|
+
// ─── Row shape ──────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
interface RateLimitRow {
|
|
79
|
+
key: string;
|
|
80
|
+
window_start: number;
|
|
81
|
+
count: number;
|
|
82
|
+
[column: string]: unknown;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ─── Factory ────────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Build a SQLite-backed rate-limit store. Initialisation is lazy — the DB
|
|
89
|
+
* handle is created up-front but no connection is opened until the first
|
|
90
|
+
* `hit()` / `gcNow()` call, matching the laziness contract of
|
|
91
|
+
* `@mandujs/core/db`.
|
|
92
|
+
*
|
|
93
|
+
* @throws {Error} Synchronously when `table` fails the safe-identifier
|
|
94
|
+
* check. Bad schedules surface at cron-registration time via the
|
|
95
|
+
* scheduler's own validator.
|
|
96
|
+
*/
|
|
97
|
+
export function createSqliteStore(
|
|
98
|
+
options: SqliteRateLimitStoreOptions = {},
|
|
99
|
+
): RateLimitStore {
|
|
100
|
+
const {
|
|
101
|
+
dbPath = DEFAULT_DB_PATH,
|
|
102
|
+
table = DEFAULT_TABLE,
|
|
103
|
+
gcSchedule = DEFAULT_GC_SCHEDULE,
|
|
104
|
+
gcOlderThanMs = DEFAULT_GC_OLDER_THAN_MS,
|
|
105
|
+
} = options;
|
|
106
|
+
|
|
107
|
+
if (!SAFE_IDENT_RE.test(table)) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
`[@mandujs/core/middleware/rate-limit] Invalid table name ${JSON.stringify(
|
|
110
|
+
table,
|
|
111
|
+
)}. Must match ${SAFE_IDENT_RE}.`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const db: Db = createDb({ url: `sqlite:${dbPath}` });
|
|
116
|
+
|
|
117
|
+
// At-most-once init. Each public method awaits this promise to guarantee
|
|
118
|
+
// the schema + PRAGMAs are in place before any other query.
|
|
119
|
+
let initPromise: Promise<void> | null = null;
|
|
120
|
+
let closed = false;
|
|
121
|
+
|
|
122
|
+
// ─── Transaction mutex ────────────────────────────────────────────────────
|
|
123
|
+
//
|
|
124
|
+
// Bun.SQL's SQLite adapter (as of 1.3.12) does NOT queue concurrent
|
|
125
|
+
// `begin()` calls on a single-connection pool — two parallel transactions
|
|
126
|
+
// surface "cannot start a transaction within a transaction" from the
|
|
127
|
+
// native driver. We serialise tx calls in-process via a Promise chain so
|
|
128
|
+
// that two `hit()` callers racing on the same key (or on different keys)
|
|
129
|
+
// still produce correct counts.
|
|
130
|
+
//
|
|
131
|
+
// Within a single process this mutex is sufficient: the event loop can't
|
|
132
|
+
// preempt synchronous code, and every yield point inside the critical
|
|
133
|
+
// section is `await tx(...)` which keeps the chain intact.
|
|
134
|
+
//
|
|
135
|
+
// Across processes (same host, shared SQLite file), SQLite's own
|
|
136
|
+
// file-level locking under WAL handles serialisation — the mutex only
|
|
137
|
+
// prevents the in-process race.
|
|
138
|
+
let txChain: Promise<unknown> = Promise.resolve();
|
|
139
|
+
async function serialise<R>(fn: () => Promise<R>): Promise<R> {
|
|
140
|
+
// Chain a new link; each link awaits the previous to settle (regardless
|
|
141
|
+
// of rejection) before running. We capture the new link's result
|
|
142
|
+
// separately so a rejection here doesn't poison the chain for the
|
|
143
|
+
// next caller.
|
|
144
|
+
const next = txChain.then(fn, fn);
|
|
145
|
+
txChain = next.then(
|
|
146
|
+
() => undefined,
|
|
147
|
+
() => undefined,
|
|
148
|
+
);
|
|
149
|
+
return await next;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function ensureInit(): Promise<void> {
|
|
153
|
+
if (initPromise) return initPromise;
|
|
154
|
+
initPromise = (async () => {
|
|
155
|
+
// D.4: enable WAL. Safe on `:memory:` (silently stays in memory
|
|
156
|
+
// journal mode) — we don't assert the return value so tests with
|
|
157
|
+
// `:memory:` still pass.
|
|
158
|
+
await db`PRAGMA journal_mode = WAL`;
|
|
159
|
+
|
|
160
|
+
// Identifier validated above — safe to interpolate.
|
|
161
|
+
await execRaw(
|
|
162
|
+
db,
|
|
163
|
+
`CREATE TABLE IF NOT EXISTS ${table} (
|
|
164
|
+
key TEXT PRIMARY KEY,
|
|
165
|
+
window_start INTEGER NOT NULL,
|
|
166
|
+
count INTEGER NOT NULL
|
|
167
|
+
)`,
|
|
168
|
+
);
|
|
169
|
+
await execRaw(
|
|
170
|
+
db,
|
|
171
|
+
`CREATE INDEX IF NOT EXISTS ${table}_window_start ON ${table}(window_start)`,
|
|
172
|
+
);
|
|
173
|
+
})();
|
|
174
|
+
return initPromise;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ─── Cron (optional) ──────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
let cronReg: CronRegistration | null = null;
|
|
180
|
+
function startCronIfEnabled(): void {
|
|
181
|
+
if (gcSchedule === false) return;
|
|
182
|
+
if (cronReg) return;
|
|
183
|
+
try {
|
|
184
|
+
const reg = defineCron({
|
|
185
|
+
[`${table}:gc`]: {
|
|
186
|
+
schedule: gcSchedule,
|
|
187
|
+
run: async () => {
|
|
188
|
+
await gcNow(gcOlderThanMs);
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
reg.start();
|
|
193
|
+
cronReg = reg;
|
|
194
|
+
} catch (err) {
|
|
195
|
+
// Bun < 1.3.12 or a malformed schedule. Warn once — manual gcNow()
|
|
196
|
+
// is still available — then keep serving traffic.
|
|
197
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
198
|
+
console.warn(
|
|
199
|
+
`[@mandujs/core/middleware/rate-limit] GC cron disabled: ${msg}. ` +
|
|
200
|
+
`Call store.gcNow() manually.`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Schedule cron after first init succeeds — same pattern as session-sqlite.
|
|
206
|
+
void ensureInit().then(startCronIfEnabled);
|
|
207
|
+
|
|
208
|
+
// ─── Store methods ────────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Atomic `SELECT → compute → INSERT OR REPLACE` inside a transaction.
|
|
212
|
+
* Two concurrent callers on the same key will serialise on the row's
|
|
213
|
+
* write lock — SQLite under WAL guarantees no lost updates.
|
|
214
|
+
*/
|
|
215
|
+
async function hit(
|
|
216
|
+
key: string,
|
|
217
|
+
limit: number,
|
|
218
|
+
windowMs: number,
|
|
219
|
+
): Promise<RateLimitResult> {
|
|
220
|
+
if (closed) {
|
|
221
|
+
throw new Error(
|
|
222
|
+
"[@mandujs/core/middleware/rate-limit] SQLite store is closed.",
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
if (typeof key !== "string" || key.length === 0) {
|
|
226
|
+
throw new TypeError(
|
|
227
|
+
"[@mandujs/core/middleware/rate-limit] hit: key must be a non-empty string.",
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
await ensureInit();
|
|
231
|
+
|
|
232
|
+
const now = Date.now();
|
|
233
|
+
|
|
234
|
+
const result = await serialise(() =>
|
|
235
|
+
db.transaction(async (tx) => {
|
|
236
|
+
const row = await queryOne<RateLimitRow>(
|
|
237
|
+
tx,
|
|
238
|
+
`SELECT key, window_start, count FROM ${table} WHERE key = $1`,
|
|
239
|
+
[key],
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
let windowStart: number;
|
|
243
|
+
let count: number;
|
|
244
|
+
if (!row || now - Number(row.window_start) >= windowMs) {
|
|
245
|
+
// Fresh window.
|
|
246
|
+
windowStart = now;
|
|
247
|
+
count = 1;
|
|
248
|
+
} else {
|
|
249
|
+
windowStart = Number(row.window_start);
|
|
250
|
+
count = Number(row.count) + 1;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
await execWithParams(
|
|
254
|
+
tx,
|
|
255
|
+
`INSERT OR REPLACE INTO ${table} (key, window_start, count) VALUES ($1, $2, $3)`,
|
|
256
|
+
[key, windowStart, count],
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
return { windowStart, count };
|
|
260
|
+
}),
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
const resetAt = result.windowStart + windowMs;
|
|
264
|
+
const allowed = result.count <= limit;
|
|
265
|
+
const remaining = Math.max(0, limit - result.count);
|
|
266
|
+
const retryAfterSeconds = allowed
|
|
267
|
+
? 0
|
|
268
|
+
: Math.max(1, Math.ceil((resetAt - now) / 1000));
|
|
269
|
+
return { allowed, remaining, resetAt, retryAfterSeconds };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function gcNow(olderThanMs: number): Promise<number> {
|
|
273
|
+
if (closed) {
|
|
274
|
+
throw new Error(
|
|
275
|
+
"[@mandujs/core/middleware/rate-limit] SQLite store is closed.",
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
if (typeof olderThanMs !== "number" || olderThanMs < 0) {
|
|
279
|
+
throw new TypeError(
|
|
280
|
+
"[@mandujs/core/middleware/rate-limit] gcNow: olderThanMs must be a non-negative number.",
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
await ensureInit();
|
|
284
|
+
|
|
285
|
+
const cutoff = Date.now() - olderThanMs;
|
|
286
|
+
|
|
287
|
+
// Count + delete inside one transaction so the returned number reflects
|
|
288
|
+
// what THIS call deleted (concurrent writers can't inflate it). Routed
|
|
289
|
+
// through the serialise mutex so it doesn't race with in-flight hits.
|
|
290
|
+
let deleted = 0;
|
|
291
|
+
await serialise(() =>
|
|
292
|
+
db.transaction(async (tx) => {
|
|
293
|
+
const row = await queryOne<{ n: number | bigint }>(
|
|
294
|
+
tx,
|
|
295
|
+
`SELECT COUNT(*) AS n FROM ${table} WHERE window_start < $1`,
|
|
296
|
+
[cutoff],
|
|
297
|
+
);
|
|
298
|
+
deleted = row ? Number(row.n) : 0;
|
|
299
|
+
await execWithParams(
|
|
300
|
+
tx,
|
|
301
|
+
`DELETE FROM ${table} WHERE window_start < $1`,
|
|
302
|
+
[cutoff],
|
|
303
|
+
);
|
|
304
|
+
}),
|
|
305
|
+
);
|
|
306
|
+
return deleted;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function close(): Promise<void> {
|
|
310
|
+
if (closed) return;
|
|
311
|
+
closed = true;
|
|
312
|
+
if (cronReg) {
|
|
313
|
+
try {
|
|
314
|
+
await cronReg.stop();
|
|
315
|
+
} catch {
|
|
316
|
+
// Best-effort shutdown — don't mask the caller's shutdown flow.
|
|
317
|
+
}
|
|
318
|
+
cronReg = null;
|
|
319
|
+
}
|
|
320
|
+
await db.close();
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return { hit, gcNow, close };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ─── DB helpers ─────────────────────────────────────────────────────────────
|
|
327
|
+
//
|
|
328
|
+
// Same `$N`-placeholder-to-TemplateStringsArray trick used in
|
|
329
|
+
// `filling/session-sqlite.ts`. We need dynamic SQL (the table name is
|
|
330
|
+
// interpolated) and Bun.SQL's only public API is tagged-template, so we
|
|
331
|
+
// synthesise a TSA at call time.
|
|
332
|
+
|
|
333
|
+
async function execWithParams(
|
|
334
|
+
dbOrTx: Db,
|
|
335
|
+
sql: string,
|
|
336
|
+
params: unknown[],
|
|
337
|
+
): Promise<void> {
|
|
338
|
+
const parts = splitPlaceholders(sql, params.length);
|
|
339
|
+
const strings = Object.assign(parts.slice(), {
|
|
340
|
+
raw: parts.slice(),
|
|
341
|
+
}) as unknown as TemplateStringsArray;
|
|
342
|
+
await dbOrTx(strings, ...params);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async function queryOne<T extends Record<string, unknown>>(
|
|
346
|
+
dbOrTx: Db,
|
|
347
|
+
sql: string,
|
|
348
|
+
params: unknown[],
|
|
349
|
+
): Promise<T | null> {
|
|
350
|
+
const parts = splitPlaceholders(sql, params.length);
|
|
351
|
+
const strings = Object.assign(parts.slice(), {
|
|
352
|
+
raw: parts.slice(),
|
|
353
|
+
}) as unknown as TemplateStringsArray;
|
|
354
|
+
const rows = await dbOrTx<T>(strings, ...params);
|
|
355
|
+
if (!rows || rows.length === 0) return null;
|
|
356
|
+
return rows[0] as T;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async function execRaw(dbOrTx: Db, sql: string): Promise<void> {
|
|
360
|
+
const strings = Object.assign([sql], {
|
|
361
|
+
raw: [sql],
|
|
362
|
+
}) as unknown as TemplateStringsArray;
|
|
363
|
+
await dbOrTx(strings);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function splitPlaceholders(sql: string, expected: number): string[] {
|
|
367
|
+
const parts: string[] = [];
|
|
368
|
+
let rest = sql;
|
|
369
|
+
for (let i = 1; i <= expected; i++) {
|
|
370
|
+
const marker = `$${i}`;
|
|
371
|
+
const idx = rest.indexOf(marker);
|
|
372
|
+
if (idx === -1) {
|
|
373
|
+
throw new Error(
|
|
374
|
+
`[@mandujs/core/middleware/rate-limit] placeholder ${marker} missing in SQL: ${sql}`,
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
parts.push(rest.slice(0, idx));
|
|
378
|
+
rest = rest.slice(idx + marker.length);
|
|
379
|
+
}
|
|
380
|
+
parts.push(rest);
|
|
381
|
+
return parts;
|
|
382
|
+
}
|