@rebasepro/server 0.13.0 → 0.13.1-canary.g394d868

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 (44) hide show
  1. package/dist/{admin_block-CcSGdH7M.js → admin_block-EmIHae6X.js} +2 -1
  2. package/dist/admin_block-EmIHae6X.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-Bm5kOV2E.js} +17 -37
  10. package/dist/auth-Bm5kOV2E.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/cron/cron-scheduler.d.ts +8 -3
  15. package/dist/cron/define-cron.d.ts +5 -3
  16. package/dist/{cron-routes-CrQ0tK-_.js → cron-routes-D5a9v1HY.js} +2 -2
  17. package/dist/{cron-routes-CrQ0tK-_.js.map → cron-routes-D5a9v1HY.js.map} +1 -1
  18. package/dist/{cron-scheduler-B3RFt0HS.js → cron-scheduler-CsQtYLMq.js} +28 -3
  19. package/dist/cron-scheduler-CsQtYLMq.js.map +1 -0
  20. package/dist/{cron-store-BywZsyfZ.js → cron-store-Cu4sOseb.js} +11 -1
  21. package/dist/cron-store-Cu4sOseb.js.map +1 -0
  22. package/dist/{errors-CgkCzoj7.js → errors-BpudWAVU.js} +2 -10
  23. package/dist/errors-BpudWAVU.js.map +1 -0
  24. package/dist/functions/define-function.d.ts +1 -1
  25. package/dist/index.es.js +586 -208
  26. package/dist/index.es.js.map +1 -1
  27. package/dist/{jwt-DD6EtpGj.js → jwt-CzeviDcB.js} +17 -2
  28. package/dist/{jwt-DD6EtpGj.js.map → jwt-CzeviDcB.js.map} +1 -1
  29. package/dist/{openapi-generator-BEwyiaAr.js → openapi-generator-DieRvJdS.js} +305 -127
  30. package/dist/openapi-generator-DieRvJdS.js.map +1 -0
  31. package/dist/{schema-editor-routes-CbF20Mf1.js → schema-editor-routes-C1DxDnDR.js} +3 -3
  32. package/dist/{schema-editor-routes-CbF20Mf1.js.map → schema-editor-routes-C1DxDnDR.js.map} +1 -1
  33. package/dist/{src-Cum9kox5.js → src-CY0jBL-x.js} +111 -30
  34. package/dist/src-CY0jBL-x.js.map +1 -0
  35. package/dist/src-_qQ3RNCK.js.map +1 -1
  36. package/dist/utils/logging.d.ts +0 -4
  37. package/package.json +6 -6
  38. package/dist/admin_block-CcSGdH7M.js.map +0 -1
  39. package/dist/auth-CuC9M2x6.js.map +0 -1
  40. package/dist/cron-scheduler-B3RFt0HS.js.map +0 -1
  41. package/dist/cron-store-BywZsyfZ.js.map +0 -1
  42. package/dist/errors-CgkCzoj7.js.map +0 -1
  43. package/dist/openapi-generator-BEwyiaAr.js.map +0 -1
  44. package/dist/src-Cum9kox5.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";
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-CY0jBL-x.js";
5
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";
6
+ import { n as ADMIN_PROPERTY_KEYS, t as ADMIN_COLLECTION_KEYS } from "./admin_block-EmIHae6X.js";
7
7
  import { t as isSQLAdmin } from "./backend-CIxN4FVm.js";
8
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";
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-Bm5kOV2E.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-Cu4sOseb.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
  /**
@@ -1055,11 +1060,18 @@ function assertKnownWriteFields(values, collection, options) {
1055
1060
  * This shapes the *response*, which is what the parameter says it does; it is
1056
1061
  * not a column pushdown, so it saves bandwidth rather than database work.
1057
1062
  *
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.
1063
+ * The collection's key always survives. Rows are addressed by it everywhere
1064
+ * above this layer — the admin table, realtime reconciliation, the offline
1065
+ * cache — and a row that arrives without one is not a smaller row, it is an
1066
+ * unusable one. Asking for `fields=title` and being unable to open the record
1067
+ * you clicked is a worse answer than one extra key.
1068
+ *
1069
+ * That is a statement about the key, not about the name `id`: a collection
1070
+ * keyed on `slug`, or on `user_id + role_id`, was projected down to the fields
1071
+ * asked for and nothing else, which is the very outcome the paragraph above
1072
+ * describes. `assertKnownWriteFields` one function up already has a dedicated
1073
+ * error for collections with no `id` column, so this layer had no excuse for
1074
+ * assuming one.
1063
1075
  */
1064
1076
  function projectResponseFields(rows, fields, collection, options) {
1065
1077
  if (!fields || fields.length === 0) return rows;
@@ -1077,7 +1089,8 @@ function projectResponseFields(rows, fields, collection, options) {
1077
1089
  collection: collection.slug
1078
1090
  });
1079
1091
  }
