akanjs 3.0.0 → 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 (127) hide show
  1. package/CHANGELOG.md +3670 -0
  2. package/base/base.ts +1 -1
  3. package/client/cookie.ts +4 -6
  4. package/client/csrTypes.ts +2 -2
  5. package/common/Logger.ts +10 -0
  6. package/common/index.ts +0 -1
  7. package/common/routeConvention.ts +1 -9
  8. package/dictionary/base.dictionary.ts +7 -0
  9. package/dictionary/trans.ts +7 -1
  10. package/document/documentQuery.ts +6 -1
  11. package/document/index.ts +1 -0
  12. package/document/into.ts +15 -7
  13. package/document/liveRowScope.ts +79 -0
  14. package/fetch/client/fetchClient.ts +64 -12
  15. package/fetch/client/httpClient.ts +13 -4
  16. package/index.ts +22 -0
  17. package/package.json +1 -1
  18. package/server/akanOption.ts +20 -1
  19. package/server/akanServer.ts +5 -0
  20. package/server/cachePolicy.ts +12 -2
  21. package/server/crawler.ts +6 -0
  22. package/server/head.tsx +0 -4
  23. package/server/mcp/McpAuth.ts +3 -2
  24. package/server/mcp/McpExecutionContext.ts +15 -14
  25. package/server/mcp/McpRateLimiter.ts +18 -67
  26. package/server/proxy/webProxyRunner.ts +2 -0
  27. package/server/resolver/database.resolver.ts +28 -13
  28. package/server/resolver/signal.resolver.ts +75 -35
  29. package/server/robots.ts +1 -23
  30. package/server/routeTreeBuilder.ts +4 -2
  31. package/server/rscWorker.tsx +12 -24
  32. package/server/rscWorkerHost.ts +16 -7
  33. package/server/ssrFromRscRenderer.tsx +19 -15
  34. package/server/ssrTypes.ts +9 -0
  35. package/server/webRouter.ts +9 -2
  36. package/service/index.ts +1 -0
  37. package/service/predefinedAdaptor/index.ts +1 -0
  38. package/service/predefinedAdaptor/rateLimit.adaptor.ts +15 -0
  39. package/service/predefinedAdaptor/sql/SqlDocumentStore.ts +119 -27
  40. package/service/predefinedAdaptor/sql/types.ts +14 -12
  41. package/service/rateLimiter.ts +80 -0
  42. package/signal/base.signal.ts +7 -6
  43. package/signal/endpointRateLimit.ts +159 -0
  44. package/signal/index.ts +1 -0
  45. package/signal/middleware.ts +6 -5
  46. package/signal/serializer/fetch.serializer.ts +15 -4
  47. package/signal/signalContext.ts +148 -67
  48. package/signal/slice.ts +30 -9
  49. package/signal/types.ts +8 -0
  50. package/test/signalTestRuntime.ts +2 -1
  51. package/types/base/base.d.ts +1 -1
  52. package/types/client/cookie.d.ts +1 -1
  53. package/types/client/csrTypes.d.ts +2 -2
  54. package/types/common/Logger.d.ts +2 -0
  55. package/types/common/index.d.ts +1 -1
  56. package/types/common/routeConvention.d.ts +0 -2
  57. package/types/dictionary/base.dictionary.d.ts +1 -1
  58. package/types/dictionary/dictionary.d.ts +8 -8
  59. package/types/document/documentQuery.d.ts +5 -1
  60. package/types/document/index.d.ts +1 -0
  61. package/types/document/into.d.ts +7 -7
  62. package/types/document/liveRowScope.d.ts +9 -0
  63. package/types/fetch/client/fetchClient.d.ts +9 -1
  64. package/types/index.d.ts +32 -0
  65. package/types/server/akanOption.d.ts +7 -1
  66. package/types/server/cachePolicy.d.ts +2 -1
  67. package/types/server/crawler.d.ts +1 -0
  68. package/types/server/head.d.ts +0 -3
  69. package/types/server/mcp/McpExecutionContext.d.ts +4 -1
  70. package/types/server/mcp/McpRateLimiter.d.ts +2 -6
  71. package/types/server/rscWorkerHost.d.ts +3 -0
  72. package/types/server/ssrFromRscRenderer.d.ts +3 -1
  73. package/types/server/ssrTypes.d.ts +8 -0
  74. package/types/service/index.d.ts +1 -0
  75. package/types/service/predefinedAdaptor/index.d.ts +1 -0
  76. package/types/service/predefinedAdaptor/rateLimit.adaptor.d.ts +10 -0
  77. package/types/service/predefinedAdaptor/sql/SqlDocumentStore.d.ts +6 -10
  78. package/types/service/predefinedAdaptor/sql/types.d.ts +13 -17
  79. package/types/service/rateLimiter.d.ts +24 -0
  80. package/types/signal/endpointRateLimit.d.ts +37 -0
  81. package/types/signal/index.d.ts +1 -0
  82. package/types/signal/signalContext.d.ts +33 -15
  83. package/types/signal/slice.d.ts +17 -5
  84. package/types/signal/types.d.ts +8 -0
  85. package/types/test/signalTestRuntime.d.ts +1 -1
  86. package/types/ui/recipe/badgeRecipe.d.ts +2 -2
  87. package/types/ui/recipe/buttonRecipe.d.ts +4 -4
  88. package/types/ui/recipe/inputRecipe.d.ts +3 -3
  89. package/types/vendor/@akanjs/native/packages/cli/src/config.d.ts +33 -0
  90. package/types/vendor/@akanjs/native/packages/cli/src/lib/bplist.d.ts +11 -0
  91. package/types/vendor/@akanjs/native/packages/cli/src/lib/dmg.d.ts +59 -0
  92. package/types/vendor/@akanjs/native/packages/cli/src/lib/dsStore.d.ts +26 -0
  93. package/types/vendor/@akanjs/native/packages/cli/src/lib/macAlias.d.ts +24 -0
  94. package/types/vendor/@akanjs/native/packages/cli/src/lib/macossigning.d.ts +0 -1
  95. package/types/vendor/@akanjs/native/packages/cli/src/lib/project.d.ts +3 -1
  96. package/types/vendor/@akanjs/native/packages/cli/src/platforms/linux-appimage.d.ts +6 -1
  97. package/types/vendor/@akanjs/native/packages/desktop/src/deeplinks.d.ts +9 -2
  98. package/types/webkit/RenderFailure.d.ts +6 -0
  99. package/types/webkit/useFrameRuntime.d.ts +0 -1
  100. package/ui/Input.tsx +7 -1
  101. package/ui/recipe/buttonRecipe.ts +1 -0
  102. package/ui/recipe/inputRecipe.ts +13 -7
  103. package/ui/styles.css +1 -0
  104. package/vendor/@akanjs/native/native/desktop/src/lib.rs +8 -1
  105. package/vendor/@akanjs/native/native/macos/dmg/background.html +18 -0
  106. package/vendor/@akanjs/native/native/macos/dmg/background.png +0 -0
  107. package/vendor/@akanjs/native/native/macos/dmg/background@2x.png +0 -0
  108. package/vendor/@akanjs/native/packages/cli/src/config.ts +23 -0
  109. package/vendor/@akanjs/native/packages/cli/src/lib/addons.ts +2 -1
  110. package/vendor/@akanjs/native/packages/cli/src/lib/bplist.ts +91 -0
  111. package/vendor/@akanjs/native/packages/cli/src/lib/configkeys.ts +9 -0
  112. package/vendor/@akanjs/native/packages/cli/src/lib/dmg.ts +331 -0
  113. package/vendor/@akanjs/native/packages/cli/src/lib/dsStore.ts +191 -0
  114. package/vendor/@akanjs/native/packages/cli/src/lib/exec.ts +11 -1
  115. package/vendor/@akanjs/native/packages/cli/src/lib/macAlias.ts +101 -0
  116. package/vendor/@akanjs/native/packages/cli/src/lib/macossigning.ts +1 -28
  117. package/vendor/@akanjs/native/packages/cli/src/lib/project.ts +6 -1
  118. package/vendor/@akanjs/native/packages/cli/src/platforms/linux-appimage.ts +17 -7
  119. package/vendor/@akanjs/native/packages/cli/src/platforms/linux.ts +6 -1
  120. package/vendor/@akanjs/native/packages/cli/src/platforms/macos.ts +5 -2
  121. package/vendor/@akanjs/native/packages/desktop/src/deeplinks.ts +33 -10
  122. package/vendor/@akanjs/native/packages/desktop/src/host.ts +1 -1
  123. package/vendor/use-agentic/CHANGELOG.md +663 -0
  124. package/webkit/CsrRouteTable.ts +0 -2
  125. package/webkit/RenderFailure.tsx +32 -0
  126. package/webkit/RenderLayer.tsx +22 -2
  127. package/webkit/useCsrValues.ts +21 -11
