@mandujs/core 0.41.2 → 0.43.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.
Files changed (88) hide show
  1. package/package.json +21 -4
  2. package/src/auth/__tests__/login.test.ts +420 -419
  3. package/src/auth/__tests__/reset.test.ts +296 -296
  4. package/src/brain/adapters/anthropic-oauth.ts +421 -420
  5. package/src/brain/adapters/index.ts +2 -1
  6. package/src/brain/adapters/ollama.ts +1 -1
  7. package/src/brain/adapters/openai-oauth.ts +534 -533
  8. package/src/brain/brain.ts +2 -1
  9. package/src/brain/redactor.ts +196 -196
  10. package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -149
  11. package/src/bundler/__tests__/cold-start.test.ts +504 -504
  12. package/src/bundler/__tests__/fast-refresh.test.ts +607 -606
  13. package/src/bundler/__tests__/hdr.test.ts +1 -1
  14. package/src/bundler/analyzer.ts +958 -958
  15. package/src/bundler/build.ts +104 -14
  16. package/src/bundler/dev.ts +125 -0
  17. package/src/bundler/hmr-types.ts +1 -0
  18. package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -0
  19. package/src/bundler/plugins/index.ts +14 -0
  20. package/src/bundler/plugins/react-compiler-lint.ts +253 -0
  21. package/src/bundler/plugins/react-compiler.ts +162 -0
  22. package/src/bundler/types.ts +12 -0
  23. package/src/change/integrity.ts +2 -1
  24. package/src/client/index.ts +10 -0
  25. package/src/client/island.ts +38 -11
  26. package/src/client/router.ts +6 -1
  27. package/src/config/mandu.ts +57 -0
  28. package/src/config/validate.ts +42 -0
  29. package/src/content/collection.ts +844 -809
  30. package/src/content/content-layer.ts +316 -314
  31. package/src/content/content.test.ts +433 -433
  32. package/src/content/digest.ts +133 -133
  33. package/src/content/generate-types.ts +168 -168
  34. package/src/content/index.ts +6 -1
  35. package/src/content/llms-txt.ts +277 -277
  36. package/src/contract/define.ts +474 -474
  37. package/src/contract/route-helpers.ts +2 -1
  38. package/src/contract/zod-utils.ts +158 -155
  39. package/src/db/index.ts +513 -513
  40. package/src/desktop/__tests__/smoke.test.ts +100 -100
  41. package/src/desktop/webview-fallback.ts +583 -583
  42. package/src/desktop/window.ts +3 -1
  43. package/src/dev-error-overlay/overlay-client.ts +300 -300
  44. package/src/devtools/ai/mcp-connector.ts +499 -498
  45. package/src/devtools/client/components/kitchen-root.tsx +7 -2
  46. package/src/email/resend.ts +163 -163
  47. package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -0
  48. package/src/guard/ast-analyzer.ts +806 -806
  49. package/src/guard/graph.ts +898 -898
  50. package/src/guard/index.ts +16 -0
  51. package/src/guard/statistics.ts +578 -578
  52. package/src/guard/tsgolint-bridge.ts +512 -0
  53. package/src/i18n/locale-resolver.ts +214 -214
  54. package/src/id/__tests__/id.test.ts +120 -120
  55. package/src/intent/index.ts +321 -321
  56. package/src/island/index.ts +39 -23
  57. package/src/kitchen/api/contract-api.ts +15 -8
  58. package/src/kitchen/kitchen-ui.ts +2137 -2137
  59. package/src/lockfile/index.ts +3 -2
  60. package/src/middleware/oauth/__tests__/oauth.test.ts +575 -574
  61. package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -642
  62. package/src/middleware/secure/index.ts +417 -417
  63. package/src/observability/event-bus.ts +2 -2
  64. package/src/observability/metrics.ts +334 -334
  65. package/src/observability/tracing.ts +694 -694
  66. package/src/openapi/generator.ts +1 -1
  67. package/src/perf/user-marks.ts +553 -553
  68. package/src/plugins/registry.ts +387 -387
  69. package/src/resource/ddl/diff.ts +392 -392
  70. package/src/resource/ddl/snapshot.ts +448 -447
  71. package/src/resource/generator-schema.ts +477 -476
  72. package/src/resource/parser.ts +4 -2
  73. package/src/resource/schema.ts +1 -1
  74. package/src/router/fs-patterns.ts +422 -422
  75. package/src/runtime/fast-refresh-types.ts +126 -128
  76. package/src/runtime/image-handler.ts +206 -195
  77. package/src/runtime/router.test.ts +476 -476
  78. package/src/runtime/security.ts +155 -155
  79. package/src/runtime/server.ts +36 -19
  80. package/src/runtime/session-key.ts +328 -328
  81. package/src/scheduler/__tests__/scheduler.test.ts +514 -514
  82. package/src/seo/resolve/index.ts +353 -353
  83. package/src/spec/load.ts +1 -1
  84. package/src/testing/reporter.ts +676 -676
  85. package/src/testing/server.ts +196 -196
  86. package/src/testing/snapshot.ts +444 -444
  87. package/src/utils/__tests__/lru-cache.test.ts +186 -186
  88. package/src/utils/bun.ts +8 -8
