akanjs 3.0.1 → 3.0.2-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/base/base.ts +1 -1
- package/client/cookie.ts +4 -6
- package/common/Logger.ts +10 -0
- package/dictionary/base.dictionary.ts +7 -0
- package/dictionary/trans.ts +7 -1
- package/document/documentQuery.ts +6 -1
- package/document/index.ts +1 -0
- package/document/into.ts +15 -7
- package/document/liveRowScope.ts +79 -0
- package/fetch/client/fetchClient.ts +64 -12
- package/fetch/client/httpClient.ts +13 -4
- package/index.ts +22 -0
- package/package.json +1 -1
- package/server/akanOption.ts +20 -1
- package/server/akanServer.ts +5 -0
- package/server/mcp/McpAuth.ts +3 -2
- package/server/mcp/McpExecutionContext.ts +15 -14
- package/server/mcp/McpRateLimiter.ts +18 -67
- package/server/proxy/webProxyRunner.ts +2 -0
- package/server/resolver/database.resolver.ts +28 -13
- package/server/resolver/signal.resolver.ts +75 -35
- package/server/rscWorkerHost.ts +16 -7
- package/server/webRouter.ts +3 -0
- package/service/index.ts +1 -0
- package/service/predefinedAdaptor/index.ts +1 -0
- package/service/predefinedAdaptor/rateLimit.adaptor.ts +15 -0
- package/service/predefinedAdaptor/sql/SqlDocumentStore.ts +119 -27
- package/service/predefinedAdaptor/sql/types.ts +14 -12
- package/service/rateLimiter.ts +80 -0
- package/signal/base.signal.ts +7 -6
- package/signal/endpointRateLimit.ts +159 -0
- package/signal/index.ts +1 -0
- package/signal/middleware.ts +6 -5
- package/signal/serializer/fetch.serializer.ts +15 -4
- package/signal/signalContext.ts +148 -67
- package/signal/slice.ts +30 -9
- package/signal/types.ts +8 -0
- package/test/signalTestRuntime.ts +2 -1
- package/types/base/base.d.ts +1 -1
- package/types/client/cookie.d.ts +1 -1
- package/types/common/Logger.d.ts +2 -0
- package/types/dictionary/base.dictionary.d.ts +1 -1
- package/types/dictionary/dictionary.d.ts +8 -8
- package/types/document/documentQuery.d.ts +5 -1
- package/types/document/index.d.ts +1 -0
- package/types/document/into.d.ts +7 -7
- package/types/document/liveRowScope.d.ts +9 -0
- package/types/fetch/client/fetchClient.d.ts +9 -1
- package/types/index.d.ts +32 -0
- package/types/server/akanOption.d.ts +7 -1
- package/types/server/mcp/McpExecutionContext.d.ts +4 -1
- package/types/server/mcp/McpRateLimiter.d.ts +2 -6
- package/types/server/rscWorkerHost.d.ts +3 -0
- package/types/service/index.d.ts +1 -0
- package/types/service/predefinedAdaptor/index.d.ts +1 -0
- package/types/service/predefinedAdaptor/rateLimit.adaptor.d.ts +10 -0
- package/types/service/predefinedAdaptor/sql/SqlDocumentStore.d.ts +6 -10
- package/types/service/predefinedAdaptor/sql/types.d.ts +13 -17
- package/types/service/rateLimiter.d.ts +24 -0
- package/types/signal/endpointRateLimit.d.ts +37 -0
- package/types/signal/index.d.ts +1 -0
- package/types/signal/signalContext.d.ts +33 -15
- package/types/signal/slice.d.ts +17 -5
- package/types/signal/types.d.ts +8 -0
- package/types/test/signalTestRuntime.d.ts +1 -1
- package/types/ui/recipe/buttonRecipe.d.ts +2 -2
- package/types/ui/recipe/inputRecipe.d.ts +3 -3
- package/types/vendor/@akanjs/native/packages/cli/src/config.d.ts +33 -0
- package/types/vendor/@akanjs/native/packages/cli/src/lib/bplist.d.ts +11 -0
- package/types/vendor/@akanjs/native/packages/cli/src/lib/dmg.d.ts +59 -0
- package/types/vendor/@akanjs/native/packages/cli/src/lib/dsStore.d.ts +26 -0
- package/types/vendor/@akanjs/native/packages/cli/src/lib/macAlias.d.ts +24 -0
- package/types/vendor/@akanjs/native/packages/cli/src/lib/macossigning.d.ts +0 -1
- package/types/vendor/@akanjs/native/packages/cli/src/lib/project.d.ts +3 -1
- package/types/vendor/@akanjs/native/packages/cli/src/platforms/linux-appimage.d.ts +6 -1
- package/types/vendor/@akanjs/native/packages/desktop/src/deeplinks.d.ts +9 -2
- package/types/webkit/RenderFailure.d.ts +6 -0
- package/ui/Input.tsx +7 -1
- package/ui/recipe/buttonRecipe.ts +1 -0
- package/ui/recipe/inputRecipe.ts +13 -7
- package/ui/styles.css +1 -0
- package/vendor/@akanjs/native/native/desktop/src/lib.rs +8 -1
- package/vendor/@akanjs/native/native/macos/dmg/background.html +18 -0
- package/vendor/@akanjs/native/native/macos/dmg/background.png +0 -0
- package/vendor/@akanjs/native/native/macos/dmg/background@2x.png +0 -0
- package/vendor/@akanjs/native/packages/cli/src/config.ts +23 -0
- package/vendor/@akanjs/native/packages/cli/src/lib/bplist.ts +91 -0
- package/vendor/@akanjs/native/packages/cli/src/lib/configkeys.ts +9 -0
- package/vendor/@akanjs/native/packages/cli/src/lib/dmg.ts +331 -0
- package/vendor/@akanjs/native/packages/cli/src/lib/dsStore.ts +191 -0
- package/vendor/@akanjs/native/packages/cli/src/lib/macAlias.ts +101 -0
- package/vendor/@akanjs/native/packages/cli/src/lib/macossigning.ts +1 -28
- package/vendor/@akanjs/native/packages/cli/src/lib/project.ts +6 -1
- package/vendor/@akanjs/native/packages/cli/src/platforms/linux-appimage.ts +17 -7
- package/vendor/@akanjs/native/packages/cli/src/platforms/linux.ts +6 -1
- package/vendor/@akanjs/native/packages/cli/src/platforms/macos.ts +5 -2
- package/vendor/@akanjs/native/packages/desktop/src/deeplinks.ts +33 -10
- package/vendor/@akanjs/native/packages/desktop/src/host.ts +1 -1
- package/webkit/RenderFailure.tsx +32 -0
- package/webkit/RenderLayer.tsx +22 -2
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { DEFAULT_VALUE, dayjs, FIELD_META } from "akanjs/base";
|
|
2
2
|
import { Logger } from "akanjs/common";
|
|
3
3
|
import { type ConstantModel, freshPrimitiveValue, getDefault } from "akanjs/constant";
|
|
4
|
+
import { Err } from "akanjs/dictionary";
|
|
4
5
|
import {
|
|
5
6
|
createDocumentId,
|
|
6
7
|
type DatabaseModel,
|
|
7
8
|
type DocumentQuery,
|
|
8
9
|
type DocumentSchema,
|
|
10
|
+
type DocumentScopeOptions,
|
|
9
11
|
type DocumentUpdate,
|
|
10
12
|
type DocumentUpdateInput,
|
|
11
13
|
type DocumentUpdateNode,
|
|
@@ -13,6 +15,7 @@ import {
|
|
|
13
15
|
documentQueryHelper,
|
|
14
16
|
isDocumentId,
|
|
15
17
|
isDocumentUpdateNode,
|
|
18
|
+
LiveRowScope,
|
|
16
19
|
NoDocumentError,
|
|
17
20
|
resolveDocumentUpdate,
|
|
18
21
|
sanitizeJson,
|
|
@@ -26,6 +29,7 @@ import {
|
|
|
26
29
|
type DocumentDatabaseOwner,
|
|
27
30
|
type DocumentRecord,
|
|
28
31
|
type FieldMap,
|
|
32
|
+
type FindIdOptions,
|
|
29
33
|
type FindManyOptions,
|
|
30
34
|
type FindOneOptions,
|
|
31
35
|
MODIFICATION_STATE,
|
|
@@ -59,6 +63,9 @@ interface DeclaredIndex {
|
|
|
59
63
|
next: string;
|
|
60
64
|
metaKey: string;
|
|
61
65
|
hash: string;
|
|
66
|
+
fields: Record<string, unknown>;
|
|
67
|
+
/** A key ordered neither 1 nor -1, as MongoDB's `"text"`: no SQL index here stands for it. */
|
|
68
|
+
unbuilt: boolean;
|
|
62
69
|
create: (name: string, concurrently: boolean) => string;
|
|
63
70
|
}
|
|
64
71
|
|
|
@@ -79,6 +86,8 @@ export class SqlDocumentStore {
|
|
|
79
86
|
static readonly #logger = new Logger("SqlDocumentStore");
|
|
80
87
|
/** The row a document was read or last written as. */
|
|
81
88
|
static readonly #storedRow = Symbol("akan.storedRow");
|
|
89
|
+
/** Set on a document read as removed, which only a `withRemoved` read hands out. */
|
|
90
|
+
static readonly #readRemoved = Symbol("akan.readRemoved");
|
|
82
91
|
|
|
83
92
|
constructor(
|
|
84
93
|
private readonly owner: DocumentDatabaseOwner,
|
|
@@ -102,7 +111,8 @@ export class SqlDocumentStore {
|
|
|
102
111
|
|
|
103
112
|
async #ensure() {
|
|
104
113
|
this.assertValidRefName(this.table);
|
|
105
|
-
const
|
|
114
|
+
const declared = await this.#declaredIndexes();
|
|
115
|
+
const indexes = declared.filter((index) => !index.unbuilt);
|
|
106
116
|
const createSchema = async () => {
|
|
107
117
|
const db = this.owner.getConnection();
|
|
108
118
|
const existed = !!this.owner.buildIndexConcurrently && !!(await this.owner.hasTable?.(this.table));
|
|
@@ -121,6 +131,7 @@ export class SqlDocumentStore {
|
|
|
121
131
|
`table:${this.table}`,
|
|
122
132
|
await descriptorHash({ table: this.table, columns: ["id", "createdAt", "updatedAt", "removedAt", "_doc"] }),
|
|
123
133
|
);
|
|
134
|
+
for (const index of declared) if (index.unbuilt) await this.#skipUnbuiltIndex(index, indexes);
|
|
124
135
|
const concurrent: (DeclaredIndex & { replace: boolean })[] = [];
|
|
125
136
|
for (const index of indexes) {
|
|
126
137
|
const stored = await this.owner.getMeta(index.metaKey);
|
|
@@ -157,16 +168,21 @@ export class SqlDocumentStore {
|
|
|
157
168
|
const declared = index.name ?? `${this.table}_${Object.keys(index.fields).map(toSafeRefName).join("_")}_${idx}`;
|
|
158
169
|
this.assertValidRefName(declared);
|
|
159
170
|
const name = this.dialect.indexName(declared);
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
171
|
+
const unbuilt = Object.values(index.fields).some((order) => order !== 1 && order !== -1);
|
|
172
|
+
const columns = unbuilt
|
|
173
|
+
? []
|
|
174
|
+
: Object.keys(index.fields).map((path) => ({
|
|
175
|
+
path,
|
|
176
|
+
expr: this.compiler.fieldExpr(path),
|
|
177
|
+
isArray: this.compiler.isArrayPath(path),
|
|
178
|
+
}));
|
|
165
179
|
return {
|
|
166
180
|
name,
|
|
167
181
|
next: this.dialect.indexName(`${declared}_next`),
|
|
168
182
|
metaKey: `index:${this.table}:${name}`,
|
|
169
183
|
hash: await descriptorHash(index),
|
|
184
|
+
fields: index.fields,
|
|
185
|
+
unbuilt,
|
|
170
186
|
create: (target: string, concurrently: boolean) =>
|
|
171
187
|
this.dialect.createIndex({
|
|
172
188
|
name: target,
|
|
@@ -180,6 +196,23 @@ export class SqlDocumentStore {
|
|
|
180
196
|
);
|
|
181
197
|
}
|
|
182
198
|
|
|
199
|
+
async #skipUnbuiltIndex(index: DeclaredIndex, built: DeclaredIndex[]) {
|
|
200
|
+
const dropped =
|
|
201
|
+
(await this.owner.getMeta(index.metaKey)) === index.hash && !built.some(({ name }) => name === index.name);
|
|
202
|
+
if (dropped) {
|
|
203
|
+
await this.owner.getConnection().execute(`DROP INDEX IF EXISTS ${quoteIdent(index.name)}`);
|
|
204
|
+
|
|
205
|
+
await this.owner.setMeta(index.metaKey, "");
|
|
206
|
+
}
|
|
207
|
+
const paths = Object.keys(index.fields);
|
|
208
|
+
SqlDocumentStore.#logger.warn(
|
|
209
|
+
`Index ${JSON.stringify(index.fields)} on ${this.table} is not built: only 1 and -1 order a key, and "text" is ` +
|
|
210
|
+
`MongoDB's text index, which as a B-tree would copy each whole value and fail long writes on Postgres. Search ` +
|
|
211
|
+
`${paths.join(", ")} through the full-text role, field(String, { text: "desc" }), or declare ` +
|
|
212
|
+
`{ ${paths[0]}: 1 } to look it up by equality.${dropped ? ` Dropped ${index.name}, which an earlier boot built from it.` : ""}`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
183
216
|
async #replaceIndex(index: DeclaredIndex) {
|
|
184
217
|
const rebuild = async () => {
|
|
185
218
|
const db = this.owner.getConnection();
|
|
@@ -263,9 +296,22 @@ export class SqlDocumentStore {
|
|
|
263
296
|
}
|
|
264
297
|
|
|
265
298
|
async updateOneByQuery(query: DocumentQuery, update: DocumentUpdateInput, options: DocumentUpdateOptions = {}) {
|
|
299
|
+
if (options.upsert && options.withRemoved)
|
|
300
|
+
throw new Error(
|
|
301
|
+
`updateOneByQuery on "${this.table}" takes upsert or { withRemoved: true }, not both: a match would stay removed while a miss inserts a live row`,
|
|
302
|
+
);
|
|
303
|
+
return await this.#updateOne(query, update, options, "updateOneByQuery");
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async #updateOne(
|
|
307
|
+
query: DocumentQuery,
|
|
308
|
+
update: DocumentUpdateInput,
|
|
309
|
+
options: DocumentUpdateOptions,
|
|
310
|
+
operation: string,
|
|
311
|
+
) {
|
|
266
312
|
const resolved = resolveDocumentUpdate(update);
|
|
267
313
|
const { assignments, params } = this.compiledUpdate(resolved);
|
|
268
|
-
const { where, params: whereParams } = this.writeQuery(query,
|
|
314
|
+
const { where, params: whereParams } = this.writeQuery(query, operation, options);
|
|
269
315
|
const subquery = `SELECT ${quoteIdent("id")} FROM ${quoteIdent(this.table)} WHERE ${where} ORDER BY ${this.compiler.orderBy()} LIMIT 1`;
|
|
270
316
|
const sql = `UPDATE ${quoteIdent(this.table)} SET ${assignments.join(", ")} WHERE ${quoteIdent("id")} IN (${subquery})`;
|
|
271
317
|
const changes = this.dialect.affectedRows(
|
|
@@ -282,9 +328,18 @@ export class SqlDocumentStore {
|
|
|
282
328
|
return { acknowledged: true, matchedCount: 0, modifiedCount: 1, upsertedId: inserted.id };
|
|
283
329
|
}
|
|
284
330
|
|
|
285
|
-
async updateManyByQuery(query: DocumentQuery, update: DocumentUpdateInput) {
|
|
331
|
+
async updateManyByQuery(query: DocumentQuery, update: DocumentUpdateInput, options: DocumentScopeOptions = {}) {
|
|
332
|
+
return await this.#updateMany(query, update, options, "updateManyByQuery");
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async #updateMany(
|
|
336
|
+
query: DocumentQuery,
|
|
337
|
+
update: DocumentUpdateInput,
|
|
338
|
+
options: DocumentScopeOptions,
|
|
339
|
+
operation: string,
|
|
340
|
+
) {
|
|
286
341
|
const { assignments, params } = this.compiledUpdate(resolveDocumentUpdate(update));
|
|
287
|
-
const { where, params: whereParams } = this.writeQuery(query,
|
|
342
|
+
const { where, params: whereParams } = this.writeQuery(query, operation, options);
|
|
288
343
|
const sql = `UPDATE ${quoteIdent(this.table)} SET ${assignments.join(", ")} WHERE ${where}`;
|
|
289
344
|
const changes = this.dialect.affectedRows(
|
|
290
345
|
await this.owner
|
|
@@ -297,12 +352,12 @@ export class SqlDocumentStore {
|
|
|
297
352
|
|
|
298
353
|
async removeManyByQuery(query: DocumentQuery) {
|
|
299
354
|
|
|
300
|
-
return this
|
|
355
|
+
return await this.#updateMany(query, { removedAt: dayjs() }, {}, "removeManyByQuery");
|
|
301
356
|
}
|
|
302
357
|
|
|
303
358
|
async removeOneByQuery(query: DocumentQuery) {
|
|
304
359
|
|
|
305
|
-
return this
|
|
360
|
+
return await this.#updateOne(query, { removedAt: dayjs() }, {}, "removeOneByQuery");
|
|
306
361
|
}
|
|
307
362
|
|
|
308
363
|
private compiledUpdate(update: DocumentUpdate) {
|
|
@@ -339,20 +394,24 @@ export class SqlDocumentStore {
|
|
|
339
394
|
const rows = await this.prepareStmt(
|
|
340
395
|
`SELECT ${this.projectionSql(projection)} ${tail}`,
|
|
341
396
|
).all<ProjectedSqliteDocumentRow>(...args);
|
|
342
|
-
return rows.map((row) =>
|
|
397
|
+
return rows.map((row) =>
|
|
398
|
+
this.#readAs(this.hydrate(this.fromProjectedRow(row, projection), undefined, { track: false }), row),
|
|
399
|
+
);
|
|
343
400
|
}
|
|
344
401
|
const rows = await this.prepareStmt(`SELECT ${star} ${tail}`).all<SqliteDocumentRow>(...args);
|
|
345
|
-
return rows.map((row) =>
|
|
402
|
+
return rows.map((row) =>
|
|
403
|
+
this.#readAs(this.#withStoredRow(this.hydrate(this.fromRow(row), undefined, { track: false }), row), row),
|
|
404
|
+
);
|
|
346
405
|
}
|
|
347
406
|
|
|
348
407
|
async findIds(query?: DocumentQuery, options: Omit<FindManyOptions, "select"> = {}) {
|
|
349
|
-
const { tail, args } = this.listQuery(query, options);
|
|
408
|
+
const { tail, args } = this.listQuery(query, options, "findIds");
|
|
350
409
|
const rows = await this.prepareStmt(`SELECT ${quoteIdent(this.table)}."id" ${tail}`).all<{ id: string }>(...args);
|
|
351
410
|
return rows.map((row) => row.id);
|
|
352
411
|
}
|
|
353
412
|
|
|
354
|
-
private listQuery(query: DocumentQuery | undefined, options: FindManyOptions) {
|
|
355
|
-
const { where, params, joins } = this.safeQuery(query);
|
|
413
|
+
private listQuery(query: DocumentQuery | undefined, options: FindManyOptions, operation = "find") {
|
|
414
|
+
const { where, params, joins } = this.safeQuery(query, operation, options);
|
|
356
415
|
const limitValue = Number(options.limit ?? 0);
|
|
357
416
|
const skipValue = Number(options.skip ?? 0);
|
|
358
417
|
const order = options.sample ? "ORDER BY random()" : `ORDER BY ${this.orderBy(options.sort, joins)}`;
|
|
@@ -369,7 +428,7 @@ export class SqlDocumentStore {
|
|
|
369
428
|
return (await this.find(query, { ...options, limit: 1, sample: options.sample ? 1 : undefined })).at(0) ?? null;
|
|
370
429
|
}
|
|
371
430
|
|
|
372
|
-
async findId(query?: DocumentQuery, options:
|
|
431
|
+
async findId(query?: DocumentQuery, options: FindIdOptions = {}) {
|
|
373
432
|
return (await this.findIds(query, { ...options, limit: 1, sample: options.sample ? 1 : undefined })).at(0) ?? null;
|
|
374
433
|
}
|
|
375
434
|
|
|
@@ -385,12 +444,12 @@ export class SqlDocumentStore {
|
|
|
385
444
|
return doc;
|
|
386
445
|
}
|
|
387
446
|
|
|
388
|
-
async exists(query?: DocumentQuery) {
|
|
389
|
-
return this.findId(query);
|
|
447
|
+
async exists(query?: DocumentQuery, options: DocumentScopeOptions = {}) {
|
|
448
|
+
return this.findId(query, options);
|
|
390
449
|
}
|
|
391
450
|
|
|
392
|
-
async count(query?: DocumentQuery) {
|
|
393
|
-
const { where, params, joins } = this.safeQuery(query);
|
|
451
|
+
async count(query?: DocumentQuery, options: DocumentScopeOptions = {}) {
|
|
452
|
+
const { where, params, joins } = this.safeQuery(query, "count", options);
|
|
394
453
|
const row = await this.prepareStmt(
|
|
395
454
|
`SELECT count(*) as count FROM ${quoteIdent(this.table)}${this.joinSql(joins)} WHERE ${where}`,
|
|
396
455
|
).get<{ count: number }>(...this.joinParams(joins), ...params);
|
|
@@ -416,12 +475,27 @@ export class SqlDocumentStore {
|
|
|
416
475
|
return result;
|
|
417
476
|
}
|
|
418
477
|
|
|
419
|
-
private safeQuery(
|
|
478
|
+
private safeQuery(
|
|
479
|
+
query: DocumentQuery | undefined,
|
|
480
|
+
operation: string,
|
|
481
|
+
{ withRemoved = false }: DocumentScopeOptions,
|
|
482
|
+
) {
|
|
483
|
+
if (withRemoved) {
|
|
484
|
+
const compiled = this.compiler.compile(query ?? {});
|
|
485
|
+
|
|
486
|
+
if (compiled.joins.length) throw new Error(`q.search() cannot see removed rows on "${this.table}"`);
|
|
487
|
+
return compiled;
|
|
488
|
+
}
|
|
489
|
+
const conflict = LiveRowScope.conflictOf(query);
|
|
490
|
+
if (conflict)
|
|
491
|
+
throw new Error(
|
|
492
|
+
`${operation} on "${this.table}" can never match (${conflict}); ${operation.startsWith("remove") ? "a remove reaches live rows only" : "pass { withRemoved: true }"}`,
|
|
493
|
+
);
|
|
420
494
|
return this.compiler.compile(documentQueryHelper.all(documentQueryHelper.empty("removedAt"), query ?? {}));
|
|
421
495
|
}
|
|
422
496
|
|
|
423
|
-
private writeQuery(query: DocumentQuery | undefined, operation: string) {
|
|
424
|
-
const compiled = this.safeQuery(query);
|
|
497
|
+
private writeQuery(query: DocumentQuery | undefined, operation: string, options: DocumentScopeOptions) {
|
|
498
|
+
const compiled = this.safeQuery(query, operation, options);
|
|
425
499
|
if (compiled.joins.length)
|
|
426
500
|
throw new Error(`q.search() cannot be used in ${operation} on "${this.table}"; query-level writes take no join.`);
|
|
427
501
|
return compiled;
|
|
@@ -738,6 +812,19 @@ export class SqlDocumentStore {
|
|
|
738
812
|
return doc;
|
|
739
813
|
}
|
|
740
814
|
|
|
815
|
+
#readAs<Doc extends object>(doc: Doc, { removedAt }: { removedAt?: unknown }): Doc {
|
|
816
|
+
if (removedAt != null)
|
|
817
|
+
Object.defineProperty(doc, SqlDocumentStore.#readRemoved, { value: true, configurable: true });
|
|
818
|
+
return doc;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
#assertLiveRow(doc: DocumentRecord) {
|
|
822
|
+
if (!(doc as Record<symbol, unknown>)[SqlDocumentStore.#readRemoved]) return;
|
|
823
|
+
throw new Error(
|
|
824
|
+
`read-only: a removed ${this.table} is revived with updateById(id, { removedAt: null }, { withRemoved: true })`,
|
|
825
|
+
);
|
|
826
|
+
}
|
|
827
|
+
|
|
741
828
|
#assertImmutableUnchanged(prepared: DocumentRecord, originalData: DocumentRecord) {
|
|
742
829
|
this.#immutableKeys ??= Object.entries(this.database.doc[FIELD_META] as unknown as FieldMap)
|
|
743
830
|
.filter(([, fieldMeta]) => fieldMeta.getProps().immutable)
|
|
@@ -746,8 +833,10 @@ export class SqlDocumentStore {
|
|
|
746
833
|
const changed = this.#immutableKeys.filter((key) => jsonStr(prepared[key]) !== jsonStr(originalData[key]));
|
|
747
834
|
if (!changed.length) return;
|
|
748
835
|
|
|
749
|
-
throw new
|
|
750
|
-
|
|
836
|
+
throw new Err(
|
|
837
|
+
"base.error.immutableField",
|
|
838
|
+
{ field: changed.join(", ") },
|
|
839
|
+
{ details: { model: this.table, id: String(prepared.id) } },
|
|
751
840
|
);
|
|
752
841
|
}
|
|
753
842
|
|
|
@@ -887,13 +976,16 @@ export class SqlDocumentStore {
|
|
|
887
976
|
},
|
|
888
977
|
save: {
|
|
889
978
|
async value(this: DocumentRecord) {
|
|
890
|
-
|
|
979
|
+
if (!this.id) return store.create(this);
|
|
980
|
+
store.#assertLiveRow(this);
|
|
981
|
+
return store.update(this.id as string, store.#changesOf(this));
|
|
891
982
|
},
|
|
892
983
|
},
|
|
893
984
|
refresh: {
|
|
894
985
|
async value(this: DocumentRecord) {
|
|
895
986
|
const fresh = (await store.pickById(this.id as string)) as DocumentRecord & Record<symbol, StoredRow>;
|
|
896
987
|
Object.assign(this, fresh);
|
|
988
|
+
Reflect.deleteProperty(this, SqlDocumentStore.#readRemoved);
|
|
897
989
|
return store.#withStoredRow(this, fresh[SqlDocumentStore.#storedRow]);
|
|
898
990
|
},
|
|
899
991
|
},
|
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
DocumentQuery,
|
|
6
6
|
DocumentQueryNode,
|
|
7
7
|
DocumentSchema,
|
|
8
|
+
DocumentScopeOptions,
|
|
8
9
|
DocumentUpdateInput,
|
|
9
10
|
DocumentUpdateOperator,
|
|
10
11
|
DocumentUpdateOptions,
|
|
@@ -65,6 +66,7 @@ export interface DocumentStore {
|
|
|
65
66
|
updateManyByQuery(
|
|
66
67
|
query: DocumentQuery,
|
|
67
68
|
update: DocumentUpdateInput,
|
|
69
|
+
options?: DocumentScopeOptions,
|
|
68
70
|
): Promise<{ acknowledged: boolean; matchedCount: number; modifiedCount: number }>;
|
|
69
71
|
removeManyByQuery(
|
|
70
72
|
query: DocumentQuery,
|
|
@@ -76,19 +78,13 @@ export interface DocumentStore {
|
|
|
76
78
|
operations: { updateOne: { filter: DocumentQuery; update: DocumentUpdateInput; upsert?: boolean } }[],
|
|
77
79
|
): Promise<{ acknowledged: boolean; matchedCount: number; modifiedCount: number; upsertedId: string | null }>;
|
|
78
80
|
find(query?: DocumentQuery, options?: FindManyOptions): Promise<any[]>;
|
|
79
|
-
findIds(
|
|
80
|
-
query?: DocumentQuery,
|
|
81
|
-
options?: { sort?: SortOption; skip?: number | null; limit?: number | null; sample?: number },
|
|
82
|
-
): Promise<string[]>;
|
|
81
|
+
findIds(query?: DocumentQuery, options?: Omit<FindManyOptions, "select">): Promise<string[]>;
|
|
83
82
|
findOne(query?: DocumentQuery, options?: FindOneOptions): Promise<any | null>;
|
|
84
|
-
findId(
|
|
85
|
-
query?: DocumentQuery,
|
|
86
|
-
options?: { sort?: SortOption; skip?: number | null; sample?: boolean },
|
|
87
|
-
): Promise<string | null>;
|
|
83
|
+
findId(query?: DocumentQuery, options?: FindIdOptions): Promise<string | null>;
|
|
88
84
|
pickOne(query?: DocumentQuery, options?: FindOneOptions): Promise<any>;
|
|
89
85
|
pickById(id: string): Promise<any>;
|
|
90
|
-
exists(query?: DocumentQuery): Promise<string | null>;
|
|
91
|
-
count(query?: DocumentQuery): Promise<number>;
|
|
86
|
+
exists(query?: DocumentQuery, options?: DocumentScopeOptions): Promise<string | null>;
|
|
87
|
+
count(query?: DocumentQuery, options?: DocumentScopeOptions): Promise<number>;
|
|
92
88
|
insight(query?: DocumentQuery): Promise<any>;
|
|
93
89
|
hydrate(data: DocumentRecord, originalData?: DocumentRecord, options?: { track?: boolean }): any;
|
|
94
90
|
/** A document as text that another process turns back into the same document. */
|
|
@@ -156,14 +152,20 @@ export type MutableDocumentRecord = Record<string, unknown>;
|
|
|
156
152
|
export type FieldMap = Record<string, { getProps: () => Record<string, unknown>; [key: string]: unknown }>;
|
|
157
153
|
export type SortOption = Record<string, 1 | -1> | null | undefined;
|
|
158
154
|
export type ProjectionOption = Partial<Record<string, boolean>> | null | undefined;
|
|
159
|
-
export type FindManyOptions = {
|
|
155
|
+
export type FindManyOptions = DocumentScopeOptions & {
|
|
160
156
|
sort?: SortOption;
|
|
161
157
|
skip?: number | null;
|
|
162
158
|
limit?: number | null;
|
|
163
159
|
sample?: number;
|
|
164
160
|
select?: ProjectionOption;
|
|
165
161
|
};
|
|
166
|
-
export type FindOneOptions =
|
|
162
|
+
export type FindOneOptions = DocumentScopeOptions & {
|
|
163
|
+
sort?: SortOption;
|
|
164
|
+
skip?: number | null;
|
|
165
|
+
sample?: boolean;
|
|
166
|
+
select?: ProjectionOption;
|
|
167
|
+
};
|
|
168
|
+
export type FindIdOptions = DocumentScopeOptions & { sort?: SortOption; skip?: number | null; sample?: boolean };
|
|
167
169
|
export type WriteHookOptions = { runSaveHooks?: boolean; crudType?: "update" | "remove" };
|
|
168
170
|
export type QueryOperatorName = Extract<DocumentQueryNode, { kind: "op" }>["op"];
|
|
169
171
|
export interface SqliteDocumentRow {
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { type Dayjs, dayjs } from "akanjs/base";
|
|
2
|
+
import { Logger } from "akanjs/common";
|
|
3
|
+
|
|
4
|
+
export interface RateCounter {
|
|
5
|
+
incr(topic: string, key: string, by?: number, option?: { expireAt?: Dayjs }): Promise<number>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface RateBudget {
|
|
9
|
+
calls: number;
|
|
10
|
+
windowMs: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type RateVerdict = { ok: true } | { ok: false; retryAfterMs: number };
|
|
14
|
+
|
|
15
|
+
interface RateWindow {
|
|
16
|
+
start: number;
|
|
17
|
+
count: number;
|
|
18
|
+
windowMs: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class RateLimiter {
|
|
22
|
+
static readonly #logger = new Logger("RateLimiter");
|
|
23
|
+
static readonly #sweepEveryMs = 60_000;
|
|
24
|
+
|
|
25
|
+
readonly #topic: string;
|
|
26
|
+
readonly #shared: RateCounter | null;
|
|
27
|
+
readonly #windows = new Map<string, RateWindow>();
|
|
28
|
+
#sweptAt = 0;
|
|
29
|
+
#sharedFailed = false;
|
|
30
|
+
|
|
31
|
+
constructor(shared: RateCounter | null = null, { topic = "rate" }: { topic?: string } = {}) {
|
|
32
|
+
this.#shared = shared;
|
|
33
|
+
this.#topic = topic;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
get isShared() {
|
|
37
|
+
return this.#shared !== null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async consume(key: string, budget: RateBudget, now = Date.now()): Promise<RateVerdict> {
|
|
41
|
+
const calls = Math.max(1, Math.floor(budget.calls));
|
|
42
|
+
const windowMs = Math.max(1, Math.floor(budget.windowMs));
|
|
43
|
+
this.#sweep(now);
|
|
44
|
+
if (this.#shared) {
|
|
45
|
+
const window = Math.floor(now / windowMs);
|
|
46
|
+
const end = (window + 1) * windowMs;
|
|
47
|
+
try {
|
|
48
|
+
const count = await this.#shared.incr(this.#topic, `${key}:${windowMs}:${window}`, 1, {
|
|
49
|
+
expireAt: dayjs(end + windowMs),
|
|
50
|
+
});
|
|
51
|
+
this.#sharedFailed = false;
|
|
52
|
+
return count > calls ? { ok: false, retryAfterMs: Math.max(1, end - now) } : { ok: true };
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (!this.#sharedFailed)
|
|
55
|
+
RateLimiter.#logger.warn(
|
|
56
|
+
`Rate limit "${this.#topic}" cannot reach the cache, so each instance counts its own window until it can: ${String(error)}`,
|
|
57
|
+
);
|
|
58
|
+
this.#sharedFailed = true;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const bucket = this.#windowOf(`${key}:${windowMs}`, windowMs, now);
|
|
62
|
+
if (bucket.count >= calls) return { ok: false, retryAfterMs: Math.max(1, bucket.start + windowMs - now) };
|
|
63
|
+
bucket.count += 1;
|
|
64
|
+
return { ok: true };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
#windowOf(key: string, windowMs: number, now: number) {
|
|
68
|
+
const found = this.#windows.get(key);
|
|
69
|
+
if (found && now - found.start < windowMs) return found;
|
|
70
|
+
const bucket: RateWindow = { start: now, count: 0, windowMs };
|
|
71
|
+
this.#windows.set(key, bucket);
|
|
72
|
+
return bucket;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
#sweep(now: number) {
|
|
76
|
+
if (now - this.#sweptAt < RateLimiter.#sweepEveryMs) return;
|
|
77
|
+
this.#sweptAt = now;
|
|
78
|
+
for (const [key, bucket] of this.#windows) if (now - bucket.start >= bucket.windowMs) this.#windows.delete(key);
|
|
79
|
+
}
|
|
80
|
+
}
|
package/signal/base.signal.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { FetchClient } from "akanjs/fetch";
|
|
|
2
2
|
import { srv } from "akanjs/service";
|
|
3
3
|
import { agent } from "./agent.signal";
|
|
4
4
|
import { endpoint } from "./endpoint";
|
|
5
|
+
import { Public } from "./guards";
|
|
5
6
|
import { internal } from "./internal";
|
|
6
7
|
import { serverSignal } from "./serverSignal";
|
|
7
8
|
import { SignalRegistry } from "./signalRegistry";
|
|
@@ -9,20 +10,20 @@ import { SignalRegistry } from "./signalRegistry";
|
|
|
9
10
|
export class BaseInternal extends internal(srv.base, ({ interval, cron }) => ({})) {}
|
|
10
11
|
|
|
11
12
|
export class BaseEndpoint extends endpoint(srv.base, ({ query, mutation, message, pubsub }) => ({
|
|
12
|
-
ping: query(String, { cache: 3000 }).exec(() => "ping"),
|
|
13
|
-
pingBody: mutation(String)
|
|
13
|
+
ping: query(String, { cache: 3000, guards: [Public], mcp: false }).exec(() => "ping"),
|
|
14
|
+
pingBody: mutation(String, { guards: [Public], mcp: false })
|
|
14
15
|
.body("data", String)
|
|
15
16
|
.exec((data) => `pingBody: ${data}`),
|
|
16
|
-
pingParam: query(String, { cache: 10000 })
|
|
17
|
+
pingParam: query(String, { cache: 10000, guards: [Public], mcp: false })
|
|
17
18
|
.param("id", String)
|
|
18
19
|
.exec((id) => `pingParam: ${id}`),
|
|
19
|
-
pingQuery: query(String, { nullable: true })
|
|
20
|
+
pingQuery: query(String, { nullable: true, guards: [Public], mcp: false })
|
|
20
21
|
.search("id", String)
|
|
21
22
|
.exec((id) => `pingQuery: ${id}`),
|
|
22
|
-
wsPing: message(String)
|
|
23
|
+
wsPing: message(String, { guards: [Public], mcp: false })
|
|
23
24
|
.msg("data", String, { nullable: true })
|
|
24
25
|
.exec((data) => `wsPing: ${data}`),
|
|
25
|
-
pubsubPing: pubsub(String).exec(() => {
|
|
26
|
+
pubsubPing: pubsub(String, { guards: [Public], mcp: false }).exec(() => {
|
|
26
27
|
}),
|
|
27
28
|
})) {}
|
|
28
29
|
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { Logger } from "akanjs/common";
|
|
2
|
+
import { CacheAdaptorRole, type RateCounter, RateLimiter } from "akanjs/service";
|
|
3
|
+
import type { EndpointInfo } from "./endpointInfo";
|
|
4
|
+
import { Exception } from "./exception";
|
|
5
|
+
import type { SignalContext } from "./signalContext";
|
|
6
|
+
|
|
7
|
+
export interface RateLimitBudget {
|
|
8
|
+
calls: number;
|
|
9
|
+
/** 60_000 when unset. */
|
|
10
|
+
windowMs?: number;
|
|
11
|
+
/** `ip` (default) counts the caller's address; `account` counts `accountKey(account)`, else the address. */
|
|
12
|
+
by?: "ip" | "account";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface RateLimitSetting {
|
|
16
|
+
/** `false` lets every budget stand aside, as for a deployment rate-limited at its edge. */
|
|
17
|
+
enabled?: boolean;
|
|
18
|
+
/** The budget of every endpoint that declares none; unset leaves those unlimited. */
|
|
19
|
+
budget?: RateLimitBudget;
|
|
20
|
+
/** By endpoint key, over what the endpoint declares (a lib's included); `false` exempts it. */
|
|
21
|
+
endpoints?: { [key: string]: Partial<RateLimitBudget> | false };
|
|
22
|
+
/** Who `by: "account"` counts; nothing returned counts the caller's address instead. */
|
|
23
|
+
accountKey?: (account: unknown) => string | null | undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type ResolvedBudget = Required<RateLimitBudget>;
|
|
27
|
+
|
|
28
|
+
export class EndpointRateLimit {
|
|
29
|
+
static readonly #logger = new Logger("EndpointRateLimit");
|
|
30
|
+
static readonly #defaultWindowMs = 60_000;
|
|
31
|
+
static #enabled: boolean | undefined;
|
|
32
|
+
static #budget: RateLimitBudget | null = null;
|
|
33
|
+
static #endpoints: NonNullable<RateLimitSetting["endpoints"]> = {};
|
|
34
|
+
static #accountKey: NonNullable<RateLimitSetting["accountKey"]> = (account) => EndpointRateLimit.#claimOf(account);
|
|
35
|
+
static readonly #limiters = new WeakMap<object, RateLimiter>();
|
|
36
|
+
static #localLimiter: RateLimiter | null = null;
|
|
37
|
+
static readonly #warned = new Set<string>();
|
|
38
|
+
|
|
39
|
+
/** Merges over what an earlier call set, so libs configure in mount order and the app's `option.ts` goes last. */
|
|
40
|
+
static configure(setting: RateLimitSetting | false) {
|
|
41
|
+
if (setting === false) {
|
|
42
|
+
EndpointRateLimit.#enabled = false;
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (setting.enabled !== undefined) EndpointRateLimit.#enabled = setting.enabled;
|
|
46
|
+
if (setting.budget) EndpointRateLimit.#budget = setting.budget;
|
|
47
|
+
if (setting.endpoints) EndpointRateLimit.#endpoints = { ...EndpointRateLimit.#endpoints, ...setting.endpoints };
|
|
48
|
+
if (setting.accountKey) EndpointRateLimit.#accountKey = setting.accountKey;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* `off` wins over the code, `on` turns budgets on in a local environment, and `<calls>[/<seconds>]` is the default
|
|
53
|
+
* budget wherever the code names none. Unset, budgets apply everywhere but `local`.
|
|
54
|
+
*/
|
|
55
|
+
static applyEnv(value: string | undefined, environment: string) {
|
|
56
|
+
const raw = value?.trim().toLowerCase() ?? "";
|
|
57
|
+
if (raw === "off" || raw === "false" || raw === "0") {
|
|
58
|
+
EndpointRateLimit.#enabled = false;
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const [calls, seconds] = raw.split("/").map((part) => Number(part));
|
|
62
|
+
const budget = Number.isInteger(calls) && calls > 0 ? { calls, windowMs: (seconds || 60) * 1000 } : null;
|
|
63
|
+
if (budget && !EndpointRateLimit.#budget) EndpointRateLimit.#budget = budget;
|
|
64
|
+
const switchedOn = raw === "on" || raw === "true" || raw === "1" || !!budget;
|
|
65
|
+
EndpointRateLimit.#enabled ??= switchedOn || environment !== "local";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
static reset() {
|
|
69
|
+
EndpointRateLimit.#enabled = undefined;
|
|
70
|
+
EndpointRateLimit.#budget = null;
|
|
71
|
+
EndpointRateLimit.#endpoints = {};
|
|
72
|
+
EndpointRateLimit.#accountKey = (account) => EndpointRateLimit.#claimOf(account);
|
|
73
|
+
EndpointRateLimit.#localLimiter = null;
|
|
74
|
+
EndpointRateLimit.#warned.clear();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
static budgetOf(key: string, endpointInfo: EndpointInfo): ResolvedBudget | null {
|
|
78
|
+
if (EndpointRateLimit.#enabled === false) return null;
|
|
79
|
+
const override = EndpointRateLimit.#endpoints[key];
|
|
80
|
+
if (override === false) return null;
|
|
81
|
+
const declared = endpointInfo.signalOption.rateLimit;
|
|
82
|
+
const base = declared === false ? null : (declared ?? EndpointRateLimit.#budget);
|
|
83
|
+
const merged = override ? { ...base, ...override } : base;
|
|
84
|
+
const calls = Math.floor(Number(merged?.calls));
|
|
85
|
+
if (!merged || !(calls >= 1)) return null;
|
|
86
|
+
const windowMs = Math.floor(Number(merged.windowMs ?? EndpointRateLimit.#defaultWindowMs));
|
|
87
|
+
return {
|
|
88
|
+
calls,
|
|
89
|
+
windowMs: windowMs >= 1 ? windowMs : EndpointRateLimit.#defaultWindowMs,
|
|
90
|
+
by: merged.by === "account" ? "account" : "ip",
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** `ip` runs before the request body is read; `account` after the middlewares resolved the caller. */
|
|
95
|
+
static async admit(context: SignalContext, phase: "ip" | "account") {
|
|
96
|
+
const budget = EndpointRateLimit.budgetOf(context.key, context.endpointInfo);
|
|
97
|
+
if (!budget || budget.by !== phase) return;
|
|
98
|
+
const caller =
|
|
99
|
+
(phase === "account" ? EndpointRateLimit.#accountOf(context) : null) ?? EndpointRateLimit.#ipOf(context);
|
|
100
|
+
const verdict = await EndpointRateLimit.#limiterOf(context).consume(`${context.key}:${caller}`, budget);
|
|
101
|
+
if (verdict.ok) return;
|
|
102
|
+
EndpointRateLimit.#warnOnce(
|
|
103
|
+
`refused:${context.key}`,
|
|
104
|
+
`"${context.key}" refused a caller over its budget of ${budget.calls} calls / ${Math.round(budget.windowMs / 1000)}s by ${budget.by}; later refusals are not logged.`,
|
|
105
|
+
);
|
|
106
|
+
throw new Exception(429, "base.error.tooManyRequests", undefined, {
|
|
107
|
+
seconds: Math.max(1, Math.ceil(verdict.retryAfterMs / 1000)),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
static #accountOf(context: SignalContext) {
|
|
112
|
+
const key = EndpointRateLimit.#accountKey(context.get("account"));
|
|
113
|
+
return typeof key === "string" && key ? `account:${key}` : null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
static #ipOf(context: SignalContext) {
|
|
117
|
+
const ip = context.getClientIp();
|
|
118
|
+
if (ip) return `ip:${ip}`;
|
|
119
|
+
EndpointRateLimit.#warnOnce(
|
|
120
|
+
"unknown-ip",
|
|
121
|
+
`Rate limit cannot resolve the caller's address (first on "${context.key}"), so every such caller shares one budget. Check that the proxy in front records X-Real-IP or X-Forwarded-For and is trusted (AKAN_TRUSTED_PROXIES).`,
|
|
122
|
+
);
|
|
123
|
+
return "ip:unknown";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
static #limiterOf(context: SignalContext) {
|
|
127
|
+
const cache = EndpointRateLimit.#cacheOf(context);
|
|
128
|
+
if (!cache) {
|
|
129
|
+
EndpointRateLimit.#localLimiter ??= new RateLimiter(null, { topic: "endpointRate" });
|
|
130
|
+
return EndpointRateLimit.#localLimiter;
|
|
131
|
+
}
|
|
132
|
+
const found = EndpointRateLimit.#limiters.get(cache);
|
|
133
|
+
if (found) return found;
|
|
134
|
+
const limiter = new RateLimiter(cache, { topic: "endpointRate" });
|
|
135
|
+
EndpointRateLimit.#limiters.set(cache, limiter);
|
|
136
|
+
return limiter;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
static #cacheOf(context: SignalContext): (RateCounter & object) | null {
|
|
140
|
+
try {
|
|
141
|
+
return context.getAdaptor(CacheAdaptorRole) as unknown as RateCounter & object;
|
|
142
|
+
} catch {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
static #claimOf(account: unknown) {
|
|
148
|
+
if (!account || typeof account !== "object") return null;
|
|
149
|
+
const { id, sub, sid } = account as { id?: unknown; sub?: unknown; sid?: unknown };
|
|
150
|
+
const claim = [id, sub, sid].find((value) => typeof value === "string" && value);
|
|
151
|
+
return typeof claim === "string" ? claim : null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
static #warnOnce(key: string, message: string) {
|
|
155
|
+
if (EndpointRateLimit.#warned.has(key)) return;
|
|
156
|
+
EndpointRateLimit.#warned.add(key);
|
|
157
|
+
EndpointRateLimit.#logger.warn(message);
|
|
158
|
+
}
|
|
159
|
+
}
|
package/signal/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ export * from "./base.signal";
|
|
|
7
7
|
export { CrossSiteGuard, type CrossSiteOption } from "./CrossSiteGuard";
|
|
8
8
|
export * from "./endpoint";
|
|
9
9
|
export * from "./endpointInfo";
|
|
10
|
+
export * from "./endpointRateLimit";
|
|
10
11
|
export * from "./exception";
|
|
11
12
|
export * from "./guard";
|
|
12
13
|
export * from "./guards";
|