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.
Files changed (101) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/base/base.ts +1 -1
  3. package/client/cookie.ts +4 -6
  4. package/common/Logger.ts +10 -0
  5. package/dictionary/base.dictionary.ts +7 -0
  6. package/dictionary/trans.ts +7 -1
  7. package/document/documentQuery.ts +6 -1
  8. package/document/index.ts +1 -0
  9. package/document/into.ts +15 -7
  10. package/document/liveRowScope.ts +79 -0
  11. package/fetch/client/fetchClient.ts +64 -12
  12. package/fetch/client/httpClient.ts +13 -4
  13. package/index.ts +22 -0
  14. package/package.json +1 -1
  15. package/server/akanOption.ts +20 -1
  16. package/server/akanServer.ts +5 -0
  17. package/server/mcp/McpAuth.ts +3 -2
  18. package/server/mcp/McpExecutionContext.ts +15 -14
  19. package/server/mcp/McpRateLimiter.ts +18 -67
  20. package/server/proxy/webProxyRunner.ts +2 -0
  21. package/server/resolver/database.resolver.ts +28 -13
  22. package/server/resolver/signal.resolver.ts +75 -35
  23. package/server/rscWorkerHost.ts +16 -7
  24. package/server/webRouter.ts +3 -0
  25. package/service/index.ts +1 -0
  26. package/service/predefinedAdaptor/index.ts +1 -0
  27. package/service/predefinedAdaptor/rateLimit.adaptor.ts +15 -0
  28. package/service/predefinedAdaptor/sql/SqlDocumentStore.ts +119 -27
  29. package/service/predefinedAdaptor/sql/types.ts +14 -12
  30. package/service/rateLimiter.ts +80 -0
  31. package/signal/base.signal.ts +7 -6
  32. package/signal/endpointRateLimit.ts +159 -0
  33. package/signal/index.ts +1 -0
  34. package/signal/middleware.ts +6 -5
  35. package/signal/serializer/fetch.serializer.ts +15 -4
  36. package/signal/signalContext.ts +148 -67
  37. package/signal/slice.ts +30 -9
  38. package/signal/types.ts +8 -0
  39. package/test/signalTestRuntime.ts +2 -1
  40. package/types/base/base.d.ts +1 -1
  41. package/types/client/cookie.d.ts +1 -1
  42. package/types/common/Logger.d.ts +2 -0
  43. package/types/dictionary/base.dictionary.d.ts +1 -1
  44. package/types/dictionary/dictionary.d.ts +8 -8
  45. package/types/document/documentQuery.d.ts +5 -1
  46. package/types/document/index.d.ts +1 -0
  47. package/types/document/into.d.ts +7 -7
  48. package/types/document/liveRowScope.d.ts +9 -0
  49. package/types/fetch/client/fetchClient.d.ts +9 -1
  50. package/types/index.d.ts +32 -0
  51. package/types/server/akanOption.d.ts +7 -1
  52. package/types/server/mcp/McpExecutionContext.d.ts +4 -1
  53. package/types/server/mcp/McpRateLimiter.d.ts +2 -6
  54. package/types/server/rscWorkerHost.d.ts +3 -0
  55. package/types/service/index.d.ts +1 -0
  56. package/types/service/predefinedAdaptor/index.d.ts +1 -0
  57. package/types/service/predefinedAdaptor/rateLimit.adaptor.d.ts +10 -0
  58. package/types/service/predefinedAdaptor/sql/SqlDocumentStore.d.ts +6 -10
  59. package/types/service/predefinedAdaptor/sql/types.d.ts +13 -17
  60. package/types/service/rateLimiter.d.ts +24 -0
  61. package/types/signal/endpointRateLimit.d.ts +37 -0
  62. package/types/signal/index.d.ts +1 -0
  63. package/types/signal/signalContext.d.ts +33 -15
  64. package/types/signal/slice.d.ts +17 -5
  65. package/types/signal/types.d.ts +8 -0
  66. package/types/test/signalTestRuntime.d.ts +1 -1
  67. package/types/ui/recipe/buttonRecipe.d.ts +2 -2
  68. package/types/ui/recipe/inputRecipe.d.ts +3 -3
  69. package/types/vendor/@akanjs/native/packages/cli/src/config.d.ts +33 -0
  70. package/types/vendor/@akanjs/native/packages/cli/src/lib/bplist.d.ts +11 -0
  71. package/types/vendor/@akanjs/native/packages/cli/src/lib/dmg.d.ts +59 -0
  72. package/types/vendor/@akanjs/native/packages/cli/src/lib/dsStore.d.ts +26 -0
  73. package/types/vendor/@akanjs/native/packages/cli/src/lib/macAlias.d.ts +24 -0
  74. package/types/vendor/@akanjs/native/packages/cli/src/lib/macossigning.d.ts +0 -1
  75. package/types/vendor/@akanjs/native/packages/cli/src/lib/project.d.ts +3 -1
  76. package/types/vendor/@akanjs/native/packages/cli/src/platforms/linux-appimage.d.ts +6 -1
  77. package/types/vendor/@akanjs/native/packages/desktop/src/deeplinks.d.ts +9 -2
  78. package/types/webkit/RenderFailure.d.ts +6 -0
  79. package/ui/Input.tsx +7 -1
  80. package/ui/recipe/buttonRecipe.ts +1 -0
  81. package/ui/recipe/inputRecipe.ts +13 -7
  82. package/ui/styles.css +1 -0
  83. package/vendor/@akanjs/native/native/desktop/src/lib.rs +8 -1
  84. package/vendor/@akanjs/native/native/macos/dmg/background.html +18 -0
  85. package/vendor/@akanjs/native/native/macos/dmg/background.png +0 -0
  86. package/vendor/@akanjs/native/native/macos/dmg/background@2x.png +0 -0
  87. package/vendor/@akanjs/native/packages/cli/src/config.ts +23 -0
  88. package/vendor/@akanjs/native/packages/cli/src/lib/bplist.ts +91 -0
  89. package/vendor/@akanjs/native/packages/cli/src/lib/configkeys.ts +9 -0
  90. package/vendor/@akanjs/native/packages/cli/src/lib/dmg.ts +331 -0
  91. package/vendor/@akanjs/native/packages/cli/src/lib/dsStore.ts +191 -0
  92. package/vendor/@akanjs/native/packages/cli/src/lib/macAlias.ts +101 -0
  93. package/vendor/@akanjs/native/packages/cli/src/lib/macossigning.ts +1 -28
  94. package/vendor/@akanjs/native/packages/cli/src/lib/project.ts +6 -1
  95. package/vendor/@akanjs/native/packages/cli/src/platforms/linux-appimage.ts +17 -7
  96. package/vendor/@akanjs/native/packages/cli/src/platforms/linux.ts +6 -1
  97. package/vendor/@akanjs/native/packages/cli/src/platforms/macos.ts +5 -2
  98. package/vendor/@akanjs/native/packages/desktop/src/deeplinks.ts +33 -10
  99. package/vendor/@akanjs/native/packages/desktop/src/host.ts +1 -1
  100. package/webkit/RenderFailure.tsx +32 -0
  101. 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 indexes = await this.#declaredIndexes();
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 columns = Object.keys(index.fields).map((path) => ({
161
- path,
162
- expr: this.compiler.fieldExpr(path),
163
- isArray: this.compiler.isArrayPath(path),
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, "updateOneByQuery");
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, "updateManyByQuery");
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.updateManyByQuery(query, { removedAt: dayjs() });
355
+ return await this.#updateMany(query, { removedAt: dayjs() }, {}, "removeManyByQuery");
301
356
  }
302
357
 
303
358
  async removeOneByQuery(query: DocumentQuery) {
304
359
 
305
- return this.updateOneByQuery(query, { removedAt: dayjs() });
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) => this.hydrate(this.fromProjectedRow(row, projection), undefined, { track: false }));
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) => this.#withStoredRow(this.hydrate(this.fromRow(row), undefined, { track: false }), 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: { sort?: SortOption; skip?: number | null; sample?: boolean } = {}) {
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(query?: DocumentQuery) {
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 Error(
750
- `Cannot modify immutable field${changed.length > 1 ? "s" : ""} on ${this.table} (${String(prepared.id)}): ${changed.join(", ")}`,
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
- return this.id ? store.update(this.id as string, store.#changesOf(this)) : store.create(this);
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 = { sort?: SortOption; skip?: number | null; sample?: boolean; select?: ProjectionOption };
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
+ }
@@ -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";