@rebasepro/server 0.13.0 → 0.13.1-canary.g06dbe5b

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 (125) hide show
  1. package/dist/{admin_block-CcSGdH7M.js → admin_block-H86dCPsj.js} +62 -2
  2. package/dist/admin_block-H86dCPsj.js.map +1 -0
  3. package/dist/api/ast-schema-editor.d.ts +74 -6
  4. package/dist/api/errors.d.ts +0 -6
  5. package/dist/api/openapi-generator.d.ts +14 -0
  6. package/dist/api/rest/api-generator.d.ts +1 -1
  7. package/dist/api/rest/idempotency.d.ts +62 -4
  8. package/dist/api/rest/query-parser.d.ts +15 -1
  9. package/dist/api/rest/write-validation.d.ts +52 -9
  10. package/dist/auth/adapter-middleware.d.ts +3 -1
  11. package/dist/auth/apple-oauth.d.ts +16 -11
  12. package/dist/auth/bitbucket-oauth.d.ts +2 -4
  13. package/dist/auth/builtin-auth-adapter.d.ts +2 -0
  14. package/dist/auth/discord-oauth.d.ts +5 -6
  15. package/dist/auth/facebook-oauth.d.ts +2 -4
  16. package/dist/auth/github-oauth.d.ts +2 -4
  17. package/dist/auth/gitlab-oauth.d.ts +6 -4
  18. package/dist/auth/google-oauth.d.ts +1 -0
  19. package/dist/auth/index.d.ts +8 -1
  20. package/dist/auth/interfaces.d.ts +68 -1
  21. package/dist/auth/jwt.d.ts +40 -0
  22. package/dist/auth/linkedin-oauth.d.ts +2 -4
  23. package/dist/auth/mfa-gate.d.ts +42 -0
  24. package/dist/auth/mfa-routes.d.ts +26 -1
  25. package/dist/auth/mfa.d.ts +16 -0
  26. package/dist/auth/microsoft-oauth.d.ts +19 -4
  27. package/dist/auth/oauth-code-flow.d.ts +66 -0
  28. package/dist/auth/oauth-signin-policy.d.ts +61 -0
  29. package/dist/auth/oidc-id-token.d.ts +61 -0
  30. package/dist/auth/rate-limiter.d.ts +37 -2
  31. package/dist/auth/rls-scope.d.ts +25 -0
  32. package/dist/auth/routes.d.ts +8 -0
  33. package/dist/auth/slack-oauth.d.ts +2 -4
  34. package/dist/auth/spotify-oauth.d.ts +2 -4
  35. package/dist/auth/twitter-oauth.d.ts +2 -5
  36. package/dist/{auth-CuC9M2x6.js → auth-DkAHqvf5.js} +1604 -391
  37. package/dist/auth-DkAHqvf5.js.map +1 -0
  38. package/dist/{backup-CVggVhR2.js → backup-DLluVyuA.js} +2 -2
  39. package/dist/{backup-CVggVhR2.js.map → backup-DLluVyuA.js.map} +1 -1
  40. package/dist/boot/boot.d.ts +19 -0
  41. package/dist/boot/ddl-bootstrap.d.ts +70 -0
  42. package/dist/{contract-routes-Dj8i5AiM.js → contract-routes-BB1U05sS.js} +3 -3
  43. package/dist/{contract-routes-Dj8i5AiM.js.map → contract-routes-BB1U05sS.js.map} +1 -1
  44. package/dist/cron/cron-scheduler.d.ts +8 -3
  45. package/dist/cron/define-cron.d.ts +17 -3
  46. package/dist/{cron-loader-B1S2MCSl.js → cron-loader-3U5aGILy.js} +2 -2
  47. package/dist/{cron-loader-B1S2MCSl.js.map → cron-loader-3U5aGILy.js.map} +1 -1
  48. package/dist/{cron-routes-CrQ0tK-_.js → cron-routes-rZgwlOz4.js} +2 -2
  49. package/dist/{cron-routes-CrQ0tK-_.js.map → cron-routes-rZgwlOz4.js.map} +1 -1
  50. package/dist/{cron-scheduler-B3RFt0HS.js → cron-scheduler-FJAaCAXm.js} +30 -5
  51. package/dist/cron-scheduler-FJAaCAXm.js.map +1 -0
  52. package/dist/{cron-store-BywZsyfZ.js → cron-store-9NmUDfzL.js} +66 -47
  53. package/dist/cron-store-9NmUDfzL.js.map +1 -0
  54. package/dist/ddl-bootstrap-BhXbTnBl.js +183 -0
  55. package/dist/ddl-bootstrap-BhXbTnBl.js.map +1 -0
  56. package/dist/email/html.d.ts +54 -0
  57. package/dist/email/index.d.ts +3 -0
  58. package/dist/email/link-base.d.ts +39 -0
  59. package/dist/email/smtp-email-service.d.ts +7 -1
  60. package/dist/email/templates.d.ts +9 -1
  61. package/dist/email/types.d.ts +11 -1
  62. package/dist/{errors-CgkCzoj7.js → errors-B1WZEdsK.js} +3 -11
  63. package/dist/errors-B1WZEdsK.js.map +1 -0
  64. package/dist/function-loader-D1SwtCa5.js +139 -0
  65. package/dist/function-loader-D1SwtCa5.js.map +1 -0
  66. package/dist/{function-routes-C0cLIy3N.js → function-routes-Btcez1T-.js} +18 -6
  67. package/dist/function-routes-Btcez1T-.js.map +1 -0
  68. package/dist/functions/define-function.d.ts +25 -6
  69. package/dist/functions/function-loader.d.ts +23 -0
  70. package/dist/functions/function-routes.d.ts +7 -1
  71. package/dist/functions/request-timeout.d.ts +34 -0
  72. package/dist/history/history-routes.d.ts +6 -0
  73. package/dist/index.d.ts +2 -1
  74. package/dist/index.es.js +1960 -794
  75. package/dist/index.es.js.map +1 -1
  76. package/dist/init/docs.d.ts +10 -1
  77. package/dist/init/process-safety.d.ts +25 -0
  78. package/dist/init.d.ts +27 -0
  79. package/dist/{jwt-DD6EtpGj.js → jwt-Cuq6MwNy.js} +70 -8
  80. package/dist/jwt-Cuq6MwNy.js.map +1 -0
  81. package/dist/logger-DfvF_8r-.js +190 -0
  82. package/dist/logger-DfvF_8r-.js.map +1 -0
  83. package/dist/openapi-generator-BQxxxDpb.js +865 -0
  84. package/dist/openapi-generator-BQxxxDpb.js.map +1 -0
  85. package/dist/request-timeout-RivJsME0.js +65 -0
  86. package/dist/request-timeout-RivJsME0.js.map +1 -0
  87. package/dist/schema-editor-routes-DZFKGXgq.js +437 -0
  88. package/dist/schema-editor-routes-DZFKGXgq.js.map +1 -0
  89. package/dist/serve-spa.d.ts +5 -4
  90. package/dist/services/outbound-url-guard.d.ts +53 -0
  91. package/dist/services/routed-realtime-service.d.ts +10 -2
  92. package/dist/services/webhook-service.d.ts +76 -1
  93. package/dist/singleton.d.ts +25 -8
  94. package/dist/{src-Cum9kox5.js → src-By48Ffg0.js} +322 -70
  95. package/dist/src-By48Ffg0.js.map +1 -0
  96. package/dist/{src-_qQ3RNCK.js → src-Ca6NhxKs.js} +79 -2
  97. package/dist/src-Ca6NhxKs.js.map +1 -0
  98. package/dist/storage/LocalStorageController.d.ts +12 -1
  99. package/dist/storage/image-transform.d.ts +57 -0
  100. package/dist/storage/keys.d.ts +98 -0
  101. package/dist/storage/routes.d.ts +11 -0
  102. package/dist/storage/tus-handler.d.ts +7 -1
  103. package/dist/utils/logger.d.ts +18 -0
  104. package/dist/utils/logging.d.ts +0 -4
  105. package/dist/utils/sql.d.ts +11 -6
  106. package/package.json +7 -6
  107. package/dist/admin_block-CcSGdH7M.js.map +0 -1
  108. package/dist/auth-CuC9M2x6.js.map +0 -1
  109. package/dist/backend-CIxN4FVm.js +0 -15
  110. package/dist/backend-CIxN4FVm.js.map +0 -1
  111. package/dist/cron-scheduler-B3RFt0HS.js.map +0 -1
  112. package/dist/cron-store-BywZsyfZ.js.map +0 -1
  113. package/dist/errors-CgkCzoj7.js.map +0 -1
  114. package/dist/function-loader-B_1fYfUY.js +0 -86
  115. package/dist/function-loader-B_1fYfUY.js.map +0 -1
  116. package/dist/function-routes-C0cLIy3N.js.map +0 -1
  117. package/dist/jwt-DD6EtpGj.js.map +0 -1
  118. package/dist/logger-BYU66ENZ.js +0 -94
  119. package/dist/logger-BYU66ENZ.js.map +0 -1
  120. package/dist/openapi-generator-BEwyiaAr.js +0 -597
  121. package/dist/openapi-generator-BEwyiaAr.js.map +0 -1
  122. package/dist/schema-editor-routes-CbF20Mf1.js +0 -248
  123. package/dist/schema-editor-routes-CbF20Mf1.js.map +0 -1
  124. package/dist/src-Cum9kox5.js.map +0 -1
  125. package/dist/src-_qQ3RNCK.js.map +0 -1
package/dist/index.es.js CHANGED
@@ -1,27 +1,27 @@
1
1
  import { createRequire as __createRequire } from "module";
2
2
  import process from "process";
3
3
  __createRequire(import.meta.url);
4
- import { S as Vector, a as serializeFilter, b as EntityRelation, c as paginateFind, d as resolveDataSource, f as getEffectiveSecurityRules, g as toSnakeCase, h as buildCompositeId, i as deserializeLogicalCondition, l as CollectionRegistry, m as resolveCollectionRelations, n as buildSdkData, o as serializeLogicalCondition, p as findRelation, r as deserializeFilter, s as collectAllPages, t as buildRoutedRebaseData, u as createDataSourceRegistry, x as GeoPoint, y as EntityReference } from "./src-Cum9kox5.js";
5
- import { a as DEFAULT_DATA_SOURCE_KEY, r as isPostgresCollectionConfig, t as getCollectionDataPath, u as toCanonicalOp } from "./src-_qQ3RNCK.js";
6
- import { n as ADMIN_PROPERTY_KEYS, t as ADMIN_COLLECTION_KEYS } from "./admin_block-CcSGdH7M.js";
7
- import { t as isSQLAdmin } from "./backend-CIxN4FVm.js";
8
- import { n as createContractRoutes } from "./contract-routes-Dj8i5AiM.js";
9
- import { $ as httpMethodToOperation, A as getEmailVerificationTemplate, B as optionalAuth, C as createDataRateLimiter, D as validatePasswordStrength, E as hashPassword, F as createAdapterAuthMiddleware, G as createApiKeyPreAuth, H as queryTokenAuth, I as createAuthMiddleware, J as isApiKeyToken, K as createFunctionApiKeyGuard, L as createRequireAuth, M as getPasswordResetTemplate, N as getUserInvitationTemplate, O as verifyPassword, P as getWelcomeEmailTemplate, Q as scopeDataDriver, R as extractUserFromToken, S as _coercedNumber, T as resolveAuthHooks, U as requireAdmin, V as publicObjectAuth, W as requireAuth, X as extractBearerToken, Y as validateApiKey, Z as safeCompare, _ as createBuiltinAuthAdapter, a as createSpotifyProvider, b as object, c as createGitLabProvider, d as createFacebookProvider, et as isOperationAllowed, f as createAppleProvider, g as createGoogleProvider, h as createLinkedinProvider, i as createApiKeyStore, it as resolveClientListLimit, j as getMagicLinkTemplate, k as generateSecurePassword, l as createDiscordProvider, m as createGitHubProvider, n as createCustomAuthAdapter, nt as isPublicStoragePath, o as createSlackProvider, p as createMicrosoftProvider, q as createStorageApiKeyGuard, r as createApiKeyRoutes, s as createBitbucketProvider, tt as PUBLIC_STORAGE_PREFIX, u as createTwitterProvider, v as ZodNumber, w as MemoryRateLimitStore, x as string, y as _enum, z as fileTokenAuth } from "./auth-CuC9M2x6.js";
10
- import { t as logger } from "./logger-BYU66ENZ.js";
11
- import { n as errorHandler, r as isRebaseApiError, t as ApiError } from "./errors-CgkCzoj7.js";
12
- import { i as generateDownloadToken, n as configureJwt } from "./jwt-DD6EtpGj.js";
4
+ import { D as Vector, E as GeoPoint, O as RebaseApiError, T as EntityRelation, _ as resolvePrimaryKeys, a as serializeFilter, c as paginateFind, d as createDataSourceRegistry, f as resolveDataSource, g as buildCompositeId, h as resolveCollectionRelations, k as RebaseClientError, l as resolveFindWindow, m as findRelation, n as buildSdkData, o as serializeLogicalCondition, p as getEffectiveSecurityRules, s as collectAllPages, t as buildRoutedRebaseData, u as CollectionRegistry, v as toSnakeCase, w as EntityReference } from "./src-By48Ffg0.js";
5
+ import { c as DEFAULT_DATA_SOURCE_KEY, i as getCollectionDataPath, n as RLS_ROLES_SQL, o as isPostgresCollectionConfig, p as toCanonicalOp, r as RLS_UID_SQL, t as RLS_JWT_SQL } from "./src-Ca6NhxKs.js";
6
+ import { n as ADMIN_PROPERTY_KEYS, t as ADMIN_COLLECTION_KEYS } from "./admin_block-H86dCPsj.js";
7
+ import { i as isSQLAdmin, r as revokeInternalTableSql } from "./ddl-bootstrap-BhXbTnBl.js";
8
+ import { n as createContractRoutes } from "./contract-routes-BB1U05sS.js";
9
+ import { $ as fileTokenAuth, A as MemoryRateLimitStore, B as getEmailVerificationTemplate, C as ZodNumber, Ct as isPublicStoragePath, D as _coercedNumber, E as string, F as resolveAuthHooks, G as RawHtml, H as getPasswordResetTemplate, I as hashPassword, J as raw, K as escapeHtml, L as validatePasswordStrength, M as createEmailService, N as assertEmailLinkBases, O as DEFAULT_FUNCTIONS_ANONYMOUS_LIMIT, P as resolveEmailLinkBase, Q as extractUserFromToken, R as verifyPassword, S as createBuiltinAuthAdapter, St as PUBLIC_STORAGE_PREFIX, T as object, U as getUserInvitationTemplate, V as getMagicLinkTemplate, W as getWelcomeEmailTemplate, X as createAuthMiddleware, Y as createAdapterAuthMiddleware, Z as createRequireAuth, _ as createLinkedinProvider, _t as scopeDataDriver, a as createSpotifyProvider, at as InvalidStorageBucketError, b as pkceTokenParams, bt as parseQueryOptions, c as createGitLabProvider, ct as canonicalStorageKey, d as createFacebookProvider, dt as createStorageApiKeyGuard, et as optionalAuth, f as createAppleProvider, ft as isApiKeyToken, g as createGitHubProvider, gt as SERVICE_IDENTITY, h as verifyOidcIdToken, ht as safeCompare, i as createApiKeyStore, it as requireAuth, j as SMTPEmailService, k as createDataRateLimiter, l as createDiscordProvider, lt as createApiKeyPreAuth, m as tryVerifyOidcIdToken, mt as extractBearerToken, n as createCustomAuthAdapter, nt as queryTokenAuth, o as createSlackProvider, ot as InvalidStorageKeyError, p as createMicrosoftProvider, pt as validateApiKey, q as html, r as createApiKeyRoutes, rt as requireAdmin, s as createBitbucketProvider, st as canonicalStorageBucket, tt as publicObjectAuth, u as createTwitterProvider, ut as createFunctionApiKeyGuard, v as createGoogleProvider, vt as httpMethodToOperation, w as _enum, x as providerVerifiedEmail, xt as resolveListLimitParam, y as oauthCodeFlowSchema, yt as isOperationAllowed, z as generateSecurePassword } from "./auth-DkAHqvf5.js";
10
+ import { t as logger } from "./logger-DfvF_8r-.js";
11
+ import { n as errorHandler, t as ApiError } from "./errors-B1WZEdsK.js";
12
+ import { i as generateDownloadToken, n as configureJwt } from "./jwt-Cuq6MwNy.js";
13
13
  import { t as logMiddleware } from "./logs-routes-BYA72C_C.js";
14
14
  import { n as DOCUMENT_MIME_TYPES, r as IMAGE_MIME_TYPES, t as DEFAULT_MAX_FILE_SIZE } from "./types-DSnOC4mF.js";
15
15
  import { t as S3StorageController } from "./S3StorageController-B2EDXXMH.js";
16
16
  import { t as GCSStorageController } from "./GCSStorageController-BEmDYFKc.js";
17
- import { n as loadFunctionsFromDirectory } from "./function-loader-B_1fYfUY.js";
18
- import { t as createFunctionRoutes } from "./function-routes-C0cLIy3N.js";
19
- import { n as loadCronJobsFromDirectory } from "./cron-loader-B1S2MCSl.js";
20
- import { r as validateCronExpression, t as CronScheduler } from "./cron-scheduler-B3RFt0HS.js";
21
- import { t as createCronRoutes } from "./cron-routes-CrQ0tK-_.js";
22
- import { t as createCronStore } from "./cron-store-BywZsyfZ.js";
23
- import { a as parseBackupTimestamp, i as parseBackupDestination, n as createBackupRoutes, o as readBackupBytes, r as listBackupObjects } from "./backup-CVggVhR2.js";
24
- import { randomBytes, randomUUID } from "node:crypto";
17
+ import { n as loadFunctionsFromDirectory } from "./function-loader-D1SwtCa5.js";
18
+ import { t as createFunctionRoutes } from "./function-routes-Btcez1T-.js";
19
+ import { n as loadCronJobsFromDirectory } from "./cron-loader-3U5aGILy.js";
20
+ import { r as validateCronExpression, t as CronScheduler } from "./cron-scheduler-FJAaCAXm.js";
21
+ import { t as createCronRoutes } from "./cron-routes-rZgwlOz4.js";
22
+ import { t as createCronStore } from "./cron-store-9NmUDfzL.js";
23
+ import { a as parseBackupTimestamp, i as parseBackupDestination, n as createBackupRoutes, o as readBackupBytes, r as listBackupObjects } from "./backup-DLluVyuA.js";
24
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
25
25
  import * as fs$2 from "fs";
26
26
  import fs, { existsSync } from "fs";
27
27
  import * as path$2 from "path";
@@ -31,79 +31,24 @@ import { Hono } from "hono";
31
31
  import { promisify } from "util";
32
32
  import * as crypto$1 from "crypto";
33
33
  import { createHmac, randomUUID as randomUUID$1 } from "crypto";
34
+ import path$1 from "node:path";
34
35
  import { bodyLimit } from "hono/body-limit";
35
36
  import { csrf } from "hono/csrf";
36
37
  import { compress } from "hono/compress";
37
38
  import fs$1 from "node:fs";
38
39
  import fsp from "node:fs/promises";
40
+ import os from "node:os";
39
41
  import { mkdir, open, unlink, writeFile } from "fs/promises";
40
42
  import { sql } from "drizzle-orm";
43
+ import { lookup } from "node:dns/promises";
44
+ import { isIP } from "node:net";
41
45
  import { serveStatic } from "@hono/node-server/serve-static";
42
46
  import { createServer } from "http";
43
47
  import { cors } from "hono/cors";
44
48
  import { secureHeaders } from "hono/secure-headers";
45
49
  import { getRequestListener } from "@hono/node-server";
46
- import path$1 from "node:path";
47
- import os from "node:os";
48
50
  import { execFile } from "node:child_process";
49
51
  import { promisify as promisify$1 } from "node:util";
50
- //#region ../types/src/errors.ts
51
- /**
52
- * The single error type thrown across the entire Rebase client surface —
53
- * HTTP data/control-plane calls, realtime/WebSocket operations, and
54
- * client-side logic errors (e.g. an unknown collection accessor). A `catch`
55
- * block only ever needs to check for this one class:
56
- *
57
- * ```ts
58
- * import { RebaseApiError } from "@rebasepro/client"; // re-exported
59
- *
60
- * try {
61
- * await client.data.products.update(id, { price: 9 });
62
- * } catch (e) {
63
- * if (e instanceof RebaseApiError) {
64
- * if (e.status === 404) { ... } // HTTP failures carry a status
65
- * console.error(e.code, e.details);
66
- * }
67
- * }
68
- * ```
69
- *
70
- * `status` is present for HTTP failures and `undefined` otherwise, so its
71
- * presence distinguishes transport-level errors from realtime/logic errors.
72
- *
73
- * @group Errors
74
- */
75
- var RebaseApiError = class extends Error {
76
- /** HTTP status code, or `undefined` for non-HTTP errors. */
77
- status;
78
- /** Stable machine-readable error code, when the server supplied one. */
79
- code;
80
- /** Structured error payload from the server, when present. */
81
- details;
82
- constructor(message, init = {}) {
83
- super(message);
84
- this.name = "RebaseApiError";
85
- this.status = init.status;
86
- this.code = init.code;
87
- this.details = init.details;
88
- if (init.cause !== void 0) this.cause = init.cause;
89
- }
90
- };
91
- /**
92
- * Client-side logic error — raised before any request is made (e.g. accessing
93
- * an unknown collection accessor when a typed dictionary is configured).
94
- *
95
- * A subclass of {@link RebaseApiError} (with no `status`), so a single
96
- * `catch (e) { if (e instanceof RebaseApiError) ... }` handles it too.
97
- *
98
- * @group Errors
99
- */
100
- var RebaseClientError = class extends RebaseApiError {
101
- constructor(message) {
102
- super(message);
103
- this.name = "RebaseClientError";
104
- }
105
- };
106
- //#endregion
107
52
  //#region ../types/src/types/storage_source.ts
108
53
  /**
109
54
  * Describes a named storage backend — a place files live.
@@ -250,23 +195,6 @@ function serializeOrderBy(orderBy) {
250
195
  if (typeof orderBy === "string") return orderBy;
251
196
  return `${orderBy[0]}:${orderBy[1]}`;
252
197
  }
253
- /**
254
- * Deserialize a wire-format `"field:direction"` string into an {@link OrderByTuple}.
255
- *
256
- * Lenient parsing (matches existing server behaviour):
257
- * - Bare field name (no colon): `"name"` → `["name", "asc"]`
258
- * - Unknown direction: `"name:foo"` → `["name", "asc"]`
259
- * - Empty / falsy input: → `undefined`
260
- *
261
- * @param raw - The wire-format string from an HTTP query parameter.
262
- * @returns The canonical tuple, or `undefined` if the input is empty/falsy.
263
- */
264
- function deserializeOrderBy(raw) {
265
- if (!raw) return void 0;
266
- const idx = raw.indexOf(":");
267
- if (idx === -1) return [raw, "asc"];
268
- return [raw.slice(0, idx), raw.slice(idx + 1) === "desc" ? "desc" : "asc"];
269
- }
270
198
  //#endregion
271
199
  //#region src/collections/BackendCollectionRegistry.ts