1080
- const keep = /* @__PURE__ */ new Set([...fields, "id"]);
1092
+ const primaryKeys = resolvePrimaryKeys(collection).map((key) => key.fieldName);
1093
+ const keep = /* @__PURE__ */ new Set([...fields, ...primaryKeys.length > 0 ? primaryKeys : ["id"]]);
1081
1094
  return rows.map((row) => {
1082
1095
  const projected = {};
1083
1096
  for (const key of Object.keys(row)) if (keep.has(key)) projected[key] = row[key];
@@ -1161,26 +1174,43 @@ function createIdempotencyStore(driver) {
1161
1174
  return ready;
1162
1175
  };
1163
1176
  return {
1164
- async recall(key, uid) {
1165
- if (!key || !await ensure()) return void 0;
1177
+ async claim(key, uid) {
1178
+ if (!key || !await ensure()) return { status: "claimed" };
1166
1179
  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;
1180
+ if ((await exec(`INSERT INTO ${TABLE} (key, uid, response, created_at)
1181
+ VALUES ($1, $2, NULL, NOW())
1182
+ ON CONFLICT (uid, key) DO UPDATE
1183
+ SET response = NULL, created_at = NOW()
1184
+ WHERE ${TABLE}.created_at < NOW() - INTERVAL '${TTL_HOURS} hours'
1185
+ RETURNING 1 AS claimed`, [key, principal(uid)])).length > 0) return { status: "claimed" };
1186
+ const row = (await exec(`SELECT response, response IS NULL AS pending FROM ${TABLE}
1187
+ WHERE uid = $1 AND key = $2`, [principal(uid), key]))[0];
1188
+ if (!row) return { status: "claimed" };
1189
+ if (row.pending) return { status: "in-flight" };
1190
+ return {
1191
+ status: "replay",
1192
+ response: row.response
1193
+ };
1169
1194
  } catch {
1170
- return;
1195
+ return { status: "claimed" };
1171
1196
  }
1172
1197
  },
1173
- async remember(key, uid, response) {
1198
+ async complete(key, uid, response) {
1174
1199
  if (!key || !await ensure()) return;
1175
1200
  try {
1176
- await exec(`INSERT INTO ${TABLE} (key, uid, response) VALUES ($1, $2, $3::jsonb)
1177
- ON CONFLICT (uid, key) DO NOTHING`, [
1178
- key,
1201
+ await exec(`UPDATE ${TABLE} SET response = $3::jsonb WHERE uid = $1 AND key = $2`, [
1179
1202
  principal(uid),
1203
+ key,
1180
1204
  JSON.stringify(response ?? null)
1181
1205
  ]);
1182
1206
  if (Math.random() < .01) await exec(`DELETE FROM ${TABLE} WHERE created_at < NOW() - INTERVAL '${TTL_HOURS} hours'`);
1183
1207
  } catch {}
1208
+ },
1209
+ async release(key, uid) {
1210
+ if (!key || !await ensure()) return;
1211
+ try {
1212
+ await exec(`DELETE FROM ${TABLE} WHERE uid = $1 AND key = $2 AND response IS NULL`, [principal(uid), key]);
1213
+ } catch {}
1184
1214
  }
1185
1215
  };
1186
1216
  }
@@ -1367,121 +1397,225 @@ var RestApiGenerator = class {
1367
1397
  if (!entity) throw ApiError.notFound("Entity not found");
1368
1398
  return c.json(projectResponseFields([entity], queryOptions.fields, resolvedCollection, { include: queryOptions.include })[0]);
1369
1399
  });
1400
+ /**
1401
+ * Run a bulk handler under an idempotency key.
1402
+ *
1403
+ * All three bulk routes need the identical claim-before-write dance —
1404
+ * claim, replay on a repeat, 409 while one is in flight, release on
1405
+ * failure, complete on success — and getting one of those steps wrong
1406
+ * is exactly the bug the key exists to prevent. Written once so the
1407
+ * three cannot drift into three different notions of "already done".
1408
+ */
1409
+ const withIdempotency = async (c, run, respond) => {
1410
+ const idempotencyKey = c.req.header(IDEMPOTENCY_HEADER);
1411
+ const uid = c.get("user")?.uid;
1412
+ const store = this.idempotency();
1413
+ const claimed = idempotencyKey && store ? await store.claim(idempotencyKey, uid) : void 0;
1414
+ if (claimed?.status === "replay") return respond(claimed.response);
1415
+ 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");
1416
+ let response;
1417
+ try {
1418
+ response = await run();
1419
+ } catch (error) {
1420
+ if (claimed?.status === "claimed" && idempotencyKey && store) await store.release(idempotencyKey, uid);
1421
+ throw error;
1422
+ }
1423
+ if (claimed?.status === "claimed" && idempotencyKey && store) await store.complete(idempotencyKey, uid, response);
1424
+ return respond(response);
1425
+ };
1426
+ /**
1427
+ * Validate the envelope every bulk route shares: an array, non-empty,
1428
+ * of objects, within the row cap.
1429
+ *
1430
+ * The cap is the load-bearing one. A batch is a single transaction that
1431
+ * holds its locks for its whole duration, so an unbounded one is a
1432
+ * self-inflicted outage — and the message names both the limit and the
1433
+ * actual count so the caller can chunk to it rather than guess.
1434
+ */
1435
+ const assertBulkShape = (items, field, requireObjects) => {
1436
+ if (!Array.isArray(items)) throw ApiError.badRequest(`Expected a JSON body of { ${field}: [...] }.`, "INVALID_BULK_BODY");
1437
+ 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");
1438
+ 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");
1439
+ };
1370
1440
  this.router.post(`${basePath}/bulk`, async (c) => {
1371
1441
  this.enforceApiKeyPermission(c, collection.slug);
1372
1442
  const driver = this.getScopedDriver(c);
1373
1443
  const path = collection.slug;
1374
1444
  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({
1445
+ assertBulkShape(body?.rows, "rows", true);
1446
+ const rows = body.rows;
1447
+ if (rows.length === 0) return c.json({
1377
1448
  data: [],
1378
1449
  meta: { written: 0 }
1379
1450
  });
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
1451
  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
1452
  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
1453
+ rows.forEach((row, rowIndex) => assertKnownWriteFields(row, resolvedCollection, { rowIndex }));
1454
+ return withIdempotency(c, async () => {
1455
+ const written = await driver.saveMany({
1456
+ path,
1457
+ rows,
1458
+ collection: resolvedCollection,
1459
+ upsert: body.upsert === true
1460
+ });
1461
+ return {
1462
+ data: written.map((row) => this.formatResponse(row)),
1463
+ meta: { written: written.length }
1464
+ };
1465
+ }, (result) => c.json(result));
1466
+ });
1467
+ this.router.patch(`${basePath}/bulk`, async (c) => {
1468
+ this.enforceApiKeyPermission(c, collection.slug);
1469
+ const driver = this.getScopedDriver(c);
1470
+ const path = collection.slug;
1471
+ const body = await parseJsonBody(c);
1472
+ assertBulkShape(body?.updates, "updates", true);
1473
+ const updates = body.updates;
1474
+ if (updates.length === 0) return c.json({
1475
+ data: [],
1476
+ meta: { written: 0 }
1391
1477
  });
1392
- return c.json({
1393
- data: rows.map((row) => this.formatResponse(row)),
1394
- meta: { written: rows.length }
1478
+ if (!driver.updateMany) throw ApiError.badRequest("This collection's data source does not support bulk updates.", "BULK_UNSUPPORTED");
1479
+ updates.forEach((entry, rowIndex) => {
1480
+ 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");
1481
+ 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");
1482
+ assertKnownWriteFields(entry.data, resolvedCollection, { rowIndex });
1395
1483
  });
1484
+ return withIdempotency(c, async () => {
1485
+ const written = await driver.updateMany({
1486
+ path,
1487
+ updates: updates.map((entry) => ({
1488
+ id: entry.id,
1489
+ values: entry.data
1490
+ })),
1491
+ collection: resolvedCollection
1492
+ });
1493
+ return {
1494
+ data: written.map((row) => this.formatResponse(row)),
1495
+ meta: { written: written.length }
1496
+ };
1497
+ }, (result) => c.json(result));
1498
+ });
1499
+ this.router.post(`${basePath}/bulk/delete`, async (c) => {
1500
+ this.enforceApiKeyPermission(c, collection.slug);
1501
+ const driver = this.getScopedDriver(c);
1502
+ const path = collection.slug;
1503
+ const body = await parseJsonBody(c);
1504
+ assertBulkShape(body?.ids, "ids", false);
1505
+ const ids = body.ids;
1506
+ if (ids.length === 0) return c.json({ meta: { deleted: 0 } });
1507
+ 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");
1508
+ if (!driver.deleteMany) throw ApiError.badRequest("This collection's data source does not support bulk deletes.", "BULK_UNSUPPORTED");
1509
+ return withIdempotency(c, async () => {
1510
+ await driver.deleteMany({
1511
+ path,
1512
+ ids,
1513
+ collection: resolvedCollection
1514
+ });
1515
+ return { meta: { deleted: ids.length } };
1516
+ }, (result) => c.json(result));
1396
1517
  });
1397
1518
  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
- }
1519
+ this.enforceApiKeyPermission(c, collection.slug);
1520
+ const driver = this.getScopedDriver(c);
1521
+ const path = collection.slug;
1522
+ const body = await parseJsonBody(c);
1523
+ const isAuth = collection.auth;
1524
+ const isAuthCollection = isAuth === true || isAuth && typeof isAuth === "object" && isAuth.enabled === true;
1525
+ const collectionAuthConfig = typeof isAuth === "object" ? isAuth : void 0;
1526
+ if (!isAuthCollection) assertKnownWriteFields(body, resolvedCollection);
1527
+ else {
1528
+ const contract = this.authAdapter?.describeUserCreationContract?.(collectionAuthConfig);
1529
+ if (contract?.validate) assertKnownWriteFields(body, resolvedCollection, { extraKnownFields: contract.extraFields });
1530
+ }
1531
+ if (isAuthCollection && this.authAdapter?.prepareUserCreation) {
1532
+ const prepared = await this.authAdapter.prepareUserCreation(body, collectionAuthConfig);
1441
1533
  const entity = await driver.save({
1442
1534
  path,
1443
- values: body,
1535
+ values: prepared.values,
1444
1536
  collection: resolvedCollection,
1445
1537
  status: "new"
1446
1538
  });
1539
+ const result = prepared.hookHandledEmail ? {
1540
+ temporaryPassword: prepared.clearPassword,
1541
+ invitationSent: prepared.invitationSent
1542
+ } : this.authAdapter.finalizeUserCreation ? await this.authAdapter.finalizeUserCreation({
1543
+ id: entity.id,
1544
+ values: entity
1545
+ }, prepared.clearPassword) : { invitationSent: false };
1447
1546
  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;
1547
+ return c.json({
1548
+ ...response,
1549
+ invitationSent: result.invitationSent,
1550
+ ...result.temporaryPassword ? { temporaryPassword: result.temporaryPassword } : {},
1551
+ ..."emailDeliveryFailed" in result && result.emailDeliveryFailed ? { emailDeliveryFailed: true } : {}
1552
+ }, 201);
1455
1553
  }
1456
- });
1457
- this.router.put(`${basePath}/:id`, async (c) => {
1554
+ const idempotencyKey = c.req.header(IDEMPOTENCY_HEADER);
1555
+ const uid = c.get("user")?.uid;
1556
+ const store = this.idempotency();
1557
+ const claimed = idempotencyKey && store ? await store.claim(idempotencyKey, uid) : void 0;
1558
+ if (claimed?.status === "replay") return c.json(claimed.response, 201);
1559
+ 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");
1560
+ let response;
1458
1561
  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
1562
  const entity = await driver.save({
1470
- path: getCollectionDataPath(collection),
1471
- id: String(id),
1563
+ path,
1472
1564
  values: body,
1473
1565
  collection: resolvedCollection,
1474
- status: "existing"
1566
+ status: "new"
1475
1567
  });
1476
- const response = this.formatResponse(entity);
1477
- return c.json(response);
1568
+ response = this.formatResponse(entity);
1478
1569
  } 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
- }
1570
+ if (claimed?.status === "claimed" && idempotencyKey && store) await store.release(idempotencyKey, uid);
1482
1571
  throw error;
1483
1572
  }
1573
+ if (claimed?.status === "claimed" && idempotencyKey && store) await store.complete(idempotencyKey, uid, response);
1574
+ return c.json(response, 201);
1484
1575
  });
1576
+ const updateEntity = async (c) => {
1577
+ this.enforceApiKeyPermission(c, collection.slug);
1578
+ const id = c.req.param("id");
1579
+ const driver = this.getScopedDriver(c);
1580
+ if (!await driver.fetchOne({
1581
+ path: getCollectionDataPath(collection),
1582
+ id: String(id),
1583
+ collection: resolvedCollection
1584
+ })) throw ApiError.notFound("Entity not found");
1585
+ const body = await parseJsonBody(c);
1586
+ assertKnownWriteFields(body, resolvedCollection);
1587
+ const entity = await driver.save({
1588
+ path: getCollectionDataPath(collection),
1589
+ id: String(id),
1590
+ values: body,
1591
+ collection: resolvedCollection,
1592
+ status: "existing"
1593
+ });
1594
+ const response = this.formatResponse(entity);
1595
+ return c.json(response);
1596
+ };
1597
+ /**
1598
+ * The verb that matches what this actually does.
1599
+ *
1600
+ * The handler merges: it writes the columns in the body and leaves the
1601
+ * rest alone, which is what the SDK's `update(id, data: Partial<M>)`
1602
+ * sends. PUT was the only route, and PUT means replace — so the
1603
+ * generated OpenAPI spec described a full replacement while the server
1604
+ * performed a merge, and it reused the *create* input schema for it,
1605
+ * `required` fields and all. A client generated from that spec was
1606
+ * given a contract the server does not implement, and a spec-validating
1607
+ * gateway in front of this API would reject partial updates the server
1608
+ * would have accepted.
1609
+ *
1610
+ * PUT stays mounted on the same handler rather than being changed or
1611
+ * removed: every published SDK sends it, and altering its semantics to
1612
+ * be a true replace would silently start nulling columns that callers
1613
+ * had simply omitted for years — a data-loss change disguised as a
1614
+ * standards fix. So PATCH is the honest name, PUT is the compatible one,
1615
+ * and both do the documented thing.
1616
+ */
1617
+ this.router.patch(`${basePath}/:id`, updateEntity);
1618
+ this.router.put(`${basePath}/:id`, updateEntity);
1485
1619
  this.router.delete(`${basePath}/:id`, async (c) => {
1486
1620
  this.enforceApiKeyPermission(c, collection.slug);
1487
1621
  const id = c.req.param("id");
@@ -1552,6 +1686,7 @@ var RestApiGenerator = class {
1552
1686
  const total = driver.count ? await driver.count({
1553
1687
  path: parsed.collectionPath,
1554
1688
  filter: queryOptions.where,
1689
+ logical: queryOptions.logical,
1555
1690
  searchString
1556
1691
  }) : 0;
1557
1692
  return c.json({ count: total });
@@ -1563,7 +1698,9 @@ var RestApiGenerator = class {
1563
1698
  id: parsed.id
1564
1699
  });
1565
1700
  if (!entity) throw ApiError.notFound("Entity not found");
1566
- return c.json(entity);
1701
+ const nestedCollection = this.resolveNestedWriteCollection(parsed.collectionPath);
1702
+ if (!nestedCollection) return c.json(entity);
1703
+ return c.json(projectResponseFields([entity], queryOptions.fields, nestedCollection, { include: queryOptions.include })[0]);
1567
1704
  } else {
1568
1705
  const queryDict = c.req.queries();
1569
1706
  const queryOptions = this.parseQuery(queryDict);
@@ -1588,8 +1725,9 @@ var RestApiGenerator = class {
1588
1725
  logical: queryOptions.logical,
1589
1726
  searchString
1590
1727
  }) : entities.length;
1728
+ const listCollection = this.resolveNestedWriteCollection(parsed.collectionPath);
1591
1729
  return c.json({
1592
- data: entities,
1730
+ data: listCollection ? projectResponseFields(entities, queryOptions.fields, listCollection, { include: queryOptions.include }) : entities,
1593
1731
  meta: {
1594
1732
  total,
1595
1733
  limit: queryOptions.limit,
@@ -1618,7 +1756,7 @@ var RestApiGenerator = class {
1618
1756
  const response = this.formatResponse(entity);
1619
1757
  return c.json(response, 201);
1620
1758
  });
1621
- this.router.put("/:parent/:parentId/:rest{.+}", async (c, next) => {
1759
+ const updateNested = async (c, next) => {
1622
1760
  const rest = c.req.param("rest");
1623
1761
  if (!rest || rest === "undefined") return next();
1624
1762
  const rawPath = `${c.req.param("parent")}/${c.req.param("parentId")}/${rest}`;
@@ -1637,7 +1775,9 @@ var RestApiGenerator = class {
1637
1775
  });
1638
1776
  const response = this.formatResponse(entity);
1639
1777
  return c.json(response);
1640
- });
1778
+ };
1779
+ this.router.patch("/:parent/:parentId/:rest{.+}", updateNested);
1780
+ this.router.put("/:parent/:parentId/:rest{.+}", updateNested);
1641
1781
  this.router.delete("/:parent/:parentId/:rest{.+}", async (c, next) => {
1642
1782
  const rest = c.req.param("rest");
1643
1783
  if (!rest || rest === "undefined") return next();
@@ -3081,7 +3221,7 @@ function assertStorageAccessControlConfigured(state, isProduction) {
3081
3221
  //#region src/init/docs.ts
3082
3222
  async function mountOpenApiDocs(app, basePath, enableSwagger, activeCollections, requireAuth) {
3083
3223
  if (enableSwagger === false || activeCollections.length === 0) return;
3084
- const { generateOpenApiSpec } = await import("./openapi-generator-BEwyiaAr.js");
3224
+ const { generateOpenApiSpec } = await import("./openapi-generator-DieRvJdS.js");
3085
3225
  app.get(`${basePath}/docs`, (c) => {
3086
3226
  const spec = generateOpenApiSpec(activeCollections, {
3087
3227
  basePath,
@@ -3418,6 +3558,13 @@ function resolveBaseUrl(configured) {
3418
3558
  function createTransport(config, environment) {
3419
3559
  const fetchFn = config.fetch || globalThis.fetch;
3420
3560
  const apiPath = config.apiPath || "/api";
3561
+ for (const field of ["baseUrl", "storageUrlOrigin"]) {
3562
+ const value = config[field];
3563
+ if (!value || !apiPath) continue;
3564
+ const trimmed = value.replace(/\/+$/, "");
3565
+ if (!trimmed.endsWith(apiPath)) continue;
3566
+ 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.`);
3567
+ }
3421
3568
  let token = config.token;
3422
3569
  let tokenGetter;
3423
3570
  let onUnauthorizedHandler = config.onUnauthorized;
@@ -3601,6 +3748,11 @@ function createAuth(transport, options) {
3601
3748
  const authFlowMode = opts.authFlowMode || "json";
3602
3749
  const STORAGE_KEY = "rebase_auth";
3603
3750
  const REFRESH_BUFFER_MS = 12e4;
3751
+ /**
3752
+ * The largest delay `setTimeout` can hold — 2^31 - 1 ms, about 24.8 days.
3753
+ * Anything larger is silently clamped to 1ms by Node and every browser.
3754
+ */
3755
+ const MAX_TIMER_DELAY_MS = 2147483647;
3604
3756
  const MAX_REFRESH_RETRIES = 5;
3605
3757
  const REFRESH_RETRY_BASE_MS = 1e3;
3606
3758
  const REFRESH_RETRY_MAX_MS = 3e4;
@@ -3628,7 +3780,9 @@ function createAuth(transport, options) {
3628
3780
  function emit(event, session) {
3629
3781
  for (const fn of listeners) try {
3630
3782
  fn(event, session);
3631
- } catch (e) {}
3783
+ } catch (e) {
3784
+ console.error("Error in auth state change listener:", e);
3785
+ }
3632
3786
  }
3633
3787
  function saveSession(session) {
3634
3788
  if (!persistSession || authFlowMode === "cookie") return;
@@ -3732,6 +3886,10 @@ function createAuth(transport, options) {
3732
3886
  attemptScheduledRefresh(0);
3733
3887
  return;
3734
3888
  }
3889
+ if (delay > MAX_TIMER_DELAY_MS) {
3890
+ refreshTimeout = setTimeout(() => scheduleRefresh(expiresAt), MAX_TIMER_DELAY_MS);
3891
+ return;
3892
+ }
3735
3893
  refreshTimeout = setTimeout(() => {
3736
3894
  attemptScheduledRefresh(0);
3737
3895
  }, delay);
@@ -4506,6 +4664,11 @@ var SDKQueryBuilder = class {
4506
4664
  return this.collection.listen(this.params, onUpdate, onError);
4507
4665
  }
4508
4666
  };
4667
+ /**
4668
+ * Counts currently in flight, keyed by the exact request they issue. Entries
4669
+ * live only for the duration of the request — see `count()` for why.
4670
+ */
4671
+ var inflightCounts = /* @__PURE__ */ new Map();
4509
4672
  function createCollectionClient(transport, slug, ws) {
4510
4673
  const basePath = `/data/${slug}`;
4511
4674
  const client = {
@@ -4550,18 +4713,63 @@ function createCollectionClient(transport, slug, ws) {
4550
4713
  body: JSON.stringify({
4551
4714
  rows: data,
4552
4715
  ...options?.upsert ? { upsert: true } : {}
4553
- })
4716
+ }),
4717
+ ...options?.idempotencyKey ? { headers: { "Idempotency-Key": options.idempotencyKey } } : {}
4554
4718
  })).data || [];
4555
4719
  },
4720
+ /**
4721
+ * Still `PUT`, deliberately, even though the server now serves `PATCH`
4722
+ * on the same handler and `PATCH` is the honest verb for a merge.
4723
+ *
4724
+ * The two are interchangeable server-side, so switching buys nothing at
4725
+ * runtime — and it costs compatibility in the direction that fails
4726
+ * quietly. A 0.14 client talking to a 0.13 server would send `PATCH` to
4727
+ * a route that does not exist and get a **404**, which is
4728
+ * indistinguishable from "that row is gone". Every write would look like
4729
+ * a missing record.
4730
+ *
4731
+ * `PATCH` is what the OpenAPI spec advertises, so anyone generating a
4732
+ * client gets the correct verb; this stays on `PUT` until the oldest
4733
+ * supported server is one that serves both.
4734
+ */
4556
4735
  async update(id, data) {
4557
4736
  return await transport.request(`${basePath}/${encodeURIComponent(String(id))}`, {
4558
4737
  method: "PUT",
4559
4738
  body: JSON.stringify(data)
4560
4739
  });
4561
4740
  },
4741
+ async updateMany(updates, options) {
4742
+ if (!Array.isArray(updates)) throw new TypeError("updateMany expects an array of { id, data } entries.");
4743
+ if (updates.length === 0) return [];
4744
+ return (await transport.request(`${basePath}/bulk`, {
4745
+ method: "PATCH",
4746
+ body: JSON.stringify({ updates }),
4747
+ ...options?.idempotencyKey ? { headers: { "Idempotency-Key": options.idempotencyKey } } : {}
4748
+ })).data || [];
4749
+ },
4562
4750
  async delete(id) {
4563
4751
  await transport.request(`${basePath}/${encodeURIComponent(String(id))}`, { method: "DELETE" });
4564
4752
  },
4753
+ /**
4754
+ * `POST .../bulk/delete`, not `DELETE .../bulk`.
4755
+ *
4756
+ * The honest verb would take the ids in a DELETE body, and that is the
4757
+ * one request shape the HTTP ecosystem handles unreliably: bodies on
4758
+ * DELETE are permitted but widely dropped by proxies and CDNs, and
4759
+ * several OpenAPI generators ignore `requestBody` on a DELETE
4760
+ * operation, so a generated client would send the request without its
4761
+ * ids. A backend deployed behind arbitrary ingress cannot take that
4762
+ * bet. Same reason `:batchDelete` exists in Google's API guidelines.
4763
+ */
4764
+ async deleteMany(ids, options) {
4765
+ if (!Array.isArray(ids)) throw new TypeError("deleteMany expects an array of ids.");
4766
+ if (ids.length === 0) return;
4767
+ await transport.request(`${basePath}/bulk/delete`, {
4768
+ method: "POST",
4769
+ body: JSON.stringify({ ids }),
4770
+ ...options?.idempotencyKey ? { headers: { "Idempotency-Key": options.idempotencyKey } } : {}
4771
+ });
4772
+ },
4565
4773
  async count(params) {
4566
4774
  const qs = buildQueryString({
4567
4775
  ...params,
@@ -4569,12 +4777,28 @@ function createCollectionClient(transport, slug, ws) {
4569
4777
  offset: void 0,
4570
4778
  include: void 0
4571
4779
  });
4572
- return (await transport.request(basePath + "/count" + qs, { method: "GET" })).count ?? 0;
4780
+ const key = basePath + "/count" + qs;
4781
+ const inflight = inflightCounts.get(key);
4782
+ if (inflight) return inflight;
4783
+ const request = transport.request(key, { method: "GET" }).then((raw) => raw.count ?? 0);
4784
+ inflightCounts.set(key, request);
4785
+ try {
4786
+ return await request;
4787
+ } finally {
4788
+ inflightCounts.delete(key);
4789
+ }
4573
4790
  },
4574
4791
  observe(params, onResult, onError, options) {
4575
4792
  let closed = false;
4576
- const emit = (result) => {
4793
+ let liveDelivered = false;
4794
+ let signature;
4795
+ const deliver = (result, fromLive) => {
4577
4796
  if (closed) return;
4797
+ if (fromLive) liveDelivered = true;
4798
+ else if (liveDelivered) return;
4799
+ const next = `${result.meta?.total ?? ""}|${JSON.stringify(result.data)}`;
4800
+ if (signature !== void 0 && next === signature) return;
4801
+ signature = next;
4578
4802
  onResult({
4579
4803
  ...result,
4580
4804
  fromCache: false,
@@ -4582,10 +4806,10 @@ function createCollectionClient(transport, slug, ws) {
4582
4806
  partial: false
4583
4807
  });
4584
4808
  };
4585
- client.find(params).then(emit).catch((error) => {
4809
+ client.find(params).then((result) => deliver(result, false)).catch((error) => {
4586
4810
  if (!closed) onError?.(error);
4587
4811
  });
4588
- const live = options?.realtime !== false && client.listen ? client.listen(params, emit, onError) : void 0;
4812
+ const live = options?.realtime !== false && client.listen ? client.listen(params, (result) => deliver(result, true), onError) : void 0;
4589
4813
  return () => {
4590
4814
  closed = true;
4591
4815
  live?.();
@@ -4593,17 +4817,24 @@ function createCollectionClient(transport, slug, ws) {
4593
4817
  },
4594
4818
  observeById(id, onResult, onError, options) {
4595
4819
  let closed = false;
4596
- const emit = (row) => {
4820
+ let liveDelivered = false;
4821
+ let signature;
4822
+ const deliver = (row, fromLive) => {
4597
4823
  if (closed) return;
4824
+ if (fromLive) liveDelivered = true;
4825
+ else if (liveDelivered) return;
4826
+ const next = row === void 0 ? "\0missing" : JSON.stringify(row);
4827
+ if (signature !== void 0 && next === signature) return;
4828
+ signature = next;
4598
4829
  onResult(row, {
4599
4830
  fromCache: false,
4600
4831
  hasPendingWrites: false
4601
4832
  });
4602
4833
  };
4603
- client.findById(id).then(emit).catch((error) => {
4834
+ client.findById(id).then((row) => deliver(row, false)).catch((error) => {
4604
4835
  if (!closed) onError?.(error);
4605
4836
  });
4606
- const live = options?.realtime !== false && client.listenById ? client.listenById(id, emit, onError) : void 0;
4837
+ const live = options?.realtime !== false && client.listenById ? client.listenById(id, (row) => deliver(row, true), onError) : void 0;
4607
4838
  return () => {
4608
4839
  closed = true;
4609
4840
  live?.();
@@ -4634,51 +4865,43 @@ function createCollectionClient(transport, slug, ws) {
4634
4865
  client.listen = (params, onUpdate, onError) => {
4635
4866
  let active = true;
4636
4867
  let lastUpdateId = 0;
4868
+ let lastKnownTotal;
4869
+ const window = resolveFindWindow(params);
4637
4870
  const unsub = ws.listenCollection({
4638
4871
  path: slug,
4639
4872
  filter: params?.where,
4873
+ logical: params?.logical,
4640
4874
  limit: params?.limit,
4641
- startAfter: params?.offset ? String(params.offset) : void 0,
4875
+ offset: window.driverOffset,
4642
4876
  orderBy: params?.orderBy?.[0],
4643
4877
  order: params?.orderBy?.[1],
4644
4878
  searchString: params?.searchString
4645
4879
  }, (incomingRows) => {
4646
4880
  const currentUpdateId = ++lastUpdateId;
4647
- const requestedLimit = params?.limit || 20;
4648
- const offset = params?.offset || 0;
4881
+ const requestedLimit = window.limit;
4882
+ const offset = window.offset;
4649
4883
  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({
4884
+ const emit = (total, hasMore) => {
4885
+ if (!active || currentUpdateId !== lastUpdateId) return;
4886
+ onUpdate({
4654
4887
  data: rows,
4655
4888
  meta: {
4656
4889
  total,
4657
4890
  limit: requestedLimit,
4658
4891
  offset,
4659
- hasMore: offset + rows.length < total
4892
+ hasMore
4660
4893
  }
4661
4894
  });
4895
+ };
4896
+ const emitWithoutCount = () => emit(offset + rows.length, rows.length >= requestedLimit);
4897
+ if (client.count) client.count(params).then((total) => {
4898
+ lastKnownTotal = total;
4899
+ emit(total, offset + rows.length < total);
4662
4900
  }).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
- }
4901
+ if (lastKnownTotal !== void 0) emit(lastKnownTotal, offset + rows.length < lastKnownTotal);
4902
+ else emitWithoutCount();
4681
4903
  });
4904
+ else emitWithoutCount();
4682
4905
  }, onError);
4683
4906
  return () => {
4684
4907
  active = false;
@@ -6061,15 +6284,10 @@ var RebaseWebSocketClient = class {
6061
6284
  }
6062
6285
  }
6063
6286
  createCollectionSubscriptionKey(props) {
6287
+ const { collection, ...query } = props;
6064
6288
  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
6289
+ ...query,
6290
+ collection: collection?.name
6073
6291
  };
6074
6292
  return JSON.stringify(key, (_, value) => {
6075
6293
  if (value && typeof value === "object" && !Array.isArray(value)) return Object.keys(value).sort().reduce((sorted, k) => {
@@ -6982,14 +7200,23 @@ function looseEquals(a, b) {
6982
7200
  */
6983
7201
  function likeToRegExp(pattern, caseInsensitive) {
6984
7202
  let source = "^";
7203
+ let lastWasWildcard = false;
6985
7204
  for (let i = 0; i < pattern.length; i++) {
6986
7205
  const char = pattern[i];
6987
7206
  if (char === "\\" && i + 1 < pattern.length) {
6988
7207
  source += pattern[i + 1].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6989
7208
  i++;
6990
- } else if (char === "%") source += "[\\s\\S]*";
6991
- else if (char === "_") source += "[\\s\\S]";
6992
- else source += char.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7209
+ lastWasWildcard = false;
7210
+ } else if (char === "%") {
7211
+ if (!lastWasWildcard) source += "[\\s\\S]*";
7212
+ lastWasWildcard = true;
7213
+ } else if (char === "_") {
7214
+ source += "[\\s\\S]";
7215
+ lastWasWildcard = false;
7216
+ } else {
7217
+ source += char.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7218
+ lastWasWildcard = false;
7219
+ }
6993
7220
  }
6994
7221
  return new RegExp(source + "$", caseInsensitive ? "i" : "");
6995
7222
  }
@@ -7121,12 +7348,20 @@ function sortRows(rows, orderBy) {
7121
7348
  function tiebreak(a, b) {
7122
7349
  return compareValues(a.id, b.id) ?? 0;
7123
7350
  }
7124
- /** Resolve `page`/`offset`/`limit` the way the server does. */
7351
+ /**
7352
+ * Resolve `page`/`offset`/`limit` the way the server does.
7353
+ *
7354
+ * It did not: this defaulted an absent limit to 20 while `/api/data` pages by
7355
+ * 50, so the same `observe()` answered with 20 rows from the local database and
7356
+ * 50 from the network — a list that changed length depending on which side
7357
+ * answered, with `page` striding differently on each. Delegated now, so the
7358
+ * sentence above is true by construction rather than by agreement.
7359
+ */
7125
7360
  function resolvePagination(params) {
7126
- const limit = params?.limit ?? 20;
7361
+ const { limit, offset } = resolveFindWindow(params);
7127
7362
  return {
7128
7363
  limit,
7129
- offset: params?.page != null ? Math.max(0, (params.page - 1) * limit) : params?.offset ?? 0
7364
+ offset
7130
7365
  };
7131
7366
  }
7132
7367
  /**
@@ -7450,6 +7685,72 @@ var OfflineManager = class {
7450
7685
  this.notifyCollection(slug);
7451
7686
  return rows;
7452
7687
  },
7688
+ updateMany: async (updates, options) => {
7689
+ await this.ensureCollection(slug);
7690
+ if (!Array.isArray(updates)) throw new TypeError("updateMany expects an array of { id, data } entries.");
7691
+ if (updates.length === 0) return [];
7692
+ const anyPending = updates.some((u) => this.hasPending(slug, u.id));
7693
+ if (this.connectivity.shouldAttempt() && !anyPending) try {
7694
+ const rows = await inner.updateMany(updates, options);
7695
+ this.connectivity.markSuccess();
7696
+ await this.ingest(slug, rows);
7697
+ this.notifyCollection(slug);
7698
+ return rows;
7699
+ } catch (error) {
7700
+ if (!isNetworkError(error)) throw error;
7701
+ this.connectivity.markFailure();
7702
+ }
7703
+ const rollback = {};
7704
+ const optimistic = [];
7705
+ for (const { id, data } of updates) {
7706
+ const base = this.rawLocalRow(slug, id);
7707
+ rollback[String(id)] = base ?? null;
7708
+ optimistic.push({
7709
+ ...base ?? {},
7710
+ ...data,
7711
+ id
7712
+ });
7713
+ }
7714
+ await this.enqueue({
7715
+ collection: slug,
7716
+ type: "updateMany",
7717
+ updates: updates.map((u) => ({
7718
+ id: u.id,
7719
+ data: u.data
7720
+ })),
7721
+ rollback: { rows: rollback }
7722
+ });
7723
+ for (const row of optimistic) this.setLocalRow(slug, row.id, row);
7724
+ this.notifyCollection(slug);
7725
+ return optimistic;
7726
+ },
7727
+ deleteMany: async (ids, options) => {
7728
+ await this.ensureCollection(slug);
7729
+ if (!Array.isArray(ids)) throw new TypeError("deleteMany expects an array of ids.");
7730
+ if (ids.length === 0) return;
7731
+ const anyPending = ids.some((id) => this.hasPending(slug, id));
7732
+ if (this.connectivity.shouldAttempt() && !anyPending) try {
7733
+ await inner.deleteMany(ids, options);
7734
+ this.connectivity.markSuccess();
7735
+ for (const id of ids) this.removeLocalRow(slug, id, true);
7736
+ this.notifyCollection(slug);
7737
+ this.scheduleRefresh(slug);
7738
+ return;
7739
+ } catch (error) {
7740
+ if (!isNetworkError(error)) throw error;
7741
+ this.connectivity.markFailure();
7742
+ }
7743
+ const rollback = {};
7744
+ for (const id of ids) rollback[String(id)] = this.rawLocalRow(slug, id) ?? null;
7745
+ await this.enqueue({
7746
+ collection: slug,
7747
+ type: "deleteMany",
7748
+ ids,
7749
+ rollback: { rows: rollback }
7750
+ });
7751
+ for (const id of ids) this.removeLocalRow(slug, id, false);
7752
+ this.notifyCollection(slug);
7753
+ },
7453
7754
  update: async (id, data) => {
7454
7755
  await this.ensureCollection(slug);
7455
7756
  if (this.connectivity.shouldAttempt() && !this.hasPending(slug, id)) try {
@@ -7748,9 +8049,8 @@ var OfflineManager = class {
7748
8049
  if (!state) return {
7749
8050
  data: [],
7750
8051
  meta: {
8052
+ ...resolvePagination(params),
7751
8053
  total: 0,
7752
- limit: params?.limit ?? 20,
7753
- offset: params?.offset ?? 0,
7754
8054
  hasMore: false
7755
8055
  },
7756
8056
  fromCache: true,
@@ -7786,16 +8086,14 @@ var OfflineManager = class {
7786
8086
  }
7787
8087
  let added = 0;
7788
8088
  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);
8089
+ if (exact && offset === 0) for (const [key, entry] of state.rows) {
8090
+ if (seen.has(key) || !this.hasPending(slug, key)) continue;
8091
+ if (!this.isLocallyCreated(slug, key)) continue;
8092
+ if (!matchesParams(entry.row, params)) continue;
8093
+ rows.push(entry.row);
8094
+ added++;
7798
8095
  }
8096
+ if (params?.orderBy) sortRows(rows, params.orderBy);
7799
8097
  return {
7800
8098
  data: rows,
7801
8099
  meta: {
@@ -7942,17 +8240,17 @@ var OfflineManager = class {
7942
8240
  return row;
7943
8241
  }
7944
8242
  recordSnapshot(slug, params, result) {
8243
+ const window = resolvePagination(params);
7945
8244
  const meta = result.meta ?? {
7946
8245
  total: result.data?.length ?? 0,
7947
- limit: 20,
7948
- offset: 0,
8246
+ ...window,
7949
8247
  hasMore: false
7950
8248
  };
7951
8249
  const snapshot = {
7952
8250
  ids: (result.data ?? []).map((row) => row.id).filter((id) => id !== void 0),
7953
8251
  total: meta.total ?? result.data?.length ?? 0,
7954
- limit: meta.limit ?? params?.limit ?? 20,
7955
- offset: meta.offset ?? params?.offset ?? 0,
8252
+ limit: meta.limit ?? window.limit,
8253
+ offset: meta.offset ?? window.offset,
7956
8254
  hasMore: meta.hasMore ?? false
7957
8255
  };
7958
8256
  const state = this.collectionState(slug);
@@ -8167,11 +8465,25 @@ var OfflineManager = class {
8167
8465
  await this.adoptServerRow(op, op.id, row);
8168
8466
  } else if (op.type === "createMany") {
8169
8467
  const queued = op.data ?? [];
8170
- const rows = await inner.createMany(queued, op.upsert ? { upsert: true } : void 0);
8468
+ const rows = await inner.createMany(queued, {
8469
+ ...op.upsert ? { upsert: true } : {},
8470
+ idempotencyKey: op.mutationId
8471
+ });
8171
8472
  for (let i = 0; i < rows.length; i++) await this.adoptServerRow(op, queued[i]?.id, rows[i]);
8473
+ } else if (op.type === "updateMany") {
8474
+ const queued = op.updates ?? [];
8475
+ const rows = await inner.updateMany(queued.map((u) => ({
8476
+ id: u.id,
8477
+ data: u.data
8478
+ })), { idempotencyKey: op.mutationId });
8479
+ for (let i = 0; i < rows.length; i++) await this.ingestReplaced(op, queued[i].id, rows[i]);
8172
8480
  } else if (op.type === "update") {
8173
8481
  const row = await inner.update(op.id, op.data);
8174
8482
  await this.ingestReplaced(op, op.id, row);
8483
+ } else if (op.type === "deleteMany") {
8484
+ const ids = op.ids ?? [];
8485
+ await inner.deleteMany(ids, { idempotencyKey: op.mutationId });
8486
+ for (const id of ids) this.removeLocalRow(op.collection, id, true);
8175
8487
  } else if (op.type === "delete") {
8176
8488
  await inner.delete(op.id);
8177
8489
  this.removeLocalRow(op.collection, op.id, true);
@@ -8414,23 +8726,37 @@ var OfflineManager = class {
8414
8726
  /**
8415
8727
  * Derive a WebSocket URL from an HTTP base URL.
8416
8728
  * `http://` → `ws://`, `https://` → `wss://`.
8729
+ *
8730
+ * A backend mounted under a path is the reason `baseUrl` accepts one, so the
8731
+ * path is kept. It used to be kept for an absolute `baseUrl` and dropped for a
8732
+ * relative one — resolved through `.origin` — so one deployment dialled two
8733
+ * different sockets depending on whether its config said `"/backend"` or
8734
+ * `"https://app.example.com/backend"`.
8735
+ *
8736
+ * Returns `""` when there is nothing to resolve against: a relative `baseUrl`
8737
+ * outside a browser has no origin, and inventing one would dial somewhere
8738
+ * arbitrary. The caller warns rather than leaving that silent.
8417
8739
  */
8418
8740
  function deriveWebSocketUrl(baseUrl) {
8741
+ const toWsProtocol = (url) => {
8742
+ const secure = /^(https|wss):/i.test(url);
8743
+ return url.replace(/^https?:\/\//i, secure ? "wss://" : "ws://").replace(/^wss?:\/\//i, secure ? "wss://" : "ws://").replace(/\/$/, "");
8744
+ };
8419
8745
  if (typeof window !== "undefined") {
8420
- let absoluteUrl = "";
8746
+ let absoluteUrl;
8421
8747
  if (!baseUrl) absoluteUrl = window.location.origin;
8422
8748
  else if (/^https?:\/\//i.test(baseUrl) || /^wss?:\/\//i.test(baseUrl)) absoluteUrl = baseUrl;
8423
8749
  else try {
8424
- absoluteUrl = new URL(baseUrl, window.location.href).origin;
8750
+ const resolved = new URL(baseUrl, window.location.href);
8751
+ absoluteUrl = resolved.origin + resolved.pathname;
8425
8752
  } catch {
8426
8753
  absoluteUrl = window.location.origin;
8427
8754
  }
8428
- const protocol = absoluteUrl.startsWith("https:") || absoluteUrl.startsWith("wss:") ? "wss:" : "ws:";
8429
- return absoluteUrl.replace(/^https?:\/\//i, `${protocol}//`).replace(/^wss?:\/\//i, `${protocol}//`).replace(/\/$/, "");
8755
+ return toWsProtocol(absoluteUrl);
8430
8756
  }
8431
8757
  if (!baseUrl) return "";
8432
8758
  if (!/^https?:\/\//i.test(baseUrl) && !/^wss?:\/\//i.test(baseUrl)) return "";
8433
- return baseUrl.replace(/^https?:\/\//i, (match) => match.toLowerCase() === "https://" ? "wss://" : "ws://").replace(/\/$/, "");
8759
+ return toWsProtocol(baseUrl);
8434
8760
  }
8435
8761
  function createRebaseClient(options) {
8436
8762
  const transport = createTransport(options, { credentialOutOfBand: options.auth?.authFlowMode === "cookie" });
@@ -8458,7 +8784,11 @@ function createRebaseClient(options) {
8458
8784
  });
8459
8785
  return storageSourcesPromise;
8460
8786
  };
8461
- const resolvedWsUrl = options.realtime !== false ? options.websocketUrl ?? deriveWebSocketUrl(options.baseUrl) : void 0;
8787
+ const realtimeEnabled = options.realtime !== false;
8788
+ const resolvedWsUrl = realtimeEnabled ? options.websocketUrl ?? deriveWebSocketUrl(options.baseUrl) : void 0;
8789
+ const realtimeUnreachable = realtimeEnabled && !resolvedWsUrl;
8790
+ 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.`;
8791
+ if (realtimeUnreachable) console.warn(`[Rebase] Realtime is enabled but ${unreachableReason} Live queries will fall back to a single fetch and channels will throw.`);
8462
8792
  let ws;
8463
8793
  /** One channel object per name — see `realtime.channel`. */
8464
8794
  const realtimeChannels = /* @__PURE__ */ new Map();
@@ -8578,7 +8908,7 @@ function createRebaseClient(options) {
8578
8908
  * cut off the others.
8579
8909
  */
8580
8910
  channel: (name, options) => {
8581
- if (!ws) throw new RebaseClientError("Realtime is disabled on this client (realtime: false), so channels are unavailable.");
8911
+ if (!ws) throw new RebaseClientError(realtimeUnreachable ? `Realtime is enabled but ${unreachableReason}` : "Realtime is disabled on this client (realtime: false), so channels are unavailable.");
8582
8912
  let existing = realtimeChannels.get(name);
8583
8913
  if (!existing) {
8584
8914
  existing = new RebaseRealtimeChannel(name, ws, options);
@@ -9249,7 +9579,7 @@ async function _initializeRebaseBackend(config) {
9249
9579
  ]) {
9250
9580
  const providerConfig = safeAuthConfig[key];
9251
9581
  if (providerConfig && requiredFields.every((f) => Boolean(providerConfig[f]))) {
9252
- const createFn = (await import("./auth-CuC9M2x6.js").then((n) => n.t))[factory];
9582
+ const createFn = (await import("./auth-Bm5kOV2E.js").then((n) => n.t))[factory];
9253
9583
  oauthProviders.push(createFn(providerConfig));
9254
9584
  }
9255
9585
  }
@@ -9343,7 +9673,7 @@ async function _initializeRebaseBackend(config) {
9343
9673
  let schemaEditorOff = schemaEditorUnavailable();
9344
9674
  let schemaEditorRoutes;
9345
9675
  if (!schemaEditorOff && config.collectionsDir) try {
9346
- schemaEditorRoutes = (await import("./schema-editor-routes-CbF20Mf1.js")).createSchemaEditorRoutes(config.collectionsDir);
9676
+ schemaEditorRoutes = (await import("./schema-editor-routes-C1DxDnDR.js")).createSchemaEditorRoutes(config.collectionsDir);
9347
9677
  } catch (err) {
9348
9678
  if (err?.code === "ERR_MODULE_NOT_FOUND") {
9349
9679
  schemaEditorOff = {
@@ -9513,7 +9843,8 @@ async function _initializeRebaseBackend(config) {
9513
9843
  Object.assign(serverClient, { sql: (query, options) => driverAdmin.executeSql(query, options) });
9514
9844
  logger.info("SQL capability attached to singleton");
9515
9845
  }
9516
- _initRebase(serverClient);
9846
+ const serverSingleton = serverClient;
9847
+ _initRebase(serverSingleton);
9517
9848
  logger.info("Rebase singleton initialized");
9518
9849
  if (defaultDriverResult.internals) {
9519
9850
  const driver = defaultDriverResult.internals.driver;
@@ -9556,12 +9887,12 @@ async function _initializeRebaseBackend(config) {
9556
9887
  let cronScheduler;
9557
9888
  if (config.cronsDir) {
9558
9889
  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);
9890
+ const { CronScheduler } = await import("./cron-scheduler-CsQtYLMq.js").then((n) => n.n);
9891
+ const { createCronRoutes } = await import("./cron-routes-D5a9v1HY.js").then((n) => n.n);
9892
+ const { createCronStore } = await import("./cron-store-Cu4sOseb.js").then((n) => n.n);
9562
9893
  const loadedCronJobs = await loadCronJobsFromDirectory(config.cronsDir);
9563
9894
  cronScheduler = new CronScheduler();
9564
- cronScheduler.setClient(serverClient);
9895
+ cronScheduler.setClient(serverSingleton);
9565
9896
  if (loadedCronJobs.length > 0) {
9566
9897
  cronScheduler.registerJobs(loadedCronJobs);
9567
9898
  const store = defaultBootstrapper.getAdmin?.(defaultDriverResult) && config.cronPersistence !== false ? createCronStore(defaultDriver) : void 0;
@@ -9583,7 +9914,7 @@ async function _initializeRebaseBackend(config) {
9583
9914
  } 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
9915
  }
9585
9916
  {
9586
- const { createBackupRoutes, parseBackupDestination } = await import("./backup-CVggVhR2.js").then((n) => n.t);
9917
+ const { createBackupRoutes, parseBackupDestination } = await import("./backup-BJiY_mM-.js").then((n) => n.t);
9587
9918
  const backupRouter = new Hono();
9588
9919
  applyAdminGate(backupRouter, "Backup");
9589
9920
  backupRouter.route("/", createBackupRoutes({
@@ -9699,7 +10030,7 @@ async function _initializeRebaseBackend(config) {
9699
10030
  * export default defineFunction((app, { rebase }) => {
9700
10031
  * app.use("/*", requireAuth);
9701
10032
  * app.get("/home", async (c) => {
9702
- * const [stats] = await rebase.sql!(`SELECT count(*) AS n FROM orders`);
10033
+ * const [stats] = await rebase.sql(`SELECT count(*) AS n FROM orders`);
9703
10034
  * return c.json({ orders: Number(stats.n) });
9704
10035
  * });
9705
10036
  * });
@@ -9731,12 +10062,14 @@ function defineFunction(definition) {
9731
10062
  * export default defineCron({
9732
10063
  * name: "Nightly cleanup",
9733
10064
  * schedule: "0 3 * * *",
9734
- * async handler({ client, log }) {
9735
- * const { data: expired } = await client.data.sessions.find({
10065
+ * async handler({ rebase, log }) {
10066
+ * // `dataAsAdmin` bypasses RLS. A cron has no per-request user, so
10067
+ * // there is no policy to fall back on — scope the filters yourself.
10068
+ * const { data: expired } = await rebase.dataAsAdmin.sessions.find({
9736
10069
  * where: { expired: ["==", true] },
9737
10070
  * });
9738
10071
  * for (const session of expired) {
9739
- * await client.data.sessions.delete(session.id);
10072
+ * await rebase.dataAsAdmin.sessions.delete(session.id as string);
9740
10073
  * }
9741
10074
  * log(`Deleted ${expired.length} expired sessions`);
9742
10075
  * },
@@ -9951,8 +10284,10 @@ var WebhookDispatcher = class {
9951
10284
  for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
9952
10285
  const result = await this.deliver(webhook, event, payload, attempt);
9953
10286
  if (result.success) return result;
9954
- if (attempt < this.maxRetries) await new Promise((r) => setTimeout(r, this.retryDelays[attempt - 1]));
9955
- else return result;
10287
+ if (attempt < this.maxRetries) {
10288
+ const backoff = this.retryDelays[attempt - 1] ?? this.retryDelays[this.retryDelays.length - 1];
10289
+ await new Promise((r) => setTimeout(r, backoff));
10290
+ } else return result;
9956
10291
  }
9957
10292
  return {
9958
10293
  webhookId: webhook.id,
@@ -11785,7 +12120,8 @@ async function bootFromBundle(options = {}) {
11785
12120
  });
11786
12121
  await ensureCollectionPolicies(bundle, dataSources, env);
11787
12122
  metrics?.setKnownCollections(backend.collectionRegistry.getCollections().map((collection) => collection.slug).filter((slug) => Boolean(slug)));
11788
- app.get("/health", async (c) => {
12123
+ const healthPaths = ["/health", `${(env.REBASE_BASE_PATH || "/api").replace(/\/$/, "")}/health`];
12124
+ for (const healthPath of [...new Set(healthPaths)]) app.get(healthPath, async (c) => {
11789
12125
  const result = await backend.healthCheck();
11790
12126
  const unhealthy = (await Promise.all(dataSources.filter((source) => source.key !== DEFAULT_DATA_SOURCE_KEY).map(async (source) => ({
11791
12127
  key: source.key,
@@ -11891,7 +12227,7 @@ async function bootStaticApp(bundle, devRoot, options) {
11891
12227
  if (metrics) app.use("/*", metrics.middleware);
11892
12228
  const server = createServer(getRequestListener(app.fetch));
11893
12229
  app.get("/livez", (c) => c.json({ status: "ok" }));
11894
- app.get("/health", (c) => c.json({
12230
+ for (const healthPath of [.../* @__PURE__ */ new Set(["/health", `${basePath.replace(/\/$/, "")}/health`])]) app.get(healthPath, (c) => c.json({
11895
12231
  status: "ok",
11896
12232
  latencyMs: 0
11897
12233
  }));
@@ -12001,6 +12337,40 @@ function warnOnDriverSkew(dataSources, runtimeVersion) {
12001
12337
  }
12002
12338
  }
12003
12339
  /**
12340
+ * The collections a data source's engine is the store for.
12341
+ *
12342
+ * A bundle's collections directory holds *every* collection the project
12343
+ * declares, whatever engine serves it — that is the point of `dataSource`
12344
+ * routing. What it is not is a list of tables to create: handing the whole
12345
+ * directory to the primary source's bootstrapper made a Firestore collection
12346
+ * declared alongside the Postgres ones arrive as an empty Postgres table, with
12347
+ * RLS policies, while the app went on reading its documents from Firestore.
12348
+ *
12349
+ * Excluding is deliberately conservative. A collection that names neither an
12350
+ * `engine` nor a `dataSource` belongs to whichever source is primary — the
12351
+ * "postgres" that `resolveDataSource` falls back to there is a default, not a
12352
+ * declaration, and must not exclude anything on its own. Only a collection that
12353
+ * explicitly routes to a *different* engine is dropped, so a project running two
12354
+ * sources on the same engine is unaffected.
12355
+ */
12356
+ function collectionsStoredBy(collections, primary, dataSources) {
12357
+ const registry = createDataSourceRegistry(dataSources.map((source) => ({
12358
+ key: source.key,
12359
+ engine: source.engine
12360
+ })));
12361
+ return collections.filter((collection) => {
12362
+ const declared = collection.engine ?? (collection.dataSource ? resolveDataSource(collection, registry).engine : void 0);
12363
+ if (!declared) return true;
12364
+ return declared === primary.engine;
12365
+ });
12366
+ }
12367
+ /** Say what was routed elsewhere, so a missing table is never a silent one. */
12368
+ function logForeignCollections(all, stored, primary) {
12369
+ if (stored.length === all.length) return;
12370
+ const foreign = all.filter((c) => !stored.includes(c)).map((c) => c.slug);
12371
+ 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.`);
12372
+ }
12373
+ /**
12004
12374
  * Bring the database's collection tables up to date before serving.
12005
12375
  *
12006
12376
  * Delegates to whichever driver bootstrapped the default data source; a driver
@@ -12049,11 +12419,17 @@ async function ensureCollectionSchema(bundle, dataSources, env) {
12049
12419
  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
12420
  return;
12051
12421
  }
12052
- const collections = await loadCollectionsFromDirectory(bundle.collectionsDir);
12053
- if (collections.length === 0) {
12422
+ const loaded = await loadCollectionsFromDirectory(bundle.collectionsDir);
12423
+ if (loaded.length === 0) {
12054
12424
  skip(`no collections were loaded from "${bundle.collectionsDir}".`, "warn");
12055
12425
  return;
12056
12426
  }
12427
+ const collections = collectionsStoredBy(loaded, primary, dataSources);
12428
+ logForeignCollections(loaded, collections, primary);
12429
+ if (collections.length === 0) {
12430
+ skip(`none of the ${loaded.length} declared collection(s) are stored by the "${primary.engine}" data source.`);
12431
+ return;
12432
+ }
12057
12433
  const { applied } = await primary.bootstrapper.ensureCollectionSchema(collections, preInitDriverResult(primary), (message) => logger.info(`schema: ${message}`));
12058
12434
  logger.info(applied > 0 ? `Applied ${applied} additive schema change(s) before boot.` : "Collection schema is up to date.");
12059
12435
  }
@@ -12103,12 +12479,14 @@ async function ensureCollectionPolicies(bundle, dataSources, env) {
12103
12479
  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
12480
  return;
12105
12481
  }
12106
- const collections = await loadCollectionsFromDirectory(bundle.collectionsDir);
12482
+ const loaded = await loadCollectionsFromDirectory(bundle.collectionsDir);
12483
+ if (loaded.length === 0) return;
12484
+ const collections = collectionsStoredBy(loaded, primary, dataSources);
12107
12485
  if (collections.length === 0) return;
12108
12486
  const { applied } = await primary.bootstrapper.ensureCollectionPolicies(collections, preInitDriverResult(primary), (message) => logger.info(`policies: ${message}`));
12109
12487
  logger.info(applied > 0 ? `Applied ${applied} RLS policy statement(s) before serving.` : "RLS policies are up to date.");
12110
12488
  }
12111
12489
  //#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 };
12490
+ 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
12491
 
12114
12492
  //# sourceMappingURL=index.es.js.map