@mandujs/core 0.21.0 → 0.22.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.
Files changed (122) hide show
  1. package/package.json +101 -69
  2. package/src/auth/__tests__/login.test.ts +419 -0
  3. package/src/auth/__tests__/password.test.ts +122 -0
  4. package/src/auth/__tests__/reset.test.ts +296 -0
  5. package/src/auth/__tests__/tokens.test.ts +274 -0
  6. package/src/auth/__tests__/verification.test.ts +274 -0
  7. package/src/auth/index.ts +76 -0
  8. package/src/auth/login.ts +225 -0
  9. package/src/auth/password.ts +120 -0
  10. package/src/auth/reset.ts +243 -0
  11. package/src/auth/tokens.ts +612 -0
  12. package/src/auth/verification.ts +253 -0
  13. package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -0
  14. package/src/bundler/__tests__/cold-start.test.ts +504 -0
  15. package/src/bundler/__tests__/csp-nonce.test.ts +278 -0
  16. package/src/bundler/__tests__/dev-reliability.test.ts +619 -0
  17. package/src/bundler/__tests__/extended-watch.test.ts +710 -0
  18. package/src/bundler/__tests__/fast-refresh.test.ts +596 -0
  19. package/src/bundler/__tests__/hdr.test.ts +353 -0
  20. package/src/bundler/__tests__/hmr-client.test.ts +532 -0
  21. package/src/bundler/__tests__/manifest-schema.test.ts +266 -0
  22. package/src/bundler/__tests__/prod-smoke.test.ts +138 -0
  23. package/src/bundler/__tests__/slot-dispatch.test.ts +573 -0
  24. package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -0
  25. package/src/bundler/__tests__/vendor-cache.test.ts +455 -0
  26. package/src/bundler/build.test.ts +8 -1
  27. package/src/bundler/build.ts +310 -18
  28. package/src/bundler/css.ts +326 -323
  29. package/src/bundler/dev.ts +1611 -59
  30. package/src/bundler/fast-refresh-plugin.ts +307 -0
  31. package/src/bundler/hmr-types.ts +252 -0
  32. package/src/bundler/manifest-schema.ts +301 -0
  33. package/src/bundler/safe-build.test.ts +128 -0
  34. package/src/bundler/safe-build.ts +77 -0
  35. package/src/bundler/scenario-matrix.ts +229 -0
  36. package/src/bundler/types.ts +11 -0
  37. package/src/bundler/vendor-cache-types.ts +130 -0
  38. package/src/bundler/vendor-cache.ts +526 -0
  39. package/src/client/router.ts +214 -56
  40. package/src/db/__tests__/db.test.ts +485 -0
  41. package/src/db/index.ts +513 -0
  42. package/src/db/migrations/__tests__/runner.test.ts +661 -0
  43. package/src/db/migrations/history-table.ts +345 -0
  44. package/src/db/migrations/lock.ts +269 -0
  45. package/src/db/migrations/runner.ts +633 -0
  46. package/src/desktop/__tests__/smoke.test.ts +100 -0
  47. package/src/desktop/__tests__/window.test.ts +172 -0
  48. package/src/desktop/__tests__/worker.test.ts +266 -0
  49. package/src/desktop/index.ts +43 -0
  50. package/src/desktop/types.ts +158 -0
  51. package/src/desktop/window.ts +492 -0
  52. package/src/desktop/worker.ts +180 -0
  53. package/src/email/__tests__/email.test.ts +355 -0
  54. package/src/email/index.ts +282 -0
  55. package/src/email/resend.ts +163 -0
  56. package/src/email/smtp.ts +64 -0
  57. package/src/filling/__tests__/session-sqlite.test.ts +454 -0
  58. package/src/filling/context.ts +72 -78
  59. package/src/filling/cookie-codec.ts +299 -0
  60. package/src/filling/deps.ts +25 -1
  61. package/src/filling/filling.ts +28 -3
  62. package/src/filling/session-sqlite.ts +617 -0
  63. package/src/filling/session.ts +265 -216
  64. package/src/guard/decision-memory.test.ts +52 -22
  65. package/src/id/__tests__/id.test.ts +120 -0
  66. package/src/id/index.ts +105 -0
  67. package/src/kitchen/index.ts +2 -2
  68. package/src/kitchen/kitchen-handler.ts +86 -0
  69. package/src/kitchen/stream/activity-sse.ts +2 -1
  70. package/src/middleware/csrf.ts +328 -0
  71. package/src/middleware/index.ts +40 -0
  72. package/src/middleware/oauth/__tests__/oauth.test.ts +574 -0
  73. package/src/middleware/oauth/index.ts +505 -0
  74. package/src/middleware/oauth/providers.ts +115 -0
  75. package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -0
  76. package/src/middleware/rate-limit/index.ts +522 -0
  77. package/src/middleware/rate-limit/sqlite-store.ts +382 -0
  78. package/src/middleware/secure/__tests__/secure.test.ts +360 -0
  79. package/src/middleware/secure/csp.ts +193 -0
  80. package/src/middleware/secure/index.ts +417 -0
  81. package/src/middleware/session.ts +174 -0
  82. package/src/observability/event-bus.ts +81 -79
  83. package/src/paths.ts +37 -0
  84. package/src/perf/hmr-markers.ts +215 -0
  85. package/src/perf/index.ts +104 -0
  86. package/src/resource/__tests__/generator.test.ts +603 -2
  87. package/src/resource/ddl/__tests__/diff.test.ts +639 -0
  88. package/src/resource/ddl/__tests__/emit.test.ts +799 -0
  89. package/src/resource/ddl/__tests__/snapshot.test.ts +499 -0
  90. package/src/resource/ddl/diff.ts +392 -0
  91. package/src/resource/ddl/emit.ts +548 -0
  92. package/src/resource/ddl/persistence-types.ts +218 -0
  93. package/src/resource/ddl/snapshot.ts +447 -0
  94. package/src/resource/ddl/type-map.ts +223 -0
  95. package/src/resource/ddl/types.ts +232 -0
  96. package/src/resource/generator-repo.ts +610 -0
  97. package/src/resource/generator-schema.ts +476 -0
  98. package/src/resource/generator.ts +117 -1
  99. package/src/resource/index.ts +17 -1
  100. package/src/resource/schema.ts +30 -0
  101. package/src/router/fs-scanner.ts +3 -0
  102. package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -0
  103. package/src/runtime/__tests__/hdr-client.test.ts +223 -0
  104. package/src/runtime/__tests__/http-errors.test.ts +117 -0
  105. package/src/runtime/__tests__/not-found.test.ts +152 -0
  106. package/src/runtime/boundary.tsx +21 -1
  107. package/src/runtime/fast-refresh-runtime.ts +322 -0
  108. package/src/runtime/fast-refresh-types.ts +128 -0
  109. package/src/runtime/hmr-client.ts +409 -0
  110. package/src/runtime/http-errors.ts +113 -0
  111. package/src/runtime/index.ts +6 -0
  112. package/src/runtime/logger.ts +678 -677
  113. package/src/runtime/not-found.ts +93 -0
  114. package/src/runtime/redirect.ts +133 -0
  115. package/src/runtime/server.ts +518 -20
  116. package/src/runtime/ssr.ts +340 -10
  117. package/src/runtime/streaming-ssr.ts +222 -19
  118. package/src/scheduler/__tests__/scheduler.test.ts +514 -0
  119. package/src/scheduler/index.ts +343 -0
  120. package/src/storage/s3/__tests__/s3.test.ts +479 -0
  121. package/src/storage/s3/index.ts +412 -0
  122. package/src/testing/index.ts +58 -0
@@ -0,0 +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
+ }