@kindgi/bounds 0.0.0-stage → 0.1.8

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/src/pattern.ts ADDED
@@ -0,0 +1,271 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ import { RE2JS, RE2JSException, RE2JSSyntaxException } from 're2js';
5
+
6
+ import { tenantInputBounds } from './bounds.js';
7
+
8
+ /**
9
+ * A tenant's pattern, compiled for the engine the process's bounds name.
10
+ * Both answers are those of the JavaScript `RegExp` it was written as,
11
+ * starting from the beginning of `input` each time (`lastIndex` 0).
12
+ */
13
+ export interface TenantPattern {
14
+ /** Whether it matches in `input`: anywhere, or at the start under the `y` flag. */
15
+ test(input: string): boolean;
16
+ /**
17
+ * How many times it matches in `input`, one after another without
18
+ * overlapping, as `input.match()` with the `g` flag counts them. It stops
19
+ * counting at `atMost`: a caller that needs "at least n" asks for n, so a
20
+ * long input costs no more than that.
21
+ */
22
+ count(input: string, atMost?: number): number;
23
+ }
24
+
25
+ export type CompiledTenantPattern =
26
+ | { readonly kind: 'ok'; readonly pattern: TenantPattern }
27
+ /** Why it can't run here: a fragment that reads after a colon, with no full stop. */
28
+ | { readonly kind: 'refused'; readonly reason: string };
29
+
30
+ /**
31
+ * Compiles a pattern a tenant wrote (a JavaScript regular expression's
32
+ * source and flags) under the process's bounds: no longer than
33
+ * `patternMaxLength` with its flags, on V8's engine or on RE2's.
34
+ *
35
+ * Under RE2 (the hosted profile), a pattern runs in linear time in its
36
+ * input. The price is what RE2 doesn't have, refused by name:
37
+ * backreferences, lookahead and lookbehind. A few things also read
38
+ * differently, the same way for every pattern (the package README lists
39
+ * them): RE2 always matches by code point and folds case as the `u` flag
40
+ * does; `.` stops only at `\n`, and with `m`, `^` and `$` only around
41
+ * `\n`; `\s` is ASCII whitespace. What RE2 would silently read otherwise
42
+ * is refused: `[]` and `[^]` (the start of a class to RE2), `[:` in a
43
+ * class (a POSIX class to RE2) and, without the `u` flag, the escapes
44
+ * JavaScript reads as the letter itself (`\p`, `\P`, `\A`, `\z`, `\Q`,
45
+ * `\a`, `\x{`). So is `\u`, which RE2 doesn't have. And it accepts only
46
+ * what `RegExp` accepts: a pattern V8 refuses is refused in V8's words, even
47
+ * where RE2 would compile it (`(?i)`, `(?P<n>…)`).
48
+ *
49
+ * Flags under RE2: `i`, `m`, `s` and `u` map to RE2's own; `y` anchors at
50
+ * the start as it does from `lastIndex` 0; `g` and `d` change no answer
51
+ * here. `v` is refused: RE2 has no set notation.
52
+ */
53
+ export function compileTenantPattern(source: string, flags = ''): CompiledTenantPattern {
54
+ const bounds = tenantInputBounds();
55
+ const tooLong = lengthProblem(source, flags);
56
+ if (tooLong !== undefined) return { kind: 'refused', reason: tooLong };
57
+ return bounds.regex === 're2' ? compileRe2(source, flags) : compileV8(source, flags);
58
+ }
59
+
60
+ /**
61
+ * Why `compileTenantPattern` would refuse `source` with `flags` for its
62
+ * syntax, or `undefined` when it wouldn't: over `patternMaxLength`, what
63
+ * `RegExp` refuses and, under RE2, a flag or a construct RE2 doesn't have
64
+ * or reads otherwise. It builds no RE2 program, so it costs about what
65
+ * V8's parse does. A pattern RE2 refuses only as it builds the program
66
+ * passes here, and is refused when it's compiled: one too large (a
67
+ * repeat over 1,000, say), or with an escape RE2 doesn't have that this
68
+ * check doesn't name (`\N`, `[\b]`, `\cA`, a long property name such as
69
+ * `\p{Letter}`).
70
+ */
71
+ export function tenantPatternSyntaxProblem(source: string, flags = ''): string | undefined {
72
+ const tooLong = lengthProblem(source, flags);
73
+ if (tooLong !== undefined) return tooLong;
74
+ if (tenantInputBounds().regex !== 're2') {
75
+ const parsed = parse(source, flags);
76
+ return typeof parsed === 'string' ? parsed : undefined;
77
+ }
78
+ const re2Flags = re2FlagsFor(flags);
79
+ return typeof re2Flags === 'string' ? re2Flags : re2SyntaxProblem(source, flags);
80
+ }
81
+
82
+ /** Why `source` with `flags` is over `patternMaxLength`, or `undefined`. */
83
+ function lengthProblem(source: string, flags: string): string | undefined {
84
+ const max = tenantInputBounds().patternMaxLength;
85
+ const length = source.length + flags.length;
86
+ return length > max ? `${length} characters with its flags, over the limit of ${max}` : undefined;
87
+ }
88
+
89
+ /** `source` with `flags` as V8 parses it, or V8's reason for refusing it. */
90
+ function parse(source: string, flags: string): RegExp | string {
91
+ try {
92
+ return new RegExp(source, flags);
93
+ } catch (error) {
94
+ return (error as Error).message;
95
+ }
96
+ }
97
+
98
+ function compileV8(source: string, flags: string): CompiledTenantPattern {
99
+ const once = parse(source, flags);
100
+ if (typeof once === 'string') return { kind: 'refused', reason: once };
101
+ const every = flags.includes('g') ? once : new RegExp(source, `${flags}g`);
102
+ const byCodePoint = flags.includes('u') || flags.includes('v');
103
+ return {
104
+ kind: 'ok',
105
+ pattern: {
106
+ test(input) {
107
+ once.lastIndex = 0;
108
+ return once.test(input);
109
+ },
110
+ count(input, atMost = Number.POSITIVE_INFINITY) {
111
+ every.lastIndex = 0;
112
+ let found = 0;
113
+ while (found < atMost) {
114
+ const match = every.exec(input);
115
+ if (match === null) break;
116
+ found += 1;
117
+ // As `match()` steps past an empty match, so the next one can't be the same.
118
+ if (match[0] === '') every.lastIndex = after(input, every.lastIndex, byCodePoint);
119
+ }
120
+ return found;
121
+ },
122
+ },
123
+ };
124
+ }
125
+
126
+ const RE2_ENGINE = 'RE2, the engine patterns run on here,';
127
+
128
+ const RE2_FLAGS: Readonly<Record<string, number>> = {
129
+ i: RE2JS.CASE_INSENSITIVE,
130
+ m: RE2JS.MULTILINE,
131
+ s: RE2JS.DOTALL,
132
+ u: 0,
133
+ g: 0,
134
+ d: 0,
135
+ y: 0,
136
+ };
137
+
138
+ /** RE2's flags for `flags`, or why there are none. */
139
+ function re2FlagsFor(flags: string): number | string {
140
+ let re2Flags = 0;
141
+ for (const [index, flag] of [...flags].entries()) {
142
+ if (flags.indexOf(flag) !== index) return `the \`${flag}\` flag is given twice`;
143
+ if (flag === 'v') return `${RE2_ENGINE} has no \`v\` flag (set notation)`;
144
+ const mapped = RE2_FLAGS[flag];
145
+ if (mapped === undefined) return `\`${flag}\` isn't a flag`;
146
+ re2Flags |= mapped;
147
+ }
148
+ return re2Flags;
149
+ }
150
+
151
+ /**
152
+ * Why RE2 can't run `source` with `flags` as JavaScript reads it, for its
153
+ * syntax; `undefined` when it can. What `RegExp` accepts, never more: RE2
154
+ * compiles `(?i)`, `(?P<n>…)` and, with `u`, `\pL` or `\x{41}`, which V8
155
+ * refuses. Constructing it only parses (it never matches), and the pattern
156
+ * is already within its length.
157
+ */
158
+ function re2SyntaxProblem(source: string, flags: string): string | undefined {
159
+ const parsed = parse(source, flags);
160
+ if (typeof parsed === 'string') return parsed;
161
+ return unsupportedOnRe2(source, flags.includes('u'));
162
+ }
163
+
164
+ function compileRe2(source: string, flags: string): CompiledTenantPattern {
165
+ const re2Flags = re2FlagsFor(flags);
166
+ if (typeof re2Flags === 'string') return { kind: 'refused', reason: re2Flags };
167
+ const problem = re2SyntaxProblem(source, flags);
168
+ if (problem !== undefined) return { kind: 'refused', reason: problem };
169
+ let re: RE2JS;
170
+ try {
171
+ re = RE2JS.compile(source, re2Flags);
172
+ } catch (error) {
173
+ if (!(error instanceof RE2JSException)) throw error;
174
+ const detail =
175
+ error instanceof RE2JSSyntaxException
176
+ ? error.message.replace(/^error parsing regexp: /, '')
177
+ : error.message;
178
+ return { kind: 'refused', reason: `${RE2_ENGINE} refuses it: ${detail}` };
179
+ }
180
+ const sticky = flags.includes('y');
181
+ return {
182
+ kind: 'ok',
183
+ pattern: {
184
+ test(input) {
185
+ return sticky ? re.matcher(input).lookingAt() : re.test(input);
186
+ },
187
+ count(input, atMost = Number.POSITIVE_INFINITY) {
188
+ const matcher = re.matcher(input);
189
+ let found = 0;
190
+ let from = 0;
191
+ while (found < atMost && from <= input.length && matcher.find(from)) {
192
+ const start = matcher.start();
193
+ const end = matcher.end();
194
+ if (sticky && start !== from) break;
195
+ found += 1;
196
+ from = end === start ? after(input, end, true) : end;
197
+ }
198
+ return found;
199
+ },
200
+ },
201
+ };
202
+ }
203
+
204
+ /**
205
+ * What RE2 doesn't have, or reads differently from JavaScript, by name
206
+ * and where it is; `undefined` when there's none. RE2's own messages
207
+ * would call a lookbehind an "invalid named capture". It runs on a
208
+ * pattern `RegExp` has already parsed with the same flags (`unicode`: `u`).
209
+ */
210
+ function unsupportedOnRe2(source: string, unicode: boolean): string | undefined {
211
+ let inClass = false;
212
+ for (let i = 0; i < source.length; i += 1) {
213
+ const c = source[i];
214
+ if (c === '\\') {
215
+ const next = source[i + 1] ?? '';
216
+ if (next === 'u') {
217
+ return `${RE2_ENGINE} has no \`\\u\` escapes: write the character itself (at index ${i})`;
218
+ }
219
+ const misread = unicode ? undefined : RE2_READS_OTHERWISE[next];
220
+ if (misread !== undefined && (next !== 'x' || source[i + 2] === '{')) {
221
+ const written = next === 'x' ? '\\x{' : `\\${next}`;
222
+ return `${RE2_ENGINE} reads \`${written}\` as ${misread}, where JavaScript without the \`u\` flag reads the letter \`${next}\` (at index ${i})`;
223
+ }
224
+ if (!inClass && next >= '1' && next <= '9') {
225
+ return `${RE2_ENGINE} has no backreferences (\`\\${next}\` at index ${i})`;
226
+ }
227
+ if (!inClass && next === 'k' && source[i + 2] === '<') {
228
+ return `${RE2_ENGINE} has no backreferences (\`\\k<\` at index ${i})`;
229
+ }
230
+ i += 1;
231
+ } else if (inClass) {
232
+ if (c === ']') inClass = false;
233
+ else if (c === '[' && source[i + 1] === ':') {
234
+ return `${RE2_ENGINE} reads \`[:\` in a class as a POSIX class such as \`[:alpha:]\`, not as JavaScript does (at index ${i})`;
235
+ }
236
+ } else if (c === '[') {
237
+ const first = source[i + 1] === '^' ? i + 2 : i + 1;
238
+ if (source[first] === ']') {
239
+ return `${RE2_ENGINE} reads \`${source.slice(i, first + 1)}\` as the start of a class, not as JavaScript does (at index ${i})`;
240
+ }
241
+ inClass = true;
242
+ i = first - 1;
243
+ } else if (c === '(' && source[i + 1] === '?') {
244
+ const look = LOOKAROUND.find((l) => source.startsWith(l, i));
245
+ if (look !== undefined) {
246
+ return `${RE2_ENGINE} has no lookahead or lookbehind (\`${look}\` at index ${i})`;
247
+ }
248
+ }
249
+ }
250
+ return undefined;
251
+ }
252
+
253
+ const LOOKAROUND = ['(?=', '(?!', '(?<=', '(?<!'] as const;
254
+
255
+ /** Escapes JavaScript without `u` reads as the letter itself, and RE2 as something else. */
256
+ const RE2_READS_OTHERWISE: Readonly<Record<string, string>> = {
257
+ p: 'a Unicode class',
258
+ P: 'a negated Unicode class',
259
+ A: 'the start of the text',
260
+ z: 'the end of the text',
261
+ Q: 'the start of a quoted literal (`\\Q…\\E`)',
262
+ a: 'the bell character',
263
+ x: 'the start of a code point (`\\x{41}`)',
264
+ };
265
+
266
+ /** The index after an empty match at `index`: past a whole surrogate pair when matching by code point. */
267
+ function after(input: string, index: number, byCodePoint: boolean): number {
268
+ if (!byCodePoint) return index + 1;
269
+ const code = input.codePointAt(index);
270
+ return index + (code !== undefined && code > 0xffff ? 2 : 1);
271
+ }
package/src/tenant.ts ADDED
@@ -0,0 +1,60 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ /**
5
+ * Whose work this is: the tenant a budget kept per tenant charges
6
+ * (`schemaCompileBudgetMs`, `templateRenderBudgetMs`), and which of its
7
+ * compiles count. The API runs each authenticated request with
8
+ * `runAsTenant`, and a request that publishes schemas in a scope of its
9
+ * own that counts every compile; a server that does a tenant's work
10
+ * outside a request (a run, a job) wraps it the same way. The scope is an
11
+ * `AsyncLocalStorage` context: it follows `fn`'s own awaits and callbacks,
12
+ * never another request's, however they interleave. Outside any scope
13
+ * (boot, Kindgi's own work), nothing is charged.
14
+ */
15
+
16
+ import { AsyncLocalStorage } from 'node:async_hooks';
17
+
18
+ /**
19
+ * Which of a tenant's schema compiles count against its budget.
20
+ * `recompiles`: compiling a schema again, after the cache dropped it; the
21
+ * first compile of a schema the tenant published doesn't count, so a
22
+ * restarted server compiles each tenant's schemas freely. `all`: every
23
+ * compile, for a request that publishes, deploys or registers schemas,
24
+ * which is where new ones come from.
25
+ */
26
+ export type TenantCompiles = 'recompiles' | 'all';
27
+
28
+ /** The tenant whose work is running, and which of its compiles count. */
29
+ export interface TenantScope {
30
+ readonly tenantId: string;
31
+ readonly compiles: TenantCompiles;
32
+ }
33
+
34
+ /** `runAsTenant`'s options. */
35
+ export interface RunAsTenantOptions {
36
+ /** Which of the tenant's compiles count: `recompiles` unless set. */
37
+ readonly compiles?: TenantCompiles;
38
+ }
39
+
40
+ const scopes = new AsyncLocalStorage<TenantScope>();
41
+
42
+ /** Runs `fn` as `tenantId`'s work, and returns what it returns. */
43
+ export function runAsTenant<T>(tenantId: string, fn: () => T, options: RunAsTenantOptions = {}): T {
44
+ return scopes.run({ tenantId, compiles: options.compiles ?? 'recompiles' }, fn);
45
+ }
46
+
47
+ /**
48
+ * Runs `fn` outside any tenant's scope, and returns what it returns: for
49
+ * Kindgi's own work that a tenant's request happens to start (compiling
50
+ * one of Kindgi's schemas the first time, say), which no tenant is
51
+ * charged for.
52
+ */
53
+ export function runOutsideTenant<T>(fn: () => T): T {
54
+ return scopes.exit(fn);
55
+ }
56
+
57
+ /** Whose work this is, or undefined outside `runAsTenant`. */
58
+ export function currentTenant(): TenantScope | undefined {
59
+ return scopes.getStore();
60
+ }
package/src/testing.ts ADDED
@@ -0,0 +1,8 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ /**
5
+ * For tests: run with other bounds than the process's, then put them back.
6
+ * Never in a server: its bounds are set once, at boot.
7
+ */
8
+ export { overrideTenantInputBounds } from './bounds.js';