@rebasepro/server 0.13.0 → 0.13.1-canary.g18cfeb7

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 (53) 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-CtxXQYV_.js} +20 -39
  10. package/dist/auth-CtxXQYV_.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/{contract-routes-Dj8i5AiM.js → contract-routes-FSt0Nyfk.js} +2 -2
  15. package/dist/{contract-routes-Dj8i5AiM.js.map → contract-routes-FSt0Nyfk.js.map} +1 -1
  16. package/dist/cron/cron-scheduler.d.ts +8 -3
  17. package/dist/cron/define-cron.d.ts +5 -3
  18. package/dist/{cron-routes-CrQ0tK-_.js → cron-routes-D5a9v1HY.js} +2 -2
  19. package/dist/{cron-routes-CrQ0tK-_.js.map → cron-routes-D5a9v1HY.js.map} +1 -1
  20. package/dist/{cron-scheduler-B3RFt0HS.js → cron-scheduler-CsQtYLMq.js} +28 -3
  21. package/dist/cron-scheduler-CsQtYLMq.js.map +1 -0
  22. package/dist/{cron-store-BywZsyfZ.js → cron-store-CHH7zrif.js} +16 -3
  23. package/dist/cron-store-CHH7zrif.js.map +1 -0
  24. package/dist/{errors-CgkCzoj7.js → errors-BpudWAVU.js} +2 -10
  25. package/dist/errors-BpudWAVU.js.map +1 -0
  26. package/dist/functions/define-function.d.ts +1 -1
  27. package/dist/index.es.js +673 -232
  28. package/dist/index.es.js.map +1 -1
  29. package/dist/internal-tables-57VaN3o1.js +105 -0
  30. package/dist/internal-tables-57VaN3o1.js.map +1 -0
  31. package/dist/{jwt-DD6EtpGj.js → jwt-CzeviDcB.js} +17 -2
  32. package/dist/{jwt-DD6EtpGj.js.map → jwt-CzeviDcB.js.map} +1 -1
  33. package/dist/{openapi-generator-BEwyiaAr.js → openapi-generator-CSlEZh7-.js} +306 -128
  34. package/dist/openapi-generator-CSlEZh7-.js.map +1 -0
  35. package/dist/{schema-editor-routes-CbF20Mf1.js → schema-editor-routes-BqjzvvWU.js} +4 -4
  36. package/dist/{schema-editor-routes-CbF20Mf1.js.map → schema-editor-routes-BqjzvvWU.js.map} +1 -1
  37. package/dist/{src-_qQ3RNCK.js → src-Ca6NhxKs.js} +79 -2
  38. package/dist/src-Ca6NhxKs.js.map +1 -0
  39. package/dist/{src-Cum9kox5.js → src-CvaxLgnM.js} +115 -34
  40. package/dist/src-CvaxLgnM.js.map +1 -0
  41. package/dist/utils/logging.d.ts +0 -4
  42. package/dist/utils/sql.d.ts +11 -6
  43. package/package.json +6 -6
  44. package/dist/admin_block-CcSGdH7M.js.map +0 -1
  45. package/dist/auth-CuC9M2x6.js.map +0 -1
  46. package/dist/backend-CIxN4FVm.js +0 -15
  47. package/dist/backend-CIxN4FVm.js.map +0 -1
  48. package/dist/cron-scheduler-B3RFt0HS.js.map +0 -1
  49. package/dist/cron-store-BywZsyfZ.js.map +0 -1
  50. package/dist/errors-CgkCzoj7.js.map +0 -1
  51. package/dist/openapi-generator-BEwyiaAr.js.map +0 -1
  52. package/dist/src-Cum9kox5.js.map +0 -1
  53. 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-CvaxLgnM.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 { n as isSQLAdmin, t as revokeInternalTableSql } from "./internal-tables-57VaN3o1.js";
8
+ import { n as createContractRoutes } from "./contract-routes-FSt0Nyfk.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, 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-CtxXQYV_.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-CHH7zrif.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";
@@ -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;
@@ -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
  */
@@ -953,17 +1025,7 @@ function parseQueryOptions(query, limits = {}) {
953
1025
  };
954
1026
  if (Object.keys(where).length > 0) options.where = where;
955
1027
  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
- }
1028
+ if (orderByVal) options.orderBy = parseOrderByParam(orderByVal);
967
1029
  const includeVal = getLastValue(query.include);
