@rebasepro/server 0.13.0 → 0.13.1-canary.g249daa1

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 (56) hide show
  1. package/dist/{admin_block-CcSGdH7M.js → admin_block-CLeUMG8G.js} +3 -1
  2. package/dist/admin_block-CLeUMG8G.js.map +1 -0
  3. package/dist/api/errors.d.ts +0 -6
  4. package/dist/api/openapi-generator.d.ts +14 -0
  5. package/dist/api/rest/idempotency.d.ts +41 -4
  6. package/dist/api/rest/write-validation.d.ts +12 -5
  7. package/dist/auth/index.d.ts +1 -1
  8. package/dist/auth/jwt.d.ts +12 -0
  9. package/dist/{auth-CuC9M2x6.js → auth-BANhRhN9.js} +194 -84
  10. package/dist/auth-BANhRhN9.js.map +1 -0
  11. package/dist/{backup-CVggVhR2.js → backup-BJiY_mM-.js} +2 -2
  12. package/dist/{backup-CVggVhR2.js.map → backup-BJiY_mM-.js.map} +1 -1
  13. package/dist/boot/boot.d.ts +19 -0
  14. package/dist/boot/ddl-bootstrap.d.ts +70 -0
  15. package/dist/{contract-routes-Dj8i5AiM.js → contract-routes-FSt0Nyfk.js} +2 -2
  16. package/dist/{contract-routes-Dj8i5AiM.js.map → contract-routes-FSt0Nyfk.js.map} +1 -1
  17. package/dist/cron/cron-scheduler.d.ts +8 -3
  18. package/dist/cron/define-cron.d.ts +5 -3
  19. package/dist/{cron-routes-CrQ0tK-_.js → cron-routes-D5a9v1HY.js} +2 -2
  20. package/dist/{cron-routes-CrQ0tK-_.js.map → cron-routes-D5a9v1HY.js.map} +1 -1
  21. package/dist/{cron-scheduler-B3RFt0HS.js → cron-scheduler-CsQtYLMq.js} +28 -3
  22. package/dist/cron-scheduler-CsQtYLMq.js.map +1 -0
  23. package/dist/{cron-store-BywZsyfZ.js → cron-store-ZbjfuvMb.js} +65 -46
  24. package/dist/cron-store-ZbjfuvMb.js.map +1 -0
  25. package/dist/ddl-bootstrap-Bp3mr6Td.js +183 -0
  26. package/dist/ddl-bootstrap-Bp3mr6Td.js.map +1 -0
  27. package/dist/{errors-CgkCzoj7.js → errors-BpudWAVU.js} +2 -10
  28. package/dist/errors-BpudWAVU.js.map +1 -0
  29. package/dist/functions/define-function.d.ts +1 -1
  30. package/dist/history/history-routes.d.ts +6 -0
  31. package/dist/index.es.js +835 -276
  32. package/dist/index.es.js.map +1 -1
  33. package/dist/{jwt-DD6EtpGj.js → jwt-CzeviDcB.js} +17 -2
  34. package/dist/{jwt-DD6EtpGj.js.map → jwt-CzeviDcB.js.map} +1 -1
  35. package/dist/{openapi-generator-BEwyiaAr.js → openapi-generator-VB0MUH8h.js} +340 -128
  36. package/dist/openapi-generator-VB0MUH8h.js.map +1 -0
  37. package/dist/{schema-editor-routes-CbF20Mf1.js → schema-editor-routes-BqjzvvWU.js} +4 -4
  38. package/dist/{schema-editor-routes-CbF20Mf1.js.map → schema-editor-routes-BqjzvvWU.js.map} +1 -1
  39. package/dist/{src-_qQ3RNCK.js → src-Ca6NhxKs.js} +79 -2
  40. package/dist/src-Ca6NhxKs.js.map +1 -0
  41. package/dist/{src-Cum9kox5.js → src-DVb5a0bp.js} +153 -39
  42. package/dist/src-DVb5a0bp.js.map +1 -0
  43. package/dist/storage/keys.d.ts +65 -0
  44. package/dist/utils/logging.d.ts +0 -4
  45. package/dist/utils/sql.d.ts +11 -6
  46. package/package.json +6 -6
  47. package/dist/admin_block-CcSGdH7M.js.map +0 -1
  48. package/dist/auth-CuC9M2x6.js.map +0 -1
  49. package/dist/backend-CIxN4FVm.js +0 -15
  50. package/dist/backend-CIxN4FVm.js.map +0 -1
  51. package/dist/cron-scheduler-B3RFt0HS.js.map +0 -1
  52. package/dist/cron-store-BywZsyfZ.js.map +0 -1
  53. package/dist/errors-CgkCzoj7.js.map +0 -1
  54. package/dist/openapi-generator-BEwyiaAr.js.map +0 -1
  55. package/dist/src-Cum9kox5.js.map +0 -1
  56. package/dist/src-_qQ3RNCK.js.map +0 -1
package/dist/index.es.js CHANGED
@@ -1,15 +1,15 @@
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";
4
+ import { C as EntityReference, E as Vector, T as GeoPoint, _ as resolvePrimaryKeys, a as serializeFilter, b as resolveClientListLimit, c as paginateFind, d as createDataSourceRegistry, f as resolveDataSource, g as buildCompositeId, h as resolveCollectionRelations, i as deserializeLogicalCondition, l as resolveFindWindow, m as findRelation, n as buildSdkData, o as serializeLogicalCondition, p as getEffectiveSecurityRules, r as deserializeFilter, s as collectAllPages, t as buildRoutedRebaseData, u as CollectionRegistry, v as toSnakeCase, w as EntityRelation } from "./src-DVb5a0bp.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-CLeUMG8G.js";
7
+ import { i as isSQLAdmin, r as revokeInternalTableSql } from "./ddl-bootstrap-Bp3mr6Td.js";
8
+ import { n as createContractRoutes } from "./contract-routes-FSt0Nyfk.js";
9
+ import { $ as safeCompare, A as getEmailVerificationTemplate, B as optionalAuth, C as createDataRateLimiter, D as validatePasswordStrength, E as hashPassword, F as createAdapterAuthMiddleware, G as InvalidStorageKeyError, H as queryTokenAuth, I as createAuthMiddleware, J as createFunctionApiKeyGuard, K as canonicalStorageKey, L as createRequireAuth, M as getPasswordResetTemplate, N as getUserInvitationTemplate, O as verifyPassword, P as getWelcomeEmailTemplate, Q as extractBearerToken, R as extractUserFromToken, S as _coercedNumber, T as resolveAuthHooks, U as requireAdmin, V as publicObjectAuth, W as requireAuth, X as isApiKeyToken, Y as createStorageApiKeyGuard, Z as validateApiKey, _ as createBuiltinAuthAdapter, a as createSpotifyProvider, b as object, c as createGitLabProvider, d as createFacebookProvider, et as scopeDataDriver, f as createAppleProvider, g as createGoogleProvider, h as createLinkedinProvider, i as createApiKeyStore, it as isPublicStoragePath, j as getMagicLinkTemplate, k as generateSecurePassword, l as createDiscordProvider, m as createGitHubProvider, n as createCustomAuthAdapter, nt as isOperationAllowed, o as createSlackProvider, p as createMicrosoftProvider, q as createApiKeyPreAuth, r as createApiKeyRoutes, rt as PUBLIC_STORAGE_PREFIX, s as createBitbucketProvider, tt as httpMethodToOperation, u as createTwitterProvider, v as ZodNumber, w as MemoryRateLimitStore, x as string, y as _enum, z as fileTokenAuth } from "./auth-BANhRhN9.js";
10
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";
11
+ import { n as errorHandler, t as ApiError } from "./errors-BpudWAVU.js";
12
+ import { i as generateDownloadToken, n as configureJwt } from "./jwt-CzeviDcB.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";
@@ -17,10 +17,10 @@ import { t as GCSStorageController } from "./GCSStorageController-BEmDYFKc.js";
17
17
  import { n as loadFunctionsFromDirectory } from "./function-loader-B_1fYfUY.js";
18
18
  import { t as createFunctionRoutes } from "./function-routes-C0cLIy3N.js";
19
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";
20
+ import { r as validateCronExpression, t as CronScheduler } from "./cron-scheduler-CsQtYLMq.js";
21
+ import { t as createCronRoutes } from "./cron-routes-D5a9v1HY.js";
22
+ import { t as createCronStore } from "./cron-store-ZbjfuvMb.js";
23
+ import { a as parseBackupTimestamp, i as parseBackupDestination, n as createBackupRoutes, o as readBackupBytes, r as listBackupObjects } from "./backup-BJiY_mM-.js";
24
24
  import { randomBytes, randomUUID } from "node:crypto";
25
25
  import * as fs$2 from "fs";
26
26
  import fs, { existsSync } from "fs";
@@ -31,6 +31,7 @@ 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";
@@ -43,7 +44,6 @@ import { createServer } from "http";
43
44
  import { cors } from "hono/cors";
44
45
  import { secureHeaders } from "hono/secure-headers";
45
46
  import { getRequestListener } from "@hono/node-server";
46
- import path$1 from "node:path";
47
47
  import os from "node:os";
48
48
  import { execFile } from "node:child_process";
49
49
  import { promisify as promisify$1 } from "node:util";
@@ -75,7 +75,7 @@ import { promisify as promisify$1 } from "node:util";
75
75
  var RebaseApiError = class extends Error {
76
76
  /** HTTP status code, or `undefined` for non-HTTP errors. */
77
77
  status;
78
- /** Stable machine-readable error code, when the server supplied one. */
78
+ /** Stable machine-readable error code, when the server supplied one. See {@link RebaseErrorCode}. */
79
79
  code;
80
80
  /** Structured error payload from the server, when present. */
81
81
  details;
@@ -311,7 +311,6 @@ function unknownKeyPolicyFromEnv(env = process.env) {
311
311
  ].includes(raw)) return "off";
312
312
  return "warn";
313
313
  }