272
200
  /**
@@ -311,7 +239,6 @@ function unknownKeyPolicyFromEnv(env = process.env) {
311
239
  ].includes(raw)) return "off";
312
240
  return "warn";
313
241
  }
314
- /** `BaseCollectionConfig`, plus every engine-specific field, plus the `admin` block. */
315
242
  var COLLECTION_KEYS = /* @__PURE__ */ new Set([
316
243
  "slug",
317
244
  "name",
@@ -333,6 +260,7 @@ var COLLECTION_KEYS = /* @__PURE__ */ new Set([
333
260
  "relations",
334
261
  "securityRules",
335
262
  "schema",
263
+ "search",
336
264
  "path",
337
265
  "subcollections",
338
266
  "admin"
@@ -785,6 +713,20 @@ function isDataDriverDelegate(obj) {
785
713
  //#endregion
786
714
  //#region src/services/routed-realtime-service.ts
787
715
  /**
716
+ * Channel/presence/broadcast messages are engine-agnostic pub/sub — which is
717
+ * why they go to a provider that implements them, not to whichever provider a
718
+ * bootstrapper happened to mark `isDefault`.
719
+ */
720
+ var CHANNEL_MESSAGE_TYPES$1 = /* @__PURE__ */ new Set([
721
+ "join_channel",
722
+ "leave_channel",
723
+ "broadcast",
724
+ "presence_track",
725
+ "presence_untrack",
726
+ "presence_state",
727
+ "channel_history"
728
+ ]);
729
+ /**
788
730
  * Compose multiple per-engine {@link RealtimeProvider}s into one that routes
789
731
  * each subscription to the provider owning the subscribed collection — the
790
732
  * realtime counterpart of `buildRoutedRebaseData`.
@@ -794,8 +736,9 @@ function isDataDriverDelegate(obj) {
794
736
  * - `subscribe_collection` / `subscribe_entity` → the provider for the
795
737
  * collection's data source (by `payload.path`).
796
738
  * - `unsubscribe` → forwarded to all providers (a no-op on non-owners).
797
- * - channel / presence / broadcast → the default provider (these are global
798
- * pub/sub, not bound to an engine).
739
+ * - channel / presence / broadcast → the first provider that declares
740
+ * `supportsChannels` (these are global pub/sub, not bound to an engine — but
741
+ * they are not implemented by every engine either).
799
742
  * - `addClient` and lifecycle (`onServerReady`/`destroy`/`stopListening`) →
800
743
  * all providers (each registers its own ws close handler for cleanup).
801
744
  */
@@ -809,6 +752,30 @@ function createRoutedRealtimeService(opts) {
809
752
  const key = resolveKey(path);
810
753
  return asWs(providers[key] ?? providers[defaultKey] ?? Object.values(providers)[0]);
811
754
  };
755
+ /**
756
+ * The provider channel frames go to, resolved once and reported once.
757
+ *
758
+ * By capability, not by default-ness: the default provider is whichever
759
+ * bootstrapper set `isDefault`, and routing pub/sub there sends every
760
+ * broadcast, presence update and catch-up request into an engine that may
761
+ * not implement any of them. When the default *does* support channels it is
762
+ * still preferred, so single-engine behaviour is unchanged.
763
+ */
764
+ let channelRouting;
765
+ const forChannels = () => {
766
+ if (!channelRouting) {
767
+ const entries = Object.entries(providers);
768
+ const supports = ([, p]) => asWs(p).supportsChannels === true;
769
+ const chosen = entries.find(([key]) => key === defaultKey && supports([key, providers[key]])) ?? entries.find(supports);
770
+ channelRouting = chosen ? {
771
+ provider: asWs(chosen[1]),
772
+ key: chosen[0]
773
+ } : {};
774
+ if (chosen) logger.info(`📡 [Realtime] Channels, presence and broadcast are served by the "${chosen[0]}" data source.`);
775
+ else logger.error(`❌ [Realtime] No configured realtime provider implements channels, presence or broadcast — every channel frame will be refused. Configured sources: [${entries.map(([k]) => k).join(", ")}]. Channels currently require a Postgres data source.`);
776
+ }
777
+ return channelRouting.provider;
778
+ };
812
779
  return {
813
780
  addClient(clientId, ws) {
814
781
  for (const p of all()) p.addClient?.(clientId, ws);
@@ -823,6 +790,12 @@ function createRoutedRealtimeService(opts) {
823
790
  await Promise.all(all().map((p) => p.handleClientMessage(clientId, message, authContext)));
824
791
  return;
825
792
  }
793
+ if (CHANNEL_MESSAGE_TYPES$1.has(type)) {
794
+ const provider = forChannels();
795
+ if (!provider) throw new Error(`Channels are not supported by any configured data source — "${type}" refused.`);
796
+ await provider.handleClientMessage(clientId, message, authContext);
797
+ return;
798
+ }
826
799
  await fallback().handleClientMessage(clientId, message, authContext);
827
800
  },
828
801
  subscribeToCollection(subscriptionId, config, callback) {
@@ -849,173 +822,22 @@ function createRoutedRealtimeService(opts) {
849
822
  };
850
823
  }
851
824
  //#endregion
852
- //#region src/api/rest/query-parser.ts
853
- function getLastValue(val) {
854
- if (Array.isArray(val)) return val[val.length - 1];
855
- return val;
856
- }
857
- /**
858
- * Parse an `or(...)` / `and(...)` logical group from its wire form.
859
- *
860
- * The wire carries the inner conditions wrapped in parens (e.g.
861
- * `(status.eq.active,age.gte.18)`); we re-attach the `or`/`and` prefix and
862
- * delegate to the canonical filter dialect (`@rebasepro/common`). Values are
863
- * preserved as strings — type coercion is the schema-aware driver's job, so
864
- * this path stays byte-for-byte consistent with the SDK/admin path (which
865
- * also parses via the shared dialect).
866
- */
867
- function parseLogicalGroup(type, raw) {
868
- let inner = String(raw).trim();
869
- if (inner.startsWith("(") && inner.endsWith(")")) inner = inner.slice(1, -1);
870
- inner = inner.trim();
871
- if (!inner) return void 0;
872
- const parsed = deserializeLogicalCondition(`${type}(${inner})`);
873
- return "type" in parsed ? parsed : void 0;
874
- }
875
- /**
876
- * Parse the `?where=` JSON filter object.
877
- *
878
- * This is the dialect the OpenAPI document publishes on every
879
- * `GET /api/data/{slug}` — `{"status":["==","active"]}`: field → canonical
880
- * `[WhereFilterOp, value]` tuple. It is normalized through the same
881
- * `deserializeFilter` as the `?field=op.value` params below, so a value that
882
- * arrives as a PostgREST dot-string (`{"status":"eq.active"}`) or as a bare
883
- * scalar (`{"status":"active"}`) compiles to the same condition. Unlike the
884
- * querystring dialect, JSON carries types — a number stays a number.
885
- *
886
- * A malformed value is a 400 rather than a silent drop: dropping the filter
887
- * would run the read unfiltered and return everything RLS happens to allow.
888
- */
889
- function parseWhereParam(raw) {
890
- const str = String(raw).trim();
891
- if (!str) return void 0;
892
- let parsed;
893
- try {
894
- parsed = JSON.parse(str);
895
- } catch {
896
- throw ApiError.badRequest("Invalid `where` parameter: expected a JSON object, e.g. {\"status\":[\"==\",\"active\"]}", "INVALID_WHERE");
897
- }
898
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw ApiError.badRequest("Invalid `where` parameter: expected a JSON object mapping fields to conditions, e.g. {\"status\":[\"==\",\"active\"]}", "INVALID_WHERE");
899
- const filter = deserializeFilter(parsed);
900
- return Object.keys(filter).length > 0 ? filter : void 0;
901
- }
902
- /**
903
- * Parse query parameters into QueryOptions
904
- */
905
- function parseQueryOptions(query, limits = {}) {
906
- const options = {};
907
- const rawLimit = getLastValue(query.limit);
908
- const offsetVal = getLastValue(query.offset);
909
- if (offsetVal) options.offset = parseInt(String(offsetVal));
910
- const pageVal = getLastValue(query.page);
911
- if (pageVal) {
912
- const page = parseInt(String(pageVal));
913
- const limit = resolveClientListLimit(rawLimit, {
914
- defaultLimit: limits.defaultLimit,
915
- maxLimit: limits.maxLimit
916
- });
917
- options.offset = (page - 1) * limit;
918
- }
919
- const orVal = getLastValue(query.or);
920
- const andVal = getLastValue(query.and);
921
- if (orVal) {
922
- const logical = parseLogicalGroup("or", orVal);
923
- if (logical) options.logical = logical;
924
- } else if (andVal) {
925
- const logical = parseLogicalGroup("and", andVal);
926
- if (logical) options.logical = logical;
927
- }
928
- const reservedQueryKeys = [
929
- "limit",
930
- "offset",
931
- "page",
932
- "orderBy",
933
- "include",
934
- "fields",
935
- "searchString",
936
- "vector_search",
937
- "vector",
938
- "vector_distance",
939
- "vector_threshold",
940
- "or",
941
- "and",
942
- "where"
943
- ];
944
- const filterDict = {};
945
- for (const [key, rawValue] of Object.entries(query)) {
946
- if (reservedQueryKeys.includes(key)) continue;
947
- filterDict[key] = rawValue;
948
- }
949
- const whereVal = getLastValue(query.where);
950
- const where = {
951
- ...whereVal !== void 0 && whereVal !== null ? parseWhereParam(whereVal) : void 0,
952
- ...deserializeFilter(filterDict)
953
- };
954
- if (Object.keys(where).length > 0) options.where = where;
955
- const orderByVal = getLastValue(query.orderBy);
956
- if (orderByVal) try {
957
- options.orderBy = typeof orderByVal === "string" ? JSON.parse(orderByVal) : orderByVal;
958
- } catch {
959
- if (typeof orderByVal === "string") {
960
- const parsed = deserializeOrderBy(orderByVal);
961
- if (parsed) options.orderBy = [{
962
- field: parsed[0],
963
- direction: parsed[1]
964
- }];
965
- }
966
- }
967
- const includeVal = getLastValue(query.include);
968
- if (includeVal) {
969
- const includeStr = String(includeVal).trim();
970
- if (includeStr === "*") options.include = ["*"];
971
- else options.include = includeStr.split(",").map((s) => s.trim()).filter(Boolean);
972
- }
973
- const fieldsVal = getLastValue(query.fields);
974
- if (fieldsVal) options.fields = String(fieldsVal).trim().split(",").map((s) => s.trim()).filter(Boolean);
975
- const vectorSearchVal = getLastValue(query.vector_search);
976
- const vectorVal = getLastValue(query.vector);
977
- if (vectorSearchVal && vectorVal) {
978
- const vectorStr = String(vectorVal);
979
- let decoded;
980
- try {
981
- decoded = JSON.parse(vectorStr);
982
- } catch {
983
- decoded = void 0;
984
- }
985
- if (!Array.isArray(decoded) || !decoded.every((v) => typeof v === "number")) throw ApiError.badRequest("Invalid `vector` format. Expected a JSON array of numbers, e.g. [0.1,0.2,0.3]", "INVALID_VECTOR");
986
- const queryVector = decoded;
987
- const distanceParamVal = getLastValue(query.vector_distance);
988
- const distanceParam = distanceParamVal ? String(distanceParamVal) : "cosine";
989
- if (distanceParam !== "cosine" && distanceParam !== "l2" && distanceParam !== "inner_product") throw ApiError.badRequest(`Invalid \`vector_distance\`: ${distanceParam}. Expected: cosine, l2, or inner_product`, "INVALID_VECTOR_DISTANCE");
990
- const vectorSearch = {
991
- property: String(vectorSearchVal),
992
- vector: queryVector,
993
- distance: distanceParam
994
- };
995
- const thresholdVal = getLastValue(query.vector_threshold);
996
- if (thresholdVal) {
997
- const threshold = parseFloat(String(thresholdVal));
998
- if (isNaN(threshold)) throw ApiError.badRequest("Invalid `vector_threshold`. Expected a number.", "INVALID_VECTOR_THRESHOLD");
999
- vectorSearch.threshold = threshold;
1000
- }
1001
- options.vectorSearch = vectorSearch;
1002
- }
1003
- options.limit = resolveClientListLimit(rawLimit, {
1004
- vectorSearch: !!options.vectorSearch,
1005
- defaultLimit: limits.defaultLimit,
1006
- maxLimit: limits.maxLimit
1007
- });
1008
- return options;
1009
- }
1010
- //#endregion
1011
825
  //#region src/api/rest/write-validation.ts
1012
826
  /**
1013
827
  * Reject a write naming a field the collection does not have.
1014
828
  *
1015
- * Unknown keys used to travel all the way into the INSERT, where Postgres
1016
- * rejected them — so a typo came back as `column "titel" does not exist`,
1017
- * phrased by the database, from a stack the caller cannot see, and only if the
1018
- * column really was absent. It is a request problem and belongs in a 400.
829
+ * An unknown key does *not* reach the INSERT and come back as `column "titel"
830
+ * does not exist`, which is what this comment used to claim. Drizzle builds
831
+ * INSERT and UPDATE from the table's own column list, so a key the table does
832
+ * not carry is left out of the statement: the write answers 201 having stored
833
+ * nothing under that name. A typo is a request problem, it belongs in a 400,
834
+ * and nothing below this was ever going to raise one.
835
+ *
836
+ * This is the config-level check and it is skipped on several paths (see the
837
+ * `strictWrites` bail below, and the auth-adapter contract at the call site).
838
+ * `assertWritableColumns` in the Postgres driver is the backstop every write
839
+ * passes through, including in-process `rebase.data` writes that never come
840
+ * near this layer.
1019
841
  *
1020
842
  * What counts as known:
1021
843
  * - a declared property (for an introspected BaaS collection these *are* the
@@ -1043,6 +865,145 @@ function assertKnownWriteFields(values, collection, options) {
1043
865
  throw ApiError.badRequest(`${where}'${collection.slug}' has no field${unknown.length > 1 ? "s" : ""} ${unknown.map((f) => `'${f}'`).join(", ")}. Known fields: ${[...known].sort().map((f) => `'${f}'`).join(", ")}.`, "VALIDATION_UNKNOWN_FIELDS");
1044
866
  }
1045
867
  /**
868
+ * The number a constraint should judge, or `undefined` if the value is not one.
869
+ *
870
+ * A JSON body may carry `"5"` for a number column and Postgres accepts it, so
871
+ * the string form is judged too — refusing to look at it would leave the exact
872
+ * bypass this function exists to close.
873
+ */
874
+ function asNumber(value) {
875
+ if (typeof value === "number") return Number.isNaN(value) ? void 0 : value;
876
+ if (typeof value === "string" && value.trim() !== "") {
877
+ const parsed = Number(value);
878
+ return Number.isNaN(parsed) ? void 0 : parsed;
879
+ }
880
+ }
881
+ /** The instant a constraint should judge, or `undefined` if the value is not one. */
882
+ function asDate(value) {
883
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? void 0 : value;
884
+ if (typeof value === "string" || typeof value === "number") {
885
+ const parsed = new Date(value);
886
+ return Number.isNaN(parsed.getTime()) ? void 0 : parsed;
887
+ }
888
+ }
889
+ /**
890
+ * `validation.matches` as a regex that can be tested repeatedly.
891
+ *
892
+ * A `RegExp` carrying `g` or `y` keeps `lastIndex` between calls, so the same
893
+ * pattern would accept and reject the same value on alternate rows of a bulk
894
+ * write. Rebuilt without those flags.
895
+ */
896
+ function toPattern(matches) {
897
+ try {
898
+ if (typeof matches === "string") return new RegExp(matches);
899
+ return new RegExp(matches.source, matches.flags.replace(/[gy]/g, ""));
900
+ } catch {
901
+ return;
902
+ }
903
+ }
904
+ function collectViolations(key, property, value, into) {
905
+ if (value === null || value === void 0) return;
906
+ switch (property.type) {
907
+ case "string": {
908
+ const rules = property.validation;
909
+ if (!rules || typeof value !== "string") return;
910
+ if (rules.length !== void 0 && value.length !== rules.length) into.push(`'${key}' must be exactly ${rules.length} character${rules.length === 1 ? "" : "s"} long (received ${value.length}).`);
911
+ if (rules.min !== void 0 && value.length < rules.min) into.push(`'${key}' must be at least ${rules.min} character${rules.min === 1 ? "" : "s"} long (received ${value.length}).`);
912
+ if (rules.max !== void 0 && value.length > rules.max) into.push(`'${key}' must be at most ${rules.max} character${rules.max === 1 ? "" : "s"} long (received ${value.length}).`);
913
+ if (rules.matches !== void 0) {
914
+ const pattern = toPattern(rules.matches);
915
+ if (pattern && !pattern.test(value)) into.push(rules.matchesMessage ?? `'${key}' does not match the required pattern ${pattern}.`);
916
+ }
917
+ return;
918
+ }
919
+ case "number": {
920
+ const rules = property.validation;
921
+ if (!rules) return;
922
+ const num = asNumber(value);
923
+ if (num === void 0) return;
924
+ if (rules.integer && !Number.isInteger(num)) into.push(`'${key}' must be a whole number (received ${num}).`);
925
+ if (rules.positive && !(num > 0)) into.push(`'${key}' must be positive (received ${num}).`);
926
+ if (rules.negative && !(num < 0)) into.push(`'${key}' must be negative (received ${num}).`);
927
+ if (rules.min !== void 0 && num < rules.min) into.push(`'${key}' must be at least ${rules.min} (received ${num}).`);
928
+ if (rules.max !== void 0 && num > rules.max) into.push(`'${key}' must be at most ${rules.max} (received ${num}).`);
929
+ if (rules.moreThan !== void 0 && !(num > rules.moreThan)) into.push(`'${key}' must be greater than ${rules.moreThan} (received ${num}).`);
930
+ if (rules.lessThan !== void 0 && !(num < rules.lessThan)) into.push(`'${key}' must be less than ${rules.lessThan} (received ${num}).`);
931
+ return;
932
+ }
933
+ case "date": {
934
+ const rules = property.validation;
935
+ if (!rules) return;
936
+ const date = asDate(value);
937
+ if (date === void 0) return;
938
+ const min = rules.min !== void 0 ? asDate(rules.min) : void 0;
939
+ const max = rules.max !== void 0 ? asDate(rules.max) : void 0;
940
+ if (min && date.getTime() < min.getTime()) into.push(`'${key}' must not be before ${min.toISOString()}.`);
941
+ if (max && date.getTime() > max.getTime()) into.push(`'${key}' must not be after ${max.toISOString()}.`);
942
+ return;
943
+ }
944
+ case "array": {
945
+ if (!Array.isArray(value)) return;
946
+ const rules = property.validation;
947
+ if (rules?.min !== void 0 && value.length < rules.min) into.push(`'${key}' must have at least ${rules.min} item${rules.min === 1 ? "" : "s"} (received ${value.length}).`);
948
+ if (rules?.max !== void 0 && value.length > rules.max) into.push(`'${key}' must have at most ${rules.max} item${rules.max === 1 ? "" : "s"} (received ${value.length}).`);
949
+ const of = property.of;
950
+ if (of && !Array.isArray(of)) value.forEach((item, index) => collectViolations(`${key}[${index}]`, of, item, into));
951
+ return;
952
+ }
953
+ case "map": {
954
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return;
955
+ const properties = property.properties;
956
+ if (!properties) return;
957
+ for (const [subKey, subValue] of Object.entries(value)) {
958
+ const subProperty = properties[subKey];
959
+ if (subProperty) collectViolations(`${key}.${subKey}`, subProperty, subValue, into);
960
+ }
961
+ return;
962
+ }
963
+ default:
964
+ }
965
+ }
966
+ /**
967
+ * Reject a write whose values break the constraints the collection declares.
968
+ *
969
+ * `validation.min`, `max`, `matches`, `positive`, `integer` and the date and
970
+ * array bounds were read by exactly three things: the DDL generators (for
971
+ * `integer` and for a `varchar` width), and the admin's client-side form. No
972
+ * layer on the write path looked at them, no `CHECK` constraint was emitted,
973
+ * and the generated OpenAPI published every one of them as `minimum`,
974
+ * `maximum`, `minLength`, `maxLength` and `pattern`. So the docs' own example —
975
+ * `price: { type: "number", validation: { min: 0 } }` — accepted `-5000` with a
976
+ * 201 while the spec said it would not, and any client or gateway generated
977
+ * from that spec believed the server was checking.
978
+ *
979
+ * Only `required` is left to the database, where it is a NOT NULL: a partial
980
+ * update legitimately omits keys, and this function sees one request's values,
981
+ * not the row they land on.
982
+ *
983
+ * A value of the wrong *type* is not this function's business — that reaches
984
+ * Postgres, which types every column and gives a 400 through the driver's
985
+ * SQLSTATE mapping. Constraints are checked against values of the right type,
986
+ * plus the numeric-string form a JSON body may carry for a number column.
987
+ *
988
+ * Every failing field is reported at once. A form that fixes one field per
989
+ * round trip is the reason validation errors get ignored.
990
+ */
991
+ function assertWriteValuesValid(values, collection, options) {
992
+ const properties = collection.properties;
993
+ if (!properties || !values) return;
994
+ const violations = [];
995
+ for (const [key, value] of Object.entries(values)) {
996
+ const property = properties[key];
997
+ if (property) collectViolations(key, property, value, violations);
998
+ }
999
+ if (violations.length === 0) return;
1000
+ const where = options?.rowIndex !== void 0 ? `Row ${options.rowIndex}: ` : "";
1001
+ throw ApiError.badRequest(`${where}${violations.join(" ")}`, "VALIDATION_CONSTRAINT", {
1002
+ collection: collection.slug,
1003
+ violations
1004
+ });
1005
+ }
1006
+ /**
1046
1007
  * Narrow response rows to the fields the caller asked for.
1047
1008
  *
1048
1009
  * `?fields=id,title` is documented in the generated OpenAPI — "Comma-separated
@@ -1055,11 +1016,18 @@ function assertKnownWriteFields(values, collection, options) {
1055
1016
  * This shapes the *response*, which is what the parameter says it does; it is
1056
1017
  * not a column pushdown, so it saves bandwidth rather than database work.
1057
1018
  *
1058
- * `id` always survives. Rows are addressed by it everywhere above this layer —
1059
- * the admin table, realtime reconciliation, the offline cache — and a row that
1060
- * arrives without one is not a smaller row, it is an unusable one. Asking for
1061
- * `fields=title` and being unable to open the record you clicked is a worse
1062
- * answer than one extra key.
1019
+ * The collection's key always survives. Rows are addressed by it everywhere
1020
+ * above this layer — the admin table, realtime reconciliation, the offline
1021
+ * cache — and a row that arrives without one is not a smaller row, it is an
1022
+ * unusable one. Asking for `fields=title` and being unable to open the record
1023
+ * you clicked is a worse answer than one extra key.
1024
+ *
1025
+ * That is a statement about the key, not about the name `id`: a collection
1026
+ * keyed on `slug`, or on `user_id + role_id`, was projected down to the fields
1027
+ * asked for and nothing else, which is the very outcome the paragraph above
1028
+ * describes. `assertKnownWriteFields` one function up already has a dedicated
1029
+ * error for collections with no `id` column, so this layer had no excuse for
1030
+ * assuming one.
1063
1031
  */
1064
1032
  function projectResponseFields(rows, fields, collection, options) {
1065
1033
  if (!fields || fields.length === 0) return rows;
@@ -1077,7 +1045,8 @@ function projectResponseFields(rows, fields, collection, options) {
1077
1045
  collection: collection.slug
1078
1046
  });
1079
1047
  }
1080
- const keep = /* @__PURE__ */ new Set([...fields, "id"]);
1048
+ const primaryKeys = resolvePrimaryKeys(collection).map((key) => key.fieldName);
1049
+ const keep = /* @__PURE__ */ new Set([...fields, ...primaryKeys.length > 0 ? primaryKeys : ["id"]]);
1081
1050
  return rows.map((row) => {
1082
1051
  const projected = {};
1083
1052
  for (const key of Object.keys(row)) if (keep.has(key)) projected[key] = row[key];
@@ -1102,6 +1071,9 @@ function projectResponseFields(rows, fields, collection, options) {
1102
1071
  * generated on the client, so keying on the id alone would let anyone who
1103
1072
  * learned (or guessed) another user's id replay their key and be handed that
1104
1073
  * user's row back — a read of someone else's data through a write endpoint.
1074
+ * That is also why an unauthenticated caller gets no idempotency at all: every
1075
+ * anonymous request shares one principal, so honouring a key there would hand
1076
+ * one visitor's stored response to the next one who sent the same string.
1105
1077
  */
1106
1078
  var TABLE = "\"rebase\".\"idempotency_keys\"";
1107
1079
  /**
@@ -1112,15 +1084,75 @@ var TABLE = "\"rebase\".\"idempotency_keys\"";
1112
1084
  */
1113
1085
  var TTL_HOURS = 24;
1114
1086
  /**
1115
- * The principal a key belongs to; anonymous and service writes share a sentinel.
1087
+ * How long a claim that has not answered yet is presumed to still be running.
1088
+ *
1089
+ * A claim is taken before the write and given back after it, so every exit the
1090
+ * process does not survive — a rolled pod, an OOM kill, a node drain, a
1091
+ * `complete` that itself fails — leaves the row pending with nobody coming back
1092
+ * for it. Reusing the 24-hour replay window as the lease made that
1093
+ * indistinguishable from a request still in flight, so every retry of that key
1094
+ * was refused for a day: the mechanism turned a write that plain retrying would
1095
+ * have completed into permanent loss, which is worse than not having it at all.
1096
+ *
1097
+ * The number trades the two ways it can be wrong, and both are bounded. Too
1098
+ * short and a genuinely slow write has its key taken over by its own retry —
1099
+ * the duplicate this exists to prevent. Too long and a crashed claim refuses
1100
+ * its retries for that long. A minute outlasts any write the row cap allows
1101
+ * and stays well inside the window the offline queue keeps retrying for.
1102
+ */
1103
+ var PENDING_LEASE_SECONDS = 60;
1104
+ /**
1105
+ * How long to wait before attempting the table creation again after it failed.
1116
1106
  *
1117
- * The NUL is written as an escape, not as a raw byte in the source. The
1118
- * sentinel itself is deliberate — a uid can never contain one — but written
1119
- * literally it makes this file test as binary, and every repo-wide grep then
1120
- * skips all 124 lines of it silently. Identical at runtime.
1107
+ * `ensure`'s answer used to be memoised unconditionally, so one transient DDL
1108
+ * failure — a database still starting, a lock on the `rebase` schema, a sibling
1109
+ * pod running the same `CREATE TABLE IF NOT EXISTS` — disabled idempotency on
1110
+ * that pod for the life of the process after a single log line. A failure is
1111
+ * retried instead, and the cooldown is what stops every keyed request re-running
1112
+ * DDL against a database that is genuinely unhappy.
1113
+ */
1114
+ var ENSURE_RETRY_COOLDOWN_MS = 3e4;
1115
+ /**
1116
+ * The principal a key belongs to, or `undefined` when there is nobody to scope
1117
+ * it to and the key must therefore be ignored.
1118
+ *
1119
+ * Anonymous callers are a supported configuration — `auth.requireAuth: false`
1120
+ * delegates access control to RLS — and they used to share one sentinel
1121
+ * principal, which is exactly the cross-principal replay the note above says
1122
+ * must not happen: two visitors of a public form who send the same key string
1123
+ * are handed each other's stored response. Refusing the key degrades them to no
1124
+ * idempotency, which is what they had before this table existed.
1121
1125
  */
1122
1126
  function principal(uid) {
1123
- return uid && uid.length > 0 ? uid : "\0anon";
1127
+ return uid && uid.length > 0 ? uid : void 0;
1128
+ }
1129
+ /**
1130
+ * A stable rendering of a JSON value: one body always produces one string,
1131
+ * whatever order its keys arrived in.
1132
+ *
1133
+ * `JSON.stringify` preserves insertion order, so two encodings of the same
1134
+ * object would fingerprint differently and a legitimate retry would be refused
1135
+ * as a reused key. Only JSON types reach here — the value came from `JSON.parse`.
1136
+ */
1137
+ function stableStringify(value) {
1138
+ if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
1139
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
1140
+ return `{${Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(",")}}`;
1141
+ }
1142
+ /**
1143
+ * What a key was claimed *for*, so presenting it on a different request is
1144
+ * caught instead of silently replaying the first one's answer.
1145
+ *
1146
+ * Nothing about the request used to enter the key, so `createMany(rows, { key })`
1147
+ * followed by `deleteMany(ids, { key })` — the reusable business id the docs
1148
+ * recommended — answered the delete with the create's `200` body and deleted
1149
+ * nothing. Method and path separate the routes; the body separates a re-run
1150
+ * carrying corrected rows from the retry of an unanswered one, which is the
1151
+ * difference between "here is your earlier answer" and silently discarding a
1152
+ * correction.
1153
+ */
1154
+ function requestFingerprint(method, path, body) {
1155
+ return createHash("sha256").update(`${method.toUpperCase()} ${path}\n${stableStringify(body)}`).digest("hex");
1124
1156
  }
1125
1157
  /**
1126
1158
  * Returns `undefined` when the driver cannot run SQL, which disables the whole
@@ -1137,9 +1169,19 @@ function createIdempotencyStore(driver) {
1137
1169
  if (!isSQLAdmin(admin)) return void 0;
1138
1170
  const exec = (sql, params) => admin.executeSql(sql, params ? { params } : void 0);
1139
1171
  let ready;
1172
+ let retryEnsureAt = 0;
1173
+ let warnedAnonymous = false;
1174
+ /** Said once per process: it is a deployment fact, not a per-request event. */
1175
+ const warnAnonymous = () => {
1176
+ if (warnedAnonymous) return;
1177
+ warnedAnonymous = true;
1178
+ logger.warn("Idempotency-Key ignored for an unauthenticated request — keys are scoped to a signed-in principal, and honouring one here would replay a stored response to whoever sends the same key next. A replayed write from an anonymous client may insert a duplicate row.");
1179
+ };
1140
1180
  /** Created on first use: most deployments never send a key at all. */
1141
1181
  const ensure = () => {
1142
- ready ??= (async () => {
1182
+ if (ready) return ready;
1183
+ if (Date.now() < retryEnsureAt) return Promise.resolve(false);
1184
+ ready = (async () => {
1143
1185
  try {
1144
1186
  await exec("CREATE SCHEMA IF NOT EXISTS rebase");
1145
1187
  await exec(`
@@ -1151,36 +1193,71 @@ function createIdempotencyStore(driver) {
1151
1193
  PRIMARY KEY (uid, key)
1152
1194
  )
1153
1195
  `);
1196
+ await exec(`ALTER TABLE ${TABLE} ADD COLUMN IF NOT EXISTS fingerprint TEXT`);
1154
1197
  await exec(`CREATE INDEX IF NOT EXISTS idx_idempotency_created ON ${TABLE}(created_at)`);
1198
+ await exec(revokeInternalTableSql("rebase", "idempotency_keys"));
1155
1199
  return true;
1156
1200
  } catch (error) {
1201
+ retryEnsureAt = Date.now() + ENSURE_RETRY_COOLDOWN_MS;
1157
1202
  logger.warn("Idempotency keys unavailable — a replayed offline write may insert a duplicate row.", { detail: error instanceof Error ? error.message : String(error) });
1158
1203
  return false;
1159
1204
  }
1160
- })();
1205
+ })().then((ok) => {
1206
+ if (!ok) ready = void 0;
1207
+ return ok;
1208
+ });
1161
1209
  return ready;
1162
1210
  };
1163
1211
  return {
1164
- async recall(key, uid) {
1165
- if (!key || !await ensure()) return void 0;
1212
+ async claim(key, uid, fingerprint) {
1213
+ const owner = principal(uid);
1214
+ if (key && !owner) warnAnonymous();
1215
+ if (!key || !owner || !await ensure()) return { status: "claimed" };
1166
1216
  try {
1167
- return (await exec(`SELECT response FROM ${TABLE}
1168
- WHERE uid = $1 AND key = $2 AND created_at > NOW() - INTERVAL '${TTL_HOURS} hours'`, [principal(uid), key]))[0]?.response;
1217
+ if ((await exec(`INSERT INTO ${TABLE} (key, uid, fingerprint, response, created_at)
1218
+ VALUES ($1, $2, $3, NULL, NOW())
1219
+ ON CONFLICT (uid, key) DO UPDATE
1220
+ SET response = NULL, created_at = NOW(), fingerprint = EXCLUDED.fingerprint
1221
+ WHERE (${TABLE}.response IS NULL
1222
+ AND ${TABLE}.created_at < NOW() - INTERVAL '${PENDING_LEASE_SECONDS} seconds')
1223
+ OR (${TABLE}.response IS NOT NULL
1224
+ AND ${TABLE}.created_at < NOW() - INTERVAL '${TTL_HOURS} hours')
1225
+ RETURNING 1 AS claimed`, [
1226
+ key,
1227
+ owner,
1228
+ fingerprint
1229
+ ])).length > 0) return { status: "claimed" };
1230
+ const row = (await exec(`SELECT response, response IS NULL AS pending, fingerprint FROM ${TABLE}
1231
+ WHERE uid = $1 AND key = $2`, [owner, key]))[0];
1232
+ if (!row) return { status: "claimed" };
1233
+ if (row.fingerprint != null && row.fingerprint !== fingerprint) return { status: "mismatch" };
1234
+ if (row.pending) return { status: "in-flight" };
1235
+ return {
1236
+ status: "replay",
1237
+ response: row.response
1238
+ };
1169
1239
  } catch {
1170
- return;
1240
+ return { status: "claimed" };
1171
1241
  }
1172
1242
  },
1173
- async remember(key, uid, response) {
1174
- if (!key || !await ensure()) return;
1243
+ async complete(key, uid, response) {
1244
+ const owner = principal(uid);
1245
+ if (!key || !owner || !await ensure()) return;
1175
1246
  try {
1176
- await exec(`INSERT INTO ${TABLE} (key, uid, response) VALUES ($1, $2, $3::jsonb)
1177
- ON CONFLICT (uid, key) DO NOTHING`, [
1247
+ await exec(`UPDATE ${TABLE} SET response = $3::jsonb WHERE uid = $1 AND key = $2`, [
1248
+ owner,
1178
1249
  key,
1179
- principal(uid),
1180
1250
  JSON.stringify(response ?? null)
1181
1251
  ]);
1182
1252
  if (Math.random() < .01) await exec(`DELETE FROM ${TABLE} WHERE created_at < NOW() - INTERVAL '${TTL_HOURS} hours'`);
1183
1253
  } catch {}
1254
+ },
1255
+ async release(key, uid) {
1256
+ const owner = principal(uid);
1257
+ if (!key || !owner || !await ensure()) return;
1258
+ try {
1259
+ await exec(`DELETE FROM ${TABLE} WHERE uid = $1 AND key = $2 AND response IS NULL`, [owner, key]);
1260
+ } catch {}
1184
1261
  }
1185
1262
  };
1186
1263
  }
@@ -1204,6 +1281,18 @@ async function parseJsonBody(c) {
1204
1281
  }
1205
1282
  }
1206
1283
  /**
1284
+ * A live key presented on a different request than the one it was claimed for.
1285
+ *
1286
+ * Refused rather than replayed, because the replay is the dangerous answer: a
1287
+ * delete sent under the key of an earlier create used to be handed the create's
1288
+ * `200` and its rows, so the caller saw a success for rows that are still
1289
+ * there. A `422` names the mistake instead — the key is well-formed, the
1290
+ * request is not repeatable under it.
1291
+ */
1292
+ function idempotencyKeyReused(key) {
1293
+ return new ApiError(422, "IDEMPOTENCY_KEY_REUSED", `Idempotency-Key '${key}' was already used for a different request. A key names one write: use a new key for each distinct request, and re-send the identical request to replay its answer.`);
1294
+ }
1295
+ /**
1207
1296
  * Lightweight REST API generator that leverages existing Rebase DataDriver.
1208
1297
  * Supports `include` query parameter for eager-loading relations via Drizzle.
1209
1298
  */
@@ -1283,7 +1372,7 @@ var RestApiGenerator = class {
1283
1372
  * Needed so a nested write can be checked against a schema at all. Without
1284
1373
  * it these routes skipped `assertKnownWriteFields` entirely, which is why a
1285
1374
  * typo `POST /posts` rejected with a 400 while the same typo on
1286
- * `POST /authors/1/posts` reached the database.
1375
+ * `POST /authors/1/posts` was dropped from the statement and answered 201.
1287
1376
  *
1288
1377
  * Returns `undefined` rather than throwing when the path cannot be walked:
1289
1378
  * the driver raises the authoritative error a moment later, and duplicating
@@ -1333,6 +1422,7 @@ var RestApiGenerator = class {
1333
1422
  const queryDict = c.req.queries();
1334
1423
  const queryOptions = this.parseQuery(queryDict);
1335
1424
  const searchString = Array.isArray(queryDict.searchString) ? queryDict.searchString[queryDict.searchString.length - 1] : void 0;
1425
+ const searchExplain = (Array.isArray(queryDict.searchExplain) ? queryDict.searchExplain[queryDict.searchExplain.length - 1] : void 0) === "true";
1336
1426
  const driver = this.getScopedDriver(c);
1337
1427
  const fetchService = driver.restFetchService;
1338
1428
  const entities = fetchService ? await fetchService.fetchCollectionForRest(collection.slug, {
@@ -1343,6 +1433,7 @@ var RestApiGenerator = class {
1343
1433
  orderBy: queryOptions.orderBy?.[0]?.field,
1344
1434
  order: queryOptions.orderBy?.[0]?.direction === "desc" ? "desc" : "asc",
1345
1435
  searchString,
1436
+ searchExplain,
1346
1437
  vectorSearch: queryOptions.vectorSearch
1347
1438
  }, queryOptions.include) : await this.fetchRawCollection(driver, resolvedCollection, queryOptions, searchString);
1348
1439
  const total = await this.countRawEntities(driver, resolvedCollection, queryOptions, searchString);
@@ -1367,121 +1458,241 @@ var RestApiGenerator = class {
1367
1458
  if (!entity) throw ApiError.notFound("Entity not found");
1368
1459
  return c.json(projectResponseFields([entity], queryOptions.fields, resolvedCollection, { include: queryOptions.include })[0]);
1369
1460
  });
1461
+ /**
1462
+ * Run a bulk handler under an idempotency key.
1463
+ *
1464
+ * All three bulk routes need the identical claim-before-write dance —
1465
+ * claim, replay on a repeat, 409 while one is in flight, release on
1466
+ * failure, complete on success — and getting one of those steps wrong
1467
+ * is exactly the bug the key exists to prevent. Written once so the
1468
+ * three cannot drift into three different notions of "already done".
1469
+ *
1470
+ * `body` is what the key is claimed *for*: the same key on a different
1471
+ * request is a caller mistake, and replaying a create's answer to a
1472
+ * delete would report a deletion that never happened.
1473
+ */
1474
+ const withIdempotency = async (c, body, run, respond) => {
1475
+ const idempotencyKey = c.req.header(IDEMPOTENCY_HEADER);
1476
+ const uid = c.get("user")?.uid;
1477
+ const store = this.idempotency();
1478
+ const claimed = idempotencyKey && store ? await store.claim(idempotencyKey, uid, requestFingerprint(c.req.method, c.req.path, body)) : void 0;
1479
+ if (claimed?.status === "replay") return respond(claimed.response);
1480
+ if (claimed?.status === "mismatch") throw idempotencyKeyReused(idempotencyKey);
1481
+ if (claimed?.status === "in-flight") throw ApiError.conflict(`A request with Idempotency-Key '${idempotencyKey}' is already in progress. Retry once it has answered; its result will be replayed.`, "IDEMPOTENCY_KEY_IN_PROGRESS");
1482
+ let response;
1483
+ try {
1484
+ response = await run();
1485
+ } catch (error) {
1486
+ if (claimed?.status === "claimed" && idempotencyKey && store) await store.release(idempotencyKey, uid);
1487
+ throw error;
1488
+ }
1489
+ if (claimed?.status === "claimed" && idempotencyKey && store) await store.complete(idempotencyKey, uid, response);
1490
+ return respond(response);
1491
+ };
1492
+ /**
1493
+ * Validate the envelope every bulk route shares: an array, non-empty,
1494
+ * of objects, within the row cap.
1495
+ *
1496
+ * The cap is the load-bearing one. A batch is a single transaction that
1497
+ * holds its locks for its whole duration, so an unbounded one is a
1498
+ * self-inflicted outage — and the message names both the limit and the
1499
+ * actual count so the caller can chunk to it rather than guess.
1500
+ */
1501
+ const assertBulkShape = (items, field, requireObjects) => {
1502
+ if (!Array.isArray(items)) throw ApiError.badRequest(`Expected a JSON body of { ${field}: [...] }.`, "INVALID_BULK_BODY");
1503
+ if (requireObjects && items.some((it) => typeof it !== "object" || it === null || Array.isArray(it))) throw ApiError.badRequest(`Every entry in \`${field}\` must be an object.`, "INVALID_BULK_BODY");
1504
+ if (items.length > this.maxBulkRows) throw ApiError.badRequest(`Too many rows: ${items.length} exceeds the ${this.maxBulkRows}-row limit for a single bulk write. Send it in chunks of ${this.maxBulkRows} or fewer.`, "BULK_TOO_LARGE");
1505
+ };
1370
1506
  this.router.post(`${basePath}/bulk`, async (c) => {
1371
1507
  this.enforceApiKeyPermission(c, collection.slug);
1372
1508
  const driver = this.getScopedDriver(c);
1373
1509
  const path = collection.slug;
1374
1510
  const body = await parseJsonBody(c);
1375
- if (!Array.isArray(body?.rows)) throw ApiError.badRequest("Expected a JSON body of { rows: [...] }.", "INVALID_BULK_BODY");
1376
- if (body.rows.length === 0) return c.json({
1511
+ assertBulkShape(body?.rows, "rows", true);
1512
+ const rows = body.rows;
1513
+ if (rows.length === 0) return c.json({
1377
1514
  data: [],
1378
1515
  meta: { written: 0 }
1379
1516
  });
1380
- if (body.rows.some((row) => typeof row !== "object" || row === null || Array.isArray(row))) throw ApiError.badRequest("Every entry in `rows` must be an object.", "INVALID_BULK_BODY");
1381
1517
  if (body.upsert !== void 0 && typeof body.upsert !== "boolean") throw ApiError.badRequest("`upsert` must be a boolean.", "INVALID_BULK_BODY");
1382
- const maxRows = this.maxBulkRows;
1383
- if (body.rows.length > maxRows) throw ApiError.badRequest(`Too many rows: ${body.rows.length} exceeds the ${maxRows}-row limit for a single bulk write. Send it in chunks of ${maxRows} or fewer.`, "BULK_TOO_LARGE");
1384
1518
  if (!driver.saveMany) throw ApiError.badRequest("This collection's data source does not support bulk writes.", "BULK_UNSUPPORTED");
1385
- body.rows.forEach((row, rowIndex) => assertKnownWriteFields(row, resolvedCollection, { rowIndex }));
1386
- const rows = await driver.saveMany({
1387
- path,
1388
- rows: body.rows,
1389
- collection: resolvedCollection,
1390
- upsert: body.upsert === true
1519
+ rows.forEach((row, rowIndex) => {
1520
+ assertKnownWriteFields(row, resolvedCollection, { rowIndex });
1521
+ assertWriteValuesValid(row, resolvedCollection, { rowIndex });
1391
1522
  });
1392
- return c.json({
1393
- data: rows.map((row) => this.formatResponse(row)),
1394
- meta: { written: rows.length }
1523
+ return withIdempotency(c, body, async () => {
1524
+ const written = await driver.saveMany({
1525
+ path,
1526
+ rows,
1527
+ collection: resolvedCollection,
1528
+ upsert: body.upsert === true
1529
+ });
1530
+ return {
1531
+ data: written.map((row) => this.formatResponse(row)),
1532
+ meta: { written: written.length }
1533
+ };
1534
+ }, (result) => c.json(result));
1535
+ });
1536
+ this.router.patch(`${basePath}/bulk`, async (c) => {
1537
+ this.enforceApiKeyPermission(c, collection.slug);
1538
+ const driver = this.getScopedDriver(c);
1539
+ const path = collection.slug;
1540
+ const body = await parseJsonBody(c);
1541
+ assertBulkShape(body?.updates, "updates", true);
1542
+ const updates = body.updates;
1543
+ if (updates.length === 0) return c.json({
1544
+ data: [],
1545
+ meta: { written: 0 }
1546
+ });
1547
+ if (!driver.updateMany) throw ApiError.badRequest("This collection's data source does not support bulk updates.", "BULK_UNSUPPORTED");
1548
+ updates.forEach((entry, rowIndex) => {
1549
+ if (entry.id === void 0 || entry.id === null || entry.id === "") throw ApiError.badRequest(`Entry ${rowIndex} of \`updates\` is missing \`id\`. Each entry must be { id, data }.`, "INVALID_BULK_BODY");
1550
+ if (typeof entry.data !== "object" || entry.data === null || Array.isArray(entry.data)) throw ApiError.badRequest(`Entry ${rowIndex} of \`updates\` is missing \`data\`, or it is not an object.`, "INVALID_BULK_BODY");
1551
+ assertKnownWriteFields(entry.data, resolvedCollection, { rowIndex });
1552
+ assertWriteValuesValid(entry.data, resolvedCollection, { rowIndex });
1395
1553
  });
1554
+ return withIdempotency(c, body, async () => {
1555
+ const written = await driver.updateMany({
1556
+ path,
1557
+ updates: updates.map((entry) => ({
1558
+ id: entry.id,
1559
+ values: entry.data
1560
+ })),
1561
+ collection: resolvedCollection
1562
+ });
1563
+ return {
1564
+ data: written.map((row) => this.formatResponse(row)),
1565
+ meta: { written: written.length }
1566
+ };
1567
+ }, (result) => c.json(result));
1568
+ });
1569
+ this.router.post(`${basePath}/bulk/delete`, async (c) => {
1570
+ this.enforceApiKeyPermission(c, collection.slug);
1571
+ const driver = this.getScopedDriver(c);
1572
+ const path = collection.slug;
1573
+ const body = await parseJsonBody(c);
1574
+ assertBulkShape(body?.ids, "ids", false);
1575
+ const ids = body.ids;
1576
+ if (ids.length === 0) return c.json({ meta: { deleted: 0 } });
1577
+ if (ids.some((id) => typeof id !== "string" && typeof id !== "number")) throw ApiError.badRequest("Every entry in `ids` must be a string or a number.", "INVALID_BULK_BODY");
1578
+ if (!driver.deleteMany) throw ApiError.badRequest("This collection's data source does not support bulk deletes.", "BULK_UNSUPPORTED");
1579
+ return withIdempotency(c, body, async () => {
1580
+ await driver.deleteMany({
1581
+ path,
1582
+ ids,
1583
+ collection: resolvedCollection
1584
+ });
1585
+ return { meta: { deleted: ids.length } };
1586
+ }, (result) => c.json(result));
1396
1587
  });
1397
1588
  this.router.post(basePath, async (c) => {
1398
- try {
1399
- this.enforceApiKeyPermission(c, collection.slug);
1400
- const driver = this.getScopedDriver(c);
1401
- const path = collection.slug;
1402
- const body = await parseJsonBody(c);
1403
- const isAuth = collection.auth;
1404
- const isAuthCollection = isAuth === true || isAuth && typeof isAuth === "object" && isAuth.enabled === true;
1405
- const collectionAuthConfig = typeof isAuth === "object" ? isAuth : void 0;
1406
- if (!isAuthCollection) assertKnownWriteFields(body, resolvedCollection);
1407
- else {
1408
- const contract = this.authAdapter?.describeUserCreationContract?.(collectionAuthConfig);
1409
- if (contract?.validate) assertKnownWriteFields(body, resolvedCollection, { extraKnownFields: contract.extraFields });
1410
- }
1411
- if (isAuthCollection && this.authAdapter?.prepareUserCreation) {
1412
- const prepared = await this.authAdapter.prepareUserCreation(body, collectionAuthConfig);
1413
- const entity = await driver.save({
1414
- path,
1415
- values: prepared.values,
1416
- collection: resolvedCollection,
1417
- status: "new"
1418
- });
1419
- const result = prepared.hookHandledEmail ? {
1420
- temporaryPassword: prepared.clearPassword,
1421
- invitationSent: prepared.invitationSent
1422
- } : this.authAdapter.finalizeUserCreation ? await this.authAdapter.finalizeUserCreation({
1423
- id: entity.id,
1424
- values: entity
1425
- }, prepared.clearPassword) : { invitationSent: false };
1426
- const response = this.formatResponse(entity);
1427
- return c.json({
1428
- ...response,
1429
- invitationSent: result.invitationSent,
1430
- ...result.temporaryPassword ? { temporaryPassword: result.temporaryPassword } : {},
1431
- ..."emailDeliveryFailed" in result && result.emailDeliveryFailed ? { emailDeliveryFailed: true } : {}
1432
- }, 201);
1433
- }
1434
- const idempotencyKey = c.req.header(IDEMPOTENCY_HEADER);
1435
- const uid = c.get("user")?.uid;
1436
- const store = this.idempotency();
1437
- if (idempotencyKey && store) {
1438
- const already = await store.recall(idempotencyKey, uid);
1439
- if (already !== void 0) return c.json(already, 201);
1589
+ this.enforceApiKeyPermission(c, collection.slug);
1590
+ const driver = this.getScopedDriver(c);
1591
+ const path = collection.slug;
1592
+ const body = await parseJsonBody(c);
1593
+ const isAuth = collection.auth;
1594
+ const isAuthCollection = isAuth === true || isAuth && typeof isAuth === "object" && isAuth.enabled === true;
1595
+ const collectionAuthConfig = typeof isAuth === "object" ? isAuth : void 0;
1596
+ if (!isAuthCollection) {
1597
+ assertKnownWriteFields(body, resolvedCollection);
1598
+ assertWriteValuesValid(body, resolvedCollection);
1599
+ } else {
1600
+ const contract = this.authAdapter?.describeUserCreationContract?.(collectionAuthConfig);
1601
+ if (contract?.validate) {
1602
+ assertKnownWriteFields(body, resolvedCollection, { extraKnownFields: contract.extraFields });
1603
+ assertWriteValuesValid(body, resolvedCollection);
1440
1604
  }
1605
+ }
1606
+ if (isAuthCollection && this.authAdapter?.prepareUserCreation) {
1607
+ const prepared = await this.authAdapter.prepareUserCreation(body, collectionAuthConfig);
1441
1608
  const entity = await driver.save({
1442
1609
  path,
1443
- values: body,
1610
+ values: prepared.values,
1444
1611
  collection: resolvedCollection,
1445
1612
  status: "new"
1446
1613
  });
1614
+ const result = prepared.hookHandledEmail ? {
1615
+ temporaryPassword: prepared.clearPassword,
1616
+ invitationSent: prepared.invitationSent
1617
+ } : this.authAdapter.finalizeUserCreation ? await this.authAdapter.finalizeUserCreation({
1618
+ id: entity.id,
1619
+ values: entity
1620
+ }, prepared.clearPassword) : { invitationSent: false };
1447
1621
  const response = this.formatResponse(entity);
1448
- if (idempotencyKey && store) await store.remember(idempotencyKey, uid, response);
1449
- return c.json(response, 201);
1450
- } catch (error) {
1451
- if (isRebaseApiError(error) && !error.code) {
1452
- if (!(error instanceof TypeError || error instanceof RangeError || error instanceof SyntaxError || error instanceof ReferenceError)) error.code = "BAD_REQUEST";
1453
- }
1454
- throw error;
1622
+ return c.json({
1623
+ ...response,
1624
+ invitationSent: result.invitationSent,
1625
+ ...result.temporaryPassword ? { temporaryPassword: result.temporaryPassword } : {},
1626
+ ..."emailDeliveryFailed" in result && result.emailDeliveryFailed ? { emailDeliveryFailed: true } : {}
1627
+ }, 201);
1455
1628
  }
1456
- });
1457
- this.router.put(`${basePath}/:id`, async (c) => {
1629
+ const idempotencyKey = c.req.header(IDEMPOTENCY_HEADER);
1630
+ const uid = c.get("user")?.uid;
1631
+ const store = this.idempotency();
1632
+ const claimed = idempotencyKey && store ? await store.claim(idempotencyKey, uid, requestFingerprint(c.req.method, c.req.path, body)) : void 0;
1633
+ if (claimed?.status === "replay") return c.json(claimed.response, 201);
1634
+ if (claimed?.status === "mismatch") throw idempotencyKeyReused(idempotencyKey);
1635
+ if (claimed?.status === "in-flight") throw ApiError.conflict(`A request with Idempotency-Key '${idempotencyKey}' is already in progress. Retry once it has answered; its result will be replayed.`, "IDEMPOTENCY_KEY_IN_PROGRESS");
1636
+ let response;
1458
1637
  try {
1459
- this.enforceApiKeyPermission(c, collection.slug);
1460
- const id = c.req.param("id");
1461
- const driver = this.getScopedDriver(c);
1462
- if (!await driver.fetchOne({
1463
- path: getCollectionDataPath(collection),
1464
- id: String(id),
1465
- collection: resolvedCollection
1466
- })) throw ApiError.notFound("Entity not found");
1467
- const body = await parseJsonBody(c);
1468
- assertKnownWriteFields(body, resolvedCollection);
1469
1638
  const entity = await driver.save({
1470
- path: getCollectionDataPath(collection),
1471
- id: String(id),
1639
+ path,
1472
1640
  values: body,
1473
1641
  collection: resolvedCollection,
1474
- status: "existing"
1642
+ status: "new"
1475
1643
  });
1476
- const response = this.formatResponse(entity);
1477
- return c.json(response);
1644
+ response = this.formatResponse(entity);
1478
1645
  } catch (error) {
1479
- if (isRebaseApiError(error) && !error.code) {
1480
- if (!(error instanceof TypeError || error instanceof RangeError || error instanceof SyntaxError || error instanceof ReferenceError)) error.code = "BAD_REQUEST";
1481
- }
1646
+ if (claimed?.status === "claimed" && idempotencyKey && store) await store.release(idempotencyKey, uid);
1482
1647
  throw error;
1483
1648
  }
1649
+ if (claimed?.status === "claimed" && idempotencyKey && store) await store.complete(idempotencyKey, uid, response);
1650
+ return c.json(response, 201);
1484
1651
  });
1652
+ const updateEntity = async (c) => {
1653
+ this.enforceApiKeyPermission(c, collection.slug);
1654
+ const id = c.req.param("id");
1655
+ const driver = this.getScopedDriver(c);
1656
+ if (!await driver.fetchOne({
1657
+ path: getCollectionDataPath(collection),
1658
+ id: String(id),
1659
+ collection: resolvedCollection
1660
+ })) throw ApiError.notFound("Entity not found");
1661
+ const body = await parseJsonBody(c);
1662
+ assertKnownWriteFields(body, resolvedCollection);
1663
+ assertWriteValuesValid(body, resolvedCollection);
1664
+ const entity = await driver.save({
1665
+ path: getCollectionDataPath(collection),
1666
+ id: String(id),
1667
+ values: body,
1668
+ collection: resolvedCollection,
1669
+ status: "existing"
1670
+ });
1671
+ const response = this.formatResponse(entity);
1672
+ return c.json(response);
1673
+ };
1674
+ /**
1675
+ * The verb that matches what this actually does.
1676
+ *
1677
+ * The handler merges: it writes the columns in the body and leaves the
1678
+ * rest alone, which is what the SDK's `update(id, data: Partial<M>)`
1679
+ * sends. PUT was the only route, and PUT means replace — so the
1680
+ * generated OpenAPI spec described a full replacement while the server
1681
+ * performed a merge, and it reused the *create* input schema for it,
1682
+ * `required` fields and all. A client generated from that spec was
1683
+ * given a contract the server does not implement, and a spec-validating
1684
+ * gateway in front of this API would reject partial updates the server
1685
+ * would have accepted.
1686
+ *
1687
+ * PUT stays mounted on the same handler rather than being changed or
1688
+ * removed: every published SDK sends it, and altering its semantics to
1689
+ * be a true replace would silently start nulling columns that callers
1690
+ * had simply omitted for years — a data-loss change disguised as a
1691
+ * standards fix. So PATCH is the honest name, PUT is the compatible one,
1692
+ * and both do the documented thing.
1693
+ */
1694
+ this.router.patch(`${basePath}/:id`, updateEntity);
1695
+ this.router.put(`${basePath}/:id`, updateEntity);
1485
1696
  this.router.delete(`${basePath}/:id`, async (c) => {
1486
1697
  this.enforceApiKeyPermission(c, collection.slug);
1487
1698
  const id = c.req.param("id");
@@ -1552,6 +1763,7 @@ var RestApiGenerator = class {
1552
1763
  const total = driver.count ? await driver.count({
1553
1764
  path: parsed.collectionPath,
1554
1765
  filter: queryOptions.where,
1766
+ logical: queryOptions.logical,
1555
1767
  searchString
1556
1768
  }) : 0;
1557
1769
  return c.json({ count: total });
@@ -1563,7 +1775,9 @@ var RestApiGenerator = class {
1563
1775
  id: parsed.id
1564
1776
  });
1565
1777
  if (!entity) throw ApiError.notFound("Entity not found");
1566
- return c.json(entity);
1778
+ const nestedCollection = this.resolveNestedWriteCollection(parsed.collectionPath);
1779
+ if (!nestedCollection) return c.json(entity);
1780
+ return c.json(projectResponseFields([entity], queryOptions.fields, nestedCollection, { include: queryOptions.include })[0]);
1567
1781
  } else {
1568
1782
  const queryDict = c.req.queries();
1569
1783
  const queryOptions = this.parseQuery(queryDict);
@@ -1588,8 +1802,9 @@ var RestApiGenerator = class {
1588
1802
  logical: queryOptions.logical,
1589
1803
  searchString
1590
1804
  }) : entities.length;
1805
+ const listCollection = this.resolveNestedWriteCollection(parsed.collectionPath);
1591
1806
  return c.json({
1592
- data: entities,
1807
+ data: listCollection ? projectResponseFields(entities, queryOptions.fields, listCollection, { include: queryOptions.include }) : entities,
1593
1808
  meta: {
1594
1809
  total,
1595
1810
  limit: queryOptions.limit,
@@ -1609,7 +1824,10 @@ var RestApiGenerator = class {
1609
1824
  this.enforceSubcollectionApiKeyPermission(c, parsed.collectionPath);
1610
1825
  const body = await parseJsonBody(c);
1611
1826
  const targetCollection = this.resolveNestedWriteCollection(parsed.collectionPath);
1612
- if (targetCollection) assertKnownWriteFields(body, targetCollection);
1827
+ if (targetCollection) {
1828
+ assertKnownWriteFields(body, targetCollection);
1829
+ assertWriteValuesValid(body, targetCollection);
1830
+ }
1613
1831
  const entity = await driver.save({
1614
1832
  path: parsed.collectionPath,
1615
1833
  values: body,
@@ -1618,7 +1836,7 @@ var RestApiGenerator = class {
1618
1836
  const response = this.formatResponse(entity);
1619
1837
  return c.json(response, 201);
1620
1838
  });
1621
- this.router.put("/:parent/:parentId/:rest{.+}", async (c, next) => {
1839
+ const updateNested = async (c, next) => {
1622
1840
  const rest = c.req.param("rest");
1623
1841
  if (!rest || rest === "undefined") return next();
1624
1842
  const rawPath = `${c.req.param("parent")}/${c.req.param("parentId")}/${rest}`;
@@ -1628,7 +1846,10 @@ var RestApiGenerator = class {
1628
1846
  this.enforceSubcollectionApiKeyPermission(c, parsed.collectionPath);
1629
1847
  const body = await parseJsonBody(c);
1630
1848
  const targetCollection = this.resolveNestedWriteCollection(parsed.collectionPath);
1631
- if (targetCollection) assertKnownWriteFields(body, targetCollection);
1849
+ if (targetCollection) {
1850
+ assertKnownWriteFields(body, targetCollection);
1851
+ assertWriteValuesValid(body, targetCollection);
1852
+ }
1632
1853
  const entity = await driver.save({
1633
1854
  path: parsed.collectionPath,
1634
1855
  id: parsed.id,
@@ -1637,7 +1858,9 @@ var RestApiGenerator = class {
1637
1858
  });
1638
1859
  const response = this.formatResponse(entity);
1639
1860
  return c.json(response);
1640
- });
1861
+ };
1862
+ this.router.patch("/:parent/:parentId/:rest{.+}", updateNested);
1863
+ this.router.put("/:parent/:parentId/:rest{.+}", updateNested);
1641
1864
  this.router.delete("/:parent/:parentId/:rest{.+}", async (c, next) => {
1642
1865
  const rest = c.req.param("rest");
1643
1866
  if (!rest || rest === "undefined") return next();
@@ -1917,7 +2140,7 @@ var LocalStorageController = class {
1917
2140
  }
1918
2141
  /**
1919
2142
  * Get the full filesystem path for a storage path, with a traversal guard
1920
- * that keeps the result inside the bucket directory.
2143
+ * that keeps the result inside the storage root.
1921
2144
  *
1922
2145
  * Defaults the bucket the way `putObject` does.
1923
2146
  *
@@ -1928,10 +2151,22 @@ var LocalStorageController = class {
1928
2151
  * design), and `listObjects` returned an empty page. So the obvious
1929
2152
  * `putObject({ key })` → `getObject(key)` did not round-trip and nothing
1930
2153
  * said why. One default, applied everywhere, removes the whole class.
2154
+ *
2155
+ * Two boundaries, checked separately, because there are two of them. The
2156
+ * key must stay inside its bucket — that check always worked. The bucket
2157
+ * must stay inside the storage root, and that one did not exist: the guard
2158
+ * derived `bucketPath` from the caller's `bucket` and then asked whether
2159
+ * the result was inside `bucketPath`, so `bucket = "../../etc"` made the
2160
+ * reference point `/etc` and `/etc/passwd` is comfortably inside it. A
2161
+ * guard whose reference point moves with its input is not a guard. The
2162
+ * route boundary rejects such a bucket outright
2163
+ * (`canonicalStorageBucket`); this is the layer underneath that does not
2164
+ * depend on it having run.
1931
2165
  */
1932
2166
  getFullPath(storagePath, bucket) {
1933
- const bucketPath = path$2.join(this.basePath, bucket ?? "default");
1934
- const resolved = path$2.resolve(path$2.join(bucketPath, storagePath));
2167
+ const bucketPath = path$2.resolve(this.basePath, bucket || "default");
2168
+ if (!bucketPath.startsWith(this.basePath + path$2.sep)) throw new Error("Path traversal detected: bucket resolves outside the storage root.");
2169
+ const resolved = path$2.resolve(bucketPath, storagePath);
1935
2170
  if (!resolved.startsWith(bucketPath + path$2.sep) && resolved !== bucketPath) throw new Error("Path traversal detected: resolved storage path is outside the bucket directory.");
1936
2171
  return resolved;
1937
2172
  }
@@ -1947,7 +2182,7 @@ var LocalStorageController = class {
1947
2182
  }
1948
2183
  async putObject({ file, key, metadata, bucket }) {
1949
2184
  this.validateFile(file);
1950
- const usedBucket = bucket ?? "default";
2185
+ const usedBucket = bucket || "default";
1951
2186
  const fullStoragePath = key;
1952
2187
  const fullPath = this.getFullPath(fullStoragePath, usedBucket);
1953
2188
  await this.ensureDir(path$2.dirname(fullPath));
@@ -2177,38 +2412,63 @@ var VALID_FITS = /* @__PURE__ */ new Set([
2177
2412
  "outside"
2178
2413
  ]);
2179
2414
  /**
2415
+ * A transform request naming parameters outside the declared bounds.
2416
+ *
2417
+ * Surfaced as a 400 by the route. Out-of-range values used to be *clamped*:
2418
+ * `width=99999` silently became 4096 and `format=tiff` silently became webp,
2419
+ * so the caller debugged a wrongly-sized `<img>` in the browser instead of
2420
+ * reading an error. A bound that rewrites its input is invisible, and this
2421
+ * endpoint's bounds are load-bearing — see {@link runTransform}.
2422
+ */
2423
+ var InvalidTransformOptionsError = class extends Error {
2424
+ constructor(message) {
2425
+ super(message);
2426
+ this.name = "InvalidTransformOptionsError";
2427
+ }
2428
+ };
2429
+ /**
2430
+ * Parse a bounded integer parameter, or throw.
2431
+ *
2432
+ * Strict digits only: `parseInt` accepted `300px` and `12abc`, which is a
2433
+ * second way for a caller to get a transform they did not describe.
2434
+ */
2435
+ function boundedInt(name, raw, min, max) {
2436
+ if (!/^\d+$/.test(raw)) throw new InvalidTransformOptionsError(`Invalid '${name}': expected an integer between ${min} and ${max}.`);
2437
+ const value = parseInt(raw, 10);
2438
+ if (value < min || value > max) throw new InvalidTransformOptionsError(`Invalid '${name}': must be between ${min} and ${max}, got ${value}.`);
2439
+ return value;
2440
+ }
2441
+ /**
2180
2442
  * Parse transform options from URL query parameters.
2181
2443
  * Returns `null` when no transformation is requested.
2444
+ *
2445
+ * Throws {@link InvalidTransformOptionsError} for anything outside the bounds
2446
+ * declared here — these are the whole parameter surface of an endpoint that is
2447
+ * reachable anonymously for public objects, so what it accepts has to be a
2448
+ * closed, stated set rather than "whatever we could coerce into range".
2182
2449
  */
2183
2450
  function parseTransformOptions(query) {
2184
2451
  const opts = {};
2185
2452
  let hasTransform = false;
2186
2453
  if (query.width) {
2187
- const w = parseInt(query.width, 10);
2188
- if (!Number.isNaN(w) && w > 0) {
2189
- opts.width = Math.min(w, MAX_DIMENSION);
2190
- hasTransform = true;
2191
- }
2454
+ opts.width = boundedInt("width", query.width, 1, MAX_DIMENSION);
2455
+ hasTransform = true;
2192
2456
  }
2193
2457
  if (query.height) {
2194
- const h = parseInt(query.height, 10);
2195
- if (!Number.isNaN(h) && h > 0) {
2196
- opts.height = Math.min(h, MAX_DIMENSION);
2197
- hasTransform = true;
2198
- }
2458
+ opts.height = boundedInt("height", query.height, 1, MAX_DIMENSION);
2459
+ hasTransform = true;
2199
2460
  }
2200
2461
  if (query.quality) {
2201
- const q = parseInt(query.quality, 10);
2202
- if (!Number.isNaN(q)) {
2203
- opts.quality = Math.min(Math.max(q, MIN_QUALITY), MAX_QUALITY);
2204
- hasTransform = true;
2205
- }
2462
+ opts.quality = boundedInt("quality", query.quality, MIN_QUALITY, MAX_QUALITY);
2463
+ hasTransform = true;
2206
2464
  }
2207
- if (query.format && VALID_FORMATS.has(query.format)) {
2465
+ if (query.format) {
2466
+ if (!VALID_FORMATS.has(query.format)) throw new InvalidTransformOptionsError(`Invalid 'format': expected one of ${[...VALID_FORMATS].join(", ")}.`);
2208
2467
  opts.format = query.format;
2209
2468
  hasTransform = true;
2210
2469
  }
2211
- if (query.fit && VALID_FITS.has(query.fit)) {
2470
+ if (query.fit) {
2471
+ if (!VALID_FITS.has(query.fit)) throw new InvalidTransformOptionsError(`Invalid 'fit': expected one of ${[...VALID_FITS].join(", ")}.`);
2212
2472
  opts.fit = query.fit;
2213
2473
  hasTransform = true;
2214
2474
  }
@@ -2226,9 +2486,72 @@ function isTransformableImage(contentType) {
2226
2486
  return contentType.startsWith("image/") && !contentType.includes("svg") && !contentType.includes("gif");
2227
2487
  }
2228
2488
  /**
2489
+ * A transform refused because the server is already doing as many as it will.
2490
+ *
2491
+ * Surfaced as a 503 by the route.
2492
+ */
2493
+ var TransformOverloadedError = class extends Error {
2494
+ constructor(message) {
2495
+ super(message);
2496
+ this.name = "TransformOverloadedError";
2497
+ }
2498
+ };
2499
+ /**
2500
+ * A bounded work queue.
2501
+ *
2502
+ * A transform is seconds of libvips CPU and a full-resolution bitmap in RSS,
2503
+ * and `GET /file/*?width=…` is reachable anonymously for public objects — so
2504
+ * without a bound, a few hundred bytes per request buys unlimited pod CPU, and
2505
+ * enough of them at once buys the heap too. `maxConcurrent` caps the CPU and
2506
+ * memory in flight; `maxQueued` is what keeps an unbounded backlog from
2507
+ * becoming the same problem one level up, refusing fast (503) instead of
2508
+ * accepting work the process will not get to.
2509
+ */
2510
+ var TransformQueue = class {
2511
+ maxConcurrent;
2512
+ maxQueued;
2513
+ active = 0;
2514
+ waiting = [];
2515
+ constructor(maxConcurrent, maxQueued) {
2516
+ this.maxConcurrent = maxConcurrent;
2517
+ this.maxQueued = maxQueued;
2518
+ }
2519
+ /** Number of tasks running plus waiting. Exposed for tests and metrics. */
2520
+ get depth() {
2521
+ return this.active + this.waiting.length;
2522
+ }
2523
+ async run(work) {
2524
+ if (this.active >= this.maxConcurrent) {
2525
+ if (this.waiting.length >= this.maxQueued) throw new TransformOverloadedError("Image transformation is at capacity. Retry shortly.");
2526
+ await new Promise((resolve) => this.waiting.push(resolve));
2527
+ }
2528
+ this.active++;
2529
+ try {
2530
+ return await work();
2531
+ } finally {
2532
+ this.active--;
2533
+ this.waiting.shift()?.();
2534
+ }
2535
+ }
2536
+ };
2537
+ /**
2538
+ * The queue every transform goes through.
2539
+ *
2540
+ * Small on purpose: transforms are CPU-bound, so more concurrency than cores
2541
+ * buys nothing but resident bitmaps.
2542
+ */
2543
+ var transformQueue = new TransformQueue(Math.max(1, Math.min(4, os.availableParallelism?.() ?? os.cpus().length)), 64);
2544
+ /**
2229
2545
  * Apply image transformations and return the result buffer + content type.
2546
+ *
2547
+ * Runs on {@link transformQueue}: the work is bounded, and callers past the
2548
+ * bound get {@link TransformOverloadedError} rather than a share of a
2549
+ * saturated CPU.
2230
2550
  */
2231
2551
  async function transformImage(buffer, options) {
2552
+ return transformQueue.run(() => runTransform(buffer, options));
2553
+ }
2554
+ async function runTransform(buffer, options) {
2232
2555
  let pipeline = (await getSharp())(buffer);
2233
2556
  if (options.width || options.height) pipeline = pipeline.resize({
2234
2557
  width: options.width,
@@ -2355,6 +2678,7 @@ var TusHandler = class {
2355
2678
  this.cleanupTimer = setInterval(() => {
2356
2679
  this.cleanupStale();
2357
2680
  }, 6e4);
2681
+ this.cleanupTimer.unref?.();
2358
2682
  }
2359
2683
  /** Remove uploads that have been idle for longer than UPLOAD_EXPIRY_MS. */
2360
2684
  async cleanupStale() {
@@ -2406,11 +2730,22 @@ var TusHandler = class {
2406
2730
  if (Number.isNaN(uploadLength) || uploadLength <= 0) throw ApiError.badRequest("Invalid Upload-Length");
2407
2731
  if (uploadLength > MAX_UPLOAD_SIZE) throw new ApiError(413, "PAYLOAD_TOO_LARGE", `Upload-Length exceeds maximum of ${MAX_UPLOAD_SIZE} bytes`);
2408
2732
  const metadata = this.parseMetadata(c.req.header("Upload-Metadata") || "");
2409
- if (this.authorizeUpload) {
2410
- const key = metadata.key || metadata.filename || "";
2411
- await this.authorizeUpload(c, key, metadata.bucket || "default");
2412
- }
2413
2733
  const id = randomUUID$1();
2734
+ const rawKey = metadata.key || metadata.filename || "";
2735
+ let key;
2736
+ try {
2737
+ key = canonicalStorageKey(rawKey) || id;
2738
+ } catch (err) {
2739
+ throw new ApiError(400, "INVALID_STORAGE_KEY", err instanceof InvalidStorageKeyError ? err.message : "Invalid storage key");
2740
+ }
2741
+ let bucket;
2742
+ try {
2743
+ bucket = canonicalStorageBucket(metadata.bucket);
2744
+ } catch (err) {
2745
+ throw new ApiError(400, "INVALID_STORAGE_BUCKET", err instanceof InvalidStorageBucketError ? err.message : "Invalid storage bucket");
2746
+ }
2747
+ const storageId = metadata.storageId || c.req.query("storageId") || void 0;
2748
+ if (this.authorizeUpload) await this.authorizeUpload(c, key, bucket || "default", storageId);
2414
2749
  const filePath = join(this.tusDir, id);
2415
2750
  await writeFile(filePath, Buffer.alloc(0));
2416
2751
  const upload = {
@@ -2420,8 +2755,9 @@ var TusHandler = class {
2420
2755
  metadata,
2421
2756
  createdAt: Date.now(),
2422
2757
  filePath,
2423
- bucket: metadata.bucket || void 0,
2424
- key: metadata.key || metadata.filename || void 0,
2758
+ bucket,
2759
+ storageId,
2760
+ key,
2425
2761
  completed: false
2426
2762
  };
2427
2763
  this.uploads.set(id, upload);
@@ -2496,7 +2832,7 @@ var TusHandler = class {
2496
2832
  */
2497
2833
  async finalize(upload) {
2498
2834
  upload.completed = true;
2499
- const storageId = upload.metadata.storageId;
2835
+ const storageId = upload.storageId;
2500
2836
  let targetController = this.storageController;
2501
2837
  if (this.storageRegistry) targetController = storageId ? this.storageRegistry.getOrDefault(storageId) : this.storageRegistry.getDefault();
2502
2838
  if (!targetController) {
@@ -2506,7 +2842,7 @@ var TusHandler = class {
2506
2842
  try {
2507
2843
  const { readFile } = await import("fs/promises");
2508
2844
  const data = await readFile(upload.filePath);
2509
- const fileName = upload.key || upload.metadata.filename || upload.id;
2845
+ const fileName = upload.key;
2510
2846
  const mimeType = upload.metadata.contentType || upload.metadata.filetype || "application/octet-stream";
2511
2847
  const file = new File([new Uint8Array(data)], fileName, { type: mimeType });
2512
2848
  await targetController.putObject({
@@ -2537,6 +2873,82 @@ var TusHandler = class {
2537
2873
  /** Shared image transform cache (LRU, 500 entries, 1 hour TTL). */
2538
2874
  var transformCache = new TransformCache();
2539
2875
  /**
2876
+ * Transforms currently being computed, keyed the same way the cache is.
2877
+ *
2878
+ * Without it, N concurrent requests for one uncached variant run N full
2879
+ * decode+encode pipelines — the cache only helps the requests that arrive
2880
+ * after the first one has finished. Joining the in-flight promise makes a
2881
+ * thundering herd cost one decode.
2882
+ */
2883
+ var transformsInFlight = /* @__PURE__ */ new Map();
2884
+ /**
2885
+ * Compute a transform, or join the one already running for this key.
2886
+ *
2887
+ * `loadSource` is only called on a real miss, so the concurrent requests that
2888
+ * join an in-flight transform do not each read the source object either.
2889
+ */
2890
+ async function transformOnce(cacheKey, options, loadSource) {
2891
+ const cached = transformCache.get(cacheKey);
2892
+ if (cached) return cached;
2893
+ const running = transformsInFlight.get(cacheKey);
2894
+ if (running) return running;
2895
+ const pending = (async () => {
2896
+ try {
2897
+ const result = await transformImage(await loadSource(), options);
2898
+ transformCache.set(cacheKey, result.data, result.contentType);
2899
+ return result;
2900
+ } catch (err) {
2901
+ if (err instanceof TransformOverloadedError) throw ApiError.serviceUnavailable(err.message, "TRANSFORM_OVERLOADED");
2902
+ throw err;
2903
+ }
2904
+ })();
2905
+ transformsInFlight.set(cacheKey, pending);
2906
+ try {
2907
+ return await pending;
2908
+ } finally {
2909
+ transformsInFlight.delete(cacheKey);
2910
+ }
2911
+ }
2912
+ /**
2913
+ * Content types served inline. Everything else is handed back as
2914
+ * `application/octet-stream` with `Content-Disposition: attachment`.
2915
+ *
2916
+ * The stored content type is the *uploader's claim* — `putObject` writes
2917
+ * `file.type` from the multipart part, and TUS takes it from a request header;
2918
+ * nothing sniffs the bytes. Echoing that claim back as the response type turns
2919
+ * `/api/storage/file/*` into an HTML hosting endpoint on the API origin, and
2920
+ * where `cookieAuth` is enabled the refresh cookie is `Path=/` on exactly that
2921
+ * origin — so an uploaded page can fetch `/api/auth/refresh` same-origin and
2922
+ * read out a fresh access token, `HttpOnly` notwithstanding.
2923
+ *
2924
+ * `image/svg+xml` is deliberately absent: an SVG is a document that can carry
2925
+ * script. `text/html` and `application/xhtml+xml` are absent for the same
2926
+ * reason, and are what the attack actually uses.
2927
+ */
2928
+ var INLINE_CONTENT_TYPE_PREFIXES = [
2929
+ "image/",
2930
+ "video/",
2931
+ "audio/"
2932
+ ];
2933
+ var INLINE_CONTENT_TYPES = /* @__PURE__ */ new Set(["application/pdf", "text/plain"]);
2934
+ /**
2935
+ * Decide what to actually serve for a stored content type.
2936
+ *
2937
+ * Returns the type to send and whether to force a download. The check is an
2938
+ * allowlist rather than a blocklist of dangerous types: the set of types a
2939
+ * browser will execute grows, and the set we want to render inline does not.
2940
+ */
2941
+ function resolveServedContentType(storedContentType) {
2942
+ const base = storedContentType.split(";")[0].trim().toLowerCase();
2943
+ return (base.includes("svg") ? false : INLINE_CONTENT_TYPES.has(base) || INLINE_CONTENT_TYPE_PREFIXES.some((p) => base.startsWith(p))) ? {
2944
+ contentType: storedContentType,
2945
+ attachment: false
2946
+ } : {
2947
+ contentType: "application/octet-stream",
2948
+ attachment: true
2949
+ };
2950
+ }
2951
+ /**
2540
2952
  * Extract the wildcard portion of a route path from the full request path.
2541
2953
  *
2542
2954
  * Hono's `c.req.param('*')` does not work reliably in sub-routers mounted
@@ -2556,16 +2968,48 @@ function extractWildcardPath(c) {
2556
2968
  return fullPath.substring(idx + prefix.length + 1);
2557
2969
  }
2558
2970
  /**
2559
- * Sanitize a user-supplied storage key to prevent path traversal and other attacks.
2560
- * Removes null bytes, ../ sequences, leading slashes, and limits length.
2971
+ * Canonicalize a caller-supplied storage key, answering 400 when it names
2972
+ * something other than what it says.
2973
+ *
2974
+ * The single place a request's key becomes canonical. Every route runs its key
2975
+ * through this before anything else touches it, so the authorize hook, the
2976
+ * storage controller and the minted download token are all looking at the same
2977
+ * string — which is the only thing that makes the hook's answer meaningful.
2978
+ * See `keys.ts` for why an unacceptable key is refused rather than repaired.
2979
+ */
2980
+ function canonicalKeyOrBadRequest(key) {
2981
+ try {
2982
+ return canonicalStorageKey(key);
2983
+ } catch (err) {
2984
+ throw new ApiError(400, "INVALID_STORAGE_KEY", err instanceof InvalidStorageKeyError ? err.message : "Invalid storage key");
2985
+ }
2986
+ }
2987
+ /**
2988
+ * Canonicalize a caller-supplied bucket name, answering 400 when it is not one.
2989
+ *
2990
+ * The bucket's counterpart to {@link canonicalKeyOrBadRequest}, and it exists
2991
+ * for the same reason: the value routes a write, so it has to be checked where
2992
+ * it enters rather than where it is used. Applied at every entry point a bucket
2993
+ * has — this route's multipart body, the `?bucket=` query, and the TUS
2994
+ * `Upload-Metadata` header.
2995
+ */
2996
+ function canonicalBucketOrBadRequest(bucket) {
2997
+ try {
2998
+ return canonicalStorageBucket(bucket);
2999
+ } catch (err) {
3000
+ throw new ApiError(400, "INVALID_STORAGE_BUCKET", err instanceof InvalidStorageBucketError ? err.message : "Invalid storage bucket");
3001
+ }
3002
+ }
3003
+ /**
3004
+ * Parse image transform query parameters, answering 400 when they are out of
3005
+ * bounds rather than clamping them to something the caller did not ask for.
2561
3006
  */
2562
- function sanitizeStorageKey(key) {
2563
- let sanitized = key;
2564
- sanitized = sanitized.replace(/\0/g, "");
2565
- sanitized = sanitized.replace(/\.\.\/|\.\.\\/g, "");
2566
- sanitized = sanitized.replace(/^\/+/, "");
2567
- sanitized = sanitized.slice(0, 1024);
2568
- return sanitized;
3007
+ function transformOptionsOrBadRequest(query) {
3008
+ try {
3009
+ return parseTransformOptions(query);
3010
+ } catch (err) {
3011
+ throw new ApiError(400, "INVALID_TRANSFORM_OPTIONS", err instanceof InvalidTransformOptionsError ? err.message : "Invalid image transform options");
3012
+ }
2569
3013
  }
2570
3014
  /**
2571
3015
  * Build adapter-aware auth middleware for storage routes.
@@ -2669,25 +3113,26 @@ function createStorageRoutes(config) {
2669
3113
  /**
2670
3114
  * Parse bucket and path from a combined file path.
2671
3115
  *
2672
- * The resolved path is run through `sanitizeStorageKey` here — the same
2673
- * function the upload route applies to incoming keys — so that read,
2674
- * delete, metadata and folder routes strip `../`, null bytes and leading
2675
- * slashes before the path reaches the controller, the authorize hook, or
2676
- * the download-token it mints. The `LocalStorageController` traversal guard
2677
- * (`getFullPath`) remains the load-bearing defence; this is the same
2678
- * normalization on the write and read sides so a `..%2f` read/delete cannot
2679
- * even reach it as a raw traversal string (it 404s as a normal miss instead
2680
- * of throwing, and never leaks whether an escape was attempted).
3116
+ * The resolved path is canonicalized here — by the same function the upload
3117
+ * route applies to incoming keys — so read, delete, metadata and folder
3118
+ * routes agree with the write side about what a key means before it reaches
3119
+ * the controller, the authorize hook, or the download token it mints.
3120
+ *
3121
+ * A key that cannot be canonicalized (a real `..` segment, a null byte) is
3122
+ * a 400, not a repaired key. The `LocalStorageController` traversal guard
3123
+ * (`getFullPath`) is the load-bearing defence for the *storage root*, and
3124
+ * `canonicalStorageBucket` for the bucket the caller names; this is what
3125
+ * defends the boundary the hook drew inside them.
2681
3126
  */
2682
3127
  const parseBucketAndPath = (filePath) => {
2683
3128
  const parts = filePath.split("/");
2684
3129
  if (parts.length > 1 && parts[0].toLowerCase() === "default") return {
2685
3130
  bucket: "default",
2686
- resolvedPath: sanitizeStorageKey(parts.slice(1).join("/"))
3131
+ resolvedPath: canonicalKeyOrBadRequest(parts.slice(1).join("/"))
2687
3132
  };
2688
3133
  return {
2689
3134
  bucket: "default",
2690
- resolvedPath: sanitizeStorageKey(filePath)
3135
+ resolvedPath: canonicalKeyOrBadRequest(filePath)
2691
3136
  };
2692
3137
  };
2693
3138
  /**
@@ -2700,9 +3145,9 @@ function createStorageRoutes(config) {
2700
3145
  const uploadedFile = body["file"];
2701
3146
  if (!uploadedFile || typeof uploadedFile === "string") throw ApiError.badRequest("No file provided");
2702
3147
  const key = typeof body["key"] === "string" ? body["key"] : "";
2703
- const bucket = typeof body["bucket"] === "string" ? body["bucket"] : void 0;
3148
+ const bucket = canonicalBucketOrBadRequest(typeof body["bucket"] === "string" ? body["bucket"] : void 0);
2704
3149
  const storageId = typeof body["storageId"] === "string" ? body["storageId"] : c.req.query("storageId");
2705
- const finalKey = sanitizeStorageKey(key || uploadedFile.name || "unnamed");
3150
+ const finalKey = canonicalKeyOrBadRequest(key || uploadedFile.name || "unnamed");
2706
3151
  const metadata = {};
2707
3152
  for (const [k, value] of Object.entries(body)) if (k.startsWith("metadata_")) metadata[k.replace("metadata_", "")] = value;
2708
3153
  await checkAuthorized(c, "write", finalKey, bucket ?? "default", storageId);
@@ -2728,15 +3173,13 @@ function createStorageRoutes(config) {
2728
3173
  const filePath = decodeURIComponent(rawPath);
2729
3174
  const storageId = c.req.query("storageId");
2730
3175
  const resolved = resolveController(storageId);
2731
- {
2732
- const { bucket, resolvedPath } = parseBucketAndPath(filePath);
2733
- await checkAuthorized(c, "read", resolvedPath, bucket, storageId);
2734
- }
2735
- const transformOpts = parseTransformOptions(c.req.query());
3176
+ const { bucket, resolvedPath } = parseBucketAndPath(filePath);
3177
+ await checkAuthorized(c, "read", resolvedPath, bucket, storageId);
3178
+ c.header("X-Content-Type-Options", "nosniff");
3179
+ const transformKeyPrefix = `${storageId || "(default)"}/${bucket}/${resolvedPath}`;
3180
+ const transformOpts = transformOptionsOrBadRequest(c.req.query());
2736
3181
  if (resolved.getType() === "local") {
2737
- const localController = resolved;
2738
- const { bucket, resolvedPath } = parseBucketAndPath(filePath);
2739
- const absolutePath = localController.getAbsolutePath(resolvedPath, bucket);
3182
+ const absolutePath = resolved.getAbsolutePath(resolvedPath, bucket);
2740
3183
  try {
2741
3184
  await fsp.access(absolutePath);
2742
3185
  } catch {
@@ -2748,37 +3191,30 @@ function createStorageRoutes(config) {
2748
3191
  const metadataRaw = await fsp.readFile(metadataPath, "utf-8");
2749
3192
  contentType = JSON.parse(metadataRaw).contentType || contentType;
2750
3193
  } catch {}
2751
- const fileContent = await fsp.readFile(absolutePath);
2752
3194
  if (transformOpts && isTransformableImage(contentType)) {
2753
- const cacheKey = transformCache.buildKey(filePath, transformOpts);
2754
- let cached = transformCache.get(cacheKey);
2755
- if (!cached) {
2756
- cached = await transformImage(Buffer.from(fileContent), transformOpts);
2757
- transformCache.set(cacheKey, cached.data, cached.contentType);
2758
- }
2759
- c.header("Content-Type", cached.contentType);
3195
+ const transformed = await transformOnce(transformCache.buildKey(transformKeyPrefix, transformOpts), transformOpts, async () => Buffer.from(await fsp.readFile(absolutePath)));
3196
+ c.header("Content-Type", transformed.contentType);
2760
3197
  c.header("Cache-Control", "public, max-age=31536000, immutable");
2761
- return c.body(new Uint8Array(cached.data));
3198
+ return c.body(new Uint8Array(transformed.data));
2762
3199
  }
2763
- c.header("Content-Type", contentType);
3200
+ const fileContent = await fsp.readFile(absolutePath);
3201
+ const served = resolveServedContentType(contentType);
3202
+ c.header("Content-Type", served.contentType);
3203
+ if (served.attachment) c.header("Content-Disposition", "attachment");
2764
3204
  return c.body(new Uint8Array(fileContent));
2765
3205
  }
2766
- const { bucket: parsedBucket, resolvedPath: parsedPath } = parseBucketAndPath(filePath);
2767
- const fileObject = await resolved.getObject(parsedPath, parsedBucket);
3206
+ const fileObject = await resolved.getObject(resolvedPath, bucket);
2768
3207
  if (!fileObject) throw ApiError.notFound("File not found");
2769
3208
  const remoteContentType = fileObject.type || "application/octet-stream";
2770
3209
  if (transformOpts && isTransformableImage(remoteContentType)) {
2771
- const cacheKey = transformCache.buildKey(filePath, transformOpts);
2772
- let cached = transformCache.get(cacheKey);
2773
- if (!cached) {
2774
- cached = await transformImage(Buffer.from(await fileObject.arrayBuffer()), transformOpts);
2775
- transformCache.set(cacheKey, cached.data, cached.contentType);
2776
- }
2777
- c.header("Content-Type", cached.contentType);
3210
+ const transformed = await transformOnce(transformCache.buildKey(transformKeyPrefix, transformOpts), transformOpts, async () => Buffer.from(await fileObject.arrayBuffer()));
3211
+ c.header("Content-Type", transformed.contentType);
2778
3212
  c.header("Cache-Control", "public, max-age=31536000, immutable");
2779
- return c.body(new Uint8Array(cached.data));
3213
+ return c.body(new Uint8Array(transformed.data));
2780
3214
  }
2781
- c.header("Content-Type", remoteContentType);
3215
+ const servedRemote = resolveServedContentType(remoteContentType);
3216
+ c.header("Content-Type", servedRemote.contentType);
3217
+ if (servedRemote.attachment) c.header("Content-Disposition", "attachment");
2782
3218
  c.header("Cache-Control", "public, max-age=3600, immutable");
2783
3219
  const buf = await fileObject.arrayBuffer();
2784
3220
  return c.body(new Uint8Array(buf));
@@ -2837,8 +3273,8 @@ function createStorageRoutes(config) {
2837
3273
  * GET /list - List files in a path
2838
3274
  */
2839
3275
  router.get("/list", writeAuthMiddleware, async (c) => {
2840
- const storagePrefix = sanitizeStorageKey(c.req.query("prefix") || c.req.query("path") || "");
2841
- const bucket = c.req.query("bucket");
3276
+ const storagePrefix = canonicalKeyOrBadRequest(c.req.query("prefix") || c.req.query("path") || "");
3277
+ const bucket = canonicalBucketOrBadRequest(c.req.query("bucket"));
2842
3278
  const maxResults = c.req.query("maxResults");
2843
3279
  const pageToken = c.req.query("pageToken");
2844
3280
  const storageId = c.req.query("storageId");
@@ -2884,8 +3320,8 @@ function createStorageRoutes(config) {
2884
3320
  }, 201);
2885
3321
  });
2886
3322
  const defaultCtrl = getDefaultController();
2887
- const tusHandler = new TusHandler(defaultCtrl.getType() === "local" ? defaultCtrl.getBasePath() : process.env.STORAGE_PATH || "./uploads", defaultCtrl, registry, authorize ? async (c, key, bucket) => {
2888
- await checkAuthorized(c, "write", sanitizeStorageKey(key), bucket, c.req.query("storageId"));
3323
+ const tusHandler = new TusHandler(defaultCtrl.getType() === "local" ? defaultCtrl.getBasePath() : process.env.STORAGE_PATH || "./uploads", defaultCtrl, registry, authorize ? async (c, key, bucket, storageId) => {
3324
+ await checkAuthorized(c, "write", key, bucket, storageId);
2889
3325
  } : void 0);
2890
3326
  tusHandler.startCleanup();
2891
3327
  router.options("/tus", (_c) => tusHandler.options());
@@ -3079,11 +3515,20 @@ function assertStorageAccessControlConfigured(state, isProduction) {
3079
3515
  }
3080
3516
  //#endregion
3081
3517
  //#region src/init/docs.ts
3082
- async function mountOpenApiDocs(app, basePath, enableSwagger, activeCollections, requireAuth) {
3083
- if (enableSwagger === false || activeCollections.length === 0) return;
3084
- const { generateOpenApiSpec } = await import("./openapi-generator-BEwyiaAr.js");
3518
+ /**
3519
+ * Serve the OpenAPI document, and Swagger UI outside production.
3520
+ *
3521
+ * `serverCollections` must be the collections the REST routes were generated
3522
+ * from — the ones whose data source has a `"server"` transport — not every
3523
+ * active collection. A direct-transport collection is served by the client
3524
+ * against its own backend; documenting it here publishes a full set of CRUD
3525
+ * paths that 404, with a Try-It button next to each.
3526
+ */
3527
+ async function mountOpenApiDocs(app, basePath, enableSwagger, serverCollections, requireAuth) {
3528
+ if (enableSwagger === false || serverCollections.length === 0) return;
3529
+ const { generateOpenApiSpec } = await import("./openapi-generator-BQxxxDpb.js");
3085
3530
  app.get(`${basePath}/docs`, (c) => {
3086
- const spec = generateOpenApiSpec(activeCollections, {
3531
+ const spec = generateOpenApiSpec(serverCollections, {
3087
3532
  basePath,
3088
3533
  requireAuth
3089
3534
  });
@@ -3258,6 +3703,54 @@ function createShutdown(config) {
3258
3703
  };
3259
3704
  }
3260
3705
  //#endregion
3706
+ //#region src/init/process-safety.ts
3707
+ /**
3708
+ * Installed once per process. The singleton is process-global for the same
3709
+ * reason (see `singleton.ts`): more than one copy of this module can be loaded
3710
+ * — the image ships one at `/app/node_modules` and a project's bundle installs
3711
+ * another — and each copy would otherwise add its own listener.
3712
+ */
3713
+ var HANDLER_SLOT = Symbol.for("@rebasepro/server:unhandled-rejection-handler");
3714
+ /**
3715
+ * Log unhandled promise rejections instead of letting Node terminate the
3716
+ * process.
3717
+ *
3718
+ * Node has terminated on an unhandled rejection since v15. Hono catches
3719
+ * anything thrown or rejected inside an awaited handler, so the ordinary
3720
+ * request path is covered — but a fire-and-forget `void syncToCrm(body)`, a
3721
+ * `setTimeout(async () => …)`, or an unawaited `.then()` in one custom function
3722
+ * rejects outside Hono's frame. The default outcome is that one project's
3723
+ * floating promise ends the process; on the managed runtime that process is
3724
+ * shared, so every other tenant on the pod goes with it, and the request log
3725
+ * points nowhere because the request itself succeeded.
3726
+ *
3727
+ * A logged rejection is the better trade here: the process keeps serving, and
3728
+ * the stack reaches the operator. `uncaughtException` is deliberately **not**
3729
+ * handled — a synchronous throw that escaped every frame leaves the process in
3730
+ * a state we cannot reason about, and swallowing it would be a worse bargain
3731
+ * than a restart.
3732
+ *
3733
+ * Set `REBASE_EXIT_ON_UNHANDLED_REJECTION=1` to restore Node's default.
3734
+ *
3735
+ * @returns An uninstall function (used by tests), or `undefined` when nothing
3736
+ * was installed.
3737
+ */
3738
+ function installUnhandledRejectionHandler() {
3739
+ if (process.env.REBASE_EXIT_ON_UNHANDLED_REJECTION === "1") return void 0;
3740
+ const slot = globalThis;
3741
+ if (slot[HANDLER_SLOT]) return void 0;
3742
+ const handler = (reason) => {
3743
+ const error = reason instanceof Error ? reason : new Error(String(reason));
3744
+ logger.error("Unhandled promise rejection — the process kept running. This is almost always a fire-and-forget call in application code (a custom function, a cron job, a callback). Await it, or attach a .catch().", { error });
3745
+ };
3746
+ slot[HANDLER_SLOT] = handler;
3747
+ process.on("unhandledRejection", handler);
3748
+ return () => {
3749
+ process.removeListener("unhandledRejection", handler);
3750
+ delete slot[HANDLER_SLOT];
3751
+ };
3752
+ }
3753
+ //#endregion
3261
3754
  //#region src/auth/collection-callback-warning.ts
3262
3755
  /** The data callbacks the auth write path does not run. */
3263
3756
  var DATA_CALLBACKS = [
@@ -3378,7 +3871,17 @@ function buildQueryString(params) {
3378
3871
  const wire = serializeOrderBy(params.orderBy);
3379
3872
  if (wire) parts.push(`orderBy=${encodeURIComponent(wire)}`);
3380
3873
  }
3381
- if (params.searchString) parts.push(`searchString=${encodeURIComponent(params.searchString)}`);
3874
+ if (params.searchString) {
3875
+ parts.push(`searchString=${encodeURIComponent(params.searchString)}`);
3876
+ if (params.searchExplain) parts.push("searchExplain=true");
3877
+ }
3878
+ if (params.vectorSearch) {
3879
+ const vs = params.vectorSearch;
3880
+ parts.push(`vector_search=${encodeURIComponent(vs.property)}`);
3881
+ parts.push(`vector=${encodeURIComponent(JSON.stringify(vs.vector))}`);
3882
+ if (vs.distance) parts.push(`vector_distance=${encodeURIComponent(vs.distance)}`);
3883
+ if (vs.threshold !== void 0) parts.push(`vector_threshold=${encodeURIComponent(String(vs.threshold))}`);
3884
+ }
3382
3885
  if (params.include && params.include.length > 0) parts.push(`include=${encodeURIComponent(params.include.join(","))}`);
3383
3886
  if (params.logical) {
3384
3887
  const root = params.logical;
@@ -3418,6 +3921,13 @@ function resolveBaseUrl(configured) {
3418
3921
  function createTransport(config, environment) {
3419
3922
  const fetchFn = config.fetch || globalThis.fetch;
3420
3923
  const apiPath = config.apiPath || "/api";
3924
+ for (const field of ["baseUrl", "storageUrlOrigin"]) {
3925
+ const value = config[field];
3926
+ if (!value || !apiPath) continue;
3927
+ const trimmed = value.replace(/\/+$/, "");
3928
+ if (!trimmed.endsWith(apiPath)) continue;
3929
+ console.warn(`[Rebase] ${field} ${JSON.stringify(value)} already ends with the API path ${JSON.stringify(apiPath)}, which is appended to it — requests will go to ${trimmed}${apiPath}/… and 404. Pass the origin only (${JSON.stringify(trimmed.slice(0, trimmed.length - apiPath.length) || "/")}), or set \`apiPath\` if the server really does mount the API one level deeper.`);
3930
+ }
3421
3931
  let token = config.token;
3422
3932
  let tokenGetter;
3423
3933
  let onUnauthorizedHandler = config.onUnauthorized;
@@ -3601,6 +4111,11 @@ function createAuth(transport, options) {
3601
4111
  const authFlowMode = opts.authFlowMode || "json";
3602
4112
  const STORAGE_KEY = "rebase_auth";
3603
4113
  const REFRESH_BUFFER_MS = 12e4;
4114
+ /**
4115
+ * The largest delay `setTimeout` can hold — 2^31 - 1 ms, about 24.8 days.
4116
+ * Anything larger is silently clamped to 1ms by Node and every browser.
4117
+ */
4118
+ const MAX_TIMER_DELAY_MS = 2147483647;
3604
4119
  const MAX_REFRESH_RETRIES = 5;
3605
4120
  const REFRESH_RETRY_BASE_MS = 1e3;
3606
4121
  const REFRESH_RETRY_MAX_MS = 3e4;
@@ -3628,7 +4143,9 @@ function createAuth(transport, options) {
3628
4143
  function emit(event, session) {
3629
4144
  for (const fn of listeners) try {
3630
4145
  fn(event, session);
3631
- } catch (e) {}
4146
+ } catch (e) {
4147
+ console.error("Error in auth state change listener:", e);
4148
+ }
3632
4149
  }
3633
4150
  function saveSession(session) {
3634
4151
  if (!persistSession || authFlowMode === "cookie") return;
@@ -3732,6 +4249,10 @@ function createAuth(transport, options) {
3732
4249
  attemptScheduledRefresh(0);
3733
4250
  return;
3734
4251
  }
4252
+ if (delay > MAX_TIMER_DELAY_MS) {
4253
+ refreshTimeout = setTimeout(() => scheduleRefresh(expiresAt), MAX_TIMER_DELAY_MS);
4254
+ return;
4255
+ }
3735
4256
  refreshTimeout = setTimeout(() => {
3736
4257
  attemptScheduledRefresh(0);
3737
4258
  }, delay);
@@ -4468,9 +4989,55 @@ var SDKQueryBuilder = class {
4468
4989
  }
4469
4990
  /**
4470
4991
  * Set a free-text search string if supported by the backend.
4992
+ *
4993
+ * By default this is a substring match across the collection's top-level
4994
+ * string properties. A Postgres collection that declares a `search` block
4995
+ * gets ranked full-text matching over the fields it named instead, and each
4996
+ * row comes back with a `_score` you can sort on:
4997
+ *
4998
+ * ```ts
4999
+ * client.data.talents.search("auditor iso 14001").orderBy("_score", "desc").find()
5000
+ * ```
5001
+ *
5002
+ * Pass `{ explain: true }` to have each row report which of the declared
5003
+ * fields matched, with a highlighted snippet, on `_matches`:
5004
+ *
5005
+ * ```ts
5006
+ * const { data } = await client.data.talents.search("iso 14001", { explain: true }).find();
5007
+ * data[0]._matches
5008
+ * // [{ field: "questionnaire.certifications", snippet: "<mark>ISO</mark> <mark>14001</mark> Lead Auditor" }]
5009
+ * ```
4471
5010
  */
4472
- search(searchString) {
5011
+ search(searchString, options) {
4473
5012
  this.params.searchString = searchString;
5013
+ if (options?.explain !== void 0) this.params.searchExplain = options.explain;
5014
+ return this;
5015
+ }
5016
+ /**
5017
+ * Order rows by nearest-neighbour distance to `vector`.
5018
+ *
5019
+ * The server has supported this from the REST layer since vectors landed;
5020
+ * this is the SDK reaching it. Results come back closest-first with a
5021
+ * `_distance` on each row, and any `where` / `orderBy` on the same query is
5022
+ * a filter applied before the ordering — distance decides the order.
5023
+ *
5024
+ * You supply the query vector. Rebase stores and searches embeddings; it
5025
+ * does not produce them, so this is where whatever model you already use
5026
+ * for the stored vectors gets called.
5027
+ *
5028
+ * @param property - Name of the `vector` property to compare against.
5029
+ * @param vector - The query embedding. Its length must match the property's
5030
+ * declared `dimensions`, or the server answers 400.
5031
+ * @example
5032
+ * client.data.docs.vectorSearch("embedding", queryVector, { threshold: 0.35 }).limit(10).find()
5033
+ */
5034
+ vectorSearch(property, vector, options) {
5035
+ this.params.vectorSearch = {
5036
+ property,
5037
+ vector,
5038
+ ...options?.distance !== void 0 && { distance: options.distance },
5039
+ ...options?.threshold !== void 0 && { threshold: options.threshold }
5040
+ };
4474
5041
  return this;
4475
5042
  }
4476
5043
  /**
@@ -4506,6 +5073,11 @@ var SDKQueryBuilder = class {
4506
5073
  return this.collection.listen(this.params, onUpdate, onError);
4507
5074
  }
4508
5075
  };
5076
+ /**
5077
+ * Counts currently in flight, keyed by the exact request they issue. Entries
5078
+ * live only for the duration of the request — see `count()` for why.
5079
+ */
5080
+ var inflightCounts = /* @__PURE__ */ new Map();
4509
5081
  function createCollectionClient(transport, slug, ws) {
4510
5082
  const basePath = `/data/${slug}`;
4511
5083
  const client = {
@@ -4550,18 +5122,63 @@ function createCollectionClient(transport, slug, ws) {
4550
5122
  body: JSON.stringify({
4551
5123
  rows: data,
4552
5124
  ...options?.upsert ? { upsert: true } : {}
4553
- })
5125
+ }),
5126
+ ...options?.idempotencyKey ? { headers: { "Idempotency-Key": options.idempotencyKey } } : {}
4554
5127
  })).data || [];
4555
5128
  },
5129
+ /**
5130
+ * Still `PUT`, deliberately, even though the server now serves `PATCH`
5131
+ * on the same handler and `PATCH` is the honest verb for a merge.
5132
+ *
5133
+ * The two are interchangeable server-side, so switching buys nothing at
5134
+ * runtime — and it costs compatibility in the direction that fails
5135
+ * quietly. A 0.14 client talking to a 0.13 server would send `PATCH` to
5136
+ * a route that does not exist and get a **404**, which is
5137
+ * indistinguishable from "that row is gone". Every write would look like
5138
+ * a missing record.
5139
+ *
5140
+ * `PATCH` is what the OpenAPI spec advertises, so anyone generating a
5141
+ * client gets the correct verb; this stays on `PUT` until the oldest
5142
+ * supported server is one that serves both.
5143
+ */
4556
5144
  async update(id, data) {
4557
5145
  return await transport.request(`${basePath}/${encodeURIComponent(String(id))}`, {
4558
5146
  method: "PUT",
4559
5147
  body: JSON.stringify(data)
4560
5148
  });
4561
5149
  },
5150
+ async updateMany(updates, options) {
5151
+ if (!Array.isArray(updates)) throw new TypeError("updateMany expects an array of { id, data } entries.");
5152
+ if (updates.length === 0) return [];
5153
+ return (await transport.request(`${basePath}/bulk`, {
5154
+ method: "PATCH",
5155
+ body: JSON.stringify({ updates }),
5156
+ ...options?.idempotencyKey ? { headers: { "Idempotency-Key": options.idempotencyKey } } : {}
5157
+ })).data || [];
5158
+ },
4562
5159
  async delete(id) {
4563
5160
  await transport.request(`${basePath}/${encodeURIComponent(String(id))}`, { method: "DELETE" });
4564
5161
  },
5162
+ /**
5163
+ * `POST .../bulk/delete`, not `DELETE .../bulk`.
5164
+ *
5165
+ * The honest verb would take the ids in a DELETE body, and that is the
5166
+ * one request shape the HTTP ecosystem handles unreliably: bodies on
5167
+ * DELETE are permitted but widely dropped by proxies and CDNs, and
5168
+ * several OpenAPI generators ignore `requestBody` on a DELETE
5169
+ * operation, so a generated client would send the request without its
5170
+ * ids. A backend deployed behind arbitrary ingress cannot take that
5171
+ * bet. Same reason `:batchDelete` exists in Google's API guidelines.
5172
+ */
5173
+ async deleteMany(ids, options) {
5174
+ if (!Array.isArray(ids)) throw new TypeError("deleteMany expects an array of ids.");
5175
+ if (ids.length === 0) return;
5176
+ await transport.request(`${basePath}/bulk/delete`, {
5177
+ method: "POST",
5178
+ body: JSON.stringify({ ids }),
5179
+ ...options?.idempotencyKey ? { headers: { "Idempotency-Key": options.idempotencyKey } } : {}
5180
+ });
5181
+ },
4565
5182
  async count(params) {
4566
5183
  const qs = buildQueryString({
4567
5184
  ...params,
@@ -4569,12 +5186,28 @@ function createCollectionClient(transport, slug, ws) {
4569
5186
  offset: void 0,
4570
5187
  include: void 0
4571
5188
  });
4572
- return (await transport.request(basePath + "/count" + qs, { method: "GET" })).count ?? 0;
5189
+ const key = basePath + "/count" + qs;
5190
+ const inflight = inflightCounts.get(key);
5191
+ if (inflight) return inflight;
5192
+ const request = transport.request(key, { method: "GET" }).then((raw) => raw.count ?? 0);
5193
+ inflightCounts.set(key, request);
5194
+ try {
5195
+ return await request;
5196
+ } finally {
5197
+ inflightCounts.delete(key);
5198
+ }
4573
5199
  },
4574
5200
  observe(params, onResult, onError, options) {
4575
5201
  let closed = false;
4576
- const emit = (result) => {
5202
+ let liveDelivered = false;
5203
+ let signature;
5204
+ const deliver = (result, fromLive) => {
4577
5205
  if (closed) return;
5206
+ if (fromLive) liveDelivered = true;
5207
+ else if (liveDelivered) return;
5208
+ const next = `${result.meta?.total ?? ""}|${JSON.stringify(result.data)}`;
5209
+ if (signature !== void 0 && next === signature) return;
5210
+ signature = next;
4578
5211
  onResult({
4579
5212
  ...result,
4580
5213
  fromCache: false,
@@ -4582,10 +5215,10 @@ function createCollectionClient(transport, slug, ws) {
4582
5215
  partial: false
4583
5216
  });
4584
5217
  };
4585
- client.find(params).then(emit).catch((error) => {
5218
+ client.find(params).then((result) => deliver(result, false)).catch((error) => {
4586
5219
  if (!closed) onError?.(error);
4587
5220
  });
4588
- const live = options?.realtime !== false && client.listen ? client.listen(params, emit, onError) : void 0;
5221
+ const live = options?.realtime !== false && client.listen ? client.listen(params, (result) => deliver(result, true), onError) : void 0;
4589
5222
  return () => {
4590
5223
  closed = true;
4591
5224
  live?.();
@@ -4593,17 +5226,24 @@ function createCollectionClient(transport, slug, ws) {
4593
5226
  },
4594
5227
  observeById(id, onResult, onError, options) {
4595
5228
  let closed = false;
4596
- const emit = (row) => {
5229
+ let liveDelivered = false;
5230
+ let signature;
5231
+ const deliver = (row, fromLive) => {
4597
5232
  if (closed) return;
5233
+ if (fromLive) liveDelivered = true;
5234
+ else if (liveDelivered) return;
5235
+ const next = row === void 0 ? "\0missing" : JSON.stringify(row);
5236
+ if (signature !== void 0 && next === signature) return;
5237
+ signature = next;
4598
5238
  onResult(row, {
4599
5239
  fromCache: false,
4600
5240
  hasPendingWrites: false
4601
5241
  });
4602
5242
  };
4603
- client.findById(id).then(emit).catch((error) => {
5243
+ client.findById(id).then((row) => deliver(row, false)).catch((error) => {
4604
5244
  if (!closed) onError?.(error);
4605
5245
  });
4606
- const live = options?.realtime !== false && client.listenById ? client.listenById(id, emit, onError) : void 0;
5246
+ const live = options?.realtime !== false && client.listenById ? client.listenById(id, (row) => deliver(row, true), onError) : void 0;
4607
5247
  return () => {
4608
5248
  closed = true;
4609
5249
  live?.();
@@ -4623,8 +5263,11 @@ function createCollectionClient(transport, slug, ws) {
4623
5263
  offset(count) {
4624
5264
  return new SDKQueryBuilder(client).offset(count);
4625
5265
  },
4626
- search(searchString) {
4627
- return new SDKQueryBuilder(client).search(searchString);
5266
+ search(searchString, options) {
5267
+ return new SDKQueryBuilder(client).search(searchString, options);
5268
+ },
5269
+ vectorSearch(property, vector, options) {
5270
+ return new SDKQueryBuilder(client).vectorSearch(property, vector, options);
4628
5271
  },
4629
5272
  include(...relations) {
4630
5273
  return new SDKQueryBuilder(client).include(...relations);
@@ -4634,51 +5277,44 @@ function createCollectionClient(transport, slug, ws) {
4634
5277
  client.listen = (params, onUpdate, onError) => {
4635
5278
  let active = true;
4636
5279
  let lastUpdateId = 0;
5280
+ let lastKnownTotal;
5281
+ const window = resolveFindWindow(params);
4637
5282
  const unsub = ws.listenCollection({
4638
5283
  path: slug,
4639
5284
  filter: params?.where,
5285
+ logical: params?.logical,
4640
5286
  limit: params?.limit,
4641
- startAfter: params?.offset ? String(params.offset) : void 0,
5287
+ offset: window.driverOffset,
4642
5288
  orderBy: params?.orderBy?.[0],
4643
5289
  order: params?.orderBy?.[1],
4644
- searchString: params?.searchString
4645
- }, (incomingRows) => {
4646
- const currentUpdateId = ++lastUpdateId;
4647
- const requestedLimit = params?.limit || 20;
4648
- const offset = params?.offset || 0;
4649
- const rows = incomingRows;
4650
- const heuristicTotal = rows.length;
4651
- const heuristicHasMore = rows.length >= requestedLimit;
4652
- if (client.count) client.count(params).then((total) => {
4653
- if (active && currentUpdateId === lastUpdateId) onUpdate({
4654
- data: rows,
4655
- meta: {
4656
- total,
4657
- limit: requestedLimit,
4658
- offset,
4659
- hasMore: offset + rows.length < total
4660
- }
4661
- });
4662
- }).catch(() => {
4663
- if (active && currentUpdateId === lastUpdateId) onUpdate({
5290
+ searchString: params?.searchString,
5291
+ searchExplain: params?.searchExplain
5292
+ }, (incomingRows) => {
5293
+ const currentUpdateId = ++lastUpdateId;
5294
+ const requestedLimit = window.limit;
5295
+ const offset = window.offset;
5296
+ const rows = incomingRows;
5297
+ const emit = (total, hasMore) => {
5298
+ if (!active || currentUpdateId !== lastUpdateId) return;
5299
+ onUpdate({
4664
5300
  data: rows,
4665
5301
  meta: {
4666
- total: heuristicTotal,
5302
+ total,
4667
5303
  limit: requestedLimit,
4668
5304
  offset,
4669
- hasMore: heuristicHasMore
5305
+ hasMore
4670
5306
  }
4671
5307
  });
5308
+ };
5309
+ const emitWithoutCount = () => emit(offset + rows.length, rows.length >= requestedLimit);
5310
+ if (client.count) client.count(params).then((total) => {
5311
+ lastKnownTotal = total;
5312
+ emit(total, offset + rows.length < total);
5313
+ }).catch(() => {
5314
+ if (lastKnownTotal !== void 0) emit(lastKnownTotal, offset + rows.length < lastKnownTotal);
5315
+ else emitWithoutCount();
4672
5316
  });
4673
- else onUpdate({
4674
- data: rows,
4675
- meta: {
4676
- total: heuristicTotal,
4677
- limit: requestedLimit,
4678
- offset,
4679
- hasMore: heuristicHasMore
4680
- }
4681
- });
5317
+ else emitWithoutCount();
4682
5318
  }, onError);
4683
5319
  return () => {
4684
5320
  active = false;
@@ -5446,6 +6082,11 @@ var RebaseWebSocketClient = class {
5446
6082
  callback.onError(new RebaseApiError(errorMessage, { code: errorCode }));
5447
6083
  }
5448
6084
  } else callback.onUpdate(message);
6085
+ return;
6086
+ }
6087
+ if (type === "ERROR" || type === "error" || message.error) {
6088
+ const { errorMessage, errorCode } = extractMessageError(message);
6089
+ console.warn(`[Rebase] Realtime error from the server${errorCode ? ` (${errorCode})` : ""}: ${errorMessage}`);
5449
6090
  }
5450
6091
  }
5451
6092
  async ensureAuthenticated(retryCount = 3) {
@@ -6061,15 +6702,10 @@ var RebaseWebSocketClient = class {
6061
6702
  }
6062
6703
  }
6063
6704
  createCollectionSubscriptionKey(props) {
6705
+ const { collection, ...query } = props;
6064
6706
  const key = {
6065
- path: props.path,
6066
- filter: props.filter,
6067
- limit: props.limit,
6068
- startAfter: props.startAfter,
6069
- orderBy: props.orderBy,
6070
- order: props.order,
6071
- searchString: props.searchString,
6072
- collection: props.collection?.name
6707
+ ...query,
6708
+ collection: collection?.name
6073
6709
  };
6074
6710
  return JSON.stringify(key, (_, value) => {
6075
6711
  if (value && typeof value === "object" && !Array.isArray(value)) return Object.keys(value).sort().reduce((sorted, k) => {
@@ -6557,11 +7193,29 @@ var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([
6557
7193
  503,
6558
7194
  504
6559
7195
  ]);
7196
+ /**
7197
+ * The server holds this key for a request it has not answered yet.
7198
+ *
7199
+ * It is a 409 like a duplicate row is a 409, and nothing but the code separates
7200
+ * them — one means "your write is already there", the other means "your write
7201
+ * may not have happened at all, ask again".
7202
+ */
7203
+ var IDEMPOTENCY_IN_PROGRESS = "IDEMPOTENCY_KEY_IN_PROGRESS";
7204
+ /**
7205
+ * Is the server still answering an earlier attempt of this same write?
7206
+ *
7207
+ * The only correct response is to ask again — which is exactly what the
7208
+ * server's own message says, and exactly what this SDK used not to do.
7209
+ */
7210
+ function isIdempotencyInProgressError(error) {
7211
+ return error instanceof RebaseApiError && error.status === 409 && error.code === IDEMPOTENCY_IN_PROGRESS;
7212
+ }
6560
7213
  /** Is this failure worth another attempt later? */
6561
7214
  function isRetryableError(error) {
6562
7215
  if (isNetworkError(error)) return true;
6563
- if (error instanceof RebaseApiError) return error.status !== void 0 && RETRYABLE_STATUSES.has(error.status);
6564
- return false;
7216
+ if (!(error instanceof RebaseApiError)) return false;
7217
+ if (isIdempotencyInProgressError(error)) return true;
7218
+ return error.status !== void 0 && RETRYABLE_STATUSES.has(error.status);
6565
7219
  }
6566
7220
  /**
6567
7221
  * Did this write fail because the row is already there?
@@ -6573,10 +7227,16 @@ function isRetryableError(error) {
6573
7227
  * The queue uses this to recognise its own earlier attempt. A create whose
6574
7228
  * response was lost is replayed, and for a row carrying an id the SDK generated
6575
7229
  * the server can only be rejecting it because the first attempt actually landed.
7230
+ *
7231
+ * Which is why the status alone cannot decide it: `IDEMPOTENCY_KEY_IN_PROGRESS`
7232
+ * is a 409 that means the opposite — the row may not exist at all. Read as a
7233
+ * duplicate, the queue looked for a row that was never written, found nothing,
7234
+ * concluded there was nothing left to do and deleted the write from the queue.
6576
7235
  */
6577
7236
  function isDuplicateKeyError(error) {
6578
7237
  if (!(error instanceof RebaseApiError)) return false;
6579
- return error.code === "23505" || error.status === 409;
7238
+ if (error.code === "23505") return true;
7239
+ return error.status === 409 && !isIdempotencyInProgressError(error);
6580
7240
  }
6581
7241
  var ConnectivityMonitor = class {
6582
7242
  state = "online";
@@ -6982,14 +7642,23 @@ function looseEquals(a, b) {
6982
7642
  */
6983
7643
  function likeToRegExp(pattern, caseInsensitive) {
6984
7644
  let source = "^";
7645
+ let lastWasWildcard = false;
6985
7646
  for (let i = 0; i < pattern.length; i++) {
6986
7647
  const char = pattern[i];
6987
7648
  if (char === "\\" && i + 1 < pattern.length) {
6988
7649
  source += pattern[i + 1].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6989
7650
  i++;
6990
- } else if (char === "%") source += "[\\s\\S]*";
6991
- else if (char === "_") source += "[\\s\\S]";
6992
- else source += char.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7651
+ lastWasWildcard = false;
7652
+ } else if (char === "%") {
7653
+ if (!lastWasWildcard) source += "[\\s\\S]*";
7654
+ lastWasWildcard = true;
7655
+ } else if (char === "_") {
7656
+ source += "[\\s\\S]";
7657
+ lastWasWildcard = false;
7658
+ } else {
7659
+ source += char.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7660
+ lastWasWildcard = false;
7661
+ }
6993
7662
  }
6994
7663
  return new RegExp(source + "$", caseInsensitive ? "i" : "");
6995
7664
  }
@@ -7121,14 +7790,41 @@ function sortRows(rows, orderBy) {
7121
7790
  function tiebreak(a, b) {
7122
7791
  return compareValues(a.id, b.id) ?? 0;
7123
7792
  }
7124
- /** Resolve `page`/`offset`/`limit` the way the server does. */
7793
+ /**
7794
+ * Resolve `page`/`offset`/`limit` the way the server does.
7795
+ *
7796
+ * It did not: this defaulted an absent limit to 20 while `/api/data` pages by
7797
+ * 50, so the same `observe()` answered with 20 rows from the local database and
7798
+ * 50 from the network — a list that changed length depending on which side
7799
+ * answered, with `page` striding differently on each. Delegated now, so the
7800
+ * sentence above is true by construction rather than by agreement.
7801
+ */
7125
7802
  function resolvePagination(params) {
7126
- const limit = params?.limit ?? 20;
7803
+ const { limit, offset } = resolveFindWindow(params);
7127
7804
  return {
7128
7805
  limit,
7129
- offset: params?.page != null ? Math.max(0, (params.page - 1) * limit) : params?.offset ?? 0
7806
+ offset
7130
7807
  };
7131
7808
  }
7809
+ /** `<`, `<=`, `>`, `>=` — the operators whose answer depends on a collation. */
7810
+ var ORDERING_OPS = /* @__PURE__ */ new Set([
7811
+ "<",
7812
+ "<=",
7813
+ ">",
7814
+ ">="
7815
+ ]);
7816
+ /** Does any condition in this `where` clause order its operands? */
7817
+ function whereOrders(where) {
7818
+ if (!where) return false;
7819
+ for (const condition of Object.values(where)) if ((isTuple(condition) ? [condition] : condition.filter(isTuple)).some(([op]) => ORDERING_OPS.has(op))) return true;
7820
+ return false;
7821
+ }
7822
+ /** The same question, through an `and(...)`/`or(...)` tree. */
7823
+ function logicalOrders(condition, depth = 0) {
7824
+ if (!condition || depth > 32) return false;
7825
+ if ("type" in condition) return (condition.conditions ?? []).some((c) => logicalOrders(c, depth + 1));
7826
+ return ORDERING_OPS.has(condition.operator);
7827
+ }
7132
7828
  /**
7133
7829
  * Can a locally evaluated answer to `params` be trusted to match the server's,
7134
7830
  * assuming the cache holds every row of the collection?
@@ -7136,11 +7832,67 @@ function resolvePagination(params) {
7136
7832
  * `include` pulls in rows from other collections that this evaluator never
7137
7833
  * sees, and `searchString` is only approximated — both make the local answer a
7138
7834
  * best effort rather than an equivalent one.
7835
+ *
7836
+ * **Ordering comparisons are refused, and that is the interesting one.**
7837
+ * `compareValues` falls back to an `Intl.Collator` for operands it cannot read
7838
+ * as numbers or instants. PostgreSQL orders text by the *database's* collation,
7839
+ * which is a property of the server this process has never been told: under the
7840
+ * C collation `'apple' < 'Banana'` is false, under `en_US.UTF-8` it is true,
7841
+ * and the collator says true. So `["<", "Banana"]` selects a different set here
7842
+ * than it does there — silently, and in whichever direction the deployment
7843
+ * happens to have been created.
7844
+ *
7845
+ * The refusal covers *every* ordering comparison rather than only the ones with
7846
+ * a string operand, because the operand type does not settle it: a numeric
7847
+ * bound against a text column (`["<", 10]` on a `varchar`) also reaches the
7848
+ * collator, and nothing in `params` says what the column holds. Conservative on
7849
+ * purpose — the cost is that a query combining an ordering filter with
7850
+ * *unsynced local writes* stops placing those writes optimistically, which is a
7851
+ * degraded answer rather than a wrong one. Claiming exactness we do not have is
7852
+ * the other way round.
7853
+ *
7854
+ * This says nothing about ordering *results*; that is a separate claim with a
7855
+ * separate answer, because a sort changes which rows come first and not which
7856
+ * rows match. See {@link isLocallySortable}.
7139
7857
  */
7140
7858
  function isExactlyEvaluable(params) {
7141
7859
  if (!params) return true;
7142
7860
  if (params.include && params.include.length > 0) return false;
7143
7861
  if (params.searchString) return false;
7862
+ if (params.vectorSearch) return false;
7863
+ if (whereOrders(params.where)) return false;
7864
+ if (logicalOrders(params.logical)) return false;
7865
+ return true;
7866
+ }
7867
+ /**
7868
+ * Would sorting `rows` locally reproduce the order the server would have sent?
7869
+ *
7870
+ * Asked of the rows rather than of the query, because unlike a filter this one
7871
+ * *is* decidable from the data in hand: {@link compareValues} reaches the
7872
+ * collator only when it cannot read both operands as numbers, and `toComparable`
7873
+ * has already turned dates and relations into numbers and ids by then. If every
7874
+ * value on the sort column normalises to a number, the collator is unreachable
7875
+ * and the local order is the server's order.
7876
+ *
7877
+ * A text column is therefore refused — see {@link isExactlyEvaluable} for why
7878
+ * the two cannot be made to agree — and so is a column this page happens to see
7879
+ * only as strings, which is the same thing from here.
7880
+ *
7881
+ * Nulls are fine either way: they are ordered by an explicit rule (last
7882
+ * ascending, first descending) that matches Postgres and never reaches the
7883
+ * comparator.
7884
+ */
7885
+ function isLocallySortable(rows, orderBy) {
7886
+ if (!orderBy) return true;
7887
+ const [field] = orderBy;
7888
+ for (const row of rows) {
7889
+ const value = toComparable(row[field]);
7890
+ if (isNullish(value)) continue;
7891
+ if (typeof value === "number" || typeof value === "boolean") continue;
7892
+ if (typeof value === "bigint") continue;
7893
+ if (typeof value === "string" && value.trim() !== "" && !Number.isNaN(Number(value))) continue;
7894
+ return false;
7895
+ }
7144
7896
  return true;
7145
7897
  }
7146
7898
  /** Run a full query — filter, sort, paginate — over a set of rows. */
@@ -7170,6 +7922,17 @@ function generateOfflineId() {
7170
7922
  return `off-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
7171
7923
  }
7172
7924
  var MISSING = "\0missing";
7925
+ /**
7926
+ * Replays to spend on a mutation whose idempotency key the server is still
7927
+ * holding, when the app has not asked for more.
7928
+ *
7929
+ * Retries double from a second and cap at the sync interval, so the default
7930
+ * budget of five covers about half a minute — less than the lease a server
7931
+ * gives a claim nobody came back for. This many outlast it with room for a slow
7932
+ * batch, and the count is what stops a server that never releases the key from
7933
+ * blocking the queue behind it indefinitely.
7934
+ */
7935
+ var IN_PROGRESS_MIN_RETRIES = 12;
7173
7936
  var OfflineManager = class {
7174
7937
  store;
7175
7938
  maxCachedQueries;
@@ -7450,6 +8213,72 @@ var OfflineManager = class {
7450
8213
  this.notifyCollection(slug);
7451
8214
  return rows;
7452
8215
  },
8216
+ updateMany: async (updates, options) => {
8217
+ await this.ensureCollection(slug);
8218
+ if (!Array.isArray(updates)) throw new TypeError("updateMany expects an array of { id, data } entries.");
8219
+ if (updates.length === 0) return [];
8220
+ const anyPending = updates.some((u) => this.hasPending(slug, u.id));
8221
+ if (this.connectivity.shouldAttempt() && !anyPending) try {
8222
+ const rows = await inner.updateMany(updates, options);
8223
+ this.connectivity.markSuccess();
8224
+ await this.ingest(slug, rows);
8225
+ this.notifyCollection(slug);
8226
+ return rows;
8227
+ } catch (error) {
8228
+ if (!isNetworkError(error)) throw error;
8229
+ this.connectivity.markFailure();
8230
+ }
8231
+ const rollback = {};
8232
+ const optimistic = [];
8233
+ for (const { id, data } of updates) {
8234
+ const base = this.rawLocalRow(slug, id);
8235
+ rollback[String(id)] = base ?? null;
8236
+ optimistic.push({
8237
+ ...base ?? {},
8238
+ ...data,
8239
+ id
8240
+ });
8241
+ }
8242
+ await this.enqueue({
8243
+ collection: slug,
8244
+ type: "updateMany",
8245
+ updates: updates.map((u) => ({
8246
+ id: u.id,
8247
+ data: u.data
8248
+ })),
8249
+ rollback: { rows: rollback }
8250
+ });
8251
+ for (const row of optimistic) this.setLocalRow(slug, row.id, row);
8252
+ this.notifyCollection(slug);
8253
+ return optimistic;
8254
+ },
8255
+ deleteMany: async (ids, options) => {
8256
+ await this.ensureCollection(slug);
8257
+ if (!Array.isArray(ids)) throw new TypeError("deleteMany expects an array of ids.");
8258
+ if (ids.length === 0) return;
8259
+ const anyPending = ids.some((id) => this.hasPending(slug, id));
8260
+ if (this.connectivity.shouldAttempt() && !anyPending) try {
8261
+ await inner.deleteMany(ids, options);
8262
+ this.connectivity.markSuccess();
8263
+ for (const id of ids) this.removeLocalRow(slug, id, true);
8264
+ this.notifyCollection(slug);
8265
+ this.scheduleRefresh(slug);
8266
+ return;
8267
+ } catch (error) {
8268
+ if (!isNetworkError(error)) throw error;
8269
+ this.connectivity.markFailure();
8270
+ }
8271
+ const rollback = {};
8272
+ for (const id of ids) rollback[String(id)] = this.rawLocalRow(slug, id) ?? null;
8273
+ await this.enqueue({
8274
+ collection: slug,
8275
+ type: "deleteMany",
8276
+ ids,
8277
+ rollback: { rows: rollback }
8278
+ });
8279
+ for (const id of ids) this.removeLocalRow(slug, id, false);
8280
+ this.notifyCollection(slug);
8281
+ },
7453
8282
  update: async (id, data) => {
7454
8283
  await this.ensureCollection(slug);
7455
8284
  if (this.connectivity.shouldAttempt() && !this.hasPending(slug, id)) try {
@@ -7528,7 +8357,8 @@ var OfflineManager = class {
7528
8357
  orderBy: (column, direction) => new SDKQueryBuilder(wrapped).orderBy(column, direction),
7529
8358
  limit: (count) => new SDKQueryBuilder(wrapped).limit(count),
7530
8359
  offset: (count) => new SDKQueryBuilder(wrapped).offset(count),
7531
- search: (searchString) => new SDKQueryBuilder(wrapped).search(searchString),
8360
+ search: (searchString, options) => new SDKQueryBuilder(wrapped).search(searchString, options),
8361
+ vectorSearch: (property, vector, options) => new SDKQueryBuilder(wrapped).vectorSearch(property, vector, options),
7532
8362
  include: (...relations) => new SDKQueryBuilder(wrapped).include(...relations)
7533
8363
  };
7534
8364
  if (inner.listen) wrapped.listen = (params, onUpdate, onError) => inner.listen(params, (response) => {
@@ -7748,9 +8578,8 @@ var OfflineManager = class {
7748
8578
  if (!state) return {
7749
8579
  data: [],
7750
8580
  meta: {
8581
+ ...resolvePagination(params),
7751
8582
  total: 0,
7752
- limit: params?.limit ?? 20,
7753
- offset: params?.offset ?? 0,
7754
8583
  hasMore: false
7755
8584
  },
7756
8585
  fromCache: true,
@@ -7786,16 +8615,15 @@ var OfflineManager = class {
7786
8615
  }
7787
8616
  let added = 0;
7788
8617
  const offset = snapshot.offset ?? 0;
7789
- if (exact && offset === 0) {
7790
- for (const [key, entry] of state.rows) {
7791
- if (seen.has(key) || !this.hasPending(slug, key)) continue;
7792
- if (!this.isLocallyCreated(slug, key)) continue;
7793
- if (!matchesParams(entry.row, params)) continue;
7794
- rows.push(entry.row);
7795
- added++;
7796
- }
7797
- if (added > 0 && params?.orderBy) sortRows(rows, params.orderBy);
8618
+ if (exact && offset === 0) for (const [key, entry] of state.rows) {
8619
+ if (seen.has(key) || !this.hasPending(slug, key)) continue;
8620
+ if (!this.isLocallyCreated(slug, key)) continue;
8621
+ if (!matchesParams(entry.row, params)) continue;
8622
+ rows.push(entry.row);
8623
+ added++;
7798
8624
  }
8625
+ const orderIsLocal = isLocallySortable(rows, params?.orderBy);
8626
+ if (params?.orderBy && orderIsLocal) sortRows(rows, params.orderBy);
7799
8627
  return {
7800
8628
  data: rows,
7801
8629
  meta: {
@@ -7806,7 +8634,7 @@ var OfflineManager = class {
7806
8634
  },
7807
8635
  fromCache,
7808
8636
  hasPendingWrites: rows.some((row) => this.hasPending(slug, row.id)),
7809
- partial: !exact
8637
+ partial: !exact || !orderIsLocal
7810
8638
  };
7811
8639
  }
7812
8640
  localFind(slug, params) {
@@ -7942,17 +8770,17 @@ var OfflineManager = class {
7942
8770
  return row;
7943
8771
  }
7944
8772
  recordSnapshot(slug, params, result) {
8773
+ const window = resolvePagination(params);
7945
8774
  const meta = result.meta ?? {
7946
8775
  total: result.data?.length ?? 0,
7947
- limit: 20,
7948
- offset: 0,
8776
+ ...window,
7949
8777
  hasMore: false
7950
8778
  };
7951
8779
  const snapshot = {
7952
8780
  ids: (result.data ?? []).map((row) => row.id).filter((id) => id !== void 0),
7953
8781
  total: meta.total ?? result.data?.length ?? 0,
7954
- limit: meta.limit ?? params?.limit ?? 20,
7955
- offset: meta.offset ?? params?.offset ?? 0,
8782
+ limit: meta.limit ?? window.limit,
8783
+ offset: meta.offset ?? window.offset,
7956
8784
  hasMore: meta.hasMore ?? false
7957
8785
  };
7958
8786
  const state = this.collectionState(slug);
@@ -8121,7 +8949,8 @@ var OfflineManager = class {
8121
8949
  }
8122
8950
  op.attempts = (op.attempts ?? 0) + 1;
8123
8951
  op.lastError = error?.message ?? String(error);
8124
- if (isRetryableError(error) && op.attempts < this.maxRetries) {
8952
+ const limit = isIdempotencyInProgressError(error) ? Math.max(this.maxRetries, IN_PROGRESS_MIN_RETRIES) : this.maxRetries;
8953
+ if (isRetryableError(error) && op.attempts < limit) {
8125
8954
  await this.store.enqueue(this.queueKey(op), op).catch(() => void 0);
8126
8955
  this.connectivity.deferRetry();
8127
8956
  this.patchStatus({ lastError: op.lastError });
@@ -8167,11 +8996,25 @@ var OfflineManager = class {
8167
8996
  await this.adoptServerRow(op, op.id, row);
8168
8997
  } else if (op.type === "createMany") {
8169
8998
  const queued = op.data ?? [];
8170
- const rows = await inner.createMany(queued, op.upsert ? { upsert: true } : void 0);
8999
+ const rows = await inner.createMany(queued, {
9000
+ ...op.upsert ? { upsert: true } : {},
9001
+ idempotencyKey: op.mutationId
9002
+ });
8171
9003
  for (let i = 0; i < rows.length; i++) await this.adoptServerRow(op, queued[i]?.id, rows[i]);
9004
+ } else if (op.type === "updateMany") {
9005
+ const queued = op.updates ?? [];
9006
+ const rows = await inner.updateMany(queued.map((u) => ({
9007
+ id: u.id,
9008
+ data: u.data
9009
+ })), { idempotencyKey: op.mutationId });
9010
+ for (let i = 0; i < rows.length; i++) await this.ingestReplaced(op, queued[i].id, rows[i]);
8172
9011
  } else if (op.type === "update") {
8173
9012
  const row = await inner.update(op.id, op.data);
8174
9013
  await this.ingestReplaced(op, op.id, row);
9014
+ } else if (op.type === "deleteMany") {
9015
+ const ids = op.ids ?? [];
9016
+ await inner.deleteMany(ids, { idempotencyKey: op.mutationId });
9017
+ for (const id of ids) this.removeLocalRow(op.collection, id, true);
8175
9018
  } else if (op.type === "delete") {
8176
9019
  await inner.delete(op.id);
8177
9020
  this.removeLocalRow(op.collection, op.id, true);
@@ -8414,23 +9257,37 @@ var OfflineManager = class {
8414
9257
  /**
8415
9258
  * Derive a WebSocket URL from an HTTP base URL.
8416
9259
  * `http://` → `ws://`, `https://` → `wss://`.
9260
+ *
9261
+ * A backend mounted under a path is the reason `baseUrl` accepts one, so the
9262
+ * path is kept. It used to be kept for an absolute `baseUrl` and dropped for a
9263
+ * relative one — resolved through `.origin` — so one deployment dialled two
9264
+ * different sockets depending on whether its config said `"/backend"` or
9265
+ * `"https://app.example.com/backend"`.
9266
+ *
9267
+ * Returns `""` when there is nothing to resolve against: a relative `baseUrl`
9268
+ * outside a browser has no origin, and inventing one would dial somewhere
9269
+ * arbitrary. The caller warns rather than leaving that silent.
8417
9270
  */
8418
9271
  function deriveWebSocketUrl(baseUrl) {
9272
+ const toWsProtocol = (url) => {
9273
+ const secure = /^(https|wss):/i.test(url);
9274
+ return url.replace(/^https?:\/\//i, secure ? "wss://" : "ws://").replace(/^wss?:\/\//i, secure ? "wss://" : "ws://").replace(/\/$/, "");
9275
+ };
8419
9276
  if (typeof window !== "undefined") {
8420
- let absoluteUrl = "";
9277
+ let absoluteUrl;
8421
9278
  if (!baseUrl) absoluteUrl = window.location.origin;
8422
9279
  else if (/^https?:\/\//i.test(baseUrl) || /^wss?:\/\//i.test(baseUrl)) absoluteUrl = baseUrl;
8423
9280
  else try {
8424
- absoluteUrl = new URL(baseUrl, window.location.href).origin;
9281
+ const resolved = new URL(baseUrl, window.location.href);
9282
+ absoluteUrl = resolved.origin + resolved.pathname;
8425
9283
  } catch {
8426
9284
  absoluteUrl = window.location.origin;
8427
9285
  }
8428
- const protocol = absoluteUrl.startsWith("https:") || absoluteUrl.startsWith("wss:") ? "wss:" : "ws:";
8429
- return absoluteUrl.replace(/^https?:\/\//i, `${protocol}//`).replace(/^wss?:\/\//i, `${protocol}//`).replace(/\/$/, "");
9286
+ return toWsProtocol(absoluteUrl);
8430
9287
  }
8431
9288
  if (!baseUrl) return "";
8432
9289
  if (!/^https?:\/\//i.test(baseUrl) && !/^wss?:\/\//i.test(baseUrl)) return "";
8433
- return baseUrl.replace(/^https?:\/\//i, (match) => match.toLowerCase() === "https://" ? "wss://" : "ws://").replace(/\/$/, "");
9290
+ return toWsProtocol(baseUrl);
8434
9291
  }
8435
9292
  function createRebaseClient(options) {
8436
9293
  const transport = createTransport(options, { credentialOutOfBand: options.auth?.authFlowMode === "cookie" });
@@ -8458,7 +9315,11 @@ function createRebaseClient(options) {
8458
9315
  });
8459
9316
  return storageSourcesPromise;
8460
9317
  };
8461
- const resolvedWsUrl = options.realtime !== false ? options.websocketUrl ?? deriveWebSocketUrl(options.baseUrl) : void 0;
9318
+ const realtimeEnabled = options.realtime !== false;
9319
+ const resolvedWsUrl = realtimeEnabled ? options.websocketUrl ?? deriveWebSocketUrl(options.baseUrl) : void 0;
9320
+ const realtimeUnreachable = realtimeEnabled && !resolvedWsUrl;
9321
+ const unreachableReason = `no WebSocket URL could be derived from baseUrl ${JSON.stringify(options.baseUrl ?? null)} — outside a browser there is no page origin to resolve a relative URL against. Pass an absolute \`baseUrl\`, set \`websocketUrl\` explicitly, or pass \`realtime: false\` to say this was intended.`;
9322
+ if (realtimeUnreachable) console.warn(`[Rebase] Realtime is enabled but ${unreachableReason} Live queries will fall back to a single fetch and channels will throw.`);
8462
9323
  let ws;
8463
9324
  /** One channel object per name — see `realtime.channel`. */
8464
9325
  const realtimeChannels = /* @__PURE__ */ new Map();
@@ -8578,7 +9439,7 @@ function createRebaseClient(options) {
8578
9439
  * cut off the others.
8579
9440
  */
8580
9441
  channel: (name, options) => {
8581
- if (!ws) throw new RebaseClientError("Realtime is disabled on this client (realtime: false), so channels are unavailable.");
9442
+ if (!ws) throw new RebaseClientError(realtimeUnreachable ? `Realtime is enabled but ${unreachableReason}` : "Realtime is disabled on this client (realtime: false), so channels are unavailable.");
8582
9443
  let existing = realtimeChannels.get(name);
8583
9444
  if (!existing) {
8584
9445
  existing = new RebaseRealtimeChannel(name, ws, options);
@@ -8621,39 +9482,88 @@ channel: (name, options) => {
8621
9482
  }
8622
9483
  //#endregion
8623
9484
  //#region src/history/history-routes.ts
9485
+ /** Entries returned when the caller names no `?limit`. */
9486
+ var HISTORY_DEFAULT_LIMIT = 20;
9487
+ /** Most entries one history read will serve. Above it the request is refused. */
9488
+ var HISTORY_MAX_LIMIT = 100;
8624
9489
  function createHistoryRoutes(params) {
8625
- const { historyService, registry, driver } = params;
9490
+ const { historyService, registry } = params;
8626
9491
  const router = new Hono();
8627
9492
  router.onError(errorHandler);
8628
9493
  /**
9494
+ * Both halves of the authorization the REST generator applies to every
9495
+ * other route on this router, for the row whose history is being asked for.
9496
+ *
9497
+ * `historyService` reads `rebase.entity_history` through the *privileged*
9498
+ * handle the bootstrapper built — not `c.get("driver")` — and it has to:
9499
+ * history is one table shadowing every audited collection, so it cannot
9500
+ * carry their row policies, and `ensure-history-table` revokes it from
9501
+ * `rebase_user` outright for exactly that reason. Nothing about the read is
9502
+ * therefore scoped to the caller. That makes this function the *entire*
9503
+ * access-control model for the route, the same way `storageAuthorize` is
9504
+ * for storage.
9505
+ *
9506
+ * It asks the two questions the data API already answers, in its order:
9507
+ *
9508
+ * - the API key's permission list, via the same predicate
9509
+ * `RestApiGenerator.enforceApiKeyPermission` uses, and keyed on the
9510
+ * request's own method so that reverting needs `write` and listing needs
9511
+ * `read` — a key scoped to no collection, or to a different one, or to
9512
+ * reading only, must not get past this;
9513
+ * - RLS, by fetching the row itself through the request-scoped driver.
9514
+ * A row the caller may not read comes back undefined, and its history is
9515
+ * then a 404 rather than a 403, because a 403 confirms the row exists.
9516
+ *
9517
+ * A missing scoped driver is an internal error, never a fallback to the
9518
+ * unscoped one — the same rule, and the same reason, as
9519
+ * {@link RestApiGenerator.getScopedDriver}.
9520
+ */
9521
+ async function authorizeEntityRead(c, collection, id) {
9522
+ const apiKey = c.get("apiKey");
9523
+ const operation = httpMethodToOperation(c.req.method);
9524
+ if (apiKey && !isOperationAllowed(apiKey.permissions, collection.slug, operation)) throw ApiError.forbidden(`API key does not have "${operation}" permission for collection "${collection.slug}"`, "API_KEY_FORBIDDEN");
9525
+ const scoped = c.get("driver");
9526
+ if (!scoped) throw ApiError.internal("Scoped driver not available");
9527
+ if (!await scoped.fetchOne({
9528
+ path: collection.slug,
9529
+ id,
9530
+ collection
9531
+ })) throw ApiError.notFound(`Entity '${id}' not found in collection '${collection.slug}'`);
9532
+ }
9533
+ /**
8629
9534
  * GET /:slug/:id/history - List history entries for a entity
8630
9535
  *
8631
9536
  * Query params:
8632
- * limit (default 20)
9537
+ * limit (default 20, maximum 100 — above that the request is refused)
8633
9538
  * offset (default 0)
8634
9539
  */
8635
9540
  router.get("/:slug/:id/history", async (c) => {
8636
9541
  const slug = c.req.param("slug");
8637
9542
  const id = c.req.param("id");
8638
- const parsedLimit = parseInt(c.req.query("limit") ?? "20", 10);
9543
+ const limit = resolveListLimitParam(c.req.query("limit"), {
9544
+ defaultLimit: HISTORY_DEFAULT_LIMIT,
9545
+ maxLimit: HISTORY_MAX_LIMIT
9546
+ });
8639
9547
  const parsedOffset = parseInt(c.req.query("offset") ?? "0", 10);
8640
- const limit = Number.isNaN(parsedLimit) ? 20 : parsedLimit;
8641
9548
  const offset = Number.isNaN(parsedOffset) ? 0 : parsedOffset;
8642
9549
  const collection = registry.getCollections().find((col) => col.slug === slug || false);
8643
9550
  if (!collection) throw ApiError.notFound(`Collection '${slug}' not found`);
8644
9551
  if (!collection.history) throw ApiError.badRequest(`History is not enabled for collection '${slug}'`);
9552
+ await authorizeEntityRead(c, collection, id);
8645
9553
  const tableName = collection.slug;
9554
+ const appliedLimit = limit;
9555
+ const appliedOffset = Math.max(offset, 0);
8646
9556
  const result = await historyService.fetchHistory(tableName, id, {
8647
- limit: Math.min(limit, 100),
8648
- offset: Math.max(offset, 0)
9557
+ limit: appliedLimit,
9558
+ offset: appliedOffset
8649
9559
  });
8650
9560
  return c.json({
8651
9561
  data: result.data,
8652
9562
  meta: {
8653
9563
  total: result.total,
8654
- limit,
8655
- offset,
8656
- hasMore: offset + result.data.length < result.total
9564
+ limit: appliedLimit,
9565
+ offset: appliedOffset,
9566
+ hasMore: appliedOffset + result.data.length < result.total
8657
9567
  }
8658
9568
  });
8659
9569
  });
@@ -8669,12 +9579,13 @@ function createHistoryRoutes(params) {
8669
9579
  const collection = registry.getCollections().find((col) => col.slug === slug || false);
8670
9580
  if (!collection) throw ApiError.notFound(`Collection '${slug}' not found`);
8671
9581
  if (!collection.history) throw ApiError.badRequest(`History is not enabled for collection '${slug}'`);
9582
+ await authorizeEntityRead(c, collection, id);
8672
9583
  const historyEntry = await historyService.fetchHistoryEntry(historyId);
8673
9584
  if (!historyEntry) throw ApiError.notFound(`History entry '${historyId}' not found`);
8674
9585
  const tableName = collection.slug;
8675
9586
  if (historyEntry.entity_id !== String(id) || historyEntry.table_name !== tableName) throw ApiError.badRequest("History entry does not belong to this entity");
8676
9587
  if (!historyEntry.values) throw ApiError.badRequest("Cannot revert: history entry has no stored values");
8677
- const authDriver = c.get("driver") || driver;
9588
+ const authDriver = c.get("driver");
8678
9589
  const path = collection.slug;
8679
9590
  const savedEntity = await authDriver.save({
8680
9591
  path,
@@ -8691,126 +9602,6 @@ function createHistoryRoutes(params) {
8691
9602
  return router;
8692
9603
  }
8693
9604
  //#endregion
8694
- //#region src/email/smtp-email-service.ts
8695
- var _nodemailer;
8696
- async function loadNodemailer() {
8697
- if (!_nodemailer) try {
8698
- _nodemailer = await import("nodemailer");
8699
- } catch {
8700
- throw new Error("nodemailer is required for SMTP email. Install it: pnpm add nodemailer");
8701
- }
8702
- return _nodemailer;
8703
- }
8704
- /**
8705
- * Safely parse a hostname from a URL string
8706
- */
8707
- function getHostname(urlStr) {
8708
- try {
8709
- return new URL(urlStr.includes("://") ? urlStr : `https://${urlStr}`).hostname;
8710
- } catch {
8711
- return;
8712
- }
8713
- }
8714
- /**
8715
- * SMTP Email Service implementation using Nodemailer
8716
- */
8717
- var SMTPEmailService = class {
8718
- transporter = null;
8719
- config;
8720
- _initialized = false;
8721
- constructor(config) {
8722
- this.config = config;
8723
- }
8724
- /**
8725
- * Lazily initialize the SMTP transporter on first use
8726
- */
8727
- async ensureTransporter() {
8728
- if (this._initialized) return;
8729
- this._initialized = true;
8730
- if (this.config.smtp) {
8731
- const nodemailer = await loadNodemailer();
8732
- let smtpName = this.config.smtp.name;
8733
- if (!smtpName) {
8734
- const urlsToTry = [
8735
- process.env.FRONTEND_URL,
8736
- this.config.resetPasswordUrl,
8737
- this.config.verifyEmailUrl
8738
- ];
8739
- for (const urlStr of urlsToTry) if (urlStr) {
8740
- const hostname = getHostname(urlStr);
8741
- if (hostname) {
8742
- smtpName = hostname;
8743
- break;
8744
- }
8745
- }
8746
- }
8747
- this.transporter = nodemailer.createTransport({
8748
- name: smtpName,
8749
- host: this.config.smtp.host,
8750
- port: this.config.smtp.port,
8751
- secure: this.config.smtp.secure ?? this.config.smtp.port === 465,
8752
- auth: this.config.smtp.auth ? {
8753
- user: this.config.smtp.auth.user,
8754
- pass: this.config.smtp.auth.pass
8755
- } : void 0
8756
- });
8757
- }
8758
- }
8759
- /**
8760
- * Check if the email service is properly configured
8761
- */
8762
- isConfigured() {
8763
- return !!(this.config.smtp || this.config.sendEmail);
8764
- }
8765
- /**
8766
- * Send an email using SMTP or custom send function
8767
- */
8768
- async send(options) {
8769
- if (this.config.sendEmail) {
8770
- await this.config.sendEmail(options);
8771
- return;
8772
- }
8773
- await this.ensureTransporter();
8774
- if (!this.transporter) throw new Error("Email service not configured. Provide SMTP config or sendEmail function.");
8775
- const to = Array.isArray(options.to) ? options.to.join(", ") : options.to;
8776
- try {
8777
- await this.transporter.sendMail({
8778
- from: this.config.from,
8779
- to,
8780
- subject: options.subject,
8781
- html: options.html,
8782
- text: options.text,
8783
- replyTo: options.replyTo
8784
- });
8785
- } catch (error) {
8786
- const message = error instanceof Error ? error.message : String(error);
8787
- logger.error("Failed to send email", { detail: message });
8788
- throw new Error(`Failed to send email: ${message}`);
8789
- }
8790
- }
8791
- /**
8792
- * Verify SMTP connection (useful for startup checks)
8793
- */
8794
- async verifyConnection() {
8795
- await this.ensureTransporter();
8796
- if (!this.transporter) return !!this.config.sendEmail;
8797
- try {
8798
- await this.transporter.verify();
8799
- return true;
8800
- } catch (error) {
8801
- const message = error instanceof Error ? error.message : String(error);
8802
- logger.error("SMTP connection verification failed", { detail: message });
8803
- return false;
8804
- }
8805
- }
8806
- };
8807
- /**
8808
- * Create an email service from configuration
8809
- */
8810
- function createEmailService(config) {
8811
- return new SMTPEmailService(config);
8812
- }
8813
- //#endregion
8814
9605
  //#region src/singleton.ts
8815
9606
  /**
8816
9607
  * The backing instance lives on a process-global slot, NOT in a module-local
@@ -8871,14 +9662,30 @@ function _resetRebaseMock() {
8871
9662
  * **Admin data plane** (`rebase.dataAsAdmin`):
8872
9663
  * Backed by the native DataDriver — calls go directly to the database without
8873
9664
  * JSON serialization, HTTP dispatch, or middleware overhead. The driver is
8874
- * scoped as `{ uid: "service", roles: ["admin"] }`, so **every read and write
8875
- * bypasses row-level-security policies**. No `REBASE_SERVICE_KEY` is required.
8876
- *
8877
- * ⚠️ Because it bypasses RLS, `rebase.dataAsAdmin` is for trusted background
8878
- * work (cron jobs, migrations, service tasks) — **not** for serving user-facing
8879
- * data. Inside a request handler, run user-scoped queries through the
8880
- * request-scoped driver (`c.var.driver`), which carries the caller's identity
8881
- * so RLS applies.
9665
+ * scoped once as `{ uid: "service", roles: ["admin"] }` (`SERVICE_IDENTITY`),
9666
+ * which makes it **admin-scoped, not RLS-bypassing**: every read and write runs
9667
+ * in a transaction that has done `SET LOCAL ROLE rebase_user` with
9668
+ * `app.uid = 'service'`, and policies are evaluated against that. No
9669
+ * `REBASE_SERVICE_KEY` is required.
9670
+ *
9671
+ * It clears the default policies through their `rolesOverlap(['admin'])` arm —
9672
+ * which is why the difference rarely shows. It shows when you write your own:
9673
+ *
9674
+ * - `policy.serverContext()` compiles to `auth.uid() IS NULL` and is therefore
9675
+ * **false** for this accessor. A collection with `disableDefaultPolicies:
9676
+ * true` whose rule is `serverContext()` denies these writes (`42501`) and
9677
+ * returns zero rows — HTTP 200, empty — for these reads.
9678
+ * - Its reach equals an `admin`-roled application user's reach. It is not a
9679
+ * private door.
9680
+ *
9681
+ * `rebase.sql()` *is* an unconditional bypass — it runs on the owner connection
9682
+ * and never goes through `withAuth`. Of the two accessors on this object, the
9683
+ * quieter one is the more privileged.
9684
+ *
9685
+ * ⚠️ `rebase.dataAsAdmin` is for trusted background work (cron jobs,
9686
+ * migrations, service tasks) — **not** for serving user-facing data. Inside a
9687
+ * request handler, run user-scoped queries through the request-scoped driver
9688
+ * (`c.var.driver`), which carries the caller's identity.
8882
9689
  *
8883
9690
  * `rebase.data` is **gone from the type**: `RebaseServerClient` omits it, so the
8884
9691
  * admin-scoped accessor has exactly one name and the privilege is visible at the
@@ -8894,7 +9701,8 @@ function _resetRebaseMock() {
8894
9701
  * ```typescript
8895
9702
  * import { rebase } from "@rebasepro/server";
8896
9703
  *
8897
- * // In a cron job, hook, or trusted service file (admin scope, bypasses RLS):
9704
+ * // In a cron job, hook, or trusted service file (admin scope, RLS evaluated
9705
+ * // as `{ uid: "service", roles: ["admin"] }`):
8898
9706
  * await rebase.email.send({ to: "admin@co.com", subject: "Alert", html: "<p>Hi</p>" });
8899
9707
  * const jobs = await rebase.dataAsAdmin.jobs.find({ limit: 10 });
8900
9708
  * ```
@@ -9009,6 +9817,7 @@ async function _initializeRebaseBackend(config) {
9009
9817
  if (config.logging?.level) configureLogLevel(config.logging.level);
9010
9818
  else configureLogLevel();
9011
9819
  logger.info("Initializing Rebase Backend");
9820
+ installUnhandledRejectionHandler();
9012
9821
  const basePath = config.basePath || "/api";
9013
9822
  const isProduction = process.env.NODE_ENV === "production";
9014
9823
  configureMiddlewares(config.app, basePath, isProduction, config);
@@ -9249,7 +10058,7 @@ async function _initializeRebaseBackend(config) {
9249
10058
  ]) {
9250
10059
  const providerConfig = safeAuthConfig[key];
9251
10060
  if (providerConfig && requiredFields.every((f) => Boolean(providerConfig[f]))) {
9252
- const createFn = (await import("./auth-CuC9M2x6.js").then((n) => n.t))[factory];
10061
+ const createFn = (await import("./auth-DkAHqvf5.js").then((n) => n.t))[factory];
9253
10062
  oauthProviders.push(createFn(providerConfig));
9254
10063
  }
9255
10064
  }
@@ -9264,6 +10073,7 @@ async function _initializeRebaseBackend(config) {
9264
10073
  allowUserLookup: safeAuthConfig.allowUserLookup ?? false,
9265
10074
  defaultRole: safeAuthConfig.defaultRole,
9266
10075
  oauthProviders,
10076
+ allowedRedirectUris: safeAuthConfig.allowedRedirectUris,
9267
10077
  serviceKey: serviceKey || internalServiceKey,
9268
10078
  authHooks: safeAuthConfig.hooks,
9269
10079
  collectionAuthConfig,
@@ -9343,7 +10153,7 @@ async function _initializeRebaseBackend(config) {
9343
10153
  let schemaEditorOff = schemaEditorUnavailable();
9344
10154
  let schemaEditorRoutes;
9345
10155
  if (!schemaEditorOff && config.collectionsDir) try {
9346
- schemaEditorRoutes = (await import("./schema-editor-routes-CbF20Mf1.js")).createSchemaEditorRoutes(config.collectionsDir);
10156
+ schemaEditorRoutes = (await import("./schema-editor-routes-DZFKGXgq.js")).createSchemaEditorRoutes(config.collectionsDir);
9347
10157
  } catch (err) {
9348
10158
  if (err?.code === "ERR_MODULE_NOT_FOUND") {
9349
10159
  schemaEditorOff = {
@@ -9413,6 +10223,7 @@ async function _initializeRebaseBackend(config) {
9413
10223
  config.app.route(`${basePath}/storage`, storageStub);
9414
10224
  logger.info("Storage not configured — /storage returns 501 STORAGE_NOT_CONFIGURED");
9415
10225
  }
10226
+ const serverCollections = activeCollections.filter((collection) => resolveDataSource(collection, dataSourceRegistry).transport === "server");
9416
10227
  if (activeCollections.length > 0) {
9417
10228
  const dataRouter = new Hono();
9418
10229
  dataRouter.onError(errorHandler);
@@ -9454,11 +10265,11 @@ async function _initializeRebaseBackend(config) {
9454
10265
  });
9455
10266
  dataRouter.route("/", historyRoutes);
9456
10267
  }
9457
- const restGenerator = new RestApiGenerator(activeCollections.filter((collection) => resolveDataSource(collection, dataSourceRegistry).transport === "server"), defaultDriver, authAdapter);
10268
+ const restGenerator = new RestApiGenerator(serverCollections, defaultDriver, authAdapter);
9458
10269
  dataRouter.route("/", restGenerator.generateRoutes());
9459
10270
  config.app.route(`${basePath}/data`, dataRouter);
9460
10271
  }
9461
- await mountOpenApiDocs(config.app, basePath, config.enableSwagger, activeCollections, resolveRequireAuth(config.auth));
10272
+ await mountOpenApiDocs(config.app, basePath, config.enableSwagger, serverCollections, resolveRequireAuth(config.auth));
9462
10273
  const serverClient = createRebaseClient({
9463
10274
  baseUrl: "http://localhost",
9464
10275
  apiPath: basePath,
@@ -9468,10 +10279,7 @@ async function _initializeRebaseBackend(config) {
9468
10279
  return await config.app.request(input, init);
9469
10280
  }
9470
10281
  });
9471
- const serviceIdentity = {
9472
- uid: "service",
9473
- roles: ["admin"]
9474
- };
10282
+ const serviceIdentity = SERVICE_IDENTITY;
9475
10283
  const defaultData = buildSdkData(await scopeDataDriver(defaultDriver, serviceIdentity));
9476
10284
  storageAuthorizeData.current = defaultData;
9477
10285
  const dataSourcesByKey = {};
@@ -9513,55 +10321,59 @@ async function _initializeRebaseBackend(config) {
9513
10321
  Object.assign(serverClient, { sql: (query, options) => driverAdmin.executeSql(query, options) });
9514
10322
  logger.info("SQL capability attached to singleton");
9515
10323
  }
9516
- _initRebase(serverClient);
10324
+ const serverSingleton = serverClient;
10325
+ _initRebase(serverSingleton);
9517
10326
  logger.info("Rebase singleton initialized");
9518
10327
  if (defaultDriverResult.internals) {
9519
10328
  const driver = defaultDriverResult.internals.driver;
9520
10329
  if (driver && "client" in driver) driver.client = serverClient;
9521
10330
  }
9522
10331
  if (config.functionsDir) {
9523
- const { loadFunctionsFromDirectory } = await import("./function-loader-B_1fYfUY.js").then((n) => n.t);
9524
- const { createFunctionRoutes } = await import("./function-routes-C0cLIy3N.js").then((n) => n.n);
9525
- const loadedFunctions = await loadFunctionsFromDirectory(config.functionsDir);
9526
- if (loadedFunctions.length > 0) {
9527
- const functionsRouter = new Hono();
9528
- functionsRouter.onError(errorHandler);
9529
- const functionsRequireAuth = false;
9530
- if (authAdapter) functionsRouter.use("/*", createAdapterAuthMiddleware({
9531
- adapter: authAdapter,
9532
- driver: defaultDriver,
9533
- requireAuth: functionsRequireAuth,
9534
- apiKeyStore
9535
- }));
9536
- else functionsRouter.use("/*", createAuthMiddleware({
9537
- driver: defaultDriver,
9538
- requireAuth: functionsRequireAuth,
9539
- serviceKey: internalServiceKey,
9540
- apiKeyStore
9541
- }));
9542
- functionsRouter.use("/*", createFunctionApiKeyGuard(`${basePath}/functions`));
9543
- if (rateLimitConfig) functionsRouter.use("/*", createDataRateLimiter({
9544
- ...rateLimitConfig,
9545
- anonymous: null
9546
- }));
9547
- const fnRoutes = createFunctionRoutes(loadedFunctions);
9548
- functionsRouter.route("/", fnRoutes);
9549
- config.app.route(`${basePath}/functions`, functionsRouter);
9550
- logger.info("Mounted custom functions", {
9551
- count: loadedFunctions.length,
9552
- path: `${basePath}/functions`
9553
- });
9554
- }
10332
+ const { loadFunctionsWithDiagnostics } = await import("./function-loader-D1SwtCa5.js").then((n) => n.t);
10333
+ const { createFunctionRoutes } = await import("./function-routes-Btcez1T-.js").then((n) => n.n);
10334
+ const { createFunctionsRequestTimeout, resolveFunctionsTimeoutMs } = await import("./request-timeout-RivJsME0.js");
10335
+ const { functions: loadedFunctions, problems } = await loadFunctionsWithDiagnostics(config.functionsDir);
10336
+ const functionsRouter = new Hono();
10337
+ functionsRouter.onError(errorHandler);
10338
+ const functionsTimeoutMs = resolveFunctionsTimeoutMs(config.functionsTimeoutMs);
10339
+ if (functionsTimeoutMs > 0) functionsRouter.use("/*", createFunctionsRequestTimeout(functionsTimeoutMs));
10340
+ const functionsRequireAuth = false;
10341
+ if (authAdapter) functionsRouter.use("/*", createAdapterAuthMiddleware({
10342
+ adapter: authAdapter,
10343
+ driver: defaultDriver,
10344
+ requireAuth: functionsRequireAuth,
10345
+ apiKeyStore
10346
+ }));
10347
+ else functionsRouter.use("/*", createAuthMiddleware({
10348
+ driver: defaultDriver,
10349
+ requireAuth: functionsRequireAuth,
10350
+ serviceKey: internalServiceKey,
10351
+ apiKeyStore
10352
+ }));
10353
+ functionsRouter.use("/*", createFunctionApiKeyGuard(`${basePath}/functions`));
10354
+ if (rateLimitConfig) functionsRouter.use("/*", createDataRateLimiter({
10355
+ ...rateLimitConfig,
10356
+ anonymous: rateLimitConfig.anonymousFunctions !== void 0 ? rateLimitConfig.anonymousFunctions : DEFAULT_FUNCTIONS_ANONYMOUS_LIMIT
10357
+ }));
10358
+ const fnRoutes = createFunctionRoutes(loadedFunctions, problems.length);
10359
+ functionsRouter.route("/", fnRoutes);
10360
+ config.app.route(`${basePath}/functions`, functionsRouter);
10361
+ if (loadedFunctions.length > 0) logger.info("Mounted custom functions", {
10362
+ count: loadedFunctions.length,
10363
+ path: `${basePath}/functions`,
10364
+ timeoutMs: functionsTimeoutMs
10365
+ });
10366
+ else logger.warn(`Function routes mounted at ${basePath}/functions, but no functions loaded from ${config.functionsDir}. ` + (problems.length > 0 ? `${problems.length} file(s) were skipped — see the messages above.` : "The directory holds no .ts/.js function files."));
9555
10367
  }
9556
10368
  let cronScheduler;
9557
10369
  if (config.cronsDir) {
9558
- const { loadCronJobsFromDirectory } = await import("./cron-loader-B1S2MCSl.js").then((n) => n.t);
9559
- const { CronScheduler } = await import("./cron-scheduler-B3RFt0HS.js").then((n) => n.n);
9560
- const { createCronRoutes } = await import("./cron-routes-CrQ0tK-_.js").then((n) => n.n);
9561
- const { createCronStore } = await import("./cron-store-BywZsyfZ.js").then((n) => n.n);
10370
+ const { loadCronJobsFromDirectory } = await import("./cron-loader-3U5aGILy.js").then((n) => n.t);
10371
+ const { CronScheduler } = await import("./cron-scheduler-FJAaCAXm.js").then((n) => n.n);
10372
+ const { createCronRoutes } = await import("./cron-routes-rZgwlOz4.js").then((n) => n.n);
10373
+ const { createCronStore } = await import("./cron-store-9NmUDfzL.js").then((n) => n.n);
9562
10374
  const loadedCronJobs = await loadCronJobsFromDirectory(config.cronsDir);
9563
10375
  cronScheduler = new CronScheduler();
9564
- cronScheduler.setClient(serverClient);
10376
+ cronScheduler.setClient(serverSingleton);
9565
10377
  if (loadedCronJobs.length > 0) {
9566
10378
  cronScheduler.registerJobs(loadedCronJobs);
9567
10379
  const store = defaultBootstrapper.getAdmin?.(defaultDriverResult) && config.cronPersistence !== false ? createCronStore(defaultDriver) : void 0;
@@ -9583,7 +10395,7 @@ async function _initializeRebaseBackend(config) {
9583
10395
  } else logger.warn(`Cron routes mounted at ${basePath}/cron, but no jobs loaded from ${config.cronsDir}. Nothing is scheduled — check the messages above for files that failed to load.`);
9584
10396
  }
9585
10397
  {
9586
- const { createBackupRoutes, parseBackupDestination } = await import("./backup-CVggVhR2.js").then((n) => n.t);
10398
+ const { createBackupRoutes, parseBackupDestination } = await import("./backup-DLluVyuA.js").then((n) => n.t);
9587
10399
  const backupRouter = new Hono();
9588
10400
  applyAdminGate(backupRouter, "Backup");
9589
10401
  backupRouter.route("/", createBackupRoutes({
@@ -9605,7 +10417,7 @@ async function _initializeRebaseBackend(config) {
9605
10417
  logger.info("Logs routes mounted", { path: `${basePath}/logs` });
9606
10418
  }
9607
10419
  {
9608
- const { createContractRoutes } = await import("./contract-routes-Dj8i5AiM.js").then((n) => n.t);
10420
+ const { createContractRoutes } = await import("./contract-routes-BB1U05sS.js").then((n) => n.t);
9609
10421
  const contractRouter = new Hono();
9610
10422
  if (adminSurfacesGated) {
9611
10423
  if (apiKeyPreAuth) contractRouter.use("/contract", apiKeyPreAuth);
@@ -9699,7 +10511,10 @@ async function _initializeRebaseBackend(config) {
9699
10511
  * export default defineFunction((app, { rebase }) => {
9700
10512
  * app.use("/*", requireAuth);
9701
10513
  * app.get("/home", async (c) => {
9702
- * const [stats] = await rebase.sql!(`SELECT count(*) AS n FROM orders`);
10514
+ * // `rebase.sql` runs on the owner connection: no RLS, no policies,
10515
+ * // every row. It is the most privileged thing in this context —
10516
+ * // more so than `dataAsAdmin`, which is merely admin-scoped.
10517
+ * const [stats] = await rebase.sql(`SELECT count(*) AS n FROM orders`);
9703
10518
  * return c.json({ orders: Number(stats.n) });
9704
10519
  * });
9705
10520
  * });
@@ -9724,6 +10539,17 @@ function defineFunction(definition) {
9724
10539
  *
9725
10540
  * @see {@link defineFunction} for the equivalent custom-functions helper.
9726
10541
  *
10542
+ * `ctx.rebase` is the `rebase` singleton itself — the same object a custom
10543
+ * function is handed — so `rebase.dataAsAdmin` here is **admin-scoped, not an
10544
+ * RLS bypass**: it is scoped once at boot as `SERVICE_IDENTITY`
10545
+ * (`{ uid: "service", roles: ["admin"] }`), and your policies are evaluated
10546
+ * against that identity, not skipped. See that constant's docblock. It clears
10547
+ * the default policies through their `rolesOverlap(['admin'])` arm, which is why
10548
+ * the distinction rarely shows; it shows against hand-written rules, where
10549
+ * `policy.serverContext()` (`auth.uid() IS NULL`) is **false** for it. Use
10550
+ * `rebase.sql()` when you need the genuine bypass — owner connection, no
10551
+ * policies.
10552
+ *
9727
10553
  * @example
9728
10554
  * ```ts
9729
10555
  * import { defineCron } from "@rebasepro/server";
@@ -9731,12 +10557,15 @@ function defineFunction(definition) {
9731
10557
  * export default defineCron({
9732
10558
  * name: "Nightly cleanup",
9733
10559
  * schedule: "0 3 * * *",
9734
- * async handler({ client, log }) {
9735
- * const { data: expired } = await client.data.sessions.find({
10560
+ * async handler({ rebase, log }) {
10561
+ * // `dataAsAdmin` runs as the service identity, with the `admin` role.
10562
+ * // A cron has no per-request user to narrow that, so scope the
10563
+ * // filters yourself — RLS will not do it for you here.
10564
+ * const { data: expired } = await rebase.dataAsAdmin.sessions.find({
9736
10565
  * where: { expired: ["==", true] },
9737
10566
  * });
9738
10567
  * for (const session of expired) {
9739
- * await client.data.sessions.delete(session.id);
10568
+ * await rebase.dataAsAdmin.sessions.delete(session.id as string);
9740
10569
  * }
9741
10570
  * log(`Deleted ${expired.length} expired sessions`);
9742
10571
  * },
@@ -9749,36 +10578,41 @@ function defineCron(definition) {
9749
10578
  //#endregion
9750
10579
  //#region src/utils/sql.ts
9751
10580
  /**
9752
- * Returns a SQL chunk calling `auth.uid()` — the current user's ID.
9753
- * This is a PostgreSQL RLS helper function created in the `auth` schema
10581
+ * Returns a SQL chunk calling `rebase.uid()` — the current user's ID.
10582
+ * This is a PostgreSQL RLS helper function created in the `rebase` schema
9754
10583
  * that reads `app.uid` set per-transaction by `withAuth()`.
9755
10584
  *
10585
+ * The function moved from `auth.uid()` to `rebase.uid()` in 1.0 — `auth` is
10586
+ * Supabase's schema, and taking it meant Rebase could not be pointed at a
10587
+ * database that already had one. Use this helper rather than writing the call
10588
+ * by hand and the move costs you nothing.
10589
+ *
9756
10590
  * @example
9757
10591
  * sql`${table.uid} = ${authUid()}`
9758
10592
  */
9759
10593
  var authUid = () => {
9760
- return sql`auth.uid()`;
10594
+ return sql.raw(RLS_UID_SQL);
9761
10595
  };
9762
10596
  /**
9763
- * Returns a SQL chunk calling `auth.roles()` — the current user's roles
10597
+ * Returns a SQL chunk calling `rebase.roles()` — the current user's roles
9764
10598
  * as a comma-separated string.
9765
10599
  * Reads `app.user_roles` set per-transaction by `withAuth()`.
9766
10600
  *
9767
10601
  * @example
9768
- * sql`auth.roles() ~ 'admin'`
10602
+ * sql`${authRoles()} ~ 'admin'`
9769
10603
  */
9770
10604
  var authRoles = () => {
9771
- return sql`auth.roles()`;
10605
+ return sql.raw(RLS_ROLES_SQL);
9772
10606
  };
9773
10607
  /**
9774
- * Returns a SQL chunk calling `auth.jwt()` — the full JWT claims as JSONB.
10608
+ * Returns a SQL chunk calling `rebase.jwt()` — the full JWT claims as JSONB.
9775
10609
  * Reads `app.jwt` set per-transaction by `withAuth()`.
9776
10610
  *
9777
10611
  * @example
9778
- * sql`auth.jwt()->>'sub'`
10612
+ * sql`${authJwt()}->>'sub'`
9779
10613
  */
9780
10614
  var authJwt = () => {
9781
- return sql`auth.jwt()`;
10615
+ return sql.raw(RLS_JWT_SQL);
9782
10616
  };
9783
10617
  //#endregion
9784
10618
  //#region src/env.ts
@@ -9915,7 +10749,158 @@ function loadEnv(options) {
9915
10749
  return env;
9916
10750
  }
9917
10751
  //#endregion
10752
+ //#region src/services/outbound-url-guard.ts
10753
+ /**
10754
+ * Refusal to send a request to a destination the guard does not allow.
10755
+ *
10756
+ * Distinct from a network error on purpose: a blocked destination is
10757
+ * *terminal* — retrying it re-runs the same refusal — while an `ENOTFOUND`
10758
+ * or a refused connection is worth another attempt.
10759
+ */
10760
+ var BlockedUrlError = class extends Error {
10761
+ constructor(message) {
10762
+ super(message);
10763
+ this.name = "BlockedUrlError";
10764
+ }
10765
+ };
10766
+ /** Names that mean "this machine" or "this network" before DNS is even asked. */
10767
+ var BLOCKED_HOST_SUFFIXES = [
10768
+ ".localhost",
10769
+ ".local",
10770
+ ".internal",
10771
+ ".home.arpa"
10772
+ ];
10773
+ async function defaultLookup(hostname) {
10774
+ return (await lookup(hostname, {
10775
+ all: true,
10776
+ verbatim: true
10777
+ })).map((e) => e.address);
10778
+ }
10779
+ /**
10780
+ * Parse an IPv6 literal into its 16 bytes. Returns `null` for anything that is
10781
+ * not one — the caller has already established the string *is* an IPv6 address
10782
+ * via `isIP`, so `null` here means "shape we do not model", which is treated as
10783
+ * blocked rather than allowed.
10784
+ */
10785
+ function parseIpv6(address) {
10786
+ let text = address;
10787
+ const zone = text.indexOf("%");
10788
+ if (zone !== -1) text = text.slice(0, zone);
10789
+ let tail = [];
10790
+ const lastColon = text.lastIndexOf(":");
10791
+ const maybeV4 = text.slice(lastColon + 1);
10792
+ if (maybeV4.includes(".")) {
10793
+ if (isIP(maybeV4) !== 4) return null;
10794
+ tail = maybeV4.split(".").map(Number);
10795
+ text = text.slice(0, lastColon);
10796
+ if (text.endsWith(":")) text += ":";
10797
+ }
10798
+ const halves = text.split("::");
10799
+ if (halves.length > 2) return null;
10800
+ const head = halves[0] ? halves[0].split(":") : [];
10801
+ const rest = halves.length === 2 ? halves[1] ? halves[1].split(":") : [] : [];
10802
+ const missing = 8 - tail.length / 2 - head.length - rest.length;
10803
+ if (halves.length === 1 ? missing !== 0 : missing < 0) return null;
10804
+ const all = [
10805
+ ...head,
10806
+ ...new Array(halves.length === 2 ? missing : 0).fill("0"),
10807
+ ...rest
10808
+ ];
10809
+ const bytes = [];
10810
+ for (const group of all) {
10811
+ const value = Number.parseInt(group, 16);
10812
+ if (!Number.isFinite(value) || value < 0 || value > 65535 || !/^[0-9a-fA-F]{1,4}$/.test(group)) return null;
10813
+ bytes.push(value >> 8 & 255, value & 255);
10814
+ }
10815
+ return [...bytes, ...tail];
10816
+ }
10817
+ /**
10818
+ * Why this IPv4 address must not be a webhook destination, or `null` if it is
10819
+ * an ordinary public address.
10820
+ */
10821
+ function blockedIpv4Reason(bytes) {
10822
+ const [a, b] = bytes;
10823
+ if (a === 0) return "\"this network\" (0.0.0.0/8)";
10824
+ if (a === 10) return "private (10.0.0.0/8)";
10825
+ if (a === 127) return "loopback (127.0.0.0/8)";
10826
+ if (a === 169 && b === 254) return "link-local (169.254.0.0/16) — the cloud metadata range";
10827
+ if (a === 172 && b >= 16 && b <= 31) return "private (172.16.0.0/12)";
10828
+ if (a === 192 && b === 168) return "private (192.168.0.0/16)";
10829
+ if (a === 100 && b >= 64 && b <= 127) return "carrier-grade NAT (100.64.0.0/10)";
10830
+ if (a === 192 && b === 0 && bytes[2] === 0) return "IETF protocol assignments (192.0.0.0/24)";
10831
+ if (a === 198 && (b === 18 || b === 19)) return "benchmarking (198.18.0.0/15)";
10832
+ if (a >= 224) return "multicast, reserved or broadcast (224.0.0.0/4 and above)";
10833
+ return null;
10834
+ }
10835
+ /**
10836
+ * Why this address must not be a webhook destination, or `null` if it is an
10837
+ * ordinary public address. Exported for tests and for anyone else adding an
10838
+ * outbound call whose destination comes from data.
10839
+ */
10840
+ function blockedAddressReason(address) {
10841
+ const version = isIP(address);
10842
+ if (version === 4) return blockedIpv4Reason(address.split(".").map(Number));
10843
+ if (version !== 6) return `not an IP address: ${address}`;
10844
+ const bytes = parseIpv6(address);
10845
+ if (!bytes || bytes.length !== 16) return `unparseable IPv6 address: ${address}`;
10846
+ const low32 = bytes.slice(12);
10847
+ const isMapped = bytes.slice(0, 10).every((x) => x === 0) && bytes[10] === 255 && bytes[11] === 255;
10848
+ const isCompat = bytes.slice(0, 12).every((x) => x === 0) && !(low32[0] === 0 && low32[1] === 0);
10849
+ const isNat64 = bytes[0] === 0 && bytes[1] === 100 && bytes[2] === 255 && bytes[3] === 155;
10850
+ if (isMapped || isCompat || isNat64) return blockedIpv4Reason(low32);
10851
+ if (bytes[0] === 32 && bytes[1] === 2) return blockedIpv4Reason(bytes.slice(2, 6));
10852
+ if (bytes.every((x) => x === 0)) return "unspecified (::)";
10853
+ if (bytes.slice(0, 15).every((x) => x === 0) && bytes[15] === 1) return "loopback (::1)";
10854
+ if (bytes[0] === 254 && (bytes[1] & 192) === 128) return "link-local (fe80::/10)";
10855
+ if ((bytes[0] & 254) === 252) return "unique local (fc00::/7)";
10856
+ if (bytes[0] === 255) return "multicast (ff00::/8)";
10857
+ return null;
10858
+ }
10859
+ /**
10860
+ * Resolve and validate a destination before anything connects to it.
10861
+ *
10862
+ * Three checks, in order, because each is cheaper than the next: the scheme,
10863
+ * the hostname, then every address the hostname resolves to. *Every* address —
10864
+ * a name that answers with one public and one private record is a rebinding
10865
+ * attempt, and taking the first record would let it through.
10866
+ *
10867
+ * Known limit: this is validate-then-fetch. Between the lookup here and the
10868
+ * connection the runtime makes there is a second resolution this code does not
10869
+ * see, so a TTL-0 name that flips between answers can still land on a blocked
10870
+ * address. Closing that needs the socket pinned to the address checked here,
10871
+ * which Node's global `fetch` does not expose without an undici dispatcher.
10872
+ * The redirect policy in {@link WebhookDispatcher} closes the much wider
10873
+ * version of the same hole — a receiver that simply *points* at the internal
10874
+ * address.
10875
+ *
10876
+ * @throws BlockedUrlError when the destination is not allowed. DNS failures
10877
+ * are rethrown as they are, so callers can tell "never send this" from
10878
+ * "could not send this right now".
10879
+ */
10880
+ async function assertAllowedOutboundUrl(rawUrl, options = {}) {
10881
+ let url;
10882
+ try {
10883
+ url = new URL(rawUrl);
10884
+ } catch {
10885
+ throw new BlockedUrlError(`Invalid webhook URL: ${JSON.stringify(rawUrl)}`);
10886
+ }
10887
+ if (url.protocol !== "https:" && url.protocol !== "http:") throw new BlockedUrlError(`Webhook URL must be http(s), got "${url.protocol}" in ${url.href}`);
10888
+ if (options.allowPrivateNetworks) return url;
10889
+ const hostname = url.hostname.startsWith("[") ? url.hostname.slice(1, -1) : url.hostname;
10890
+ const lowered = hostname.toLowerCase();
10891
+ if (lowered === "localhost" || BLOCKED_HOST_SUFFIXES.some((suffix) => lowered.endsWith(suffix))) throw new BlockedUrlError(`Webhook URL host "${hostname}" is an internal name`);
10892
+ const addresses = isIP(hostname) ? [hostname] : await (options.lookup ?? defaultLookup)(hostname);
10893
+ if (addresses.length === 0) throw new BlockedUrlError(`Webhook URL host "${hostname}" resolved to no addresses`);
10894
+ for (const address of addresses) {
10895
+ const reason = blockedAddressReason(address);
10896
+ if (reason) throw new BlockedUrlError(`Webhook URL host "${hostname}" resolves to ${address}, which is ${reason}. Set allowPrivateNetworks to deliver to it anyway.`);
10897
+ }
10898
+ return url;
10899
+ }
10900
+ //#endregion
9918
10901
  //#region src/services/webhook-service.ts
10902
+ /** Read at most this much of a response before cancelling the rest. */
10903
+ var MAX_RESPONSE_BYTES = 64 * 1024;
9919
10904
  var WebhookDispatcher = class {
9920
10905
  webhooks = [];
9921
10906
  maxRetries = 3;
@@ -9924,35 +10909,107 @@ var WebhookDispatcher = class {
9924
10909
  5e3,
9925
10910
  15e3
9926
10911
  ];
10912
+ options;
10913
+ timeoutMs;
10914
+ queue = [];
10915
+ draining = null;
10916
+ constructor(options = {}) {
10917
+ this.options = options;
10918
+ this.timeoutMs = options.timeoutMs ?? 1e4;
10919
+ }
9927
10920
  /** Register webhooks to watch */
9928
10921
  setWebhooks(webhooks) {
9929
10922
  this.webhooks = webhooks.filter((w) => w.enabled);
9930
10923
  }
9931
- /** Called when a entity changes — checks if any webhook matches */
10924
+ /**
10925
+ * Called when a entity changes — checks if any webhook matches, and awaits
10926
+ * every delivery, retries included.
10927
+ *
10928
+ * **Do not await this inside a collection callback.** `afterSave` and
10929
+ * `afterDelete` run inside the write's Postgres transaction, so awaiting a
10930
+ * delivery here holds a pooled connection and the row's locks for as long
10931
+ * as the receiver takes to answer — up to ~36s across three attempts, per
10932
+ * matching webhook. Use {@link enqueueEntityChange} there; this method is
10933
+ * for a custom function or a job that wants the results.
10934
+ */
9932
10935
  async onEntityChange(table, event, id, entity, previousEntity) {
9933
- const matchingWebhooks = this.webhooks.filter((w) => w.table === table && w.events.includes(event));
9934
- if (matchingWebhooks.length === 0) return [];
10936
+ const jobs = this.buildDeliveries(table, event, entity, previousEntity);
9935
10937
  const results = [];
9936
- for (const webhook of matchingWebhooks) {
9937
- const payload = {
10938
+ for (const job of jobs) {
10939
+ const result = await this.deliverWithRetry(job.webhook, job.event, job.payload);
10940
+ this.options.onDelivery?.(result);
10941
+ results.push(result);
10942
+ }
10943
+ return results;
10944
+ }
10945
+ /**
10946
+ * Queue the same deliveries and return immediately.
10947
+ *
10948
+ * This is the form a collection callback wants. `afterSave` is awaited
10949
+ * *inside* the transaction that wrote the row, so anything it awaits is
10950
+ * transaction time; queueing hands the HTTP to a drain loop that runs
10951
+ * after the callback returns, and the transaction commits without waiting
10952
+ * for a third party. It also means a receiver that is down can no longer
10953
+ * fail the customer's write.
10954
+ *
10955
+ * The cost, stated plainly: the queue is in-process and in-memory. A crash
10956
+ * or a deploy between the enqueue and the delivery drops the event, and a
10957
+ * receiver may see the notification a few milliseconds before the row it
10958
+ * describes is committed. Use {@link flush} on shutdown, and
10959
+ * `onDelivery` to record failures.
10960
+ */
10961
+ enqueueEntityChange(table, event, id, entity, previousEntity) {
10962
+ const jobs = this.buildDeliveries(table, event, entity, previousEntity);
10963
+ if (jobs.length === 0) return;
10964
+ this.queue.push(...jobs);
10965
+ this.startDraining();
10966
+ }
10967
+ /**
10968
+ * Wait for every queued delivery to finish. For graceful shutdown, and for
10969
+ * tests that need to observe what {@link enqueueEntityChange} sent.
10970
+ */
10971
+ async flush() {
10972
+ while (this.draining) await this.draining;
10973
+ }
10974
+ startDraining() {
10975
+ if (this.draining) return;
10976
+ const run = this.drainQueue().finally(() => {
10977
+ if (this.draining === run) this.draining = null;
10978
+ });
10979
+ this.draining = run;
10980
+ }
10981
+ async drainQueue() {
10982
+ while (this.queue.length > 0) {
10983
+ const job = this.queue.shift();
10984
+ try {
10985
+ const result = await this.deliverWithRetry(job.webhook, job.event, job.payload);
10986
+ this.options.onDelivery?.(result);
10987
+ } catch {}
10988
+ }
10989
+ }
10990
+ /** The payload each matching webhook gets for this change. */
10991
+ buildDeliveries(table, event, entity, previousEntity) {
10992
+ return this.webhooks.filter((w) => w.table === table && w.events.includes(event)).map((webhook) => ({
10993
+ webhook,
10994
+ event,
10995
+ payload: {
9938
10996
  type: event,
9939
10997
  table,
9940
10998
  record: entity,
9941
10999
  old_record: event === "UPDATE" ? previousEntity : void 0,
9942
11000
  schema: "public",
9943
11001
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
9944
- };
9945
- const result = await this.deliverWithRetry(webhook, event, payload);
9946
- results.push(result);
9947
- }
9948
- return results;
11002
+ }
11003
+ }));
9949
11004
  }
9950
11005
  async deliverWithRetry(webhook, event, payload) {
9951
11006
  for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
9952
- const result = await this.deliver(webhook, event, payload, attempt);
9953
- if (result.success) return result;
9954
- if (attempt < this.maxRetries) await new Promise((r) => setTimeout(r, this.retryDelays[attempt - 1]));
9955
- else return result;
11007
+ const { result, terminal } = await this.deliver(webhook, event, payload, attempt);
11008
+ if (result.success || terminal) return result;
11009
+ if (attempt < this.maxRetries) {
11010
+ const backoff = this.retryDelays[attempt - 1] ?? this.retryDelays[this.retryDelays.length - 1];
11011
+ await new Promise((r) => setTimeout(r, backoff));
11012
+ } else return result;
9956
11013
  }
9957
11014
  return {
9958
11015
  webhookId: webhook.id,
@@ -9975,41 +11032,106 @@ var WebhookDispatcher = class {
9975
11032
  ...webhook.headers || {}
9976
11033
  };
9977
11034
  if (webhook.secret) headers["X-Webhook-Signature"] = `sha256=${createHmac("sha256", webhook.secret).update(body).digest("hex")}`;
11035
+ const controller = new AbortController();
11036
+ const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
9978
11037
  try {
9979
- const controller = new AbortController();
9980
- const timeout = setTimeout(() => controller.abort(), 1e4);
9981
- const response = await fetch(webhook.url, {
11038
+ const url = await assertAllowedOutboundUrl(webhook.url, {
11039
+ allowPrivateNetworks: this.options.allowPrivateNetworks,
11040
+ lookup: this.options.lookup
11041
+ });
11042
+ const response = await fetch(url.href, {
9982
11043
  method: "POST",
9983
11044
  headers,
9984
11045
  body,
9985
- signal: controller.signal
11046
+ signal: controller.signal,
11047
+ redirect: "manual"
9986
11048
  });
9987
- clearTimeout(timeout);
9988
- const responseBody = await response.text().catch(() => "");
11049
+ if (response.status >= 300 && response.status < 400) {
11050
+ const location = typeof response.headers?.get === "function" ? response.headers.get("location") : null;
11051
+ return {
11052
+ terminal: true,
11053
+ result: {
11054
+ webhookId: webhook.id,
11055
+ event,
11056
+ payload,
11057
+ statusCode: response.status,
11058
+ responseBody: `Webhook receivers must not redirect: HTTP ${response.status}` + (location ? ` to ${location}` : "") + ". The redirect target is not re-validated, so it is not followed.",
11059
+ success: false,
11060
+ attemptNumber
11061
+ }
11062
+ };
11063
+ }
11064
+ const responseBody = await this.readCappedBody(response);
9989
11065
  const success = response.status >= 200 && response.status < 300;
9990
11066
  return {
9991
- webhookId: webhook.id,
9992
- event,
9993
- payload,
9994
- statusCode: response.status,
9995
- responseBody: responseBody.slice(0, 1e3),
9996
- success,
9997
- attemptNumber
11067
+ terminal: false,
11068
+ result: {
11069
+ webhookId: webhook.id,
11070
+ event,
11071
+ payload,
11072
+ statusCode: response.status,
11073
+ responseBody: responseBody.slice(0, 1e3),
11074
+ success,
11075
+ attemptNumber
11076
+ }
9998
11077
  };
9999
11078
  } catch (error) {
10000
11079
  const message = error instanceof Error ? error.message : String(error);
10001
11080
  return {
10002
- webhookId: webhook.id,
10003
- event,
10004
- payload,
10005
- statusCode: 0,
10006
- responseBody: message.slice(0, 1e3),
10007
- success: false,
10008
- attemptNumber
11081
+ terminal: error instanceof BlockedUrlError,
11082
+ result: {
11083
+ webhookId: webhook.id,
11084
+ event,
11085
+ payload,
11086
+ statusCode: 0,
11087
+ responseBody: message.slice(0, 1e3),
11088
+ success: false,
11089
+ attemptNumber
11090
+ }
10009
11091
  };
11092
+ } finally {
11093
+ clearTimeout(timeout);
11094
+ }
11095
+ }
11096
+ /**
11097
+ * Read at most {@link MAX_RESPONSE_BYTES} of the response and cancel the
11098
+ * rest, so a receiver cannot buffer a 10 GB body into the pod before the
11099
+ * caller truncates it to 1000 characters.
11100
+ */
11101
+ async readCappedBody(response) {
11102
+ const stream = response.body;
11103
+ if (!stream || typeof stream.getReader !== "function") return await response.text().catch(() => "");
11104
+ const reader = stream.getReader();
11105
+ const chunks = [];
11106
+ let total = 0;
11107
+ try {
11108
+ for (;;) {
11109
+ const { done, value } = await reader.read();
11110
+ if (done) break;
11111
+ if (!value) continue;
11112
+ chunks.push(value);
11113
+ total += value.byteLength;
11114
+ if (total >= MAX_RESPONSE_BYTES) break;
11115
+ }
11116
+ } catch {
11117
+ return new TextDecoder().decode(concat(chunks, total));
11118
+ } finally {
11119
+ await reader.cancel().catch(() => void 0);
10010
11120
  }
11121
+ return new TextDecoder().decode(concat(chunks, total));
10011
11122
  }
10012
11123
  };
11124
+ function concat(chunks, total) {
11125
+ const out = new Uint8Array(total);
11126
+ let offset = 0;
11127
+ for (const chunk of chunks) {
11128
+ const room = out.length - offset;
11129
+ if (room <= 0) break;
11130
+ out.set(chunk.length > room ? chunk.subarray(0, room) : chunk, offset);
11131
+ offset += chunk.length;
11132
+ }
11133
+ return out;
11134
+ }
10013
11135
  //#endregion
10014
11136
  //#region src/utils/dev-port.ts
10015
11137
  var MAX_PORT_ATTEMPTS = 20;
@@ -10170,10 +11292,11 @@ function isUnderPath(requestPath, prefix) {
10170
11292
  * Serve a Single Page Application from an Hono app.
10171
11293
  *
10172
11294
  * @internal Not part of the stable public API. Exported only because the
10173
- * official app template (`packages/cli/templates/template/backend/src/index.ts`
10174
- * and `app/backend/src/index.ts`) calls it to serve the built frontend in
10175
- * production. Its request-handling behavior is an implementation detail and
10176
- * may change without a major version bump.
11295
+ * entrypoint `rebase eject` writes
11296
+ * (`packages/cli/templates/eject/backend/src/index.ts`) and `app/backend/src/index.ts`
11297
+ * call it to serve the built frontend in production — the scaffolded template
11298
+ * has no entrypoint of its own. Its request-handling behavior is an
11299
+ * implementation detail and may change without a major version bump.
10177
11300
  */
10178
11301
  function serveSPA(app, config) {
10179
11302
  const { frontendPath, apiBasePath = "/api", excludePaths = [], indexFile = "index.html", spa = true } = config;
@@ -11785,7 +12908,8 @@ async function bootFromBundle(options = {}) {
11785
12908
  });
11786
12909
  await ensureCollectionPolicies(bundle, dataSources, env);
11787
12910
  metrics?.setKnownCollections(backend.collectionRegistry.getCollections().map((collection) => collection.slug).filter((slug) => Boolean(slug)));
11788
- app.get("/health", async (c) => {
12911
+ const healthPaths = ["/health", `${(env.REBASE_BASE_PATH || "/api").replace(/\/$/, "")}/health`];
12912
+ for (const healthPath of [...new Set(healthPaths)]) app.get(healthPath, async (c) => {
11789
12913
  const result = await backend.healthCheck();
11790
12914
  const unhealthy = (await Promise.all(dataSources.filter((source) => source.key !== DEFAULT_DATA_SOURCE_KEY).map(async (source) => ({
11791
12915
  key: source.key,
@@ -11891,7 +13015,7 @@ async function bootStaticApp(bundle, devRoot, options) {
11891
13015
  if (metrics) app.use("/*", metrics.middleware);
11892
13016
  const server = createServer(getRequestListener(app.fetch));
11893
13017
  app.get("/livez", (c) => c.json({ status: "ok" }));
11894
- app.get("/health", (c) => c.json({
13018
+ for (const healthPath of [.../* @__PURE__ */ new Set(["/health", `${basePath.replace(/\/$/, "")}/health`])]) app.get(healthPath, (c) => c.json({
11895
13019
  status: "ok",
11896
13020
  latencyMs: 0
11897
13021
  }));
@@ -12001,6 +13125,40 @@ function warnOnDriverSkew(dataSources, runtimeVersion) {
12001
13125
  }
12002
13126
  }
12003
13127
  /**
13128
+ * The collections a data source's engine is the store for.
13129
+ *
13130
+ * A bundle's collections directory holds *every* collection the project
13131
+ * declares, whatever engine serves it — that is the point of `dataSource`
13132
+ * routing. What it is not is a list of tables to create: handing the whole
13133
+ * directory to the primary source's bootstrapper made a Firestore collection
13134
+ * declared alongside the Postgres ones arrive as an empty Postgres table, with
13135
+ * RLS policies, while the app went on reading its documents from Firestore.
13136
+ *
13137
+ * Excluding is deliberately conservative. A collection that names neither an
13138
+ * `engine` nor a `dataSource` belongs to whichever source is primary — the
13139
+ * "postgres" that `resolveDataSource` falls back to there is a default, not a
13140
+ * declaration, and must not exclude anything on its own. Only a collection that
13141
+ * explicitly routes to a *different* engine is dropped, so a project running two
13142
+ * sources on the same engine is unaffected.
13143
+ */
13144
+ function collectionsStoredBy(collections, primary, dataSources) {
13145
+ const registry = createDataSourceRegistry(dataSources.map((source) => ({
13146
+ key: source.key,
13147
+ engine: source.engine
13148
+ })));
13149
+ return collections.filter((collection) => {
13150
+ const declared = collection.engine ?? (collection.dataSource ? resolveDataSource(collection, registry).engine : void 0);
13151
+ if (!declared) return true;
13152
+ return declared === primary.engine;
13153
+ });
13154
+ }
13155
+ /** Say what was routed elsewhere, so a missing table is never a silent one. */
13156
+ function logForeignCollections(all, stored, primary) {
13157
+ if (stored.length === all.length) return;
13158
+ const foreign = all.filter((c) => !stored.includes(c)).map((c) => c.slug);
13159
+ logger.info(`Skipping ${foreign.length} collection(s) served by another engine, not "${primary.engine}": ${foreign.join(", ")}. Their storage is not managed by this data source.`);
13160
+ }
13161
+ /**
12004
13162
  * Bring the database's collection tables up to date before serving.
12005
13163
  *
12006
13164
  * Delegates to whichever driver bootstrapped the default data source; a driver
@@ -12049,11 +13207,17 @@ async function ensureCollectionSchema(bundle, dataSources, env) {
12049
13207
  skip(`the adapter from "${primary.driverPackage}" (engine "${primary.engine}") does not expose collection-table creation. ` + (skew.detail ? `${skew.detail} ` : "") + "The driver package may well implement it on a class the adapter does not forward, so check the adapter's shape before blaming its version. Collection tables will NOT be created, so every /api/data route will fail on a missing relation.\n" + schemaRecoveryGuidance({ staleDriver: skew.stale }), "warn");
12050
13208
  return;
12051
13209
  }
12052
- const collections = await loadCollectionsFromDirectory(bundle.collectionsDir);
12053
- if (collections.length === 0) {
13210
+ const loaded = await loadCollectionsFromDirectory(bundle.collectionsDir);
13211
+ if (loaded.length === 0) {
12054
13212
  skip(`no collections were loaded from "${bundle.collectionsDir}".`, "warn");
12055
13213
  return;
12056
13214
  }
13215
+ const collections = collectionsStoredBy(loaded, primary, dataSources);
13216
+ logForeignCollections(loaded, collections, primary);
13217
+ if (collections.length === 0) {
13218
+ skip(`none of the ${loaded.length} declared collection(s) are stored by the "${primary.engine}" data source.`);
13219
+ return;
13220
+ }
12057
13221
  const { applied } = await primary.bootstrapper.ensureCollectionSchema(collections, preInitDriverResult(primary), (message) => logger.info(`schema: ${message}`));
12058
13222
  logger.info(applied > 0 ? `Applied ${applied} additive schema change(s) before boot.` : "Collection schema is up to date.");
12059
13223
  }
@@ -12103,12 +13267,14 @@ async function ensureCollectionPolicies(bundle, dataSources, env) {
12103
13267
  logger.warn(`Collection policies: skipped — the "${primary.driverPackage}" driver (engine "${primary.engine}") does not apply RLS policies at boot. ` + (skew.detail ? `${skew.detail} ` : "") + "Collections will deny reads until policies are applied.\n" + schemaRecoveryGuidance({ staleDriver: skew.stale }));
12104
13268
  return;
12105
13269
  }
12106
- const collections = await loadCollectionsFromDirectory(bundle.collectionsDir);
13270
+ const loaded = await loadCollectionsFromDirectory(bundle.collectionsDir);
13271
+ if (loaded.length === 0) return;
13272
+ const collections = collectionsStoredBy(loaded, primary, dataSources);
12107
13273
  if (collections.length === 0) return;
12108
13274
  const { applied } = await primary.bootstrapper.ensureCollectionPolicies(collections, preInitDriverResult(primary), (message) => logger.info(`policies: ${message}`));
12109
13275
  logger.info(applied > 0 ? `Applied ${applied} RLS policy statement(s) before serving.` : "RLS policies are up to date.");
12110
13276
  }
12111
13277
  //#endregion
12112
- export { ApiError, BundleError, CronScheduler, DEFAULT_DRIVER_ID, DEFAULT_MAX_FILE_SIZE, DEFAULT_STORAGE_ID, DOCUMENT_MIME_TYPES, DefaultDriverRegistry, DefaultStorageRegistry, GCSStorageController, IMAGE_MIME_TYPES, LocalStorageController, MetricsRegistry, S3StorageController, SMTPEmailService, TransformCache, TusHandler, WebhookDispatcher, _resetRebaseMock, _setRebaseMock, applyCollectionDefaults, assertCollectionConfigs, assertDistinctSuffixes, authJwt, authRoles, authUid, bootFromBundle, classifySurface, cleanupDevPortFile, createAppleProvider, createBackupRoutes, createBitbucketProvider, createBuiltinAuthAdapter, createContractRoutes, createCronRoutes, createCronStore, createCustomAuthAdapter, createDiscordProvider, createEmailService, createFacebookProvider, createFunctionRoutes, createGitHubProvider, createGitLabProvider, createGoogleProvider, createHistoryRoutes, createLinkedinProvider, createMetricsMiddleware, createMetricsRoutes, createMicrosoftProvider, createSlackProvider, createSourceBundle, createSpotifyProvider, createStorageController, createStorageRoutes, createTwitterProvider, defineCron, defineFunction, envSuffixForKey, errorHandler, extractUserFromToken, fileTokenAuth, findCollectionConfigProblems, generateSecurePassword, getEmailVerificationTemplate, getMagicLinkTemplate, getPasswordResetTemplate, getUserInvitationTemplate, getWelcomeEmailTemplate, hashPassword, httpMethodToOperation, initializeDataSource, initializeDataSources, initializeRebaseBackend, installShutdownHandlers, isApiKeyToken, isAuthAdapter, isDatabaseAdapter, isLocalhostOrigin, isOperationAllowed, isRebaseApiError, isTransformableImage, listBackupObjects, listenWithPortRetry, loadBootEnv, loadBundle, loadBundleConfigExports, loadBundleSchema, loadCollectionsFromDirectory, loadCronJobsFromDirectory, loadDeclaredStorageSources, loadEnv, loadFunctionsFromDirectory, loadUsersCollection, logger, optionalAuth, parseBackupDestination, parseBackupTimestamp, parseTransformOptions, queryTokenAuth, readBackupBytes, readBundleManifest, rebase, requireAdmin, requireAuth, resolveAuthHooks, resolveAuthOptions, resolveCorsOrigin, resolveDataSources, resolveEmailOptions, resolveRequireAuth, resolveStorageBackend, resolveStorageSources, runFromBundle, safeCompare, serveSPA, transformImage, unknownKeyPolicyFromEnv, validateApiKey, validateCronExpression, validatePasswordStrength, verifyPassword };
13278
+ export { ApiError, BlockedUrlError, BundleError, CronScheduler, DEFAULT_DRIVER_ID, DEFAULT_MAX_FILE_SIZE, DEFAULT_STORAGE_ID, DOCUMENT_MIME_TYPES, DefaultDriverRegistry, DefaultStorageRegistry, GCSStorageController, IMAGE_MIME_TYPES, LocalStorageController, MetricsRegistry, RawHtml, S3StorageController, SMTPEmailService, TransformCache, TusHandler, WebhookDispatcher, _resetRebaseMock, _setRebaseMock, applyCollectionDefaults, assertAllowedOutboundUrl, assertCollectionConfigs, assertDistinctSuffixes, assertEmailLinkBases, authJwt, authRoles, authUid, blockedAddressReason, bootFromBundle, classifySurface, cleanupDevPortFile, createAppleProvider, createBackupRoutes, createBitbucketProvider, createBuiltinAuthAdapter, createContractRoutes, createCronRoutes, createCronStore, createCustomAuthAdapter, createDiscordProvider, createEmailService, createFacebookProvider, createFunctionRoutes, createGitHubProvider, createGitLabProvider, createGoogleProvider, createHistoryRoutes, createLinkedinProvider, createMetricsMiddleware, createMetricsRoutes, createMicrosoftProvider, createSlackProvider, createSourceBundle, createSpotifyProvider, createStorageController, createStorageRoutes, createTwitterProvider, defineCron, defineFunction, envSuffixForKey, errorHandler, escapeHtml, extractUserFromToken, fileTokenAuth, findCollectionConfigProblems, generateSecurePassword, getEmailVerificationTemplate, getMagicLinkTemplate, getPasswordResetTemplate, getUserInvitationTemplate, getWelcomeEmailTemplate, hashPassword, html, httpMethodToOperation, initializeDataSource, initializeDataSources, initializeRebaseBackend, installShutdownHandlers, isApiKeyToken, isAuthAdapter, isDatabaseAdapter, isLocalhostOrigin, isOperationAllowed, isTransformableImage, listBackupObjects, listenWithPortRetry, loadBootEnv, loadBundle, loadBundleConfigExports, loadBundleSchema, loadCollectionsFromDirectory, loadCronJobsFromDirectory, loadDeclaredStorageSources, loadEnv, loadFunctionsFromDirectory, loadUsersCollection, logger, oauthCodeFlowSchema, optionalAuth, parseBackupDestination, parseBackupTimestamp, parseTransformOptions, pkceTokenParams, providerVerifiedEmail, queryTokenAuth, raw, readBackupBytes, readBundleManifest, rebase, requireAdmin, requireAuth, resolveAuthHooks, resolveAuthOptions, resolveCorsOrigin, resolveDataSources, resolveEmailLinkBase, resolveEmailOptions, resolveRequireAuth, resolveStorageBackend, resolveStorageSources, runFromBundle, safeCompare, serveSPA, transformImage, tryVerifyOidcIdToken, unknownKeyPolicyFromEnv, validateApiKey, validateCronExpression, validatePasswordStrength, verifyOidcIdToken, verifyPassword };
12113
13279
 
12114
13280
  //# sourceMappingURL=index.es.js.map