968
1030
  if (includeVal) {
969
1031
  const includeStr = String(includeVal).trim();
@@ -1055,11 +1117,18 @@ function assertKnownWriteFields(values, collection, options) {
1055
1117
  * This shapes the *response*, which is what the parameter says it does; it is
1056
1118
  * not a column pushdown, so it saves bandwidth rather than database work.
1057
1119
  *
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.
1120
+ * The collection's key always survives. Rows are addressed by it everywhere
1121
+ * above this layer — the admin table, realtime reconciliation, the offline
1122
+ * cache — and a row that arrives without one is not a smaller row, it is an
1123
+ * unusable one. Asking for `fields=title` and being unable to open the record
1124
+ * you clicked is a worse answer than one extra key.
1125
+ *
1126
+ * That is a statement about the key, not about the name `id`: a collection
1127
+ * keyed on `slug`, or on `user_id + role_id`, was projected down to the fields
1128
+ * asked for and nothing else, which is the very outcome the paragraph above
1129
+ * describes. `assertKnownWriteFields` one function up already has a dedicated
1130
+ * error for collections with no `id` column, so this layer had no excuse for
1131
+ * assuming one.
1063
1132
  */
1064
1133
  function projectResponseFields(rows, fields, collection, options) {
1065
1134
  if (!fields || fields.length === 0) return rows;
@@ -1077,7 +1146,8 @@ function projectResponseFields(rows, fields, collection, options) {
1077
1146
  collection: collection.slug
1078
1147
  });
1079
1148
  }
1080
- const keep = /* @__PURE__ */ new Set([...fields, "id"]);
1149
+ const primaryKeys = resolvePrimaryKeys(collection).map((key) => key.fieldName);
1150
+ const keep = /* @__PURE__ */ new Set([...fields, ...primaryKeys.length > 0 ? primaryKeys : ["id"]]);
1081
1151
  return rows.map((row) => {
1082
1152
  const projected = {};
1083
1153
  for (const key of Object.keys(row)) if (keep.has(key)) projected[key] = row[key];
@@ -1152,6 +1222,7 @@ function createIdempotencyStore(driver) {
1152
1222
  )
1153
1223
  `);
1154
1224
  await exec(`CREATE INDEX IF NOT EXISTS idx_idempotency_created ON ${TABLE}(created_at)`);
1225
+ await exec(revokeInternalTableSql("rebase", "idempotency_keys"));
1155
1226
  return true;
1156
1227
  } catch (error) {
1157
1228
  logger.warn("Idempotency keys unavailable — a replayed offline write may insert a duplicate row.", { detail: error instanceof Error ? error.message : String(error) });
@@ -1161,26 +1232,43 @@ function createIdempotencyStore(driver) {
1161
1232
  return ready;
1162
1233
  };
1163
1234
  return {
1164
- async recall(key, uid) {
1165
- if (!key || !await ensure()) return void 0;
1235
+ async claim(key, uid) {
1236
+ if (!key || !await ensure()) return { status: "claimed" };
1166
1237
  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;
1238
+ if ((await exec(`INSERT INTO ${TABLE} (key, uid, response, created_at)
1239
+ VALUES ($1, $2, NULL, NOW())
1240
+ ON CONFLICT (uid, key) DO UPDATE
1241
+ SET response = NULL, created_at = NOW()
1242
+ WHERE ${TABLE}.created_at < NOW() - INTERVAL '${TTL_HOURS} hours'
1243
+ RETURNING 1 AS claimed`, [key, principal(uid)])).length > 0) return { status: "claimed" };
1244
+ const row = (await exec(`SELECT response, response IS NULL AS pending FROM ${TABLE}
1245
+ WHERE uid = $1 AND key = $2`, [principal(uid), key]))[0];
1246
+ if (!row) return { status: "claimed" };
1247
+ if (row.pending) return { status: "in-flight" };
1248
+ return {
1249
+ status: "replay",
1250
+ response: row.response
1251
+ };
1169
1252
  } catch {
1170
- return;
1253
+ return { status: "claimed" };
1171
1254
  }
1172
1255
  },
1173
- async remember(key, uid, response) {
1256
+ async complete(key, uid, response) {
1174
1257
  if (!key || !await ensure()) return;
1175
1258
  try {
1176
- await exec(`INSERT INTO ${TABLE} (key, uid, response) VALUES ($1, $2, $3::jsonb)
1177
- ON CONFLICT (uid, key) DO NOTHING`, [
1178
- key,
1259
+ await exec(`UPDATE ${TABLE} SET response = $3::jsonb WHERE uid = $1 AND key = $2`, [
1179
1260
  principal(uid),
1261
+ key,
1180
1262
  JSON.stringify(response ?? null)
1181
1263
  ]);
1182
1264
  if (Math.random() < .01) await exec(`DELETE FROM ${TABLE} WHERE created_at < NOW() - INTERVAL '${TTL_HOURS} hours'`);
1183
1265
  } catch {}
1266
+ },
1267
+ async release(key, uid) {
1268
+ if (!key || !await ensure()) return;
1269
+ try {
1270
+ await exec(`DELETE FROM ${TABLE} WHERE uid = $1 AND key = $2 AND response IS NULL`, [principal(uid), key]);
1271
+ } catch {}
1184
1272
  }
1185
1273
  };
1186
1274
  }
@@ -1367,121 +1455,225 @@ var RestApiGenerator = class {
1367
1455
  if (!entity) throw ApiError.notFound("Entity not found");
1368
1456
  return c.json(projectResponseFields([entity], queryOptions.fields, resolvedCollection, { include: queryOptions.include })[0]);
1369
1457
  });
1458
+ /**
1459
+ * Run a bulk handler under an idempotency key.
1460
+ *
1461
+ * All three bulk routes need the identical claim-before-write dance —
1462
+ * claim, replay on a repeat, 409 while one is in flight, release on
1463
+ * failure, complete on success — and getting one of those steps wrong
1464
+ * is exactly the bug the key exists to prevent. Written once so the
1465
+ * three cannot drift into three different notions of "already done".
1466
+ */
1467
+ const withIdempotency = async (c, run, respond) => {
1468
+ const idempotencyKey = c.req.header(IDEMPOTENCY_HEADER);
1469
+ const uid = c.get("user")?.uid;
1470
+ const store = this.idempotency();
1471
+ const claimed = idempotencyKey && store ? await store.claim(idempotencyKey, uid) : void 0;
1472
+ if (claimed?.status === "replay") return respond(claimed.response);
1473
+ 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");
1474
+ let response;
1475
+ try {
1476
+ response = await run();
1477
+ } catch (error) {
1478
+ if (claimed?.status === "claimed" && idempotencyKey && store) await store.release(idempotencyKey, uid);
1479
+ throw error;
1480
+ }
1481
+ if (claimed?.status === "claimed" && idempotencyKey && store) await store.complete(idempotencyKey, uid, response);
1482
+ return respond(response);
1483
+ };
1484
+ /**
1485
+ * Validate the envelope every bulk route shares: an array, non-empty,
1486
+ * of objects, within the row cap.
1487
+ *
1488
+ * The cap is the load-bearing one. A batch is a single transaction that
1489
+ * holds its locks for its whole duration, so an unbounded one is a
1490
+ * self-inflicted outage — and the message names both the limit and the
1491
+ * actual count so the caller can chunk to it rather than guess.
1492
+ */
1493
+ const assertBulkShape = (items, field, requireObjects) => {
1494
+ if (!Array.isArray(items)) throw ApiError.badRequest(`Expected a JSON body of { ${field}: [...] }.`, "INVALID_BULK_BODY");
1495
+ 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");
1496
+ 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");
1497
+ };
1370
1498
  this.router.post(`${basePath}/bulk`, async (c) => {
1371
1499
  this.enforceApiKeyPermission(c, collection.slug);
1372
1500
  const driver = this.getScopedDriver(c);
1373
1501
  const path = collection.slug;
1374
1502
  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({
1503
+ assertBulkShape(body?.rows, "rows", true);
1504
+ const rows = body.rows;
1505
+ if (rows.length === 0) return c.json({
1377
1506
  data: [],
1378
1507
  meta: { written: 0 }
1379
1508
  });
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
1509
  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
1510
  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
1511
+ rows.forEach((row, rowIndex) => assertKnownWriteFields(row, resolvedCollection, { rowIndex }));
1512
+ return withIdempotency(c, async () => {
1513
+ const written = await driver.saveMany({
1514
+ path,
1515
+ rows,
1516
+ collection: resolvedCollection,
1517
+ upsert: body.upsert === true
1518
+ });
1519
+ return {
1520
+ data: written.map((row) => this.formatResponse(row)),
1521
+ meta: { written: written.length }
1522
+ };
1523
+ }, (result) => c.json(result));
1524
+ });
1525
+ this.router.patch(`${basePath}/bulk`, async (c) => {
1526
+ this.enforceApiKeyPermission(c, collection.slug);
1527
+ const driver = this.getScopedDriver(c);
1528
+ const path = collection.slug;
1529
+ const body = await parseJsonBody(c);
1530
+ assertBulkShape(body?.updates, "updates", true);
1531
+ const updates = body.updates;
1532
+ if (updates.length === 0) return c.json({
1533
+ data: [],
1534
+ meta: { written: 0 }
1391
1535
  });
1392
- return c.json({
1393
- data: rows.map((row) => this.formatResponse(row)),
1394
- meta: { written: rows.length }
1536
+ if (!driver.updateMany) throw ApiError.badRequest("This collection's data source does not support bulk updates.", "BULK_UNSUPPORTED");
1537
+ updates.forEach((entry, rowIndex) => {
1538
+ 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");
1539
+ 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");
1540
+ assertKnownWriteFields(entry.data, resolvedCollection, { rowIndex });
1395
1541
  });
1542
+ return withIdempotency(c, async () => {
1543
+ const written = await driver.updateMany({
1544
+ path,
1545
+ updates: updates.map((entry) => ({
1546
+ id: entry.id,
1547
+ values: entry.data
1548
+ })),
1549
+ collection: resolvedCollection
1550
+ });
1551
+ return {
1552
+ data: written.map((row) => this.formatResponse(row)),
1553
+ meta: { written: written.length }
1554
+ };
1555
+ }, (result) => c.json(result));
1556
+ });
1557
+ this.router.post(`${basePath}/bulk/delete`, async (c) => {
1558
+ this.enforceApiKeyPermission(c, collection.slug);
1559
+ const driver = this.getScopedDriver(c);
1560
+ const path = collection.slug;
1561
+ const body = await parseJsonBody(c);
1562
+ assertBulkShape(body?.ids, "ids", false);
1563
+ const ids = body.ids;
1564
+ if (ids.length === 0) return c.json({ meta: { deleted: 0 } });
1565
+ 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");
1566
+ if (!driver.deleteMany) throw ApiError.badRequest("This collection's data source does not support bulk deletes.", "BULK_UNSUPPORTED");
1567
+ return withIdempotency(c, async () => {
1568
+ await driver.deleteMany({
1569
+ path,
1570
+ ids,
1571
+ collection: resolvedCollection
1572
+ });
1573
+ return { meta: { deleted: ids.length } };
1574
+ }, (result) => c.json(result));
1396
1575
  });
1397
1576
  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
- }
1577
+ this.enforceApiKeyPermission(c, collection.slug);
1578
+ const driver = this.getScopedDriver(c);
1579
+ const path = collection.slug;
1580
+ const body = await parseJsonBody(c);
1581
+ const isAuth = collection.auth;
1582
+ const isAuthCollection = isAuth === true || isAuth && typeof isAuth === "object" && isAuth.enabled === true;
1583
+ const collectionAuthConfig = typeof isAuth === "object" ? isAuth : void 0;
1584
+ if (!isAuthCollection) assertKnownWriteFields(body, resolvedCollection);
1585
+ else {
1586
+ const contract = this.authAdapter?.describeUserCreationContract?.(collectionAuthConfig);
1587
+ if (contract?.validate) assertKnownWriteFields(body, resolvedCollection, { extraKnownFields: contract.extraFields });
1588
+ }
1589
+ if (isAuthCollection && this.authAdapter?.prepareUserCreation) {
1590
+ const prepared = await this.authAdapter.prepareUserCreation(body, collectionAuthConfig);
1441
1591
  const entity = await driver.save({
1442
1592
  path,
1443
- values: body,
1593
+ values: prepared.values,
1444
1594
  collection: resolvedCollection,
1445
1595
  status: "new"
1446
1596
  });
1597
+ const result = prepared.hookHandledEmail ? {
1598
+ temporaryPassword: prepared.clearPassword,
1599
+ invitationSent: prepared.invitationSent
1600
+ } : this.authAdapter.finalizeUserCreation ? await this.authAdapter.finalizeUserCreation({
1601
+ id: entity.id,
1602
+ values: entity
1603
+ }, prepared.clearPassword) : { invitationSent: false };
1447
1604
  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;
1605
+ return c.json({
1606
+ ...response,
1607
+ invitationSent: result.invitationSent,
1608
+ ...result.temporaryPassword ? { temporaryPassword: result.temporaryPassword } : {},
1609
+ ..."emailDeliveryFailed" in result && result.emailDeliveryFailed ? { emailDeliveryFailed: true } : {}
1610
+ }, 201);
1455
1611
  }
1456
- });
1457
- this.router.put(`${basePath}/:id`, async (c) => {
1612
+ const idempotencyKey = c.req.header(IDEMPOTENCY_HEADER);
1613
+ const uid = c.get("user")?.uid;
1614
+ const store = this.idempotency();
1615
+ const claimed = idempotencyKey && store ? await store.claim(idempotencyKey, uid) : void 0;
1616
+ if (claimed?.status === "replay") return c.json(claimed.response, 201);
1617
+ 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");
1618
+ let response;
1458
1619
  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
1620
  const entity = await driver.save({
1470
- path: getCollectionDataPath(collection),
1471
- id: String(id),
1621
+ path,
1472
1622
  values: body,
1473
1623
  collection: resolvedCollection,
1474
- status: "existing"
1624
+ status: "new"
1475
1625
  });
1476
- const response = this.formatResponse(entity);
1477
- return c.json(response);
1626
+ response = this.formatResponse(entity);
1478
1627
  } 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
- }
1628
+ if (claimed?.status === "claimed" && idempotencyKey && store) await store.release(idempotencyKey, uid);
1482
1629
  throw error;
1483
1630
  }
1631
+ if (claimed?.status === "claimed" && idempotencyKey && store) await store.complete(idempotencyKey, uid, response);
1632
+ return c.json(response, 201);
1484
1633
  });
1634
+ const updateEntity = async (c) => {
1635
+ this.enforceApiKeyPermission(c, collection.slug);
1636
+ const id = c.req.param("id");
1637
+ const driver = this.getScopedDriver(c);
1638
+ if (!await driver.fetchOne({
1639
+ path: getCollectionDataPath(collection),
1640
+ id: String(id),
1641
+ collection: resolvedCollection
1642
+ })) throw ApiError.notFound("Entity not found");
1643
+ const body = await parseJsonBody(c);
1644
+ assertKnownWriteFields(body, resolvedCollection);
1645
+ const entity = await driver.save({
1646
+ path: getCollectionDataPath(collection),
1647
+ id: String(id),
1648
+ values: body,
1649
+ collection: resolvedCollection,
1650
+ status: "existing"
1651
+ });
1652
+ const response = this.formatResponse(entity);
1653
+ return c.json(response);
1654
+ };
1655
+ /**
1656
+ * The verb that matches what this actually does.
1657
+ *
1658
+ * The handler merges: it writes the columns in the body and leaves the
1659
+ * rest alone, which is what the SDK's `update(id, data: Partial<M>)`
1660
+ * sends. PUT was the only route, and PUT means replace — so the
1661
+ * generated OpenAPI spec described a full replacement while the server
1662
+ * performed a merge, and it reused the *create* input schema for it,
1663
+ * `required` fields and all. A client generated from that spec was
1664
+ * given a contract the server does not implement, and a spec-validating
1665
+ * gateway in front of this API would reject partial updates the server
1666
+ * would have accepted.
1667
+ *
1668
+ * PUT stays mounted on the same handler rather than being changed or
1669
+ * removed: every published SDK sends it, and altering its semantics to
1670
+ * be a true replace would silently start nulling columns that callers
1671
+ * had simply omitted for years — a data-loss change disguised as a
1672
+ * standards fix. So PATCH is the honest name, PUT is the compatible one,
1673
+ * and both do the documented thing.
1674
+ */
1675
+ this.router.patch(`${basePath}/:id`, updateEntity);
1676
+ this.router.put(`${basePath}/:id`, updateEntity);
1485
1677
  this.router.delete(`${basePath}/:id`, async (c) => {
1486
1678
  this.enforceApiKeyPermission(c, collection.slug);
1487
1679
  const id = c.req.param("id");
@@ -1552,6 +1744,7 @@ var RestApiGenerator = class {
1552
1744
  const total = driver.count ? await driver.count({
1553
1745
  path: parsed.collectionPath,
1554
1746
  filter: queryOptions.where,
1747
+ logical: queryOptions.logical,
1555
1748
  searchString
1556
1749
  }) : 0;
1557
1750
  return c.json({ count: total });
@@ -1563,7 +1756,9 @@ var RestApiGenerator = class {
1563
1756
  id: parsed.id
1564
1757
  });
1565
1758
  if (!entity) throw ApiError.notFound("Entity not found");
1566
- return c.json(entity);
1759
+ const nestedCollection = this.resolveNestedWriteCollection(parsed.collectionPath);
1760
+ if (!nestedCollection) return c.json(entity);
1761
+ return c.json(projectResponseFields([entity], queryOptions.fields, nestedCollection, { include: queryOptions.include })[0]);
1567
1762
  } else {
1568
1763
  const queryDict = c.req.queries();
1569
1764
  const queryOptions = this.parseQuery(queryDict);
@@ -1588,8 +1783,9 @@ var RestApiGenerator = class {
1588
1783
  logical: queryOptions.logical,
1589
1784
  searchString
1590
1785
  }) : entities.length;
1786
+ const listCollection = this.resolveNestedWriteCollection(parsed.collectionPath);
1591
1787
  return c.json({
1592
- data: entities,
1788
+ data: listCollection ? projectResponseFields(entities, queryOptions.fields, listCollection, { include: queryOptions.include }) : entities,
1593
1789
  meta: {
1594
1790
  total,
1595
1791
  limit: queryOptions.limit,
@@ -1618,7 +1814,7 @@ var RestApiGenerator = class {
1618
1814
  const response = this.formatResponse(entity);
1619
1815
  return c.json(response, 201);
1620
1816
  });
1621
- this.router.put("/:parent/:parentId/:rest{.+}", async (c, next) => {
1817
+ const updateNested = async (c, next) => {
1622
1818
  const rest = c.req.param("rest");
1623
1819
  if (!rest || rest === "undefined") return next();
1624
1820
  const rawPath = `${c.req.param("parent")}/${c.req.param("parentId")}/${rest}`;
@@ -1637,7 +1833,9 @@ var RestApiGenerator = class {
1637
1833
  });
1638
1834
  const response = this.formatResponse(entity);
1639
1835
  return c.json(response);
1640
- });
1836
+ };
1837
+ this.router.patch("/:parent/:parentId/:rest{.+}", updateNested);
1838
+ this.router.put("/:parent/:parentId/:rest{.+}", updateNested);
1641
1839
  this.router.delete("/:parent/:parentId/:rest{.+}", async (c, next) => {
1642
1840
  const rest = c.req.param("rest");
1643
1841
  if (!rest || rest === "undefined") return next();
@@ -3081,7 +3279,7 @@ function assertStorageAccessControlConfigured(state, isProduction) {
3081
3279
  //#region src/init/docs.ts
3082
3280
  async function mountOpenApiDocs(app, basePath, enableSwagger, activeCollections, requireAuth) {
3083
3281
  if (enableSwagger === false || activeCollections.length === 0) return;
3084
- const { generateOpenApiSpec } = await import("./openapi-generator-BEwyiaAr.js");
3282
+ const { generateOpenApiSpec } = await import("./openapi-generator-CSlEZh7-.js");
3085
3283
  app.get(`${basePath}/docs`, (c) => {
3086
3284
  const spec = generateOpenApiSpec(activeCollections, {
3087
3285
  basePath,
@@ -3418,6 +3616,13 @@ function resolveBaseUrl(configured) {
3418
3616
  function createTransport(config, environment) {
3419
3617
  const fetchFn = config.fetch || globalThis.fetch;
3420
3618
  const apiPath = config.apiPath || "/api";
3619
+ for (const field of ["baseUrl", "storageUrlOrigin"]) {
3620
+ const value = config[field];
3621
+ if (!value || !apiPath) continue;
3622
+ const trimmed = value.replace(/\/+$/, "");
3623
+ if (!trimmed.endsWith(apiPath)) continue;
3624
+ 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.`);
3625
+ }
3421
3626
  let token = config.token;
3422
3627
  let tokenGetter;
3423
3628
  let onUnauthorizedHandler = config.onUnauthorized;
@@ -3601,6 +3806,11 @@ function createAuth(transport, options) {
3601
3806
  const authFlowMode = opts.authFlowMode || "json";
3602
3807
  const STORAGE_KEY = "rebase_auth";
3603
3808
  const REFRESH_BUFFER_MS = 12e4;
3809
+ /**
3810
+ * The largest delay `setTimeout` can hold — 2^31 - 1 ms, about 24.8 days.
3811
+ * Anything larger is silently clamped to 1ms by Node and every browser.
3812
+ */
3813
+ const MAX_TIMER_DELAY_MS = 2147483647;
3604
3814
  const MAX_REFRESH_RETRIES = 5;
3605
3815
  const REFRESH_RETRY_BASE_MS = 1e3;
3606
3816
  const REFRESH_RETRY_MAX_MS = 3e4;
@@ -3628,7 +3838,9 @@ function createAuth(transport, options) {
3628
3838
  function emit(event, session) {
3629
3839
  for (const fn of listeners) try {
3630
3840
  fn(event, session);
3631
- } catch (e) {}
3841
+ } catch (e) {
3842
+ console.error("Error in auth state change listener:", e);
3843
+ }
3632
3844
  }
3633
3845
  function saveSession(session) {
3634
3846
  if (!persistSession || authFlowMode === "cookie") return;
@@ -3732,6 +3944,10 @@ function createAuth(transport, options) {
3732
3944
  attemptScheduledRefresh(0);
3733
3945
  return;
3734
3946
  }
3947
+ if (delay > MAX_TIMER_DELAY_MS) {
3948
+ refreshTimeout = setTimeout(() => scheduleRefresh(expiresAt), MAX_TIMER_DELAY_MS);
3949
+ return;
3950
+ }
3735
3951
  refreshTimeout = setTimeout(() => {
3736
3952
  attemptScheduledRefresh(0);
3737
3953
  }, delay);
@@ -4506,6 +4722,11 @@ var SDKQueryBuilder = class {
4506
4722
  return this.collection.listen(this.params, onUpdate, onError);
4507
4723
  }
4508
4724
  };
4725
+ /**
4726
+ * Counts currently in flight, keyed by the exact request they issue. Entries
4727
+ * live only for the duration of the request — see `count()` for why.
4728
+ */
4729
+ var inflightCounts = /* @__PURE__ */ new Map();
4509
4730
  function createCollectionClient(transport, slug, ws) {
4510
4731
  const basePath = `/data/${slug}`;
4511
4732
  const client = {
@@ -4550,18 +4771,63 @@ function createCollectionClient(transport, slug, ws) {
4550
4771
  body: JSON.stringify({
4551
4772
  rows: data,
4552
4773
  ...options?.upsert ? { upsert: true } : {}
4553
- })
4774
+ }),
4775
+ ...options?.idempotencyKey ? { headers: { "Idempotency-Key": options.idempotencyKey } } : {}
4554
4776
  })).data || [];