@@ -17,6 +17,7 @@ import {
17
17
  pushRequestFallback,
18
18
  requestStorage,
19
19
  setRequestFrameState,
20
+ setRequestTheme,
20
21
  untrackedCookies,
21
22
  untrackedRequest,
22
23
  updateRequestPolicy,
@@ -36,12 +37,7 @@ import {
36
37
  resolveRouteCacheStoreTtl,
37
38
  shouldStoreRouteCache,
38
39
  } from "./cachePolicy";
39
- import {
40
- createAkanLocaleAlternateHeadSnapshot,
41
- mergeAkanHeadSnapshots,
42
- renderAkanHeadSnapshot,
43
- shouldRenderLocaleAlternates,
44
- } from "./head";
40
+ import { createAkanLocaleAlternateHeadSnapshot, mergeAkanHeadSnapshots, renderAkanHeadSnapshot } from "./head";
45
41
  import { LogForwarder } from "./logging/logForwarder";
46
42
  import { ProcessMetricsCollector } from "./processMetricsCollector";
47
43
  import { RouteElementComposer } from "./routeElementComposer";
@@ -1223,7 +1219,7 @@ export class RscRenderer {
1223
1219
  searchParams,
1224
1220
  })
1225
1221
  : { node: undefined };
1226
- const routeHeadSnapshot = this.#createRouteHeadSnapshot(url, routeHead, {});
1222
+ const routeHeadSnapshot = this.#createRouteHeadSnapshot(url, routeHead);
1227
1223
  return (
1228
1224
  <html lang={params.lang ?? getPathnameLocale(pathname, this.#i18n)} suppressHydrationWarning>
1229
1225
  <head key="head">
@@ -1254,14 +1250,16 @@ export class RscRenderer {
1254
1250
  basePath: this.#getBasePath(url),
1255
1251
  });
1256
1252
  setRequestFrameState(pathRoute.pageState);
1253
+
1254
+ const rootThemes = await Promise.all(pathRoute.renderRootLayouts.map((render) => render.getLayoutTheme?.()));
1255
+ const rootTheme = rootThemes.find((theme) => theme !== undefined);
1256
+ if (rootTheme) setRequestTheme(rootTheme);
1257
1257
  const routeHead = await RouteElementComposer.resolveHeadWithSnapshot({
1258
1258
  pathRoute,
1259
1259
  params: match.params,
1260
1260
  searchParams,
1261
1261
  });
1262
- const routeHeadSnapshot = this.#createRouteHeadSnapshot(url, routeHead, {
1263
- isSpecialRoute: pathRoute.isSpecialRoute,
1264
- });
1262
+ const routeHeadSnapshot = this.#createRouteHeadSnapshot(url, routeHead);
1265
1263
  const body = RouteElementComposer.compose({
1266
1264
  pathRoute,
1267
1265
  params: match.params,
@@ -1277,9 +1275,7 @@ export class RscRenderer {
1277
1275
  {routeHeadSnapshot
1278
1276
  ? renderAkanHeadSnapshot(routeHeadSnapshot)
1279
1277
  : (routeHead.node ?? this.#renderDefaultHead())}
1280
- {!routeHeadSnapshot && shouldRenderLocaleAlternates({ isSpecialRoute: pathRoute.isSpecialRoute })
1281
- ? this.#renderLocaleAlternates(url)
1282
- : null}
1278
+ {routeHeadSnapshot ? null : this.#renderLocaleAlternates(url)}
1283
1279
  {this.#renderStylesheet(url.pathname)}
1284
1280
  </head>
1285
1281
  <body key="body">{body}</body>
@@ -1370,22 +1366,14 @@ export class RscRenderer {
1370
1366
  params: match.params,
1371
1367
  searchParams,
1372
1368
  });
1373
- return this.#createRouteHeadSnapshot(url, routeHead, {
1374
- isSpecialRoute: match.pathRoute.isSpecialRoute,
1375
- });
1369
+ return this.#createRouteHeadSnapshot(url, routeHead);
1376
1370
  }
1377
1371
 
1378
- #createRouteHeadSnapshot(
1379
- url: URL,
1380
- routeHead: ResolvedHead,
1381
- options: { isSpecialRoute?: boolean },
1382
- ): ResolvedHead["headSnapshot"] {
1372
+ #createRouteHeadSnapshot(url: URL, routeHead: ResolvedHead): ResolvedHead["headSnapshot"] {
1383
1373
  if (!routeHead.headSnapshot) return undefined;
1384
1374
  return mergeAkanHeadSnapshots(
1385
1375
  routeHead.headSnapshot,
1386
- shouldRenderLocaleAlternates(options)
1387
- ? createAkanLocaleAlternateHeadSnapshot(this.#getLocaleAlternateLanguages(url))
1388
- : undefined,
1376
+ createAkanLocaleAlternateHeadSnapshot(this.#getLocaleAlternateLanguages(url)),
1389
1377
  );
1390
1378
  }
1391
1379
 
@@ -452,13 +452,13 @@ export class RscWorker {
452
452
 
453
453
  renderWithMeta(
454
454
  req: Request,
455
- options: { clientManifest?: ClientManifest; signal?: AbortSignal } = {},
455
+ options: { clientManifest?: ClientManifest; signal?: AbortSignal; clientIp?: string | null } = {},
456
456
  ): Promise<RscRenderResult> {
457
457
  const requestId = crypto.randomUUID();
458
458
  return createRscHostRenderStream({
459
459
  setPending: (pending) => this.#pending.set(requestId, pending),
460
460
  deletePending: () => this.#pending.delete(requestId),
461
- sendRenderOrQueue: () => this.#sendRenderOrQueue(requestId, req, options.clientManifest),
461
+ sendRenderOrQueue: () => this.#sendRenderOrQueue(requestId, req, options.clientManifest, options.clientIp),
462
462
  cancelRender: () => this.#cancelRender(requestId),
463
463
  signal: options.signal,
464
464
  onPendingChunkOverflow: () => {
@@ -851,14 +851,23 @@ export class RscWorker {
851
851
  this.#pending.delete(requestId);
852
852
  }
853
853
 
854
- #sendRenderOrQueue(requestId: string, req: Request, clientManifest?: ClientManifest): void {
854
+ /** The page request's headers as the worker sees them, `x-real-ip` being only the address the host resolved. */
855
+ static workerHeaders(req: Request, clientIp: string | null | undefined): Record<string, string> {
856
+ const headers: Record<string, string> = {};
857
+ req.headers.forEach((value, key) => {
858
+ headers[key] = value;
859
+ });
860
+
861
+ delete headers["x-real-ip"];
862
+ if (clientIp) headers["x-real-ip"] = clientIp;
863
+ return headers;
864
+ }
865
+
866
+ #sendRenderOrQueue(requestId: string, req: Request, clientManifest?: ClientManifest, clientIp?: string | null): void {
855
867
  const send = () => {
856
868
  if (!this.#pending.has(requestId)) return;
857
869
  try {
858
- const headers: Record<string, string> = {};
859
- req.headers.forEach((value, key) => {
860
- headers[key] = value;
861
- });
870
+ const headers = RscWorker.workerHeaders(req, clientIp);
862
871
  this.#proc.send({ type: "render", requestId, url: req.url, method: req.method, headers, clientManifest });
863
872
  } catch (err) {
864
873
  this.#resolvePending(requestId, (p) =>
@@ -9,7 +9,7 @@ import { renderToReadableStream } from "react-dom/server.browser";
9
9
  import { createFromNodeStream } from "react-server-dom-webpack/client.node";
10
10
  import { parsePositiveInt } from "./cachePolicy";
11
11
  import { concatBytes } from "./rscHttp";
12
- import type { SsrChunkRegistryStats, SsrFromRscInput, SsrLateRedirect } from "./ssrTypes";
12
+ import type { SsrChunkRegistryStats, SsrDocumentOptions, SsrFromRscInput, SsrLateRedirect } from "./ssrTypes";
13
13
 
14
14
  const DEFAULT_SSR_CHUNK_REGISTRY_MAX_ENTRIES = 1024;
15
15
  const DEFAULT_MAX_PENDING_INLINE_RSC_SCRIPTS = 32;
@@ -593,21 +593,13 @@ export class SsrFromRscRenderer {
593
593
  const base = `${SsrFromRscRenderer.#clientBootstrap}${SsrFromRscRenderer.#prefixBootstrap()}`;
594
594
  const bootstrap = input.extraBootstrapInline ? `${base}\n${input.extraBootstrapInline}` : base;
595
595
 
596
- const waitForAllReady = input.waitForAllReady || process.env.AKAN_SSR_WAIT_FOR_ALL_READY === "1";
597
- const renderHtml = async () => {
598
- const root = await thenable;
599
- const stream = await renderToReadableStream(root, {
600
- bootstrapScriptContent: bootstrap,
601
- onError: (error) => SsrFromRscRenderer.#reportRenderError(error, input),
596
+ const renderHtml = async () =>
597
+ await SsrFromRscRenderer.renderDocument(await thenable, {
598
+ bootstrap,
599
+ waitForAllReady: input.waitForAllReady || process.env.AKAN_SSR_WAIT_FOR_ALL_READY === "1",
600
+ inlineBoundaries: input.inlineBoundaries,
601
+ onError: (error, phase) => SsrFromRscRenderer.#reportRenderError(error, input, phase),
602
602
  });
603
- if (waitForAllReady) await stream.allReady;
604
- else {
605
- SsrFromRscRenderer.holdPostShellErrors(stream, (error) =>
606
- SsrFromRscRenderer.#reportRenderError(error, input, "post-shell"),
607
- );
608
- }
609
- return stream;
610
- };
611
603
  const requestContext = input.requestStore ?? input.request;
612
604
  const htmlStream =
613
605
  requestContext && requestStorage ? await requestStorage.run(requestContext, renderHtml) : await renderHtml();
@@ -661,6 +653,18 @@ export class SsrFromRscRenderer {
661
653
  };
662
654
  }
663
655
 
656
+ static async renderDocument(root: ReactNode, options: SsrDocumentOptions): Promise<ReadableStream<Uint8Array>> {
657
+ const stream = await renderToReadableStream(root, {
658
+ bootstrapScriptContent: options.bootstrap,
659
+
660
+ ...(options.inlineBoundaries ? { progressiveChunkSize: Number.POSITIVE_INFINITY } : {}),
661
+ onError: (error) => options.onError(error),
662
+ });
663
+ if (options.waitForAllReady || options.inlineBoundaries) await stream.allReady;
664
+ else SsrFromRscRenderer.holdPostShellErrors(stream, (error) => options.onError(error, "post-shell"));
665
+ return stream;
666
+ }
667
+
664
668
  static holdPostShellErrors(stream: { allReady: Promise<void> }, report: (error: unknown) => void): void {
665
669
  void stream.allReady.catch(report);
666
670
  }
@@ -61,4 +61,13 @@ export interface SsrFromRscInput {
61
61
  onCancel?: (reason?: unknown) => void;
62
62
  /** Buffers until `stream.allReady` (`pageConfig.ssr: "block"`); `AKAN_SSR_WAIT_FOR_ALL_READY=1` forces it globally. */
63
63
  waitForAllReady?: boolean;
64
+ /** Buffers like `waitForAllReady` and writes every Suspense boundary in place, for a reader that runs no script. */
65
+ inlineBoundaries?: boolean;
66
+ }
67
+
68
+ export interface SsrDocumentOptions {
69
+ bootstrap: string;
70
+ waitForAllReady?: boolean;
71
+ inlineBoundaries?: boolean;
72
+ onError: (error: unknown, phase?: string) => void;
64
73
  }
@@ -13,6 +13,7 @@ import {
13
13
  import { type AkanRequestStore, createRequestStore, parseCookieHeader } from "akanjs/fetch";
14
14
  import type { AkanMetricsReport } from "akanjs/service";
15
15
  import type { PagePromptSource } from "../signal/mcp/pagePrompt";
16
+ import { SignalContext } from "../signal/signalContext";
16
17
  import {
17
18
  type BuilderRpc,
18
19
  type MergedManifest,
@@ -36,6 +37,7 @@ import {
36
37
  shouldStoreRouteCache,
37
38
  } from "./cachePolicy";
38
39
  import { encodedFileResponse } from "./contentEncoding";
40
+ import { isCrawlerUserAgent } from "./crawler";
39
41
  import { HMR_CLIENT_SCRIPT } from "./hmr/clientScript";
40
42
  import { CSR_DEV_ROUTE_PREFIX, resolveDevCsrMode, SSR_DEV_DIRNAME, SSR_DEV_ROUTE_PREFIX } from "./hmr/csrDevManifest";
41
43
  import { CsrDevShell } from "./hmr/csrDevShell";
@@ -501,6 +503,7 @@ export class WebRouter {
501
503
  const result = await this.#rsc.renderWithMeta(rscReq, {
502
504
  clientManifest: manifest.clientManifest,
503
505
  signal: req.signal,
506
+ clientIp: SignalContext.clientIpOf(req),
504
507
  });
505
508
  if (result.type === "redirect")
506
509
  return createRscRedirectResponse(result.location, result.method, result.status);
@@ -600,7 +603,8 @@ export class WebRouter {
600
603
  try {
601
604
  this.#requestStats.fullSsr += 1;
602
605
  const manifest = await this.#ensureRoute(url);
603
- const htmlCacheDecision = this.#getHtmlCacheEntry(req, url);
606
+ const isCrawler = isCrawlerUserAgent(req.headers.get("user-agent"));
607
+ const htmlCacheDecision = this.#getHtmlCacheEntry(req, url, isCrawler);
604
608
  const htmlCacheEntry = htmlCacheDecision.entry;
605
609
  const cachedHtml = htmlCacheEntry ? this.#getCachedHtml(htmlCacheEntry.key) : null;
606
610
  if (cachedHtml) {
@@ -611,6 +615,7 @@ export class WebRouter {
611
615
  const rscResult = await this.#rsc.renderWithMeta(req, {
612
616
  clientManifest: manifest.clientManifest,
613
617
  signal: req.signal,
618
+ clientIp: SignalContext.clientIpOf(req),
614
619
  });
615
620
  if (rscResult.type === "redirect")
616
621
  return Response.redirect(new URL(rscResult.location, url.origin), rscResult.status);
@@ -643,6 +648,7 @@ export class WebRouter {
643
648
  },
644
649
  lateControl: rscResult.lateControl,
645
650
  waitForAllReady: rscResult.trace?.ssrBlocking ?? false,
651
+ inlineBoundaries: isCrawler,
646
652
  onCancel: (reason: unknown) => {
647
653
  rscResult.cancel(reason);
648
654
  },
@@ -814,7 +820,7 @@ export class WebRouter {
814
820
  }
815
821
  }
816
822
 
817
- #getHtmlCacheEntry(req: Request, url: URL): { entry: RouteCacheEntry | null; reason?: string } {
823
+ #getHtmlCacheEntry(req: Request, url: URL, isCrawler: boolean): { entry: RouteCacheEntry | null; reason?: string } {
818
824
 
819
825
  if (this.#ssrDevShell) {
820
826
  this.#htmlCacheBypass += 1;
@@ -824,6 +830,7 @@ export class WebRouter {
824
830
  request: req,
825
831
  url,
826
832
  theme: WebRouter.#cookieValue(req, "theme"),
833
+ variant: isCrawler ? "crawler" : undefined,
827
834
  defaultEnabled: this.#prodMode,
828
835
  defaultAllow: this.#prodMode,
829
836
  env: {
package/service/index.ts CHANGED
@@ -5,6 +5,7 @@ export * from "./injectInfo";
5
5
  export * from "./ipcTypes";
6
6
  export * from "./liveSyncHub";
7
7
  export * from "./predefinedAdaptor";
8
+ export * from "./rateLimiter";
8
9
  export * from "./serve";
9
10
  export * from "./serviceModule";
10
11
  export * from "./serviceRegistry";
@@ -10,6 +10,7 @@ export * from "./logging.adaptor";
10
10
  export * from "./openaiDialect";
11
11
  export * from "./openaiLlm";
12
12
  export * from "./queue.adaptor";
13
+ export * from "./rateLimit.adaptor";
13
14
  export * from "./role.adaptor";
14
15
  export * from "./schedule.adaptor";
15
16
  export * from "./searchIndex";
@@ -0,0 +1,15 @@
1
+ import { adapt } from "../adapt";
2
+ import { type RateBudget, RateLimiter, type RateVerdict } from "../rateLimiter";
3
+ import { CacheAdaptorRole } from "./role.adaptor";
4
+
5
+ /** Counts a business key (a phone number, an email) across instances; unlike an endpoint's budget, no env turns it off. */
6
+ export class RateLimit extends adapt("rateLimit", ({ plug }) => ({
7
+ cache: plug(CacheAdaptorRole),
8
+ })) {
9
+ #limiter: RateLimiter | null = null;
10
+
11
+ async consume(key: string, budget: RateBudget): Promise<RateVerdict> {
12
+ this.#limiter ??= new RateLimiter(this.cache, { topic: "rateLimit" });
13
+ return await this.#limiter.consume(key, budget);
14
+ }
15
+ }
@@ -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
  },