314
- /** `BaseCollectionConfig`, plus every engine-specific field, plus the `admin` block. */
315
314
  var COLLECTION_KEYS = /* @__PURE__ */ new Set([
316
315
  "slug",
317
316
  "name",
@@ -333,6 +332,7 @@ var COLLECTION_KEYS = /* @__PURE__ */ new Set([
333
332
  "relations",
334
333
  "securityRules",
335
334
  "schema",
335
+ "search",
336
336
  "path",
337
337
  "subcollections",
338
338
  "admin"
@@ -869,7 +869,12 @@ function parseLogicalGroup(type, raw) {
869
869
  if (inner.startsWith("(") && inner.endsWith(")")) inner = inner.slice(1, -1);
870
870
  inner = inner.trim();
871
871
  if (!inner) return void 0;
872
- const parsed = deserializeLogicalCondition(`${type}(${inner})`);
872
+ let parsed;
873
+ try {
874
+ parsed = deserializeLogicalCondition(`${type}(${inner})`);
875
+ } catch (e) {
876
+ throw ApiError.badRequest(`Invalid \`${type}\` parameter: ${e instanceof Error ? e.message : String(e)}`, "INVALID_LOGICAL_GROUP");
877
+ }
873
878
  return "type" in parsed ? parsed : void 0;
874
879
  }
875
880
  /**
@@ -899,6 +904,73 @@ function parseWhereParam(raw) {
899
904
  const filter = deserializeFilter(parsed);
900
905
  return Object.keys(filter).length > 0 ? filter : void 0;
901
906
  }
907
+ function invalidOrderBy(detail) {
908
+ throw ApiError.badRequest(`Invalid \`orderBy\` parameter: ${detail}. Expected \`field\`, \`field:desc\`, or a JSON array like [{"field":"created_at","direction":"desc"}]`, "INVALID_ORDER_BY");
909
+ }
910
+ /** `asc`/`desc`, in any case. Anything else is a request to sort in a way that does not exist. */
911
+ function toDirection(raw, context) {
912
+ if (raw === void 0 || raw === null) return "asc";
913
+ if (typeof raw !== "string") invalidOrderBy(`${context} has a non-string \`direction\``);
914
+ const lowered = raw.toLowerCase();
915
+ if (lowered !== "asc" && lowered !== "desc") invalidOrderBy(`${context} has direction '${raw}'`);
916
+ return lowered;
917
+ }
918
+ /** One entry: the canonical `{field, direction}`, or the `field:direction` shorthand as a string. */
919
+ function toOrderByEntry(raw, index) {
920
+ const context = `entry ${index}`;
921
+ if (typeof raw === "string") {
922
+ const tuple = deserializeOrderBy(raw);
923
+ if (!tuple || !tuple[0]) invalidOrderBy(`${context} is an empty field name`);
924
+ return {
925
+ field: tuple[0],
926
+ direction: toDirection(tuple[1], context)
927
+ };
928
+ }
929
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) invalidOrderBy(`${context} is not a field name or a {field, direction} object`);
930
+ const entry = raw;
931
+ if (typeof entry.field !== "string" || entry.field.trim() === "") invalidOrderBy(`${context} has no \`field\``);
932
+ return {
933
+ field: entry.field,
934
+ direction: toDirection(entry.direction, context)
935
+ };
936
+ }
937
+ /**
938
+ * Parse the `orderBy` query parameter.
939
+ *
940
+ * The field *name* has been validated against the schema for a while — an
941
+ * `?orderBy=titel` is a 400 rather than 200 with unsorted rows, on the grounds
942
+ * that silently dropping the sort leaves the caller believing in an order that
943
+ * is not there. The parameter's *shape* was never checked the same way, and it
944
+ * failed in exactly the same silent manner one layer earlier: whatever
945
+ * `JSON.parse` returned was assigned to an option declared as an array of
946
+ * `{field, direction}`, and the REST layer reads only `orderBy[0].field`. So
947
+ * `?orderBy={"field":"name"}` — an object rather than an array, and the most
948
+ * natural thing for a client to try — read `undefined`, dropped the ORDER BY,
949
+ * and answered 200. So did a number, a boolean, `null`, and `["name"]`.
950
+ *
951
+ * This refuses those, the way `parseWhereParam` above already refuses a
952
+ * malformed filter and for the same reason. What it keeps working is every
953
+ * shape that worked before: the `field` and `field:desc` shorthands, and the
954
+ * canonical JSON array.
955
+ */
956
+ function parseOrderByParam(raw) {
957
+ if (Array.isArray(raw)) return raw.length === 0 ? void 0 : raw.map(toOrderByEntry);
958
+ const str = String(raw).trim();
959
+ if (!str) return void 0;
960
+ let parsed;
961
+ try {
962
+ parsed = JSON.parse(str);
963
+ } catch {
964
+ return [toOrderByEntry(str, 0)];
965
+ }
966
+ if (Array.isArray(parsed)) {
967
+ if (parsed.length === 0) return void 0;
968
+ return parsed.map(toOrderByEntry);
969
+ }
970
+ if (typeof parsed === "string") return [toOrderByEntry(parsed, 0)];
971
+ if (typeof parsed === "object" && parsed !== null) invalidOrderBy("a single object was given where a JSON array was expected");
972
+ invalidOrderBy(`${typeof parsed} is not a field name or a list of them`);
973
+ }
902
974
  /**
903
975
  * Parse query parameters into QueryOptions
904
976
  */
@@ -933,6 +1005,7 @@ function parseQueryOptions(query, limits = {}) {
933
1005
  "include",
934
1006
  "fields",
935
1007
  "searchString",
1008
+ "searchExplain",
936
1009
  "vector_search",
937
1010
  "vector",
938
1011
  "vector_distance",
@@ -953,17 +1026,7 @@ function parseQueryOptions(query, limits = {}) {
953
1026
  };
954
1027
  if (Object.keys(where).length > 0) options.where = where;
955
1028
  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
- }
1029
+ if (orderByVal) options.orderBy = parseOrderByParam(orderByVal);
967
1030
  const includeVal = getLastValue(query.include);
968
1031
  if (includeVal) {
969
1032
  const includeStr = String(includeVal).trim();
@@ -1055,11 +1118,18 @@ function assertKnownWriteFields(values, collection, options) {
1055
1118
  * This shapes the *response*, which is what the parameter says it does; it is
1056
1119
  * not a column pushdown, so it saves bandwidth rather than database work.
1057
1120
  *
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.
1121
+ * The collection's key always survives. Rows are addressed by it everywhere
1122
+ * above this layer — the admin table, realtime reconciliation, the offline
1123
+ * cache — and a row that arrives without one is not a smaller row, it is an
1124
+ * unusable one. Asking for `fields=title` and being unable to open the record
1125
+ * you clicked is a worse answer than one extra key.
1126
+ *
1127
+ * That is a statement about the key, not about the name `id`: a collection
1128
+ * keyed on `slug`, or on `user_id + role_id`, was projected down to the fields
1129
+ * asked for and nothing else, which is the very outcome the paragraph above
1130
+ * describes. `assertKnownWriteFields` one function up already has a dedicated
1131
+ * error for collections with no `id` column, so this layer had no excuse for
1132
+ * assuming one.
1063
1133
  */
1064
1134
  function projectResponseFields(rows, fields, collection, options) {
1065
1135
  if (!fields || fields.length === 0) return rows;
@@ -1077,7 +1147,8 @@ function projectResponseFields(rows, fields, collection, options) {
1077
1147
  collection: collection.slug
1078
1148
  });
1079
1149
  }
1080
- const keep = /* @__PURE__ */ new Set([...fields, "id"]);
1150
+ const primaryKeys = resolvePrimaryKeys(collection).map((key) => key.fieldName);
1151
+ const keep = /* @__PURE__ */ new Set([...fields, ...primaryKeys.length > 0 ? primaryKeys : ["id"]]);
1081
1152
  return rows.map((row) => {
1082
1153
  const projected = {};
1083
1154
  for (const key of Object.keys(row)) if (keep.has(key)) projected[key] = row[key];
@@ -1152,6 +1223,7 @@ function createIdempotencyStore(driver) {
1152
1223
  )
1153
1224
  `);
1154
1225
  await exec(`CREATE INDEX IF NOT EXISTS idx_idempotency_created ON ${TABLE}(created_at)`);
1226
+ await exec(revokeInternalTableSql("rebase", "idempotency_keys"));
1155
1227
  return true;
1156
1228
  } catch (error) {
1157
1229
  logger.warn("Idempotency keys unavailable — a replayed offline write may insert a duplicate row.", { detail: error instanceof Error ? error.message : String(error) });
@@ -1161,26 +1233,43 @@ function createIdempotencyStore(driver) {
1161
1233
  return ready;
1162
1234
  };
1163
1235
  return {
1164
- async recall(key, uid) {
1165
- if (!key || !await ensure()) return void 0;
1236
+ async claim(key, uid) {
1237
+ if (!key || !await ensure()) return { status: "claimed" };
1166
1238
  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;
1239
+ if ((await exec(`INSERT INTO ${TABLE} (key, uid, response, created_at)
1240
+ VALUES ($1, $2, NULL, NOW())
1241
+ ON CONFLICT (uid, key) DO UPDATE
1242
+ SET response = NULL, created_at = NOW()
1243
+ WHERE ${TABLE}.created_at < NOW() - INTERVAL '${TTL_HOURS} hours'
1244
+ RETURNING 1 AS claimed`, [key, principal(uid)])).length > 0) return { status: "claimed" };
1245
+ const row = (await exec(`SELECT response, response IS NULL AS pending FROM ${TABLE}
1246
+ WHERE uid = $1 AND key = $2`, [principal(uid), key]))[0];
1247
+ if (!row) return { status: "claimed" };
1248
+ if (row.pending) return { status: "in-flight" };
1249
+ return {
1250
+ status: "replay",
1251
+ response: row.response
1252
+ };
1169
1253
  } catch {
1170
- return;
1254
+ return { status: "claimed" };
1171
1255
  }
1172
1256
  },
1173
- async remember(key, uid, response) {
1257
+ async complete(key, uid, response) {
1174
1258
  if (!key || !await ensure()) return;
1175
1259
  try {
1176
- await exec(`INSERT INTO ${TABLE} (key, uid, response) VALUES ($1, $2, $3::jsonb)
1177
- ON CONFLICT (uid, key) DO NOTHING`, [
1178
- key,
1260
+ await exec(`UPDATE ${TABLE} SET response = $3::jsonb WHERE uid = $1 AND key = $2`, [
1179
1261
  principal(uid),
1262
+ key,
1180
1263
  JSON.stringify(response ?? null)
1181
1264
  ]);
1182
1265
  if (Math.random() < .01) await exec(`DELETE FROM ${TABLE} WHERE created_at < NOW() - INTERVAL '${TTL_HOURS} hours'`);
1183
1266
  } catch {}
1267
+ },
1268
+ async release(key, uid) {
1269
+ if (!key || !await ensure()) return;
1270
+ try {
1271
+ await exec(`DELETE FROM ${TABLE} WHERE uid = $1 AND key = $2 AND response IS NULL`, [principal(uid), key]);
1272
+ } catch {}
1184
1273
  }
1185
1274
  };
1186
1275
  }
@@ -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,225 @@ 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
+ const withIdempotency = async (c, run, respond) => {
1471
+ const idempotencyKey = c.req.header(IDEMPOTENCY_HEADER);
1472
+ const uid = c.get("user")?.uid;
1473
+ const store = this.idempotency();
1474
+ const claimed = idempotencyKey && store ? await store.claim(idempotencyKey, uid) : void 0;
1475
+ if (claimed?.status === "replay") return respond(claimed.response);
1476
+ 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");
1477
+ let response;
1478
+ try {
1479
+ response = await run();
1480
+ } catch (error) {
1481
+ if (claimed?.status === "claimed" && idempotencyKey && store) await store.release(idempotencyKey, uid);
1482
+ throw error;
1483
+ }
1484
+ if (claimed?.status === "claimed" && idempotencyKey && store) await store.complete(idempotencyKey, uid, response);
1485
+ return respond(response);
1486
+ };
1487
+ /**
1488
+ * Validate the envelope every bulk route shares: an array, non-empty,
1489
+ * of objects, within the row cap.
1490
+ *
1491
+ * The cap is the load-bearing one. A batch is a single transaction that
1492
+ * holds its locks for its whole duration, so an unbounded one is a
1493
+ * self-inflicted outage — and the message names both the limit and the
1494
+ * actual count so the caller can chunk to it rather than guess.
1495
+ */
1496
+ const assertBulkShape = (items, field, requireObjects) => {
1497
+ if (!Array.isArray(items)) throw ApiError.badRequest(`Expected a JSON body of { ${field}: [...] }.`, "INVALID_BULK_BODY");
1498
+ 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");
1499
+ 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");
1500
+ };
1370
1501
  this.router.post(`${basePath}/bulk`, async (c) => {
1371
1502
  this.enforceApiKeyPermission(c, collection.slug);
1372
1503
  const driver = this.getScopedDriver(c);
1373
1504
  const path = collection.slug;
1374
1505
  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({
1506
+ assertBulkShape(body?.rows, "rows", true);
1507
+ const rows = body.rows;
1508
+ if (rows.length === 0) return c.json({
1377
1509
  data: [],
1378
1510
  meta: { written: 0 }
1379
1511
  });
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
1512
  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
1513
  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
1514
+ rows.forEach((row, rowIndex) => assertKnownWriteFields(row, resolvedCollection, { rowIndex }));
1515
+ return withIdempotency(c, async () => {
1516
+ const written = await driver.saveMany({
1517
+ path,
1518
+ rows,
1519
+ collection: resolvedCollection,
1520
+ upsert: body.upsert === true
1521
+ });
1522
+ return {
1523
+ data: written.map((row) => this.formatResponse(row)),
1524
+ meta: { written: written.length }
1525
+ };
1526
+ }, (result) => c.json(result));
1527
+ });
1528
+ this.router.patch(`${basePath}/bulk`, async (c) => {
1529
+ this.enforceApiKeyPermission(c, collection.slug);
1530
+ const driver = this.getScopedDriver(c);
1531
+ const path = collection.slug;
1532
+ const body = await parseJsonBody(c);
1533
+ assertBulkShape(body?.updates, "updates", true);
1534
+ const updates = body.updates;
1535
+ if (updates.length === 0) return c.json({
1536
+ data: [],
1537
+ meta: { written: 0 }
1391
1538
  });
1392
- return c.json({
1393
- data: rows.map((row) => this.formatResponse(row)),
1394
- meta: { written: rows.length }
1539
+ if (!driver.updateMany) throw ApiError.badRequest("This collection's data source does not support bulk updates.", "BULK_UNSUPPORTED");
1540
+ updates.forEach((entry, rowIndex) => {
1541
+ 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");
1542
+ 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");
1543
+ assertKnownWriteFields(entry.data, resolvedCollection, { rowIndex });
1395
1544
  });
1545
+ return withIdempotency(c, async () => {
1546
+ const written = await driver.updateMany({
1547
+ path,
1548
+ updates: updates.map((entry) => ({
1549
+ id: entry.id,
1550
+ values: entry.data
1551
+ })),
1552
+ collection: resolvedCollection
1553
+ });
1554
+ return {
1555
+ data: written.map((row) => this.formatResponse(row)),
1556
+ meta: { written: written.length }
1557
+ };
1558
+ }, (result) => c.json(result));
1559
+ });
1560
+ this.router.post(`${basePath}/bulk/delete`, async (c) => {
1561
+ this.enforceApiKeyPermission(c, collection.slug);
1562
+ const driver = this.getScopedDriver(c);
1563
+ const path = collection.slug;
1564
+ const body = await parseJsonBody(c);
1565
+ assertBulkShape(body?.ids, "ids", false);
1566
+ const ids = body.ids;
1567
+ if (ids.length === 0) return c.json({ meta: { deleted: 0 } });
1568
+ 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");
1569
+ if (!driver.deleteMany) throw ApiError.badRequest("This collection's data source does not support bulk deletes.", "BULK_UNSUPPORTED");
1570
+ return withIdempotency(c, async () => {
1571
+ await driver.deleteMany({
1572
+ path,
1573
+ ids,
1574
+ collection: resolvedCollection
1575
+ });
1576
+ return { meta: { deleted: ids.length } };
1577
+ }, (result) => c.json(result));
1396
1578
  });
1397
1579
  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);
1440
- }
1580
+ this.enforceApiKeyPermission(c, collection.slug);
1581
+ const driver = this.getScopedDriver(c);
1582
+ const path = collection.slug;
1583
+ const body = await parseJsonBody(c);
1584
+ const isAuth = collection.auth;
1585
+ const isAuthCollection = isAuth === true || isAuth && typeof isAuth === "object" && isAuth.enabled === true;
1586
+ const collectionAuthConfig = typeof isAuth === "object" ? isAuth : void 0;
1587
+ if (!isAuthCollection) assertKnownWriteFields(body, resolvedCollection);
1588
+ else {
1589
+ const contract = this.authAdapter?.describeUserCreationContract?.(collectionAuthConfig);
1590
+ if (contract?.validate) assertKnownWriteFields(body, resolvedCollection, { extraKnownFields: contract.extraFields });
1591
+ }
1592
+ if (isAuthCollection && this.authAdapter?.prepareUserCreation) {
1593
+ const prepared = await this.authAdapter.prepareUserCreation(body, collectionAuthConfig);
1441
1594
  const entity = await driver.save({
1442
1595
  path,
1443
- values: body,
1596
+ values: prepared.values,
1444
1597
  collection: resolvedCollection,
1445
1598
  status: "new"
1446
1599
  });
1600
+ const result = prepared.hookHandledEmail ? {
1601
+ temporaryPassword: prepared.clearPassword,
1602
+ invitationSent: prepared.invitationSent
1603
+ } : this.authAdapter.finalizeUserCreation ? await this.authAdapter.finalizeUserCreation({
1604
+ id: entity.id,
1605
+ values: entity
1606
+ }, prepared.clearPassword) : { invitationSent: false };
1447
1607
  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;
1608
+ return c.json({
1609
+ ...response,
1610
+ invitationSent: result.invitationSent,
1611
+ ...result.temporaryPassword ? { temporaryPassword: result.temporaryPassword } : {},
1612
+ ..."emailDeliveryFailed" in result && result.emailDeliveryFailed ? { emailDeliveryFailed: true } : {}
1613
+ }, 201);
1455
1614
  }
1456
- });
1457
- this.router.put(`${basePath}/:id`, async (c) => {
1615
+ const idempotencyKey = c.req.header(IDEMPOTENCY_HEADER);
1616
+ const uid = c.get("user")?.uid;
1617
+ const store = this.idempotency();
1618
+ const claimed = idempotencyKey && store ? await store.claim(idempotencyKey, uid) : void 0;
1619
+ if (claimed?.status === "replay") return c.json(claimed.response, 201);
1620
+ 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");
1621
+ let response;
1458
1622
  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
1623
  const entity = await driver.save({
1470
- path: getCollectionDataPath(collection),
1471
- id: String(id),
1624
+ path,
1472
1625
  values: body,
1473
1626
  collection: resolvedCollection,
1474
- status: "existing"
1627
+ status: "new"
1475
1628
  });
1476
- const response = this.formatResponse(entity);
1477
- return c.json(response);
1629
+ response = this.formatResponse(entity);
1478
1630
  } 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
- }
1631
+ if (claimed?.status === "claimed" && idempotencyKey && store) await store.release(idempotencyKey, uid);
1482
1632
  throw error;
1483
1633
  }
1634
+ if (claimed?.status === "claimed" && idempotencyKey && store) await store.complete(idempotencyKey, uid, response);
1635
+ return c.json(response, 201);
1484
1636
  });
1637
+ const updateEntity = async (c) => {
1638
+ this.enforceApiKeyPermission(c, collection.slug);
1639
+ const id = c.req.param("id");
1640
+ const driver = this.getScopedDriver(c);
1641
+ if (!await driver.fetchOne({
1642
+ path: getCollectionDataPath(collection),
1643
+ id: String(id),
1644
+ collection: resolvedCollection
1645
+ })) throw ApiError.notFound("Entity not found");
1646
+ const body = await parseJsonBody(c);
1647
+ assertKnownWriteFields(body, resolvedCollection);
1648
+ const entity = await driver.save({
1649
+ path: getCollectionDataPath(collection),
1650
+ id: String(id),
1651
+ values: body,
1652
+ collection: resolvedCollection,
1653
+ status: "existing"
1654
+ });
1655
+ const response = this.formatResponse(entity);
1656
+ return c.json(response);
1657
+ };
1658
+ /**
1659
+ * The verb that matches what this actually does.
1660
+ *
1661
+ * The handler merges: it writes the columns in the body and leaves the
1662
+ * rest alone, which is what the SDK's `update(id, data: Partial<M>)`
1663
+ * sends. PUT was the only route, and PUT means replace — so the
1664
+ * generated OpenAPI spec described a full replacement while the server
1665
+ * performed a merge, and it reused the *create* input schema for it,
1666
+ * `required` fields and all. A client generated from that spec was
1667
+ * given a contract the server does not implement, and a spec-validating
1668
+ * gateway in front of this API would reject partial updates the server
1669
+ * would have accepted.
1670
+ *
1671
+ * PUT stays mounted on the same handler rather than being changed or
1672
+ * removed: every published SDK sends it, and altering its semantics to
1673
+ * be a true replace would silently start nulling columns that callers
1674
+ * had simply omitted for years — a data-loss change disguised as a
1675
+ * standards fix. So PATCH is the honest name, PUT is the compatible one,
1676
+ * and both do the documented thing.
1677
+ */
1678
+ this.router.patch(`${basePath}/:id`, updateEntity);
1679
+ this.router.put(`${basePath}/:id`, updateEntity);
1485
1680
  this.router.delete(`${basePath}/:id`, async (c) => {
1486
1681
  this.enforceApiKeyPermission(c, collection.slug);
1487
1682
  const id = c.req.param("id");
@@ -1552,6 +1747,7 @@ var RestApiGenerator = class {
1552
1747
  const total = driver.count ? await driver.count({
1553
1748
  path: parsed.collectionPath,
1554
1749
  filter: queryOptions.where,
1750
+ logical: queryOptions.logical,
1555
1751
  searchString
1556
1752
  }) : 0;
1557
1753
  return c.json({ count: total });
@@ -1563,7 +1759,9 @@ var RestApiGenerator = class {
1563
1759
  id: parsed.id
1564
1760
  });
1565
1761
  if (!entity) throw ApiError.notFound("Entity not found");
1566
- return c.json(entity);
1762
+ const nestedCollection = this.resolveNestedWriteCollection(parsed.collectionPath);
1763
+ if (!nestedCollection) return c.json(entity);
1764
+ return c.json(projectResponseFields([entity], queryOptions.fields, nestedCollection, { include: queryOptions.include })[0]);
1567
1765
  } else {
1568
1766
  const queryDict = c.req.queries();
1569
1767
  const queryOptions = this.parseQuery(queryDict);
@@ -1588,8 +1786,9 @@ var RestApiGenerator = class {
1588
1786
  logical: queryOptions.logical,
1589
1787
  searchString
1590
1788
  }) : entities.length;
1789
+ const listCollection = this.resolveNestedWriteCollection(parsed.collectionPath);
1591
1790
  return c.json({
1592
- data: entities,
1791
+ data: listCollection ? projectResponseFields(entities, queryOptions.fields, listCollection, { include: queryOptions.include }) : entities,
1593
1792
  meta: {
1594
1793
  total,
1595
1794
  limit: queryOptions.limit,
@@ -1618,7 +1817,7 @@ var RestApiGenerator = class {
1618
1817
  const response = this.formatResponse(entity);
1619
1818
  return c.json(response, 201);
1620
1819
  });
1621
- this.router.put("/:parent/:parentId/:rest{.+}", async (c, next) => {
1820
+ const updateNested = async (c, next) => {
1622
1821
  const rest = c.req.param("rest");
1623
1822
  if (!rest || rest === "undefined") return next();
1624
1823
  const rawPath = `${c.req.param("parent")}/${c.req.param("parentId")}/${rest}`;
@@ -1637,7 +1836,9 @@ var RestApiGenerator = class {
1637
1836
  });
1638
1837
  const response = this.formatResponse(entity);
1639
1838
  return c.json(response);
1640
- });
1839
+ };
1840
+ this.router.patch("/:parent/:parentId/:rest{.+}", updateNested);
1841
+ this.router.put("/:parent/:parentId/:rest{.+}", updateNested);
1641
1842
  this.router.delete("/:parent/:parentId/:rest{.+}", async (c, next) => {
1642
1843
  const rest = c.req.param("rest");
1643
1844
  if (!rest || rest === "undefined") return next();
@@ -2406,11 +2607,15 @@ var TusHandler = class {
2406
2607
  if (Number.isNaN(uploadLength) || uploadLength <= 0) throw ApiError.badRequest("Invalid Upload-Length");
2407
2608
  if (uploadLength > MAX_UPLOAD_SIZE) throw new ApiError(413, "PAYLOAD_TOO_LARGE", `Upload-Length exceeds maximum of ${MAX_UPLOAD_SIZE} bytes`);
2408
2609
  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
2610
  const id = randomUUID$1();
2611
+ const rawKey = metadata.key || metadata.filename || "";
2612
+ let key;
2613
+ try {
2614
+ key = canonicalStorageKey(rawKey) || id;
2615
+ } catch (err) {
2616
+ throw new ApiError(400, "INVALID_STORAGE_KEY", err instanceof InvalidStorageKeyError ? err.message : "Invalid storage key");
2617
+ }
2618
+ if (this.authorizeUpload) await this.authorizeUpload(c, key, metadata.bucket || "default");
2414
2619
  const filePath = join(this.tusDir, id);
2415
2620
  await writeFile(filePath, Buffer.alloc(0));
2416
2621
  const upload = {
@@ -2421,7 +2626,7 @@ var TusHandler = class {
2421
2626
  createdAt: Date.now(),
2422
2627
  filePath,
2423
2628
  bucket: metadata.bucket || void 0,
2424
- key: metadata.key || metadata.filename || void 0,
2629
+ key,
2425
2630
  completed: false
2426
2631
  };
2427
2632
  this.uploads.set(id, upload);
@@ -2506,7 +2711,7 @@ var TusHandler = class {
2506
2711
  try {
2507
2712
  const { readFile } = await import("fs/promises");
2508
2713
  const data = await readFile(upload.filePath);
2509
- const fileName = upload.key || upload.metadata.filename || upload.id;
2714
+ const fileName = upload.key;
2510
2715
  const mimeType = upload.metadata.contentType || upload.metadata.filetype || "application/octet-stream";
2511
2716
  const file = new File([new Uint8Array(data)], fileName, { type: mimeType });
2512
2717
  await targetController.putObject({
@@ -2556,16 +2761,21 @@ function extractWildcardPath(c) {
2556
2761
  return fullPath.substring(idx + prefix.length + 1);
2557
2762
  }
2558
2763
  /**
2559
- * Sanitize a user-supplied storage key to prevent path traversal and other attacks.
2560
- * Removes null bytes, ../ sequences, leading slashes, and limits length.
2764
+ * Canonicalize a caller-supplied storage key, answering 400 when it names
2765
+ * something other than what it says.
2766
+ *
2767
+ * The single place a request's key becomes canonical. Every route runs its key
2768
+ * through this before anything else touches it, so the authorize hook, the
2769
+ * storage controller and the minted download token are all looking at the same
2770
+ * string — which is the only thing that makes the hook's answer meaningful.
2771
+ * See `keys.ts` for why an unacceptable key is refused rather than repaired.
2561
2772
  */
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;
2773
+ function canonicalKeyOrBadRequest(key) {
2774
+ try {
2775
+ return canonicalStorageKey(key);
2776
+ } catch (err) {
2777
+ throw new ApiError(400, "INVALID_STORAGE_KEY", err instanceof InvalidStorageKeyError ? err.message : "Invalid storage key");
2778
+ }
2569
2779
  }
2570
2780
  /**
2571
2781
  * Build adapter-aware auth middleware for storage routes.
@@ -2669,25 +2879,25 @@ function createStorageRoutes(config) {
2669
2879
  /**
2670
2880
  * Parse bucket and path from a combined file path.
2671
2881
  *
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).
2882
+ * The resolved path is canonicalized here — by the same function the upload
2883
+ * route applies to incoming keys — so read, delete, metadata and folder
2884
+ * routes agree with the write side about what a key means before it reaches
2885
+ * the controller, the authorize hook, or the download token it mints.
2886
+ *
2887
+ * A key that cannot be canonicalized (a real `..` segment, a null byte) is
2888
+ * a 400, not a repaired key. The `LocalStorageController` traversal guard
2889
+ * (`getFullPath`) is still the load-bearing defence for the *bucket*
2890
+ * boundary; this is what defends the boundary the hook drew inside it.
2681
2891
  */
2682
2892
  const parseBucketAndPath = (filePath) => {
2683
2893
  const parts = filePath.split("/");
2684
2894
  if (parts.length > 1 && parts[0].toLowerCase() === "default") return {
2685
2895
  bucket: "default",
2686
- resolvedPath: sanitizeStorageKey(parts.slice(1).join("/"))
2896
+ resolvedPath: canonicalKeyOrBadRequest(parts.slice(1).join("/"))
2687
2897
  };
2688
2898
  return {
2689
2899
  bucket: "default",
2690
- resolvedPath: sanitizeStorageKey(filePath)
2900
+ resolvedPath: canonicalKeyOrBadRequest(filePath)
2691
2901
  };
2692
2902
  };
2693
2903
  /**
@@ -2702,7 +2912,7 @@ function createStorageRoutes(config) {
2702
2912
  const key = typeof body["key"] === "string" ? body["key"] : "";
2703
2913
  const bucket = typeof body["bucket"] === "string" ? body["bucket"] : void 0;
2704
2914
  const storageId = typeof body["storageId"] === "string" ? body["storageId"] : c.req.query("storageId");
2705
- const finalKey = sanitizeStorageKey(key || uploadedFile.name || "unnamed");
2915
+ const finalKey = canonicalKeyOrBadRequest(key || uploadedFile.name || "unnamed");
2706
2916
  const metadata = {};
2707
2917
  for (const [k, value] of Object.entries(body)) if (k.startsWith("metadata_")) metadata[k.replace("metadata_", "")] = value;
2708
2918
  await checkAuthorized(c, "write", finalKey, bucket ?? "default", storageId);
@@ -2837,7 +3047,7 @@ function createStorageRoutes(config) {
2837
3047
  * GET /list - List files in a path
2838
3048
  */
2839
3049
  router.get("/list", writeAuthMiddleware, async (c) => {
2840
- const storagePrefix = sanitizeStorageKey(c.req.query("prefix") || c.req.query("path") || "");
3050
+ const storagePrefix = canonicalKeyOrBadRequest(c.req.query("prefix") || c.req.query("path") || "");
2841
3051
  const bucket = c.req.query("bucket");
2842
3052
  const maxResults = c.req.query("maxResults");
2843
3053
  const pageToken = c.req.query("pageToken");
@@ -2885,7 +3095,7 @@ function createStorageRoutes(config) {
2885
3095
  });
2886
3096
  const defaultCtrl = getDefaultController();
2887
3097
  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"));
3098
+ await checkAuthorized(c, "write", key, bucket, c.req.query("storageId"));
2889
3099
  } : void 0);
2890
3100
  tusHandler.startCleanup();
2891
3101
  router.options("/tus", (_c) => tusHandler.options());
@@ -3081,7 +3291,7 @@ function assertStorageAccessControlConfigured(state, isProduction) {
3081
3291
  //#region src/init/docs.ts
3082
3292
  async function mountOpenApiDocs(app, basePath, enableSwagger, activeCollections, requireAuth) {
3083
3293
  if (enableSwagger === false || activeCollections.length === 0) return;
3084
- const { generateOpenApiSpec } = await import("./openapi-generator-BEwyiaAr.js");
3294
+ const { generateOpenApiSpec } = await import("./openapi-generator-VB0MUH8h.js");
3085
3295
  app.get(`${basePath}/docs`, (c) => {
3086
3296
  const spec = generateOpenApiSpec(activeCollections, {
3087
3297
  basePath,
@@ -3378,7 +3588,17 @@ function buildQueryString(params) {
3378
3588
  const wire = serializeOrderBy(params.orderBy);
3379
3589
  if (wire) parts.push(`orderBy=${encodeURIComponent(wire)}`);
3380
3590
  }
3381
- if (params.searchString) parts.push(`searchString=${encodeURIComponent(params.searchString)}`);
3591
+ if (params.searchString) {
3592
+ parts.push(`searchString=${encodeURIComponent(params.searchString)}`);
3593
+ if (params.searchExplain) parts.push("searchExplain=true");
3594
+ }
3595
+ if (params.vectorSearch) {
3596
+ const vs = params.vectorSearch;
3597
+ parts.push(`vector_search=${encodeURIComponent(vs.property)}`);
3598
+ parts.push(`vector=${encodeURIComponent(JSON.stringify(vs.vector))}`);
3599
+ if (vs.distance) parts.push(`vector_distance=${encodeURIComponent(vs.distance)}`);
3600
+ if (vs.threshold !== void 0) parts.push(`vector_threshold=${encodeURIComponent(String(vs.threshold))}`);
3601
+ }
3382
3602
  if (params.include && params.include.length > 0) parts.push(`include=${encodeURIComponent(params.include.join(","))}`);
3383
3603
  if (params.logical) {
3384
3604
  const root = params.logical;
@@ -3418,6 +3638,13 @@ function resolveBaseUrl(configured) {
3418
3638
  function createTransport(config, environment) {
3419
3639
  const fetchFn = config.fetch || globalThis.fetch;
3420
3640
  const apiPath = config.apiPath || "/api";
3641
+ for (const field of ["baseUrl", "storageUrlOrigin"]) {
3642
+ const value = config[field];
3643
+ if (!value || !apiPath) continue;
3644
+ const trimmed = value.replace(/\/+$/, "");
3645
+ if (!trimmed.endsWith(apiPath)) continue;
3646
+ 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.`);
3647
+ }
3421
3648
  let token = config.token;
3422
3649
  let tokenGetter;
3423
3650
  let onUnauthorizedHandler = config.onUnauthorized;
@@ -3601,6 +3828,11 @@ function createAuth(transport, options) {
3601
3828
  const authFlowMode = opts.authFlowMode || "json";
3602
3829
  const STORAGE_KEY = "rebase_auth";
3603
3830
  const REFRESH_BUFFER_MS = 12e4;
3831
+ /**
3832
+ * The largest delay `setTimeout` can hold — 2^31 - 1 ms, about 24.8 days.
3833
+ * Anything larger is silently clamped to 1ms by Node and every browser.
3834
+ */
3835
+ const MAX_TIMER_DELAY_MS = 2147483647;
3604
3836
  const MAX_REFRESH_RETRIES = 5;
3605
3837
  const REFRESH_RETRY_BASE_MS = 1e3;
3606
3838
  const REFRESH_RETRY_MAX_MS = 3e4;
@@ -3628,7 +3860,9 @@ function createAuth(transport, options) {
3628
3860
  function emit(event, session) {
3629
3861
  for (const fn of listeners) try {
3630
3862
  fn(event, session);
3631
- } catch (e) {}
3863
+ } catch (e) {
3864
+ console.error("Error in auth state change listener:", e);
3865
+ }
3632
3866
  }
3633
3867
  function saveSession(session) {
3634
3868
  if (!persistSession || authFlowMode === "cookie") return;
@@ -3732,6 +3966,10 @@ function createAuth(transport, options) {
3732
3966
  attemptScheduledRefresh(0);
3733
3967
  return;
3734
3968
  }
3969
+ if (delay > MAX_TIMER_DELAY_MS) {
3970
+ refreshTimeout = setTimeout(() => scheduleRefresh(expiresAt), MAX_TIMER_DELAY_MS);
3971
+ return;
3972
+ }
3735
3973
  refreshTimeout = setTimeout(() => {
3736
3974
  attemptScheduledRefresh(0);
3737
3975
  }, delay);
@@ -4468,9 +4706,55 @@ var SDKQueryBuilder = class {
4468
4706
  }
4469
4707
  /**
4470
4708
  * Set a free-text search string if supported by the backend.
4709
+ *
4710
+ * By default this is a substring match across the collection's top-level
4711
+ * string properties. A Postgres collection that declares a `search` block
4712
+ * gets ranked full-text matching over the fields it named instead, and each
4713
+ * row comes back with a `_score` you can sort on:
4714
+ *
4715
+ * ```ts
4716
+ * client.data.talents.search("auditor iso 14001").orderBy("_score", "desc").find()
4717
+ * ```
4718
+ *
4719
+ * Pass `{ explain: true }` to have each row report which of the declared
4720
+ * fields matched, with a highlighted snippet, on `_matches`:
4721
+ *
4722
+ * ```ts
4723
+ * const { data } = await client.data.talents.search("iso 14001", { explain: true }).find();
4724
+ * data[0]._matches
4725
+ * // [{ field: "questionnaire.certifications", snippet: "<mark>ISO</mark> <mark>14001</mark> Lead Auditor" }]
4726
+ * ```
4471
4727
  */
4472
- search(searchString) {
4728
+ search(searchString, options) {
4473
4729
  this.params.searchString = searchString;
4730
+ if (options?.explain !== void 0) this.params.searchExplain = options.explain;
4731
+ return this;
4732
+ }
4733
+ /**
4734
+ * Order rows by nearest-neighbour distance to `vector`.
4735
+ *
4736
+ * The server has supported this from the REST layer since vectors landed;
4737
+ * this is the SDK reaching it. Results come back closest-first with a
4738
+ * `_distance` on each row, and any `where` / `orderBy` on the same query is
4739
+ * a filter applied before the ordering — distance decides the order.
4740
+ *
4741
+ * You supply the query vector. Rebase stores and searches embeddings; it
4742
+ * does not produce them, so this is where whatever model you already use
4743
+ * for the stored vectors gets called.
4744
+ *
4745
+ * @param property - Name of the `vector` property to compare against.
4746
+ * @param vector - The query embedding. Its length must match the property's
4747
+ * declared `dimensions`, or the server answers 400.
4748
+ * @example
4749
+ * client.data.docs.vectorSearch("embedding", queryVector, { threshold: 0.35 }).limit(10).find()
4750
+ */
4751
+ vectorSearch(property, vector, options) {
4752
+ this.params.vectorSearch = {
4753
+ property,
4754
+ vector,
4755
+ ...options?.distance !== void 0 && { distance: options.distance },
4756
+ ...options?.threshold !== void 0 && { threshold: options.threshold }
4757
+ };
4474
4758
  return this;
4475
4759
  }
4476
4760
  /**
@@ -4506,6 +4790,11 @@ var SDKQueryBuilder = class {
4506
4790
  return this.collection.listen(this.params, onUpdate, onError);
4507
4791
  }
4508
4792
  };
4793
+ /**
4794
+ * Counts currently in flight, keyed by the exact request they issue. Entries
4795
+ * live only for the duration of the request — see `count()` for why.
4796
+ */
4797
+ var inflightCounts = /* @__PURE__ */ new Map();
4509
4798
  function createCollectionClient(transport, slug, ws) {
4510
4799
  const basePath = `/data/${slug}`;
4511
4800
  const client = {
@@ -4550,18 +4839,63 @@ function createCollectionClient(transport, slug, ws) {
4550
4839
  body: JSON.stringify({
4551
4840
  rows: data,
4552
4841
  ...options?.upsert ? { upsert: true } : {}
4553
- })
4842
+ }),
4843
+ ...options?.idempotencyKey ? { headers: { "Idempotency-Key": options.idempotencyKey } } : {}
4554
4844
  })).data || [];
4555
4845
  },
4846
+ /**
4847
+ * Still `PUT`, deliberately, even though the server now serves `PATCH`
4848
+ * on the same handler and `PATCH` is the honest verb for a merge.
4849
+ *
4850
+ * The two are interchangeable server-side, so switching buys nothing at
4851
+ * runtime — and it costs compatibility in the direction that fails
4852
+ * quietly. A 0.14 client talking to a 0.13 server would send `PATCH` to
4853
+ * a route that does not exist and get a **404**, which is
4854
+ * indistinguishable from "that row is gone". Every write would look like
4855
+ * a missing record.
4856
+ *
4857
+ * `PATCH` is what the OpenAPI spec advertises, so anyone generating a
4858
+ * client gets the correct verb; this stays on `PUT` until the oldest
4859
+ * supported server is one that serves both.
4860
+ */
4556
4861
  async update(id, data) {
4557
4862
  return await transport.request(`${basePath}/${encodeURIComponent(String(id))}`, {
4558
4863
  method: "PUT",
4559
4864
  body: JSON.stringify(data)
4560
4865
  });
4561
4866
  },
4867
+ async updateMany(updates, options) {
4868
+ if (!Array.isArray(updates)) throw new TypeError("updateMany expects an array of { id, data } entries.");
4869
+ if (updates.length === 0) return [];
4870
+ return (await transport.request(`${basePath}/bulk`, {
4871
+ method: "PATCH",
4872
+ body: JSON.stringify({ updates }),
4873
+ ...options?.idempotencyKey ? { headers: { "Idempotency-Key": options.idempotencyKey } } : {}
4874
+ })).data || [];
4875
+ },
4562
4876
  async delete(id) {
4563
4877
  await transport.request(`${basePath}/${encodeURIComponent(String(id))}`, { method: "DELETE" });
4564
4878
  },
4879
+ /**
4880
+ * `POST .../bulk/delete`, not `DELETE .../bulk`.
4881
+ *
4882
+ * The honest verb would take the ids in a DELETE body, and that is the
4883
+ * one request shape the HTTP ecosystem handles unreliably: bodies on
4884
+ * DELETE are permitted but widely dropped by proxies and CDNs, and
4885
+ * several OpenAPI generators ignore `requestBody` on a DELETE
4886
+ * operation, so a generated client would send the request without its
4887
+ * ids. A backend deployed behind arbitrary ingress cannot take that
4888
+ * bet. Same reason `:batchDelete` exists in Google's API guidelines.
4889
+ */
4890
+ async deleteMany(ids, options) {
4891
+ if (!Array.isArray(ids)) throw new TypeError("deleteMany expects an array of ids.");
4892
+ if (ids.length === 0) return;
4893
+ await transport.request(`${basePath}/bulk/delete`, {
4894
+ method: "POST",
4895
+ body: JSON.stringify({ ids }),
4896
+ ...options?.idempotencyKey ? { headers: { "Idempotency-Key": options.idempotencyKey } } : {}
4897
+ });
4898
+ },
4565
4899
  async count(params) {
4566
4900
  const qs = buildQueryString({
4567
4901
  ...params,
@@ -4569,12 +4903,28 @@ function createCollectionClient(transport, slug, ws) {
4569
4903
  offset: void 0,
4570
4904
  include: void 0
4571
4905
  });
4572
- return (await transport.request(basePath + "/count" + qs, { method: "GET" })).count ?? 0;
4906
+ const key = basePath + "/count" + qs;
4907
+ const inflight = inflightCounts.get(key);
4908
+ if (inflight) return inflight;
4909
+ const request = transport.request(key, { method: "GET" }).then((raw) => raw.count ?? 0);
4910
+ inflightCounts.set(key, request);
4911
+ try {
4912
+ return await request;
4913
+ } finally {
4914
+ inflightCounts.delete(key);
4915
+ }
4573
4916
  },
4574
4917
  observe(params, onResult, onError, options) {
4575
4918
  let closed = false;
4576
- const emit = (result) => {
4919
+ let liveDelivered = false;
4920
+ let signature;
4921
+ const deliver = (result, fromLive) => {
4577
4922
  if (closed) return;
4923
+ if (fromLive) liveDelivered = true;
4924
+ else if (liveDelivered) return;
4925
+ const next = `${result.meta?.total ?? ""}|${JSON.stringify(result.data)}`;
4926
+ if (signature !== void 0 && next === signature) return;
4927
+ signature = next;
4578
4928
  onResult({
4579
4929
  ...result,
4580
4930
  fromCache: false,
@@ -4582,10 +4932,10 @@ function createCollectionClient(transport, slug, ws) {
4582
4932
  partial: false
4583
4933
  });
4584
4934
  };
4585
- client.find(params).then(emit).catch((error) => {
4935
+ client.find(params).then((result) => deliver(result, false)).catch((error) => {
4586
4936
  if (!closed) onError?.(error);
4587
4937
  });
4588
- const live = options?.realtime !== false && client.listen ? client.listen(params, emit, onError) : void 0;
4938
+ const live = options?.realtime !== false && client.listen ? client.listen(params, (result) => deliver(result, true), onError) : void 0;
4589
4939
  return () => {
4590
4940
  closed = true;
4591
4941
  live?.();
@@ -4593,17 +4943,24 @@ function createCollectionClient(transport, slug, ws) {
4593
4943
  },
4594
4944
  observeById(id, onResult, onError, options) {
4595
4945
  let closed = false;
4596
- const emit = (row) => {
4946
+ let liveDelivered = false;
4947
+ let signature;
4948
+ const deliver = (row, fromLive) => {
4597
4949
  if (closed) return;
4950
+ if (fromLive) liveDelivered = true;
4951
+ else if (liveDelivered) return;
4952
+ const next = row === void 0 ? "\0missing" : JSON.stringify(row);
4953
+ if (signature !== void 0 && next === signature) return;
4954
+ signature = next;
4598
4955
  onResult(row, {
4599
4956
  fromCache: false,
4600
4957
  hasPendingWrites: false
4601
4958
  });
4602
4959
  };
4603
- client.findById(id).then(emit).catch((error) => {
4960
+ client.findById(id).then((row) => deliver(row, false)).catch((error) => {
4604
4961
  if (!closed) onError?.(error);
4605
4962
  });
4606
- const live = options?.realtime !== false && client.listenById ? client.listenById(id, emit, onError) : void 0;
4963
+ const live = options?.realtime !== false && client.listenById ? client.listenById(id, (row) => deliver(row, true), onError) : void 0;
4607
4964
  return () => {
4608
4965
  closed = true;
4609
4966
  live?.();
@@ -4623,8 +4980,11 @@ function createCollectionClient(transport, slug, ws) {
4623
4980
  offset(count) {
4624
4981
  return new SDKQueryBuilder(client).offset(count);
4625
4982
  },
4626
- search(searchString) {
4627
- return new SDKQueryBuilder(client).search(searchString);
4983
+ search(searchString, options) {
4984
+ return new SDKQueryBuilder(client).search(searchString, options);
4985
+ },
4986
+ vectorSearch(property, vector, options) {
4987
+ return new SDKQueryBuilder(client).vectorSearch(property, vector, options);
4628
4988
  },
4629
4989
  include(...relations) {
4630
4990
  return new SDKQueryBuilder(client).include(...relations);
@@ -4634,51 +4994,44 @@ function createCollectionClient(transport, slug, ws) {
4634
4994
  client.listen = (params, onUpdate, onError) => {
4635
4995
  let active = true;
4636
4996
  let lastUpdateId = 0;
4997
+ let lastKnownTotal;
4998
+ const window = resolveFindWindow(params);
4637
4999
  const unsub = ws.listenCollection({
4638
5000
  path: slug,
4639
5001
  filter: params?.where,
5002
+ logical: params?.logical,
4640
5003
  limit: params?.limit,
4641
- startAfter: params?.offset ? String(params.offset) : void 0,
5004
+ offset: window.driverOffset,
4642
5005
  orderBy: params?.orderBy?.[0],
4643
5006
  order: params?.orderBy?.[1],
4644
- searchString: params?.searchString
5007
+ searchString: params?.searchString,
5008
+ searchExplain: params?.searchExplain
4645
5009
  }, (incomingRows) => {
4646
5010
  const currentUpdateId = ++lastUpdateId;
4647
- const requestedLimit = params?.limit || 20;
4648
- const offset = params?.offset || 0;
5011
+ const requestedLimit = window.limit;
5012
+ const offset = window.offset;
4649
5013
  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({
5014
+ const emit = (total, hasMore) => {
5015
+ if (!active || currentUpdateId !== lastUpdateId) return;
5016
+ onUpdate({
4654
5017
  data: rows,
4655
5018
  meta: {
4656
5019
  total,
4657
5020
  limit: requestedLimit,
4658
5021
  offset,
4659
- hasMore: offset + rows.length < total
5022
+ hasMore
4660
5023
  }
4661
5024
  });
5025
+ };
5026
+ const emitWithoutCount = () => emit(offset + rows.length, rows.length >= requestedLimit);
5027
+ if (client.count) client.count(params).then((total) => {
5028
+ lastKnownTotal = total;
5029
+ emit(total, offset + rows.length < total);
4662
5030
  }).catch(() => {
4663
- if (active && currentUpdateId === lastUpdateId) onUpdate({
4664
- data: rows,
4665
- meta: {
4666
- total: heuristicTotal,
4667
- limit: requestedLimit,
4668
- offset,
4669
- hasMore: heuristicHasMore
4670
- }
4671
- });
4672
- });
4673
- else onUpdate({
4674
- data: rows,
4675
- meta: {
4676
- total: heuristicTotal,
4677
- limit: requestedLimit,
4678
- offset,
4679
- hasMore: heuristicHasMore
4680
- }
5031
+ if (lastKnownTotal !== void 0) emit(lastKnownTotal, offset + rows.length < lastKnownTotal);
5032
+ else emitWithoutCount();
4681
5033
  });
5034
+ else emitWithoutCount();
4682
5035
  }, onError);
4683
5036
  return () => {
4684
5037
  active = false;
@@ -6061,15 +6414,10 @@ var RebaseWebSocketClient = class {
6061
6414
  }
6062
6415
  }
6063
6416
  createCollectionSubscriptionKey(props) {
6417
+ const { collection, ...query } = props;
6064
6418
  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
6419
+ ...query,
6420
+ collection: collection?.name
6073
6421
  };
6074
6422
  return JSON.stringify(key, (_, value) => {
6075
6423
  if (value && typeof value === "object" && !Array.isArray(value)) return Object.keys(value).sort().reduce((sorted, k) => {
@@ -6982,14 +7330,23 @@ function looseEquals(a, b) {
6982
7330
  */
6983
7331
  function likeToRegExp(pattern, caseInsensitive) {
6984
7332
  let source = "^";
7333
+ let lastWasWildcard = false;
6985
7334
  for (let i = 0; i < pattern.length; i++) {
6986
7335
  const char = pattern[i];
6987
7336
  if (char === "\\" && i + 1 < pattern.length) {
6988
7337
  source += pattern[i + 1].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6989
7338
  i++;
6990
- } else if (char === "%") source += "[\\s\\S]*";
6991
- else if (char === "_") source += "[\\s\\S]";
6992
- else source += char.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7339
+ lastWasWildcard = false;
7340
+ } else if (char === "%") {
7341
+ if (!lastWasWildcard) source += "[\\s\\S]*";
7342
+ lastWasWildcard = true;
7343
+ } else if (char === "_") {
7344
+ source += "[\\s\\S]";
7345
+ lastWasWildcard = false;
7346
+ } else {
7347
+ source += char.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7348
+ lastWasWildcard = false;
7349
+ }
6993
7350
  }
6994
7351
  return new RegExp(source + "$", caseInsensitive ? "i" : "");
6995
7352
  }
@@ -7121,12 +7478,20 @@ function sortRows(rows, orderBy) {
7121
7478
  function tiebreak(a, b) {
7122
7479
  return compareValues(a.id, b.id) ?? 0;
7123
7480
  }
7124
- /** Resolve `page`/`offset`/`limit` the way the server does. */
7481
+ /**
7482
+ * Resolve `page`/`offset`/`limit` the way the server does.
7483
+ *
7484
+ * It did not: this defaulted an absent limit to 20 while `/api/data` pages by
7485
+ * 50, so the same `observe()` answered with 20 rows from the local database and
7486
+ * 50 from the network — a list that changed length depending on which side
7487
+ * answered, with `page` striding differently on each. Delegated now, so the
7488
+ * sentence above is true by construction rather than by agreement.
7489
+ */
7125
7490
  function resolvePagination(params) {
7126
- const limit = params?.limit ?? 20;
7491
+ const { limit, offset } = resolveFindWindow(params);
7127
7492
  return {
7128
7493
  limit,
7129
- offset: params?.page != null ? Math.max(0, (params.page - 1) * limit) : params?.offset ?? 0
7494
+ offset
7130
7495
  };
7131
7496
  }
7132
7497
  /**
@@ -7141,6 +7506,7 @@ function isExactlyEvaluable(params) {
7141
7506
  if (!params) return true;
7142
7507
  if (params.include && params.include.length > 0) return false;
7143
7508
  if (params.searchString) return false;
7509
+ if (params.vectorSearch) return false;
7144
7510
  return true;
7145
7511
  }
7146
7512
  /** Run a full query — filter, sort, paginate — over a set of rows. */
@@ -7450,6 +7816,72 @@ var OfflineManager = class {
7450
7816
  this.notifyCollection(slug);
7451
7817
  return rows;
7452
7818
  },
7819
+ updateMany: async (updates, options) => {
7820
+ await this.ensureCollection(slug);
7821
+ if (!Array.isArray(updates)) throw new TypeError("updateMany expects an array of { id, data } entries.");
7822
+ if (updates.length === 0) return [];
7823
+ const anyPending = updates.some((u) => this.hasPending(slug, u.id));
7824
+ if (this.connectivity.shouldAttempt() && !anyPending) try {
7825
+ const rows = await inner.updateMany(updates, options);
7826
+ this.connectivity.markSuccess();
7827
+ await this.ingest(slug, rows);
7828
+ this.notifyCollection(slug);
7829
+ return rows;
7830
+ } catch (error) {
7831
+ if (!isNetworkError(error)) throw error;
7832
+ this.connectivity.markFailure();
7833
+ }
7834
+ const rollback = {};
7835
+ const optimistic = [];
7836
+ for (const { id, data } of updates) {
7837
+ const base = this.rawLocalRow(slug, id);
7838
+ rollback[String(id)] = base ?? null;
7839
+ optimistic.push({
7840
+ ...base ?? {},
7841
+ ...data,
7842
+ id
7843
+ });
7844
+ }
7845
+ await this.enqueue({
7846
+ collection: slug,
7847
+ type: "updateMany",
7848
+ updates: updates.map((u) => ({
7849
+ id: u.id,
7850
+ data: u.data
7851
+ })),
7852
+ rollback: { rows: rollback }
7853
+ });
7854
+ for (const row of optimistic) this.setLocalRow(slug, row.id, row);
7855
+ this.notifyCollection(slug);
7856
+ return optimistic;
7857
+ },
7858
+ deleteMany: async (ids, options) => {
7859
+ await this.ensureCollection(slug);
7860
+ if (!Array.isArray(ids)) throw new TypeError("deleteMany expects an array of ids.");
7861
+ if (ids.length === 0) return;
7862
+ const anyPending = ids.some((id) => this.hasPending(slug, id));
7863
+ if (this.connectivity.shouldAttempt() && !anyPending) try {
7864
+ await inner.deleteMany(ids, options);
7865
+ this.connectivity.markSuccess();
7866
+ for (const id of ids) this.removeLocalRow(slug, id, true);
7867
+ this.notifyCollection(slug);
7868
+ this.scheduleRefresh(slug);
7869
+ return;
7870
+ } catch (error) {
7871
+ if (!isNetworkError(error)) throw error;
7872
+ this.connectivity.markFailure();
7873
+ }
7874
+ const rollback = {};
7875
+ for (const id of ids) rollback[String(id)] = this.rawLocalRow(slug, id) ?? null;
7876
+ await this.enqueue({
7877
+ collection: slug,
7878
+ type: "deleteMany",
7879
+ ids,
7880
+ rollback: { rows: rollback }
7881
+ });
7882
+ for (const id of ids) this.removeLocalRow(slug, id, false);
7883
+ this.notifyCollection(slug);
7884
+ },
7453
7885
  update: async (id, data) => {
7454
7886
  await this.ensureCollection(slug);
7455
7887
  if (this.connectivity.shouldAttempt() && !this.hasPending(slug, id)) try {
@@ -7528,7 +7960,8 @@ var OfflineManager = class {
7528
7960
  orderBy: (column, direction) => new SDKQueryBuilder(wrapped).orderBy(column, direction),
7529
7961
  limit: (count) => new SDKQueryBuilder(wrapped).limit(count),
7530
7962
  offset: (count) => new SDKQueryBuilder(wrapped).offset(count),
7531
- search: (searchString) => new SDKQueryBuilder(wrapped).search(searchString),
7963
+ search: (searchString, options) => new SDKQueryBuilder(wrapped).search(searchString, options),
7964
+ vectorSearch: (property, vector, options) => new SDKQueryBuilder(wrapped).vectorSearch(property, vector, options),
7532
7965
  include: (...relations) => new SDKQueryBuilder(wrapped).include(...relations)
7533
7966
  };
7534
7967
  if (inner.listen) wrapped.listen = (params, onUpdate, onError) => inner.listen(params, (response) => {
@@ -7748,9 +8181,8 @@ var OfflineManager = class {
7748
8181
  if (!state) return {
7749
8182
  data: [],
7750
8183
  meta: {
8184
+ ...resolvePagination(params),
7751
8185
  total: 0,
7752
- limit: params?.limit ?? 20,
7753
- offset: params?.offset ?? 0,
7754
8186
  hasMore: false
7755
8187
  },
7756
8188
  fromCache: true,
@@ -7786,16 +8218,14 @@ var OfflineManager = class {
7786
8218
  }
7787
8219
  let added = 0;
7788
8220
  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);
8221
+ if (exact && offset === 0) for (const [key, entry] of state.rows) {
8222
+ if (seen.has(key) || !this.hasPending(slug, key)) continue;
8223
+ if (!this.isLocallyCreated(slug, key)) continue;
8224
+ if (!matchesParams(entry.row, params)) continue;
8225
+ rows.push(entry.row);
8226
+ added++;
7798
8227
  }
8228
+ if (params?.orderBy) sortRows(rows, params.orderBy);
7799
8229
  return {
7800
8230
  data: rows,
7801
8231
  meta: {
@@ -7942,17 +8372,17 @@ var OfflineManager = class {
7942
8372
  return row;
7943
8373
  }
7944
8374
  recordSnapshot(slug, params, result) {
8375
+ const window = resolvePagination(params);
7945
8376
  const meta = result.meta ?? {
7946
8377
  total: result.data?.length ?? 0,
7947
- limit: 20,
7948
- offset: 0,
8378
+ ...window,
7949
8379
  hasMore: false
7950
8380
  };
7951
8381
  const snapshot = {
7952
8382
  ids: (result.data ?? []).map((row) => row.id).filter((id) => id !== void 0),
7953
8383
  total: meta.total ?? result.data?.length ?? 0,
7954
- limit: meta.limit ?? params?.limit ?? 20,
7955
- offset: meta.offset ?? params?.offset ?? 0,
8384
+ limit: meta.limit ?? window.limit,
8385
+ offset: meta.offset ?? window.offset,
7956
8386
  hasMore: meta.hasMore ?? false
7957
8387
  };
7958
8388
  const state = this.collectionState(slug);
@@ -8167,11 +8597,25 @@ var OfflineManager = class {
8167
8597
  await this.adoptServerRow(op, op.id, row);
8168
8598
  } else if (op.type === "createMany") {
8169
8599
  const queued = op.data ?? [];
8170
- const rows = await inner.createMany(queued, op.upsert ? { upsert: true } : void 0);
8600
+ const rows = await inner.createMany(queued, {
8601
+ ...op.upsert ? { upsert: true } : {},
8602
+ idempotencyKey: op.mutationId
8603
+ });
8171
8604
  for (let i = 0; i < rows.length; i++) await this.adoptServerRow(op, queued[i]?.id, rows[i]);
8605
+ } else if (op.type === "updateMany") {
8606
+ const queued = op.updates ?? [];
8607
+ const rows = await inner.updateMany(queued.map((u) => ({
8608
+ id: u.id,
8609
+ data: u.data
8610
+ })), { idempotencyKey: op.mutationId });
8611
+ for (let i = 0; i < rows.length; i++) await this.ingestReplaced(op, queued[i].id, rows[i]);
8172
8612
  } else if (op.type === "update") {
8173
8613
  const row = await inner.update(op.id, op.data);
8174
8614
  await this.ingestReplaced(op, op.id, row);
8615
+ } else if (op.type === "deleteMany") {
8616
+ const ids = op.ids ?? [];
8617
+ await inner.deleteMany(ids, { idempotencyKey: op.mutationId });
8618
+ for (const id of ids) this.removeLocalRow(op.collection, id, true);
8175
8619
  } else if (op.type === "delete") {
8176
8620
  await inner.delete(op.id);
8177
8621
  this.removeLocalRow(op.collection, op.id, true);
@@ -8414,23 +8858,37 @@ var OfflineManager = class {
8414
8858
  /**
8415
8859
  * Derive a WebSocket URL from an HTTP base URL.
8416
8860
  * `http://` → `ws://`, `https://` → `wss://`.
8861
+ *
8862
+ * A backend mounted under a path is the reason `baseUrl` accepts one, so the
8863
+ * path is kept. It used to be kept for an absolute `baseUrl` and dropped for a
8864
+ * relative one — resolved through `.origin` — so one deployment dialled two
8865
+ * different sockets depending on whether its config said `"/backend"` or
8866
+ * `"https://app.example.com/backend"`.
8867
+ *
8868
+ * Returns `""` when there is nothing to resolve against: a relative `baseUrl`
8869
+ * outside a browser has no origin, and inventing one would dial somewhere
8870
+ * arbitrary. The caller warns rather than leaving that silent.
8417
8871
  */
8418
8872
  function deriveWebSocketUrl(baseUrl) {
8873
+ const toWsProtocol = (url) => {
8874
+ const secure = /^(https|wss):/i.test(url);
8875
+ return url.replace(/^https?:\/\//i, secure ? "wss://" : "ws://").replace(/^wss?:\/\//i, secure ? "wss://" : "ws://").replace(/\/$/, "");
8876
+ };
8419
8877
  if (typeof window !== "undefined") {
8420
- let absoluteUrl = "";
8878
+ let absoluteUrl;
8421
8879
  if (!baseUrl) absoluteUrl = window.location.origin;
8422
8880
  else if (/^https?:\/\//i.test(baseUrl) || /^wss?:\/\//i.test(baseUrl)) absoluteUrl = baseUrl;
8423
8881
  else try {
8424
- absoluteUrl = new URL(baseUrl, window.location.href).origin;
8882
+ const resolved = new URL(baseUrl, window.location.href);
8883
+ absoluteUrl = resolved.origin + resolved.pathname;
8425
8884
  } catch {
8426
8885
  absoluteUrl = window.location.origin;
8427
8886
  }
8428
- const protocol = absoluteUrl.startsWith("https:") || absoluteUrl.startsWith("wss:") ? "wss:" : "ws:";
8429
- return absoluteUrl.replace(/^https?:\/\//i, `${protocol}//`).replace(/^wss?:\/\//i, `${protocol}//`).replace(/\/$/, "");
8887
+ return toWsProtocol(absoluteUrl);
8430
8888
  }
8431
8889
  if (!baseUrl) return "";
8432
8890
  if (!/^https?:\/\//i.test(baseUrl) && !/^wss?:\/\//i.test(baseUrl)) return "";
8433
- return baseUrl.replace(/^https?:\/\//i, (match) => match.toLowerCase() === "https://" ? "wss://" : "ws://").replace(/\/$/, "");
8891
+ return toWsProtocol(baseUrl);
8434
8892
  }
8435
8893
  function createRebaseClient(options) {
8436
8894
  const transport = createTransport(options, { credentialOutOfBand: options.auth?.authFlowMode === "cookie" });
@@ -8458,7 +8916,11 @@ function createRebaseClient(options) {
8458
8916
  });
8459
8917
  return storageSourcesPromise;
8460
8918
  };
8461
- const resolvedWsUrl = options.realtime !== false ? options.websocketUrl ?? deriveWebSocketUrl(options.baseUrl) : void 0;
8919
+ const realtimeEnabled = options.realtime !== false;
8920
+ const resolvedWsUrl = realtimeEnabled ? options.websocketUrl ?? deriveWebSocketUrl(options.baseUrl) : void 0;
8921
+ const realtimeUnreachable = realtimeEnabled && !resolvedWsUrl;
8922
+ 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.`;
8923
+ if (realtimeUnreachable) console.warn(`[Rebase] Realtime is enabled but ${unreachableReason} Live queries will fall back to a single fetch and channels will throw.`);
8462
8924
  let ws;
8463
8925
  /** One channel object per name — see `realtime.channel`. */
8464
8926
  const realtimeChannels = /* @__PURE__ */ new Map();
@@ -8578,7 +9040,7 @@ function createRebaseClient(options) {
8578
9040
  * cut off the others.
8579
9041
  */
8580
9042
  channel: (name, options) => {
8581
- if (!ws) throw new RebaseClientError("Realtime is disabled on this client (realtime: false), so channels are unavailable.");
9043
+ if (!ws) throw new RebaseClientError(realtimeUnreachable ? `Realtime is enabled but ${unreachableReason}` : "Realtime is disabled on this client (realtime: false), so channels are unavailable.");
8582
9044
  let existing = realtimeChannels.get(name);
8583
9045
  if (!existing) {
8584
9046
  existing = new RebaseRealtimeChannel(name, ws, options);
@@ -8622,10 +9084,50 @@ channel: (name, options) => {
8622
9084
  //#endregion
8623
9085
  //#region src/history/history-routes.ts
8624
9086
  function createHistoryRoutes(params) {
8625
- const { historyService, registry, driver } = params;
9087
+ const { historyService, registry } = params;
8626
9088
  const router = new Hono();
8627
9089
  router.onError(errorHandler);
8628
9090
  /**
9091
+ * Both halves of the authorization the REST generator applies to every
9092
+ * other route on this router, for the row whose history is being asked for.
9093
+ *
9094
+ * `historyService` reads `rebase.entity_history` through the *privileged*
9095
+ * handle the bootstrapper built — not `c.get("driver")` — and it has to:
9096
+ * history is one table shadowing every audited collection, so it cannot
9097
+ * carry their row policies, and `ensure-history-table` revokes it from
9098
+ * `rebase_user` outright for exactly that reason. Nothing about the read is
9099
+ * therefore scoped to the caller. That makes this function the *entire*
9100
+ * access-control model for the route, the same way `storageAuthorize` is
9101
+ * for storage.
9102
+ *
9103
+ * It asks the two questions the data API already answers, in its order:
9104
+ *
9105
+ * - the API key's permission list, via the same predicate
9106
+ * `RestApiGenerator.enforceApiKeyPermission` uses, and keyed on the
9107
+ * request's own method so that reverting needs `write` and listing needs
9108
+ * `read` — a key scoped to no collection, or to a different one, or to
9109
+ * reading only, must not get past this;
9110
+ * - RLS, by fetching the row itself through the request-scoped driver.
9111
+ * A row the caller may not read comes back undefined, and its history is
9112
+ * then a 404 rather than a 403, because a 403 confirms the row exists.
9113
+ *
9114
+ * A missing scoped driver is an internal error, never a fallback to the
9115
+ * unscoped one — the same rule, and the same reason, as
9116
+ * {@link RestApiGenerator.getScopedDriver}.
9117
+ */
9118
+ async function authorizeEntityRead(c, collection, id) {
9119
+ const apiKey = c.get("apiKey");
9120
+ const operation = httpMethodToOperation(c.req.method);
9121
+ 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");
9122
+ const scoped = c.get("driver");
9123
+ if (!scoped) throw ApiError.internal("Scoped driver not available");
9124
+ if (!await scoped.fetchOne({
9125
+ path: collection.slug,
9126
+ id,
9127
+ collection
9128
+ })) throw ApiError.notFound(`Entity '${id}' not found in collection '${collection.slug}'`);
9129
+ }
9130
+ /**
8629
9131
  * GET /:slug/:id/history - List history entries for a entity
8630
9132
  *
8631
9133
  * Query params:
@@ -8642,18 +9144,21 @@ function createHistoryRoutes(params) {
8642
9144
  const collection = registry.getCollections().find((col) => col.slug === slug || false);
8643
9145
  if (!collection) throw ApiError.notFound(`Collection '${slug}' not found`);
8644
9146
  if (!collection.history) throw ApiError.badRequest(`History is not enabled for collection '${slug}'`);
9147
+ await authorizeEntityRead(c, collection, id);
8645
9148
  const tableName = collection.slug;
9149
+ const appliedLimit = Math.min(limit, 100);
9150
+ const appliedOffset = Math.max(offset, 0);
8646
9151
  const result = await historyService.fetchHistory(tableName, id, {
8647
- limit: Math.min(limit, 100),
8648
- offset: Math.max(offset, 0)
9152
+ limit: appliedLimit,
9153
+ offset: appliedOffset
8649
9154
  });
8650
9155
  return c.json({
8651
9156
  data: result.data,
8652
9157
  meta: {
8653
9158
  total: result.total,
8654
- limit,
8655
- offset,
8656
- hasMore: offset + result.data.length < result.total
9159
+ limit: appliedLimit,
9160
+ offset: appliedOffset,
9161
+ hasMore: appliedOffset + result.data.length < result.total
8657
9162
  }
8658
9163
  });
8659
9164
  });
@@ -8669,12 +9174,13 @@ function createHistoryRoutes(params) {
8669
9174
  const collection = registry.getCollections().find((col) => col.slug === slug || false);
8670
9175
  if (!collection) throw ApiError.notFound(`Collection '${slug}' not found`);
8671
9176
  if (!collection.history) throw ApiError.badRequest(`History is not enabled for collection '${slug}'`);
9177
+ await authorizeEntityRead(c, collection, id);
8672
9178
  const historyEntry = await historyService.fetchHistoryEntry(historyId);
8673
9179
  if (!historyEntry) throw ApiError.notFound(`History entry '${historyId}' not found`);
8674
9180
  const tableName = collection.slug;
8675
9181
  if (historyEntry.entity_id !== String(id) || historyEntry.table_name !== tableName) throw ApiError.badRequest("History entry does not belong to this entity");
8676
9182
  if (!historyEntry.values) throw ApiError.badRequest("Cannot revert: history entry has no stored values");
8677
- const authDriver = c.get("driver") || driver;
9183
+ const authDriver = c.get("driver");
8678
9184
  const path = collection.slug;
8679
9185
  const savedEntity = await authDriver.save({
8680
9186
  path,
@@ -9249,7 +9755,7 @@ async function _initializeRebaseBackend(config) {
9249
9755
  ]) {
9250
9756
  const providerConfig = safeAuthConfig[key];
9251
9757
  if (providerConfig && requiredFields.every((f) => Boolean(providerConfig[f]))) {
9252
- const createFn = (await import("./auth-CuC9M2x6.js").then((n) => n.t))[factory];
9758
+ const createFn = (await import("./auth-BANhRhN9.js").then((n) => n.t))[factory];
9253
9759
  oauthProviders.push(createFn(providerConfig));
9254
9760
  }
9255
9761
  }
@@ -9343,7 +9849,7 @@ async function _initializeRebaseBackend(config) {
9343
9849
  let schemaEditorOff = schemaEditorUnavailable();
9344
9850
  let schemaEditorRoutes;
9345
9851
  if (!schemaEditorOff && config.collectionsDir) try {
9346
- schemaEditorRoutes = (await import("./schema-editor-routes-CbF20Mf1.js")).createSchemaEditorRoutes(config.collectionsDir);
9852
+ schemaEditorRoutes = (await import("./schema-editor-routes-BqjzvvWU.js")).createSchemaEditorRoutes(config.collectionsDir);
9347
9853
  } catch (err) {
9348
9854
  if (err?.code === "ERR_MODULE_NOT_FOUND") {
9349
9855
  schemaEditorOff = {
@@ -9513,7 +10019,8 @@ async function _initializeRebaseBackend(config) {
9513
10019
  Object.assign(serverClient, { sql: (query, options) => driverAdmin.executeSql(query, options) });
9514
10020
  logger.info("SQL capability attached to singleton");
9515
10021
  }
9516
- _initRebase(serverClient);
10022
+ const serverSingleton = serverClient;
10023
+ _initRebase(serverSingleton);
9517
10024
  logger.info("Rebase singleton initialized");
9518
10025
  if (defaultDriverResult.internals) {
9519
10026
  const driver = defaultDriverResult.internals.driver;
@@ -9556,12 +10063,12 @@ async function _initializeRebaseBackend(config) {
9556
10063
  let cronScheduler;
9557
10064
  if (config.cronsDir) {
9558
10065
  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);
10066
+ const { CronScheduler } = await import("./cron-scheduler-CsQtYLMq.js").then((n) => n.n);
10067
+ const { createCronRoutes } = await import("./cron-routes-D5a9v1HY.js").then((n) => n.n);
10068
+ const { createCronStore } = await import("./cron-store-ZbjfuvMb.js").then((n) => n.n);
9562
10069
  const loadedCronJobs = await loadCronJobsFromDirectory(config.cronsDir);
9563
10070
  cronScheduler = new CronScheduler();
9564
- cronScheduler.setClient(serverClient);
10071
+ cronScheduler.setClient(serverSingleton);
9565
10072
  if (loadedCronJobs.length > 0) {
9566
10073
  cronScheduler.registerJobs(loadedCronJobs);
9567
10074
  const store = defaultBootstrapper.getAdmin?.(defaultDriverResult) && config.cronPersistence !== false ? createCronStore(defaultDriver) : void 0;
@@ -9583,7 +10090,7 @@ async function _initializeRebaseBackend(config) {
9583
10090
  } 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
10091
  }
9585
10092
  {
9586
- const { createBackupRoutes, parseBackupDestination } = await import("./backup-CVggVhR2.js").then((n) => n.t);
10093
+ const { createBackupRoutes, parseBackupDestination } = await import("./backup-BJiY_mM-.js").then((n) => n.t);
9587
10094
  const backupRouter = new Hono();
9588
10095
  applyAdminGate(backupRouter, "Backup");
9589
10096
  backupRouter.route("/", createBackupRoutes({
@@ -9605,7 +10112,7 @@ async function _initializeRebaseBackend(config) {
9605
10112
  logger.info("Logs routes mounted", { path: `${basePath}/logs` });
9606
10113
  }
9607
10114
  {
9608
- const { createContractRoutes } = await import("./contract-routes-Dj8i5AiM.js").then((n) => n.t);
10115
+ const { createContractRoutes } = await import("./contract-routes-FSt0Nyfk.js").then((n) => n.t);
9609
10116
  const contractRouter = new Hono();
9610
10117
  if (adminSurfacesGated) {
9611
10118
  if (apiKeyPreAuth) contractRouter.use("/contract", apiKeyPreAuth);
@@ -9699,7 +10206,7 @@ async function _initializeRebaseBackend(config) {
9699
10206
  * export default defineFunction((app, { rebase }) => {
9700
10207
  * app.use("/*", requireAuth);
9701
10208
  * app.get("/home", async (c) => {
9702
- * const [stats] = await rebase.sql!(`SELECT count(*) AS n FROM orders`);
10209
+ * const [stats] = await rebase.sql(`SELECT count(*) AS n FROM orders`);
9703
10210
  * return c.json({ orders: Number(stats.n) });
9704
10211
  * });
9705
10212
  * });
@@ -9731,12 +10238,14 @@ function defineFunction(definition) {
9731
10238
  * export default defineCron({
9732
10239
  * name: "Nightly cleanup",
9733
10240
  * schedule: "0 3 * * *",
9734
- * async handler({ client, log }) {
9735
- * const { data: expired } = await client.data.sessions.find({
10241
+ * async handler({ rebase, log }) {
10242
+ * // `dataAsAdmin` bypasses RLS. A cron has no per-request user, so
10243
+ * // there is no policy to fall back on — scope the filters yourself.
10244
+ * const { data: expired } = await rebase.dataAsAdmin.sessions.find({
9736
10245
  * where: { expired: ["==", true] },
9737
10246
  * });
9738
10247
  * for (const session of expired) {
9739
- * await client.data.sessions.delete(session.id);
10248
+ * await rebase.dataAsAdmin.sessions.delete(session.id as string);
9740
10249
  * }
9741
10250
  * log(`Deleted ${expired.length} expired sessions`);
9742
10251
  * },
@@ -9749,36 +10258,41 @@ function defineCron(definition) {
9749
10258
  //#endregion
9750
10259
  //#region src/utils/sql.ts
9751
10260
  /**
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
10261
+ * Returns a SQL chunk calling `rebase.uid()` — the current user's ID.
10262
+ * This is a PostgreSQL RLS helper function created in the `rebase` schema
9754
10263
  * that reads `app.uid` set per-transaction by `withAuth()`.
9755
10264
  *
10265
+ * The function moved from `auth.uid()` to `rebase.uid()` in 1.0 — `auth` is
10266
+ * Supabase's schema, and taking it meant Rebase could not be pointed at a
10267
+ * database that already had one. Use this helper rather than writing the call
10268
+ * by hand and the move costs you nothing.
10269
+ *
9756
10270
  * @example
9757
10271
  * sql`${table.uid} = ${authUid()}`
9758
10272
  */
9759
10273
  var authUid = () => {
9760
- return sql`auth.uid()`;
10274
+ return sql.raw(RLS_UID_SQL);
9761
10275
  };
9762
10276
  /**
9763
- * Returns a SQL chunk calling `auth.roles()` — the current user's roles
10277
+ * Returns a SQL chunk calling `rebase.roles()` — the current user's roles
9764
10278
  * as a comma-separated string.
9765
10279
  * Reads `app.user_roles` set per-transaction by `withAuth()`.
9766
10280
  *
9767
10281
  * @example
9768
- * sql`auth.roles() ~ 'admin'`
10282
+ * sql`${authRoles()} ~ 'admin'`
9769
10283
  */
9770
10284
  var authRoles = () => {
9771
- return sql`auth.roles()`;
10285
+ return sql.raw(RLS_ROLES_SQL);
9772
10286
  };
9773
10287
  /**
9774
- * Returns a SQL chunk calling `auth.jwt()` — the full JWT claims as JSONB.
10288
+ * Returns a SQL chunk calling `rebase.jwt()` — the full JWT claims as JSONB.
9775
10289
  * Reads `app.jwt` set per-transaction by `withAuth()`.
9776
10290
  *
9777
10291
  * @example
9778
- * sql`auth.jwt()->>'sub'`
10292
+ * sql`${authJwt()}->>'sub'`
9779
10293
  */
9780
10294
  var authJwt = () => {
9781
- return sql`auth.jwt()`;
10295
+ return sql.raw(RLS_JWT_SQL);
9782
10296
  };
9783
10297
  //#endregion
9784
10298
  //#region src/env.ts
@@ -9951,8 +10465,10 @@ var WebhookDispatcher = class {
9951
10465
  for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
9952
10466
  const result = await this.deliver(webhook, event, payload, attempt);
9953
10467
  if (result.success) return result;
9954
- if (attempt < this.maxRetries) await new Promise((r) => setTimeout(r, this.retryDelays[attempt - 1]));
9955
- else return result;
10468
+ if (attempt < this.maxRetries) {
10469
+ const backoff = this.retryDelays[attempt - 1] ?? this.retryDelays[this.retryDelays.length - 1];
10470
+ await new Promise((r) => setTimeout(r, backoff));
10471
+ } else return result;
9956
10472
  }
9957
10473
  return {
9958
10474
  webhookId: webhook.id,
@@ -11785,7 +12301,8 @@ async function bootFromBundle(options = {}) {
11785
12301
  });
11786
12302
  await ensureCollectionPolicies(bundle, dataSources, env);
11787
12303
  metrics?.setKnownCollections(backend.collectionRegistry.getCollections().map((collection) => collection.slug).filter((slug) => Boolean(slug)));
11788
- app.get("/health", async (c) => {
12304
+ const healthPaths = ["/health", `${(env.REBASE_BASE_PATH || "/api").replace(/\/$/, "")}/health`];
12305
+ for (const healthPath of [...new Set(healthPaths)]) app.get(healthPath, async (c) => {
11789
12306
  const result = await backend.healthCheck();
11790
12307
  const unhealthy = (await Promise.all(dataSources.filter((source) => source.key !== DEFAULT_DATA_SOURCE_KEY).map(async (source) => ({
11791
12308
  key: source.key,
@@ -11891,7 +12408,7 @@ async function bootStaticApp(bundle, devRoot, options) {
11891
12408
  if (metrics) app.use("/*", metrics.middleware);
11892
12409
  const server = createServer(getRequestListener(app.fetch));
11893
12410
  app.get("/livez", (c) => c.json({ status: "ok" }));
11894
- app.get("/health", (c) => c.json({
12411
+ for (const healthPath of [.../* @__PURE__ */ new Set(["/health", `${basePath.replace(/\/$/, "")}/health`])]) app.get(healthPath, (c) => c.json({
11895
12412
  status: "ok",
11896
12413
  latencyMs: 0
11897
12414
  }));
@@ -12001,6 +12518,40 @@ function warnOnDriverSkew(dataSources, runtimeVersion) {
12001
12518
  }
12002
12519
  }
12003
12520
  /**
12521
+ * The collections a data source's engine is the store for.
12522
+ *
12523
+ * A bundle's collections directory holds *every* collection the project
12524
+ * declares, whatever engine serves it — that is the point of `dataSource`
12525
+ * routing. What it is not is a list of tables to create: handing the whole
12526
+ * directory to the primary source's bootstrapper made a Firestore collection
12527
+ * declared alongside the Postgres ones arrive as an empty Postgres table, with
12528
+ * RLS policies, while the app went on reading its documents from Firestore.
12529
+ *
12530
+ * Excluding is deliberately conservative. A collection that names neither an
12531
+ * `engine` nor a `dataSource` belongs to whichever source is primary — the
12532
+ * "postgres" that `resolveDataSource` falls back to there is a default, not a
12533
+ * declaration, and must not exclude anything on its own. Only a collection that
12534
+ * explicitly routes to a *different* engine is dropped, so a project running two
12535
+ * sources on the same engine is unaffected.
12536
+ */
12537
+ function collectionsStoredBy(collections, primary, dataSources) {
12538
+ const registry = createDataSourceRegistry(dataSources.map((source) => ({
12539
+ key: source.key,
12540
+ engine: source.engine
12541
+ })));
12542
+ return collections.filter((collection) => {
12543
+ const declared = collection.engine ?? (collection.dataSource ? resolveDataSource(collection, registry).engine : void 0);
12544
+ if (!declared) return true;
12545
+ return declared === primary.engine;
12546
+ });
12547
+ }
12548
+ /** Say what was routed elsewhere, so a missing table is never a silent one. */
12549
+ function logForeignCollections(all, stored, primary) {
12550
+ if (stored.length === all.length) return;
12551
+ const foreign = all.filter((c) => !stored.includes(c)).map((c) => c.slug);
12552
+ 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.`);
12553
+ }
12554
+ /**
12004
12555
  * Bring the database's collection tables up to date before serving.
12005
12556
  *
12006
12557
  * Delegates to whichever driver bootstrapped the default data source; a driver
@@ -12049,11 +12600,17 @@ async function ensureCollectionSchema(bundle, dataSources, env) {
12049
12600
  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
12601
  return;
12051
12602
  }
12052
- const collections = await loadCollectionsFromDirectory(bundle.collectionsDir);
12053
- if (collections.length === 0) {
12603
+ const loaded = await loadCollectionsFromDirectory(bundle.collectionsDir);
12604
+ if (loaded.length === 0) {
12054
12605
  skip(`no collections were loaded from "${bundle.collectionsDir}".`, "warn");
12055
12606
  return;
12056
12607
  }
12608
+ const collections = collectionsStoredBy(loaded, primary, dataSources);
12609
+ logForeignCollections(loaded, collections, primary);
12610
+ if (collections.length === 0) {
12611
+ skip(`none of the ${loaded.length} declared collection(s) are stored by the "${primary.engine}" data source.`);
12612
+ return;
12613
+ }
12057
12614
  const { applied } = await primary.bootstrapper.ensureCollectionSchema(collections, preInitDriverResult(primary), (message) => logger.info(`schema: ${message}`));
12058
12615
  logger.info(applied > 0 ? `Applied ${applied} additive schema change(s) before boot.` : "Collection schema is up to date.");
12059
12616
  }
@@ -12103,12 +12660,14 @@ async function ensureCollectionPolicies(bundle, dataSources, env) {
12103
12660
  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
12661
  return;
12105
12662
  }
12106
- const collections = await loadCollectionsFromDirectory(bundle.collectionsDir);
12663
+ const loaded = await loadCollectionsFromDirectory(bundle.collectionsDir);
12664
+ if (loaded.length === 0) return;
12665
+ const collections = collectionsStoredBy(loaded, primary, dataSources);
12107
12666
  if (collections.length === 0) return;
12108
12667
  const { applied } = await primary.bootstrapper.ensureCollectionPolicies(collections, preInitDriverResult(primary), (message) => logger.info(`policies: ${message}`));
12109
12668
  logger.info(applied > 0 ? `Applied ${applied} RLS policy statement(s) before serving.` : "RLS policies are up to date.");
12110
12669
  }
12111
12670
  //#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 };
12671
+ 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, 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 };
12113
12672
 
12114
12673
  //# sourceMappingURL=index.es.js.map