4555
4777
  },
4778
+ /**
4779
+ * Still `PUT`, deliberately, even though the server now serves `PATCH`
4780
+ * on the same handler and `PATCH` is the honest verb for a merge.
4781
+ *
4782
+ * The two are interchangeable server-side, so switching buys nothing at
4783
+ * runtime — and it costs compatibility in the direction that fails
4784
+ * quietly. A 0.14 client talking to a 0.13 server would send `PATCH` to
4785
+ * a route that does not exist and get a **404**, which is
4786
+ * indistinguishable from "that row is gone". Every write would look like
4787
+ * a missing record.
4788
+ *
4789
+ * `PATCH` is what the OpenAPI spec advertises, so anyone generating a
4790
+ * client gets the correct verb; this stays on `PUT` until the oldest
4791
+ * supported server is one that serves both.
4792
+ */
4556
4793
  async update(id, data) {
4557
4794
  return await transport.request(`${basePath}/${encodeURIComponent(String(id))}`, {
4558
4795
  method: "PUT",
4559
4796
  body: JSON.stringify(data)
4560
4797
  });
4561
4798
  },
4799
+ async updateMany(updates, options) {
4800
+ if (!Array.isArray(updates)) throw new TypeError("updateMany expects an array of { id, data } entries.");
4801
+ if (updates.length === 0) return [];
4802
+ return (await transport.request(`${basePath}/bulk`, {
4803
+ method: "PATCH",
4804
+ body: JSON.stringify({ updates }),
4805
+ ...options?.idempotencyKey ? { headers: { "Idempotency-Key": options.idempotencyKey } } : {}
4806
+ })).data || [];
4807
+ },
4562
4808
  async delete(id) {
4563
4809
  await transport.request(`${basePath}/${encodeURIComponent(String(id))}`, { method: "DELETE" });
4564
4810
  },