@@ -1,214 +1,214 @@
1
- /**
2
- * Phase 18.μ — locale resolution.
3
- *
4
- * Given a request + i18n config, return the active {@link ResolvedLocale}.
5
- * Resolution order depends on `config.strategy`:
6
- *
7
- * path-prefix → URL path segment → cookie → Accept-Language → default
8
- * domain → Host header → cookie → Accept-Language → default
9
- * header → Accept-Language → cookie → default
10
- * cookie → Cookie → Accept-Language → default
11
- *
12
- * In every mode, an invalid locale signal (e.g. `/fr/docs` when `fr`
13
- * isn't configured) falls through to the next signal — invalid inputs
14
- * never short-circuit to an error. The final tier is always the
15
- * configured `defaultLocale` or `fallback`.
16
- *
17
- * This module is pure; it never touches the server registry or
18
- * `ctx.cookies`. Callers assemble the result and bolt it onto the
19
- * request via `ManduContext` — see `runtime/server.ts` μ dispatch
20
- * section.
21
- */
22
-
23
- import type { I18nDefinition, LocaleCode, ResolvedLocale } from "./types";
24
- import { DEFAULT_I18N_COOKIE } from "./define";
25
-
26
- /**
27
- * Remove a locale prefix from a URL pathname. Returns `{ locale, rest }`
28
- * when the pathname starts with `/<locale>(/...)?` for a known locale;
29
- * otherwise `{ locale: undefined, rest: pathname }`.
30
- *
31
- * Handles trailing + empty slashes so `/en`, `/en/`, `/en/docs` all
32
- * match `locale="en"` with the rest normalized to `"/"`, `"/"`, `"/docs"`.
33
- */
34
- export function stripLocalePrefix(
35
- pathname: string,
36
- locales: readonly LocaleCode[]
37
- ): { locale: LocaleCode | undefined; rest: string } {
38
- if (!pathname.startsWith("/")) {
39
- return { locale: undefined, rest: pathname };
40
- }
41
- // Fast path: root.
42
- if (pathname === "/") return { locale: undefined, rest: "/" };
43
- const slashIdx = pathname.indexOf("/", 1);
44
- const first = slashIdx === -1 ? pathname.slice(1) : pathname.slice(1, slashIdx);
45
- if (!locales.includes(first)) {
46
- return { locale: undefined, rest: pathname };
47
- }
48
- const rest = slashIdx === -1 ? "/" : pathname.slice(slashIdx);
49
- return { locale: first, rest };
50
- }
51
-
52
- /**
53
- * Parse the `Accept-Language` header and pick the best supported
54
- * locale. Honours q-weights; falls back to undefined when nothing
55
- * matches.
56
- *
57
- * Comparison is case-insensitive, and we accept exact-match only
58
- * (no Accept-Language `en-US` → `en` widening — userland can list
59
- * both in `config.locales` when they want fallback behaviour).
60
- */
61
- export function parseAcceptLanguage(
62
- header: string | null | undefined,
63
- locales: readonly LocaleCode[]
64
- ): LocaleCode | undefined {
65
- if (!header) return undefined;
66
- const want = new Map<string, number>();
67
- for (const piece of header.split(",")) {
68
- const [tagRaw, ...params] = piece.trim().split(";");
69
- if (!tagRaw) continue;
70
- const tag = tagRaw.trim().toLowerCase();
71
- if (!tag || tag === "*") continue;
72
- let q = 1.0;
73
- for (const p of params) {
74
- const m = /^\s*q\s*=\s*([0-9.]+)\s*$/i.exec(p);
75
- if (m) q = parseFloat(m[1]!);
76
- }
77
- if (Number.isFinite(q) && q > 0 && !want.has(tag)) {
78
- want.set(tag, q);
79
- }
80
- }
81
- // Highest-q first; stable within equal weights.
82
- const sorted = [...want.entries()].sort((a, b) => b[1] - a[1]);
83
- const lowerLocales = locales.map((l) => ({ raw: l, lower: l.toLowerCase() }));
84
- for (const [tag] of sorted) {
85
- for (const { raw, lower } of lowerLocales) {
86
- if (lower === tag) return raw;
87
- }
88
- }
89
- // Widening fallback: `zh-TW` → try `zh` if only `zh` is configured.
90
- for (const [tag] of sorted) {
91
- const short = tag.split("-")[0]!;
92
- for (const { raw, lower } of lowerLocales) {
93
- if (lower === short) return raw;
94
- }
95
- }
96
- return undefined;
97
- }
98
-
99
- /**
100
- * Read the locale cookie from the raw `Cookie` header. A nano-parser
101
- * so the resolver stays dependency-free (the `CookieManager` in
102
- * `filling/context.ts` lives on the request-wrapping side, not here).
103
- */
104
- export function readLocaleCookie(
105
- header: string | null | undefined,
106
- cookieName: string,
107
- locales: readonly LocaleCode[]
108
- ): LocaleCode | undefined {
109
- if (!header) return undefined;
110
- const target = `${cookieName}=`;
111
- for (const piece of header.split(";")) {
112
- const trimmed = piece.trim();
113
- if (!trimmed.startsWith(target)) continue;
114
- const raw = trimmed.slice(target.length);
115
- try {
116
- const value = decodeURIComponent(raw);
117
- if (locales.includes(value)) return value;
118
- } catch {
119
- // fallthrough
120
- }
121
- }
122
- return undefined;
123
- }
124
-
125
- /**
126
- * Resolve the locale for this request.
127
- *
128
- * The returned {@link ResolvedLocale} is always valid w.r.t.
129
- * `config.locales` — when every signal misses, we fall back to
130
- * `fallback` then `defaultLocale` (in that order) and flag the
131
- * result with `strategy: "fallback"` / `"default"`.
132
- */
133
- export function resolveLocale(
134
- request: Request,
135
- config: I18nDefinition
136
- ): ResolvedLocale {
137
- const url = new URL(request.url);
138
- const cookieName = config.cookieName ?? DEFAULT_I18N_COOKIE;
139
- const cookieHeader = request.headers.get("cookie");
140
- const acceptLanguage = request.headers.get("accept-language");
141
-
142
- switch (config.strategy) {
143
- case "path-prefix": {
144
- const { locale } = stripLocalePrefix(url.pathname, config.locales);
145
- if (locale) {
146
- return { code: locale, isDefault: false, strategy: "path-prefix", source: url.pathname };
147
- }
148
- const cookie = readLocaleCookie(cookieHeader, cookieName, config.locales);
149
- if (cookie) {
150
- return { code: cookie, isDefault: false, strategy: "cookie", source: cookieHeader ?? undefined };
151
- }
152
- const accept = parseAcceptLanguage(acceptLanguage, config.locales);
153
- if (accept) {
154
- return { code: accept, isDefault: false, strategy: "header", source: acceptLanguage ?? undefined };
155
- }
156
- return finalFallback(config);
157
- }
158
-
159
- case "domain": {
160
- const host = request.headers.get("host") || url.host;
161
- const mapped = config.domains?.[host];
162
- if (mapped) {
163
- return { code: mapped, isDefault: false, strategy: "domain", source: host };
164
- }
165
- const cookie = readLocaleCookie(cookieHeader, cookieName, config.locales);
166
- if (cookie) {
167
- return { code: cookie, isDefault: false, strategy: "cookie", source: cookieHeader ?? undefined };
168
- }
169
- const accept = parseAcceptLanguage(acceptLanguage, config.locales);
170
- if (accept) {
171
- return { code: accept, isDefault: false, strategy: "header", source: acceptLanguage ?? undefined };
172
- }
173
- return finalFallback(config);
174
- }
175
-
176
- case "cookie": {
177
- const cookie = readLocaleCookie(cookieHeader, cookieName, config.locales);
178
- if (cookie) {
179
- return { code: cookie, isDefault: false, strategy: "cookie", source: cookieHeader ?? undefined };
180
- }
181
- const accept = parseAcceptLanguage(acceptLanguage, config.locales);
182
- if (accept) {
183
- return { code: accept, isDefault: false, strategy: "header", source: acceptLanguage ?? undefined };
184
- }
185
- return finalFallback(config);
186
- }
187
-
188
- case "header": {
189
- const accept = parseAcceptLanguage(acceptLanguage, config.locales);
190
- if (accept) {
191
- return { code: accept, isDefault: false, strategy: "header", source: acceptLanguage ?? undefined };
192
- }
193
- const cookie = readLocaleCookie(cookieHeader, cookieName, config.locales);
194
- if (cookie) {
195
- return { code: cookie, isDefault: false, strategy: "cookie", source: cookieHeader ?? undefined };
196
- }
197
- return finalFallback(config);
198
- }
199
- }
200
-
201
- // Exhaustive check
202
- return finalFallback(config);
203
- }
204
-
205
- function finalFallback(config: I18nDefinition): ResolvedLocale {
206
- if (config.fallback && config.fallback !== config.defaultLocale) {
207
- return {
208
- code: config.fallback,
209
- isDefault: config.fallback === config.defaultLocale,
210
- strategy: "fallback",
211
- };
212
- }
213
- return { code: config.defaultLocale, isDefault: true, strategy: "default" };
214
- }
1
+ /**
2
+ * Phase 18.μ — locale resolution.
3
+ *
4
+ * Given a request + i18n config, return the active {@link ResolvedLocale}.
5
+ * Resolution order depends on `config.strategy`:
6
+ *
7
+ * path-prefix → URL path segment → cookie → Accept-Language → default
8
+ * domain → Host header → cookie → Accept-Language → default
9
+ * header → Accept-Language → cookie → default
10
+ * cookie → Cookie → Accept-Language → default
11
+ *
12
+ * In every mode, an invalid locale signal (e.g. `/fr/docs` when `fr`
13
+ * isn't configured) falls through to the next signal — invalid inputs
14
+ * never short-circuit to an error. The final tier is always the
15
+ * configured `defaultLocale` or `fallback`.
16
+ *
17
+ * This module is pure; it never touches the server registry or
18
+ * `ctx.cookies`. Callers assemble the result and bolt it onto the
19
+ * request via `ManduContext` — see `runtime/server.ts` μ dispatch
20
+ * section.
21
+ */
22
+
23
+ import type { I18nDefinition, LocaleCode, ResolvedLocale } from "./types";
24
+ import { DEFAULT_I18N_COOKIE } from "./define";
25
+
26
+ /**
27
+ * Remove a locale prefix from a URL pathname. Returns `{ locale, rest }`
28
+ * when the pathname starts with `/<locale>(/...)?` for a known locale;
29
+ * otherwise `{ locale: undefined, rest: pathname }`.
30
+ *
31
+ * Handles trailing + empty slashes so `/en`, `/en/`, `/en/docs` all
32
+ * match `locale="en"` with the rest normalized to `"/"`, `"/"`, `"/docs"`.
33
+ */
34
+ export function stripLocalePrefix(
35
+ pathname: string,
36
+ locales: readonly LocaleCode[]
37
+ ): { locale: LocaleCode | undefined; rest: string } {
38
+ if (!pathname.startsWith("/")) {
39
+ return { locale: undefined, rest: pathname };
40
+ }
41
+ // Fast path: root.
42
+ if (pathname === "/") return { locale: undefined, rest: "/" };
43
+ const slashIdx = pathname.indexOf("/", 1);
44
+ const first = slashIdx === -1 ? pathname.slice(1) : pathname.slice(1, slashIdx);
45
+ if (!locales.includes(first)) {
46
+ return { locale: undefined, rest: pathname };
47
+ }
48
+ const rest = slashIdx === -1 ? "/" : pathname.slice(slashIdx);
49
+ return { locale: first, rest };
50
+ }
51
+
52
+ /**
53
+ * Parse the `Accept-Language` header and pick the best supported
54
+ * locale. Honours q-weights; falls back to undefined when nothing
55
+ * matches.
56
+ *
57
+ * Comparison is case-insensitive, and we accept exact-match only
58
+ * (no Accept-Language `en-US` → `en` widening — userland can list
59
+ * both in `config.locales` when they want fallback behaviour).
60
+ */
61
+ export function parseAcceptLanguage(
62
+ header: string | null | undefined,
63
+ locales: readonly LocaleCode[]
64
+ ): LocaleCode | undefined {
65
+ if (!header) return undefined;
66
+ const want = new Map<string, number>();
67
+ for (const piece of header.split(",")) {
68
+ const [tagRaw, ...params] = piece.trim().split(";");
69
+ if (!tagRaw) continue;
70
+ const tag = tagRaw.trim().toLowerCase();
71
+ if (!tag || tag === "*") continue;
72
+ let q = 1.0;
73
+ for (const p of params) {
74
+ const m = /^\s*q\s*=\s*([0-9.]+)\s*$/i.exec(p);
75
+ if (m) q = parseFloat(m[1]!);
76
+ }
77
+ if (Number.isFinite(q) && q > 0 && !want.has(tag)) {
78
+ want.set(tag, q);
79
+ }
80
+ }
81
+ // Highest-q first; stable within equal weights.
82
+ const sorted = [...want.entries()].sort((a, b) => b[1] - a[1]);
83
+ const lowerLocales = locales.map((l) => ({ raw: l, lower: l.toLowerCase() }));
84
+ for (const [tag] of sorted) {
85
+ for (const { raw, lower } of lowerLocales) {
86
+ if (lower === tag) return raw;
87
+ }
88
+ }
89
+ // Widening fallback: `zh-TW` → try `zh` if only `zh` is configured.
90
+ for (const [tag] of sorted) {
91
+ const short = tag.split("-")[0]!;
92
+ for (const { raw, lower } of lowerLocales) {
93
+ if (lower === short) return raw;
94
+ }
95
+ }
96
+ return undefined;
97
+ }
98
+
99
+ /**
100
+ * Read the locale cookie from the raw `Cookie` header. A nano-parser
101
+ * so the resolver stays dependency-free (the `CookieManager` in
102
+ * `filling/context.ts` lives on the request-wrapping side, not here).
103
+ */
104
+ export function readLocaleCookie(
105
+ header: string | null | undefined,
106
+ cookieName: string,
107
+ locales: readonly LocaleCode[]
108
+ ): LocaleCode | undefined {
109
+ if (!header) return undefined;
110
+ const target = `${cookieName}=`;
111
+ for (const piece of header.split(";")) {
112
+ const trimmed = piece.trim();
113
+ if (!trimmed.startsWith(target)) continue;
114
+ const raw = trimmed.slice(target.length);
115
+ try {
116
+ const value = decodeURIComponent(raw);
117
+ if (locales.includes(value)) return value;
118
+ } catch {
119
+ // fallthrough
120
+ }
121
+ }
122
+ return undefined;
123
+ }
124
+
125
+ /**
126
+ * Resolve the locale for this request.
127
+ *
128
+ * The returned {@link ResolvedLocale} is always valid w.r.t.
129
+ * `config.locales` — when every signal misses, we fall back to
130
+ * `fallback` then `defaultLocale` (in that order) and flag the
131
+ * result with `strategy: "fallback"` / `"default"`.
132
+ */
133
+ export function resolveLocale(
134
+ request: Request,
135
+ config: I18nDefinition
136
+ ): ResolvedLocale {
137
+ const url = new URL(request.url);
138
+ const cookieName = config.cookieName ?? DEFAULT_I18N_COOKIE;
139
+ const cookieHeader = request.headers.get("cookie");
140
+ const acceptLanguage = request.headers.get("accept-language");
141
+
142
+ switch (config.strategy) {
143
+ case "path-prefix": {
144
+ const { locale } = stripLocalePrefix(url.pathname, config.locales);
145
+ if (locale) {
146
+ return { code: locale, isDefault: false, strategy: "path-prefix", source: url.pathname };
147
+ }
148
+ const cookie = readLocaleCookie(cookieHeader, cookieName, config.locales);
149
+ if (cookie) {
150
+ return { code: cookie, isDefault: false, strategy: "cookie", source: cookieHeader ?? undefined };
151
+ }
152
+ const accept = parseAcceptLanguage(acceptLanguage, config.locales);
153
+ if (accept) {
154
+ return { code: accept, isDefault: false, strategy: "header", source: acceptLanguage ?? undefined };
155
+ }
156
+ return finalFallback(config);
157
+ }
158
+
159
+ case "domain": {
160
+ const host = request.headers.get("host") || url.host;
161
+ const mapped = config.domains?.[host];
162
+ if (mapped) {
163
+ return { code: mapped, isDefault: false, strategy: "domain", source: host };
164
+ }
165
+ const cookie = readLocaleCookie(cookieHeader, cookieName, config.locales);
166
+ if (cookie) {
167
+ return { code: cookie, isDefault: false, strategy: "cookie", source: cookieHeader ?? undefined };
168
+ }
169
+ const accept = parseAcceptLanguage(acceptLanguage, config.locales);
170
+ if (accept) {
171
+ return { code: accept, isDefault: false, strategy: "header", source: acceptLanguage ?? undefined };
172
+ }
173
+ return finalFallback(config);
174
+ }
175
+
176
+ case "cookie": {
177
+ const cookie = readLocaleCookie(cookieHeader, cookieName, config.locales);
178
+ if (cookie) {
179
+ return { code: cookie, isDefault: false, strategy: "cookie", source: cookieHeader ?? undefined };
180
+ }
181
+ const accept = parseAcceptLanguage(acceptLanguage, config.locales);
182
+ if (accept) {
183
+ return { code: accept, isDefault: false, strategy: "header", source: acceptLanguage ?? undefined };
184
+ }
185
+ return finalFallback(config);
186
+ }
187
+
188
+ case "header": {
189
+ const accept = parseAcceptLanguage(acceptLanguage, config.locales);
190
+ if (accept) {
191
+ return { code: accept, isDefault: false, strategy: "header", source: acceptLanguage ?? undefined };
192
+ }
193
+ const cookie = readLocaleCookie(cookieHeader, cookieName, config.locales);
194
+ if (cookie) {
195
+ return { code: cookie, isDefault: false, strategy: "cookie", source: cookieHeader ?? undefined };
196
+ }
197
+ return finalFallback(config);
198
+ }
199
+ }
200
+
201
+ // Exhaustive check
202
+ return finalFallback(config);
203
+ }
204
+
205
+ function finalFallback(config: I18nDefinition): ResolvedLocale {
206
+ if (config.fallback && config.fallback !== config.defaultLocale) {
207
+ return {
208
+ code: config.fallback,
209
+ isDefault: config.fallback === config.defaultLocale,
210
+ strategy: "fallback",
211
+ };
212
+ }
213
+ return { code: config.defaultLocale, isDefault: true, strategy: "default" };
214
+ }
@@ -1,120 +1,120 @@
1
- import { describe, it, expect } from "bun:test";
2
- import { newId, newShortId, _resolveGenerator } from "../index";
3
-
4
- const UUID_REGEX =
5
- /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
6
- const HEX8_REGEX = /^[0-9a-f]{8}$/i;
7
-
8
- interface CryptoLike {
9
- randomUUIDv7?: () => string;
10
- randomUUID: () => string;
11
- }
12
-
13
- describe("@mandujs/core/id — newId", () => {
14
- it("returns a canonical UUID string", () => {
15
- const id = newId();
16
- expect(id).toMatch(UUID_REGEX);
17
- expect(id.length).toBe(36);
18
- });
19
-
20
- it("returns distinct values across calls", () => {
21
- const seen = new Set<string>();
22
- for (let i = 0; i < 1000; i++) {
23
- seen.add(newId());
24
- }
25
- expect(seen.size).toBe(1000);
26
- });
27
-
28
- it("is monotonic: lexicographic sort preserves generation order (v7 property)", async () => {
29
- // Skip this invariant if neither global exposes v7 — the v4 fallback is
30
- // correctly non-monotonic by design.
31
- const c = globalThis.crypto as unknown as CryptoLike;
32
- const bun = (globalThis as unknown as { Bun?: { randomUUIDv7?: () => string } }).Bun;
33
- const v7Available =
34
- typeof c.randomUUIDv7 === "function" ||
35
- (bun !== undefined && typeof bun.randomUUIDv7 === "function");
36
- if (!v7Available) {
37
- return;
38
- }
39
-
40
- const generated: string[] = [];
41
- for (let i = 0; i < 1000; i++) {
42
- generated.push(newId());
43
- // Force sub-millisecond spread across a few ticks so the time component
44
- // advances at least once during the run. UUID v7 counter handles
45
- // intra-millisecond monotonicity on its own.
46
- if (i % 200 === 0) {
47
- await new Promise((r) => setTimeout(r, 1));
48
- }
49
- }
50
-
51
- const sorted = [...generated].sort();
52
-
53
- // Require overall monotonic trend: the full sorted list equals the
54
- // generation order. Bun's v7 implementation uses a per-millisecond
55
- // monotonic counter, so ties within the same ms remain ordered.
56
- expect(sorted).toEqual(generated);
57
- });
58
- });
59
-
60
- describe("@mandujs/core/id — newShortId", () => {
61
- it("returns exactly 8 hex characters", () => {
62
- const short = newShortId();
63
- expect(short).toMatch(HEX8_REGEX);
64
- expect(short.length).toBe(8);
65
- });
66
-
67
- it("matches the prefix of the underlying UUID", () => {
68
- // The short form is defined as the first 8 chars of the full ID, which is
69
- // the first hex group of the UUID (pre-hyphen).
70
- for (let i = 0; i < 50; i++) {
71
- const short = newShortId();
72
- expect(short).not.toContain("-");
73
- }
74
- });
75
- });
76
-
77
- describe("@mandujs/core/id — generator resolution", () => {
78
- it("prefers crypto.randomUUIDv7 when present", () => {
79
- const sentinel = "01912345-6789-7abc-def0-123456789abc";
80
- const fakeCrypto = {
81
- randomUUIDv7: () => sentinel,
82
- randomUUID: () => "00000000-0000-4000-8000-000000000000",
83
- };
84
- const fakeBun = { randomUUIDv7: () => "SHOULD_NOT_BE_CALLED" };
85
- const gen = _resolveGenerator(fakeCrypto, fakeBun);
86
- expect(gen()).toBe(sentinel);
87
- });
88
-
89
- it("uses Bun.randomUUIDv7 when crypto.randomUUIDv7 is absent", () => {
90
- const sentinel = "01abcdef-0123-7456-8789-abcdef012345";
91
- const fakeCrypto = {
92
- randomUUID: () => "00000000-0000-4000-8000-000000000000",
93
- };
94
- const fakeBun = { randomUUIDv7: () => sentinel };
95
- const gen = _resolveGenerator(fakeCrypto, fakeBun);
96
- expect(gen()).toBe(sentinel);
97
- });
98
-
99
- it("falls back to crypto.randomUUID when neither v7 source is available", () => {
100
- const fakeCrypto = {
101
- // No randomUUIDv7 — force fallback.
102
- randomUUID: () => "00000000-0000-4000-8000-000000000000",
103
- };
104
- const fakeBun = {}; // also no randomUUIDv7
105
- const gen = _resolveGenerator(fakeCrypto, fakeBun);
106
- const id = gen();
107
- expect(id).toMatch(UUID_REGEX);
108
- expect(id.length).toBe(36);
109
- });
110
-
111
- it("falls back to crypto.randomUUID when Bun stub lacks randomUUIDv7", () => {
112
- const fakeCrypto = {
113
- randomUUID: () => "11111111-1111-4111-8111-111111111111",
114
- };
115
- // Pass an empty stub explicitly rather than `undefined` (which would
116
- // trigger the default-parameter fallback to the real Bun global).
117
- const gen = _resolveGenerator(fakeCrypto, {});
118
- expect(gen()).toBe("11111111-1111-4111-8111-111111111111");
119
- });
120
- });
1
+ import { describe, it, expect } from "bun:test";
2
+ import { newId, newShortId, _resolveGenerator } from "../index";
3
+
4
+ const UUID_REGEX =
5
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
6
+ const HEX8_REGEX = /^[0-9a-f]{8}$/i;
7
+
8
+ interface CryptoLike {
9
+ randomUUIDv7?: () => string;
10
+ randomUUID: () => string;
11
+ }
12
+
13
+ describe("@mandujs/core/id — newId", () => {
14
+ it("returns a canonical UUID string", () => {
15
+ const id = newId();
16
+ expect(id).toMatch(UUID_REGEX);
17
+ expect(id.length).toBe(36);
18
+ });
19
+
20
+ it("returns distinct values across calls", () => {
21
+ const seen = new Set<string>();
22
+ for (let i = 0; i < 1000; i++) {
23
+ seen.add(newId());
24
+ }
25
+ expect(seen.size).toBe(1000);
26
+ });
27
+
28
+ it("is monotonic: lexicographic sort preserves generation order (v7 property)", async () => {
29
+ // Skip this invariant if neither global exposes v7 — the v4 fallback is
30
+ // correctly non-monotonic by design.
31
+ const c = globalThis.crypto as unknown as CryptoLike;
32
+ const bun = (globalThis as unknown as { Bun?: { randomUUIDv7?: () => string } }).Bun;
33
+ const v7Available =
34
+ typeof c.randomUUIDv7 === "function" ||
35
+ (bun !== undefined && typeof bun.randomUUIDv7 === "function");
36
+ if (!v7Available) {
37
+ return;
38
+ }
39
+
40
+ const generated: string[] = [];
41
+ for (let i = 0; i < 1000; i++) {
42
+ generated.push(newId());
43
+ // Force sub-millisecond spread across a few ticks so the time component
44
+ // advances at least once during the run. UUID v7 counter handles
45
+ // intra-millisecond monotonicity on its own.
46
+ if (i % 200 === 0) {
47
+ await new Promise((r) => setTimeout(r, 1));
48
+ }
49
+ }
50
+
51
+ const sorted = [...generated].sort();
52
+
53
+ // Require overall monotonic trend: the full sorted list equals the
54
+ // generation order. Bun's v7 implementation uses a per-millisecond
55
+ // monotonic counter, so ties within the same ms remain ordered.
56
+ expect(sorted).toEqual(generated);
57
+ });
58
+ });
59
+
60
+ describe("@mandujs/core/id — newShortId", () => {
61
+ it("returns exactly 8 hex characters", () => {
62
+ const short = newShortId();
63
+ expect(short).toMatch(HEX8_REGEX);
64
+ expect(short.length).toBe(8);
65
+ });
66
+
67
+ it("matches the prefix of the underlying UUID", () => {
68
+ // The short form is defined as the first 8 chars of the full ID, which is
69
+ // the first hex group of the UUID (pre-hyphen).
70
+ for (let i = 0; i < 50; i++) {
71
+ const short = newShortId();
72
+ expect(short).not.toContain("-");
73
+ }
74
+ });
75
+ });
76
+
77
+ describe("@mandujs/core/id — generator resolution", () => {
78
+ it("prefers crypto.randomUUIDv7 when present", () => {
79
+ const sentinel = "01912345-6789-7abc-def0-123456789abc";
80
+ const fakeCrypto = {
81
+ randomUUIDv7: () => sentinel,
82
+ randomUUID: () => "00000000-0000-4000-8000-000000000000",
83
+ };
84
+ const fakeBun = { randomUUIDv7: () => "SHOULD_NOT_BE_CALLED" };
85
+ const gen = _resolveGenerator(fakeCrypto, fakeBun);
86
+ expect(gen()).toBe(sentinel);
87
+ });
88
+
89
+ it("uses Bun.randomUUIDv7 when crypto.randomUUIDv7 is absent", () => {
90
+ const sentinel = "01abcdef-0123-7456-8789-abcdef012345";
91
+ const fakeCrypto = {
92
+ randomUUID: () => "00000000-0000-4000-8000-000000000000",
93
+ };
94
+ const fakeBun = { randomUUIDv7: () => sentinel };
95
+ const gen = _resolveGenerator(fakeCrypto, fakeBun);
96
+ expect(gen()).toBe(sentinel);
97
+ });
98
+
99
+ it("falls back to crypto.randomUUID when neither v7 source is available", () => {
100
+ const fakeCrypto = {
101
+ // No randomUUIDv7 — force fallback.
102
+ randomUUID: () => "00000000-0000-4000-8000-000000000000",
103
+ };
104
+ const fakeBun = {}; // also no randomUUIDv7
105
+ const gen = _resolveGenerator(fakeCrypto, fakeBun);
106
+ const id = gen();
107
+ expect(id).toMatch(UUID_REGEX);
108
+ expect(id.length).toBe(36);
109
+ });
110
+
111
+ it("falls back to crypto.randomUUID when Bun stub lacks randomUUIDv7", () => {
112
+ const fakeCrypto = {
113
+ randomUUID: () => "11111111-1111-4111-8111-111111111111",
114
+ };
115
+ // Pass an empty stub explicitly rather than `undefined` (which would
116
+ // trigger the default-parameter fallback to the real Bun global).
117
+ const gen = _resolveGenerator(fakeCrypto, {});
118
+ expect(gen()).toBe("11111111-1111-4111-8111-111111111111");
119
+ });
120
+ });