4811
+ /**
4812
+ * `POST .../bulk/delete`, not `DELETE .../bulk`.
4813
+ *
4814
+ * The honest verb would take the ids in a DELETE body, and that is the
4815
+ * one request shape the HTTP ecosystem handles unreliably: bodies on
4816
+ * DELETE are permitted but widely dropped by proxies and CDNs, and
4817
+ * several OpenAPI generators ignore `requestBody` on a DELETE
4818
+ * operation, so a generated client would send the request without its
4819
+ * ids. A backend deployed behind arbitrary ingress cannot take that
4820
+ * bet. Same reason `:batchDelete` exists in Google's API guidelines.
4821
+ */
4822
+ async deleteMany(ids, options) {
4823
+ if (!Array.isArray(ids)) throw new TypeError("deleteMany expects an array of ids.");
4824
+ if (ids.length === 0) return;
4825
+ await transport.request(`${basePath}/bulk/delete`, {
4826
+ method: "POST",
4827
+ body: JSON.stringify({ ids }),
4828
+ ...options?.idempotencyKey ? { headers: { "Idempotency-Key": options.idempotencyKey } } : {}
4829
+ });
4830
+ },
4565
4831
  async count(params) {
4566
4832
  const qs = buildQueryString({
4567
4833
  ...params,
@@ -4569,12 +4835,28 @@ function createCollectionClient(transport, slug, ws) {
4569
4835
  offset: void 0,
4570
4836
  include: void 0
4571
4837
  });
4572
- return (await transport.request(basePath + "/count" + qs, { method: "GET" })).count ?? 0;
4838
+ const key = basePath + "/count" + qs;
4839
+ const inflight = inflightCounts.get(key);
4840
+ if (inflight) return inflight;
4841
+ const request = transport.request(key, { method: "GET" }).then((raw) => raw.count ?? 0);
4842
+ inflightCounts.set(key, request);
4843
+ try {
4844
+ return await request;
4845
+ } finally {
4846
+ inflightCounts.delete(key);
4847
+ }
4573
4848
  },
4574
4849
  observe(params, onResult, onError, options) {
4575
4850
  let closed = false;
4576
- const emit = (result) => {
4851
+ let liveDelivered = false;
4852
+ let signature;
4853
+ const deliver = (result, fromLive) => {
4577
4854
  if (closed) return;
4855
+ if (fromLive) liveDelivered = true;
4856
+ else if (liveDelivered) return;
4857
+ const next = `${result.meta?.total ?? ""}|${JSON.stringify(result.data)}`;
4858
+ if (signature !== void 0 && next === signature) return;
4859
+ signature = next;
4578
4860
  onResult({
4579
4861
  ...result,
4580
4862
  fromCache: false,
@@ -4582,10 +4864,10 @@ function createCollectionClient(transport, slug, ws) {
4582
4864
  partial: false
4583
4865
  });
4584
4866
  };
4585
- client.find(params).then(emit).catch((error) => {
4867
+ client.find(params).then((result) => deliver(result, false)).catch((error) => {
4586
4868
  if (!closed) onError?.(error);
4587
4869
  });
4588
- const live = options?.realtime !== false && client.listen ? client.listen(params, emit, onError) : void 0;
4870
+ const live = options?.realtime !== false && client.listen ? client.listen(params, (result) => deliver(result, true), onError) : void 0;
4589
4871
  return () => {
4590
4872
  closed = true;
4591
4873
  live?.();
@@ -4593,17 +4875,24 @@ function createCollectionClient(transport, slug, ws) {
4593
4875
  },
4594
4876
  observeById(id, onResult, onError, options) {
4595
4877
  let closed = false;
4596
- const emit = (row) => {
4878
+ let liveDelivered = false;
4879
+ let signature;
4880
+ const deliver = (row, fromLive) => {
4597
4881
  if (closed) return;
4882
+ if (fromLive) liveDelivered = true;
4883
+ else if (liveDelivered) return;
4884
+ const next = row === void 0 ? "\0missing" : JSON.stringify(row);
4885
+ if (signature !== void 0 && next === signature) return;
4886
+ signature = next;
4598
4887
  onResult(row, {
4599
4888
  fromCache: false,
4600
4889
  hasPendingWrites: false
4601
4890
  });
4602
4891
  };
4603
- client.findById(id).then(emit).catch((error) => {
4892
+ client.findById(id).then((row) => deliver(row, false)).catch((error) => {
4604
4893
  if (!closed) onError?.(error);
4605
4894
  });
4606
- const live = options?.realtime !== false && client.listenById ? client.listenById(id, emit, onError) : void 0;
4895
+ const live = options?.realtime !== false && client.listenById ? client.listenById(id, (row) => deliver(row, true), onError) : void 0;
4607
4896
  return () => {
4608
4897
  closed = true;
4609
4898
  live?.();
@@ -4634,51 +4923,43 @@ function createCollectionClient(transport, slug, ws) {
4634
4923
  client.listen = (params, onUpdate, onError) => {
4635
4924
  let active = true;
4636
4925
  let lastUpdateId = 0;
4926
+ let lastKnownTotal;
4927
+ const window = resolveFindWindow(params);
4637
4928
  const unsub = ws.listenCollection({
4638
4929
  path: slug,
4639
4930
  filter: params?.where,
4931
+ logical: params?.logical,
4640
4932
  limit: params?.limit,
4641
- startAfter: params?.offset ? String(params.offset) : void 0,
4933
+ offset: window.driverOffset,
4642
4934
  orderBy: params?.orderBy?.[0],
4643
4935
  order: params?.orderBy?.[1],
4644
4936
  searchString: params?.searchString
4645
4937
  }, (incomingRows) => {
4646
4938
  const currentUpdateId = ++lastUpdateId;
4647
- const requestedLimit = params?.limit || 20;
4648
- const offset = params?.offset || 0;
4939
+ const requestedLimit = window.limit;
4940
+ const offset = window.offset;
4649
4941
  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({
4942
+ const emit = (total, hasMore) => {
4943
+ if (!active || currentUpdateId !== lastUpdateId) return;
4944
+ onUpdate({
4654
4945
  data: rows,
4655
4946
  meta: {
4656
4947
  total,
4657
4948
  limit: requestedLimit,
4658
4949
  offset,
4659
- hasMore: offset + rows.length < total
4950
+ hasMore
4660
4951
  }
4661
4952
  });
4953
+ };
4954
+ const emitWithoutCount = () => emit(offset + rows.length, rows.length >= requestedLimit);
4955
+ if (client.count) client.count(params).then((total) => {
4956
+ lastKnownTotal = total;
4957
+ emit(total, offset + rows.length < total);
4662
4958
  }).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
- }
4959
+ if (lastKnownTotal !== void 0) emit(lastKnownTotal, offset + rows.length < lastKnownTotal);
4960
+ else emitWithoutCount();
4681
4961
  });
4962
+ else emitWithoutCount();
4682
4963
  }, onError);
4683
4964
  return () => {
4684
4965
  active = false;
@@ -6061,15 +6342,10 @@ var RebaseWebSocketClient = class {
6061
6342
  }
6062
6343
  }
6063
6344
  createCollectionSubscriptionKey(props) {
6345
+ const { collection, ...query } = props;
6064
6346
  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
6347
+ ...query,
6348
+ collection: collection?.name
6073
6349
  };
6074
6350
  return JSON.stringify(key, (_, value) => {
6075
6351
  if (value && typeof value === "object" && !Array.isArray(value)) return Object.keys(value).sort().reduce((sorted, k) => {
@@ -6982,14 +7258,23 @@ function looseEquals(a, b) {
6982
7258
  */
6983
7259
  function likeToRegExp(pattern, caseInsensitive) {
6984
7260
  let source = "^";
7261
+ let lastWasWildcard = false;
6985
7262
  for (let i = 0; i < pattern.length; i++) {
6986
7263
  const char = pattern[i];
6987
7264
  if (char === "\\" && i + 1 < pattern.length) {
6988
7265
  source += pattern[i + 1].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6989
7266
  i++;
6990
- } else if (char === "%") source += "[\\s\\S]*";
6991
- else if (char === "_") source += "[\\s\\S]";
6992
- else source += char.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7267
+ lastWasWildcard = false;
7268
+ } else if (char === "%") {
7269
+ if (!lastWasWildcard) source += "[\\s\\S]*";
7270
+ lastWasWildcard = true;
7271
+ } else if (char === "_") {
7272
+ source += "[\\s\\S]";
7273
+ lastWasWildcard = false;
7274
+ } else {
7275
+ source += char.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7276
+ lastWasWildcard = false;
7277
+ }
6993
7278
  }
6994
7279
  return new RegExp(source + "$", caseInsensitive ? "i" : "");
6995
7280
  }
@@ -7121,12 +7406,20 @@ function sortRows(rows, orderBy) {
7121
7406
  function tiebreak(a, b) {
7122
7407
  return compareValues(a.id, b.id) ?? 0;
7123
7408
  }
7124
- /** Resolve `page`/`offset`/`limit` the way the server does. */
7409
+ /**
7410
+ * Resolve `page`/`offset`/`limit` the way the server does.
7411
+ *
7412
+ * It did not: this defaulted an absent limit to 20 while `/api/data` pages by
7413
+ * 50, so the same `observe()` answered with 20 rows from the local database and
7414
+ * 50 from the network — a list that changed length depending on which side
7415
+ * answered, with `page` striding differently on each. Delegated now, so the
7416
+ * sentence above is true by construction rather than by agreement.
7417
+ */
7125
7418
  function resolvePagination(params) {
7126
- const limit = params?.limit ?? 20;
7419
+ const { limit, offset } = resolveFindWindow(params);
7127
7420
  return {
7128
7421
  limit,
7129
- offset: params?.page != null ? Math.max(0, (params.page - 1) * limit) : params?.offset ?? 0
7422
+ offset
7130
7423
  };
7131
7424
  }
7132
7425
  /**
@@ -7450,6 +7743,72 @@ var OfflineManager = class {
7450
7743
  this.notifyCollection(slug);
7451
7744
  return rows;
7452
7745
  },
7746
+ updateMany: async (updates, options) => {
7747
+ await this.ensureCollection(slug);
7748
+ if (!Array.isArray(updates)) throw new TypeError("updateMany expects an array of { id, data } entries.");
7749
+ if (updates.length === 0) return [];
7750
+ const anyPending = updates.some((u) => this.hasPending(slug, u.id));
7751
+ if (this.connectivity.shouldAttempt() && !anyPending) try {
7752
+ const rows = await inner.updateMany(updates, options);
7753
+ this.connectivity.markSuccess();
7754
+ await this.ingest(slug, rows);
7755
+ this.notifyCollection(slug);
7756
+ return rows;
7757
+ } catch (error) {
7758
+ if (!isNetworkError(error)) throw error;
7759
+ this.connectivity.markFailure();
7760
+ }
7761
+ const rollback = {};
7762
+ const optimistic = [];
7763
+ for (const { id, data } of updates) {
7764
+ const base = this.rawLocalRow(slug, id);
7765
+ rollback[String(id)] = base ?? null;
7766
+ optimistic.push({
7767
+ ...base ?? {},
7768
+ ...data,
7769
+ id
7770
+ });
7771
+ }
7772
+ await this.enqueue({
7773
+ collection: slug,
7774
+ type: "updateMany",
7775
+ updates: updates.map((u) => ({
7776
+ id: u.id,
7777
+ data: u.data
7778
+ })),
7779
+ rollback: { rows: rollback }
7780
+ });
7781
+ for (const row of optimistic) this.setLocalRow(slug, row.id, row);
7782
+ this.notifyCollection(slug);
7783
+ return optimistic;
7784
+ },
7785
+ deleteMany: async (ids, options) => {
7786
+ await this.ensureCollection(slug);
7787
+ if (!Array.isArray(ids)) throw new TypeError("deleteMany expects an array of ids.");
7788
+ if (ids.length === 0) return;
7789
+ const anyPending = ids.some((id) => this.hasPending(slug, id));
7790
+ if (this.connectivity.shouldAttempt() && !anyPending) try {
7791
+ await inner.deleteMany(ids, options);
7792
+ this.connectivity.markSuccess();
7793
+ for (const id of ids) this.removeLocalRow(slug, id, true);
7794
+ this.notifyCollection(slug);
7795
+ this.scheduleRefresh(slug);
7796
+ return;
7797
+ } catch (error) {
7798
+ if (!isNetworkError(error)) throw error;
7799
+ this.connectivity.markFailure();
7800
+ }
7801
+ const rollback = {};
7802
+ for (const id of ids) rollback[String(id)] = this.rawLocalRow(slug, id) ?? null;
7803
+ await this.enqueue({
7804
+ collection: slug,
7805
+ type: "deleteMany",
7806
+ ids,
7807
+ rollback: { rows: rollback }
7808
+ });
7809
+ for (const id of ids) this.removeLocalRow(slug, id, false);
7810
+ this.notifyCollection(slug);
7811
+ },
7453
7812
  update: async (id, data) => {
7454
7813
  await this.ensureCollection(slug);
7455
7814
  if (this.connectivity.shouldAttempt() && !this.hasPending(slug, id)) try {
@@ -7748,9 +8107,8 @@ var OfflineManager = class {
7748
8107
  if (!state) return {
7749
8108
  data: [],
7750
8109
  meta: {
8110
+ ...resolvePagination(params),
7751
8111
  total: 0,
7752
- limit: params?.limit ?? 20,
7753
- offset: params?.offset ?? 0,
7754
8112
  hasMore: false
7755
8113
  },
7756
8114
  fromCache: true,
@@ -7786,16 +8144,14 @@ var OfflineManager = class {
7786
8144
  }
7787
8145
  let added = 0;
7788
8146
  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);
8147
+ if (exact && offset === 0) for (const [key, entry] of state.rows) {
8148
+ if (seen.has(key) || !this.hasPending(slug, key)) continue;
8149
+ if (!this.isLocallyCreated(slug, key)) continue;
8150
+ if (!matchesParams(entry.row, params)) continue;
8151
+ rows.push(entry.row);
8152
+ added++;
7798
8153
  }
8154
+ if (params?.orderBy) sortRows(rows, params.orderBy);
7799
8155
  return {
7800
8156
  data: rows,
7801
8157
  meta: {
@@ -7942,17 +8298,17 @@ var OfflineManager = class {
7942
8298
  return row;
7943
8299
  }
7944
8300
  recordSnapshot(slug, params, result) {
8301
+ const window = resolvePagination(params);
7945
8302
  const meta = result.meta ?? {
7946
8303
  total: result.data?.length ?? 0,
7947
- limit: 20,
7948
- offset: 0,
8304
+ ...window,
7949
8305
  hasMore: false
7950
8306
  };
7951
8307
  const snapshot = {
7952
8308
  ids: (result.data ?? []).map((row) => row.id).filter((id) => id !== void 0),
7953
8309
  total: meta.total ?? result.data?.length ?? 0,
7954
- limit: meta.limit ?? params?.limit ?? 20,
7955
- offset: meta.offset ?? params?.offset ?? 0,
8310
+ limit: meta.limit ?? window.limit,
8311
+ offset: meta.offset ?? window.offset,
7956
8312
  hasMore: meta.hasMore ?? false
7957
8313
  };
7958
8314
  const state = this.collectionState(slug);
@@ -8167,11 +8523,25 @@ var OfflineManager = class {
8167
8523
  await this.adoptServerRow(op, op.id, row);
8168
8524
  } else if (op.type === "createMany") {
8169
8525
  const queued = op.data ?? [];
8170
- const rows = await inner.createMany(queued, op.upsert ? { upsert: true } : void 0);
8526
+ const rows = await inner.createMany(queued, {
8527
+ ...op.upsert ? { upsert: true } : {},
8528
+ idempotencyKey: op.mutationId
8529
+ });
8171
8530
  for (let i = 0; i < rows.length; i++) await this.adoptServerRow(op, queued[i]?.id, rows[i]);
8531
+ } else if (op.type === "updateMany") {
8532
+ const queued = op.updates ?? [];
8533
+ const rows = await inner.updateMany(queued.map((u) => ({
8534
+ id: u.id,
8535
+ data: u.data
8536
+ })), { idempotencyKey: op.mutationId });
8537
+ for (let i = 0; i < rows.length; i++) await this.ingestReplaced(op, queued[i].id, rows[i]);
8172
8538
  } else if (op.type === "update") {
8173
8539
  const row = await inner.update(op.id, op.data);
8174
8540
  await this.ingestReplaced(op, op.id, row);
8541
+ } else if (op.type === "deleteMany") {
8542
+ const ids = op.ids ?? [];
8543
+ await inner.deleteMany(ids, { idempotencyKey: op.mutationId });
8544
+ for (const id of ids) this.removeLocalRow(op.collection, id, true);
8175
8545
  } else if (op.type === "delete") {
8176
8546
  await inner.delete(op.id);
8177
8547
  this.removeLocalRow(op.collection, op.id, true);
@@ -8414,23 +8784,37 @@ var OfflineManager = class {
8414
8784
  /**
8415
8785
  * Derive a WebSocket URL from an HTTP base URL.
8416
8786
  * `http://` → `ws://`, `https://` → `wss://`.
8787
+ *
8788
+ * A backend mounted under a path is the reason `baseUrl` accepts one, so the
8789
+ * path is kept. It used to be kept for an absolute `baseUrl` and dropped for a
8790
+ * relative one — resolved through `.origin` — so one deployment dialled two
8791
+ * different sockets depending on whether its config said `"/backend"` or
8792
+ * `"https://app.example.com/backend"`.
8793
+ *
8794
+ * Returns `""` when there is nothing to resolve against: a relative `baseUrl`
8795
+ * outside a browser has no origin, and inventing one would dial somewhere
8796
+ * arbitrary. The caller warns rather than leaving that silent.
8417
8797
  */
8418
8798
  function deriveWebSocketUrl(baseUrl) {
8799
+ const toWsProtocol = (url) => {
8800
+ const secure = /^(https|wss):/i.test(url);
8801
+ return url.replace(/^https?:\/\//i, secure ? "wss://" : "ws://").replace(/^wss?:\/\//i, secure ? "wss://" : "ws://").replace(/\/$/, "");
8802
+ };
8419
8803
  if (typeof window !== "undefined") {
8420
- let absoluteUrl = "";
8804
+ let absoluteUrl;
8421
8805
  if (!baseUrl) absoluteUrl = window.location.origin;
8422
8806
  else if (/^https?:\/\//i.test(baseUrl) || /^wss?:\/\//i.test(baseUrl)) absoluteUrl = baseUrl;
8423
8807
  else try {
8424
- absoluteUrl = new URL(baseUrl, window.location.href).origin;
8808
+ const resolved = new URL(baseUrl, window.location.href);
8809
+ absoluteUrl = resolved.origin + resolved.pathname;
8425
8810
  } catch {
8426
8811
  absoluteUrl = window.location.origin;
8427
8812
  }
8428
- const protocol = absoluteUrl.startsWith("https:") || absoluteUrl.startsWith("wss:") ? "wss:" : "ws:";
8429
- return absoluteUrl.replace(/^https?:\/\//i, `${protocol}//`).replace(/^wss?:\/\//i, `${protocol}//`).replace(/\/$/, "");
8813
+ return toWsProtocol(absoluteUrl);
8430
8814
  }
8431
8815
  if (!baseUrl) return "";
8432
8816
  if (!/^https?:\/\//i.test(baseUrl) && !/^wss?:\/\//i.test(baseUrl)) return "";
8433
- return baseUrl.replace(/^https?:\/\//i, (match) => match.toLowerCase() === "https://" ? "wss://" : "ws://").replace(/\/$/, "");
8817
+ return toWsProtocol(baseUrl);
8434
8818
  }
8435
8819
  function createRebaseClient(options) {
8436
8820
  const transport = createTransport(options, { credentialOutOfBand: options.auth?.authFlowMode === "cookie" });
@@ -8458,7 +8842,11 @@ function createRebaseClient(options) {
8458
8842
  });
8459
8843
  return storageSourcesPromise;
8460
8844
  };
8461
- const resolvedWsUrl = options.realtime !== false ? options.websocketUrl ?? deriveWebSocketUrl(options.baseUrl) : void 0;
8845
+ const realtimeEnabled = options.realtime !== false;
8846
+ const resolvedWsUrl = realtimeEnabled ? options.websocketUrl ?? deriveWebSocketUrl(options.baseUrl) : void 0;
8847
+ const realtimeUnreachable = realtimeEnabled && !resolvedWsUrl;
8848
+ 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.`;
8849
+ if (realtimeUnreachable) console.warn(`[Rebase] Realtime is enabled but ${unreachableReason} Live queries will fall back to a single fetch and channels will throw.`);
8462
8850
  let ws;
8463
8851
  /** One channel object per name — see `realtime.channel`. */
8464
8852
  const realtimeChannels = /* @__PURE__ */ new Map();
@@ -8578,7 +8966,7 @@ function createRebaseClient(options) {
8578
8966
  * cut off the others.
8579
8967
  */
8580
8968
  channel: (name, options) => {
8581
- if (!ws) throw new RebaseClientError("Realtime is disabled on this client (realtime: false), so channels are unavailable.");
8969
+ if (!ws) throw new RebaseClientError(realtimeUnreachable ? `Realtime is enabled but ${unreachableReason}` : "Realtime is disabled on this client (realtime: false), so channels are unavailable.");
8582
8970
  let existing = realtimeChannels.get(name);
8583
8971
  if (!existing) {
8584
8972
  existing = new RebaseRealtimeChannel(name, ws, options);
@@ -9249,7 +9637,7 @@ async function _initializeRebaseBackend(config) {
9249
9637
  ]) {
9250
9638
  const providerConfig = safeAuthConfig[key];
9251
9639
  if (providerConfig && requiredFields.every((f) => Boolean(providerConfig[f]))) {
9252
- const createFn = (await import("./auth-CuC9M2x6.js").then((n) => n.t))[factory];
9640
+ const createFn = (await import("./auth-CtxXQYV_.js").then((n) => n.t))[factory];
9253
9641
  oauthProviders.push(createFn(providerConfig));
9254
9642
  }
9255
9643
  }
@@ -9343,7 +9731,7 @@ async function _initializeRebaseBackend(config) {
9343
9731
  let schemaEditorOff = schemaEditorUnavailable();
9344
9732
  let schemaEditorRoutes;
9345
9733
  if (!schemaEditorOff && config.collectionsDir) try {
9346
- schemaEditorRoutes = (await import("./schema-editor-routes-CbF20Mf1.js")).createSchemaEditorRoutes(config.collectionsDir);
9734
+ schemaEditorRoutes = (await import("./schema-editor-routes-BqjzvvWU.js")).createSchemaEditorRoutes(config.collectionsDir);
9347
9735
  } catch (err) {
9348
9736
  if (err?.code === "ERR_MODULE_NOT_FOUND") {
9349
9737
  schemaEditorOff = {
@@ -9513,7 +9901,8 @@ async function _initializeRebaseBackend(config) {
9513
9901
  Object.assign(serverClient, { sql: (query, options) => driverAdmin.executeSql(query, options) });
9514
9902
  logger.info("SQL capability attached to singleton");
9515
9903
  }
9516
- _initRebase(serverClient);
9904
+ const serverSingleton = serverClient;
9905
+ _initRebase(serverSingleton);
9517
9906
  logger.info("Rebase singleton initialized");
9518
9907
  if (defaultDriverResult.internals) {
9519
9908
  const driver = defaultDriverResult.internals.driver;
@@ -9556,12 +9945,12 @@ async function _initializeRebaseBackend(config) {
9556
9945
  let cronScheduler;
9557
9946
  if (config.cronsDir) {
9558
9947
  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);
9948
+ const { CronScheduler } = await import("./cron-scheduler-CsQtYLMq.js").then((n) => n.n);
9949
+ const { createCronRoutes } = await import("./cron-routes-D5a9v1HY.js").then((n) => n.n);
9950
+ const { createCronStore } = await import("./cron-store-CHH7zrif.js").then((n) => n.n);
9562
9951
  const loadedCronJobs = await loadCronJobsFromDirectory(config.cronsDir);
9563
9952
  cronScheduler = new CronScheduler();
9564
- cronScheduler.setClient(serverClient);
9953
+ cronScheduler.setClient(serverSingleton);
9565
9954
  if (loadedCronJobs.length > 0) {
9566
9955
  cronScheduler.registerJobs(loadedCronJobs);
9567
9956
  const store = defaultBootstrapper.getAdmin?.(defaultDriverResult) && config.cronPersistence !== false ? createCronStore(defaultDriver) : void 0;
@@ -9583,7 +9972,7 @@ async function _initializeRebaseBackend(config) {
9583
9972
  } 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
9973
  }
9585
9974
  {
9586
- const { createBackupRoutes, parseBackupDestination } = await import("./backup-CVggVhR2.js").then((n) => n.t);
9975
+ const { createBackupRoutes, parseBackupDestination } = await import("./backup-BJiY_mM-.js").then((n) => n.t);
9587
9976
  const backupRouter = new Hono();
9588
9977
  applyAdminGate(backupRouter, "Backup");
9589
9978
  backupRouter.route("/", createBackupRoutes({
@@ -9605,7 +9994,7 @@ async function _initializeRebaseBackend(config) {
9605
9994
  logger.info("Logs routes mounted", { path: `${basePath}/logs` });
9606
9995
  }
9607
9996
  {
9608
- const { createContractRoutes } = await import("./contract-routes-Dj8i5AiM.js").then((n) => n.t);
9997
+ const { createContractRoutes } = await import("./contract-routes-FSt0Nyfk.js").then((n) => n.t);
9609
9998
  const contractRouter = new Hono();
9610
9999
  if (adminSurfacesGated) {
9611
10000
  if (apiKeyPreAuth) contractRouter.use("/contract", apiKeyPreAuth);
@@ -9699,7 +10088,7 @@ async function _initializeRebaseBackend(config) {
9699
10088
  * export default defineFunction((app, { rebase }) => {
9700
10089
  * app.use("/*", requireAuth);
9701
10090
  * app.get("/home", async (c) => {
9702
- * const [stats] = await rebase.sql!(`SELECT count(*) AS n FROM orders`);
10091
+ * const [stats] = await rebase.sql(`SELECT count(*) AS n FROM orders`);
9703
10092
  * return c.json({ orders: Number(stats.n) });
9704
10093
  * });
9705
10094
  * });
@@ -9731,12 +10120,14 @@ function defineFunction(definition) {
9731
10120
  * export default defineCron({
9732
10121
  * name: "Nightly cleanup",
9733
10122
  * schedule: "0 3 * * *",
9734
- * async handler({ client, log }) {
9735
- * const { data: expired } = await client.data.sessions.find({
10123
+ * async handler({ rebase, log }) {
10124
+ * // `dataAsAdmin` bypasses RLS. A cron has no per-request user, so
10125
+ * // there is no policy to fall back on — scope the filters yourself.
10126
+ * const { data: expired } = await rebase.dataAsAdmin.sessions.find({
9736
10127
  * where: { expired: ["==", true] },
9737
10128
  * });
9738
10129
  * for (const session of expired) {
9739
- * await client.data.sessions.delete(session.id);
10130
+ * await rebase.dataAsAdmin.sessions.delete(session.id as string);
9740
10131
  * }
9741
10132
  * log(`Deleted ${expired.length} expired sessions`);
9742
10133
  * },
@@ -9749,36 +10140,41 @@ function defineCron(definition) {
9749
10140
  //#endregion
9750
10141
  //#region src/utils/sql.ts
9751
10142
  /**
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
10143
+ * Returns a SQL chunk calling `rebase.uid()` — the current user's ID.
10144
+ * This is a PostgreSQL RLS helper function created in the `rebase` schema
9754
10145
  * that reads `app.uid` set per-transaction by `withAuth()`.
9755
10146
  *
10147
+ * The function moved from `auth.uid()` to `rebase.uid()` in 1.0 — `auth` is
10148
+ * Supabase's schema, and taking it meant Rebase could not be pointed at a
10149
+ * database that already had one. Use this helper rather than writing the call
10150
+ * by hand and the move costs you nothing.
10151
+ *
9756
10152
  * @example
9757
10153
  * sql`${table.uid} = ${authUid()}`
9758
10154
  */
9759
10155
  var authUid = () => {
9760
- return sql`auth.uid()`;
10156
+ return sql.raw(RLS_UID_SQL);
9761
10157
  };
9762
10158
  /**
9763
- * Returns a SQL chunk calling `auth.roles()` — the current user's roles
10159
+ * Returns a SQL chunk calling `rebase.roles()` — the current user's roles
9764
10160
  * as a comma-separated string.
9765
10161
  * Reads `app.user_roles` set per-transaction by `withAuth()`.
9766
10162
  *
9767
10163
  * @example
9768
- * sql`auth.roles() ~ 'admin'`
10164
+ * sql`${authRoles()} ~ 'admin'`
9769
10165
  */
9770
10166
  var authRoles = () => {
9771
- return sql`auth.roles()`;
10167
+ return sql.raw(RLS_ROLES_SQL);
9772
10168
  };
9773
10169
  /**
9774
- * Returns a SQL chunk calling `auth.jwt()` — the full JWT claims as JSONB.
10170
+ * Returns a SQL chunk calling `rebase.jwt()` — the full JWT claims as JSONB.
9775
10171
  * Reads `app.jwt` set per-transaction by `withAuth()`.
9776
10172
  *
9777
10173
  * @example
9778
- * sql`auth.jwt()->>'sub'`
10174
+ * sql`${authJwt()}->>'sub'`
9779
10175
  */
9780
10176
  var authJwt = () => {
9781
- return sql`auth.jwt()`;
10177
+ return sql.raw(RLS_JWT_SQL);
9782
10178
  };
9783
10179
  //#endregion
9784
10180
  //#region src/env.ts
@@ -9951,8 +10347,10 @@ var WebhookDispatcher = class {
9951
10347
  for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
9952
10348
  const result = await this.deliver(webhook, event, payload, attempt);
9953
10349
  if (result.success) return result;
9954
- if (attempt < this.maxRetries) await new Promise((r) => setTimeout(r, this.retryDelays[attempt - 1]));
9955
- else return result;
10350
+ if (attempt < this.maxRetries) {
10351
+ const backoff = this.retryDelays[attempt - 1] ?? this.retryDelays[this.retryDelays.length - 1];
10352
+ await new Promise((r) => setTimeout(r, backoff));
10353
+ } else return result;
9956
10354
  }
9957
10355
  return {
9958
10356
  webhookId: webhook.id,
@@ -11785,7 +12183,8 @@ async function bootFromBundle(options = {}) {
11785
12183
  });
11786
12184
  await ensureCollectionPolicies(bundle, dataSources, env);
11787
12185
  metrics?.setKnownCollections(backend.collectionRegistry.getCollections().map((collection) => collection.slug).filter((slug) => Boolean(slug)));
11788
- app.get("/health", async (c) => {
12186
+ const healthPaths = ["/health", `${(env.REBASE_BASE_PATH || "/api").replace(/\/$/, "")}/health`];
12187
+ for (const healthPath of [...new Set(healthPaths)]) app.get(healthPath, async (c) => {
11789
12188
  const result = await backend.healthCheck();
11790
12189
  const unhealthy = (await Promise.all(dataSources.filter((source) => source.key !== DEFAULT_DATA_SOURCE_KEY).map(async (source) => ({
11791
12190
  key: source.key,
@@ -11891,7 +12290,7 @@ async function bootStaticApp(bundle, devRoot, options) {
11891
12290
  if (metrics) app.use("/*", metrics.middleware);
11892
12291
  const server = createServer(getRequestListener(app.fetch));
11893
12292
  app.get("/livez", (c) => c.json({ status: "ok" }));
11894
- app.get("/health", (c) => c.json({
12293
+ for (const healthPath of [.../* @__PURE__ */ new Set(["/health", `${basePath.replace(/\/$/, "")}/health`])]) app.get(healthPath, (c) => c.json({
11895
12294
  status: "ok",
11896
12295
  latencyMs: 0
11897
12296
  }));
@@ -12001,6 +12400,40 @@ function warnOnDriverSkew(dataSources, runtimeVersion) {
12001
12400
  }
12002
12401
  }
12003
12402
  /**
12403
+ * The collections a data source's engine is the store for.
12404
+ *
12405
+ * A bundle's collections directory holds *every* collection the project
12406
+ * declares, whatever engine serves it — that is the point of `dataSource`
12407
+ * routing. What it is not is a list of tables to create: handing the whole
12408
+ * directory to the primary source's bootstrapper made a Firestore collection
12409
+ * declared alongside the Postgres ones arrive as an empty Postgres table, with
12410
+ * RLS policies, while the app went on reading its documents from Firestore.
12411
+ *
12412
+ * Excluding is deliberately conservative. A collection that names neither an
12413
+ * `engine` nor a `dataSource` belongs to whichever source is primary — the
12414
+ * "postgres" that `resolveDataSource` falls back to there is a default, not a
12415
+ * declaration, and must not exclude anything on its own. Only a collection that
12416
+ * explicitly routes to a *different* engine is dropped, so a project running two
12417
+ * sources on the same engine is unaffected.
12418
+ */
12419
+ function collectionsStoredBy(collections, primary, dataSources) {
12420
+ const registry = createDataSourceRegistry(dataSources.map((source) => ({
12421
+ key: source.key,
12422
+ engine: source.engine
12423
+ })));
12424
+ return collections.filter((collection) => {
12425
+ const declared = collection.engine ?? (collection.dataSource ? resolveDataSource(collection, registry).engine : void 0);
12426
+ if (!declared) return true;
12427
+ return declared === primary.engine;
12428
+ });
12429
+ }
12430
+ /** Say what was routed elsewhere, so a missing table is never a silent one. */
12431
+ function logForeignCollections(all, stored, primary) {
12432
+ if (stored.length === all.length) return;
12433
+ const foreign = all.filter((c) => !stored.includes(c)).map((c) => c.slug);
12434
+ 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.`);
12435
+ }
12436
+ /**
12004
12437
  * Bring the database's collection tables up to date before serving.
12005
12438
  *
12006
12439
  * Delegates to whichever driver bootstrapped the default data source; a driver
@@ -12049,11 +12482,17 @@ async function ensureCollectionSchema(bundle, dataSources, env) {
12049
12482
  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
12483
  return;
12051
12484
  }
12052
- const collections = await loadCollectionsFromDirectory(bundle.collectionsDir);
12053
- if (collections.length === 0) {
12485
+ const loaded = await loadCollectionsFromDirectory(bundle.collectionsDir);
12486
+ if (loaded.length === 0) {
12054
12487
  skip(`no collections were loaded from "${bundle.collectionsDir}".`, "warn");
12055
12488
  return;
12056
12489
  }
12490
+ const collections = collectionsStoredBy(loaded, primary, dataSources);
12491
+ logForeignCollections(loaded, collections, primary);
12492
+ if (collections.length === 0) {
12493
+ skip(`none of the ${loaded.length} declared collection(s) are stored by the "${primary.engine}" data source.`);
12494
+ return;
12495
+ }
12057
12496
  const { applied } = await primary.bootstrapper.ensureCollectionSchema(collections, preInitDriverResult(primary), (message) => logger.info(`schema: ${message}`));
12058
12497
  logger.info(applied > 0 ? `Applied ${applied} additive schema change(s) before boot.` : "Collection schema is up to date.");
12059
12498
  }
@@ -12103,12 +12542,14 @@ async function ensureCollectionPolicies(bundle, dataSources, env) {
12103
12542
  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
12543
  return;
12105
12544
  }
12106
- const collections = await loadCollectionsFromDirectory(bundle.collectionsDir);
12545
+ const loaded = await loadCollectionsFromDirectory(bundle.collectionsDir);
12546
+ if (loaded.length === 0) return;
12547
+ const collections = collectionsStoredBy(loaded, primary, dataSources);
12107
12548
  if (collections.length === 0) return;
12108
12549
  const { applied } = await primary.bootstrapper.ensureCollectionPolicies(collections, preInitDriverResult(primary), (message) => logger.info(`policies: ${message}`));
12109
12550
  logger.info(applied > 0 ? `Applied ${applied} RLS policy statement(s) before serving.` : "RLS policies are up to date.");
12110
12551
  }
12111
12552
  //#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 };
12553
+ 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
12554
 
12114
12555
  //# sourceMappingURL=index.es.js.map