@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.
- package/dist/{admin_block-CcSGdH7M.js → admin_block-EmIHae6X.js} +2 -1
- package/dist/admin_block-EmIHae6X.js.map +1 -0
- package/dist/api/errors.d.ts +0 -6
- package/dist/api/openapi-generator.d.ts +14 -0
- package/dist/api/rest/idempotency.d.ts +41 -4
- package/dist/api/rest/write-validation.d.ts +12 -5
- package/dist/auth/index.d.ts +1 -1
- package/dist/auth/jwt.d.ts +12 -0
- package/dist/{auth-CuC9M2x6.js → auth-Bm5kOV2E.js} +17 -37
- package/dist/auth-Bm5kOV2E.js.map +1 -0
- package/dist/{backup-CVggVhR2.js → backup-BJiY_mM-.js} +2 -2
- package/dist/{backup-CVggVhR2.js.map → backup-BJiY_mM-.js.map} +1 -1
- package/dist/boot/boot.d.ts +19 -0
- package/dist/cron/cron-scheduler.d.ts +8 -3
- package/dist/cron/define-cron.d.ts +5 -3
- package/dist/{cron-routes-CrQ0tK-_.js → cron-routes-D5a9v1HY.js} +2 -2
- package/dist/{cron-routes-CrQ0tK-_.js.map → cron-routes-D5a9v1HY.js.map} +1 -1
- package/dist/{cron-scheduler-B3RFt0HS.js → cron-scheduler-CsQtYLMq.js} +28 -3
- package/dist/cron-scheduler-CsQtYLMq.js.map +1 -0
- package/dist/{cron-store-BywZsyfZ.js → cron-store-Cu4sOseb.js} +11 -1
- package/dist/cron-store-Cu4sOseb.js.map +1 -0
- package/dist/{errors-CgkCzoj7.js → errors-BpudWAVU.js} +2 -10
- package/dist/errors-BpudWAVU.js.map +1 -0
- package/dist/functions/define-function.d.ts +1 -1
- package/dist/index.es.js +586 -208
- package/dist/index.es.js.map +1 -1
- package/dist/{jwt-DD6EtpGj.js → jwt-CzeviDcB.js} +17 -2
- package/dist/{jwt-DD6EtpGj.js.map → jwt-CzeviDcB.js.map} +1 -1
- package/dist/{openapi-generator-BEwyiaAr.js → openapi-generator-DieRvJdS.js} +305 -127
- package/dist/openapi-generator-DieRvJdS.js.map +1 -0
- package/dist/{schema-editor-routes-CbF20Mf1.js → schema-editor-routes-C1DxDnDR.js} +3 -3
- package/dist/{schema-editor-routes-CbF20Mf1.js.map → schema-editor-routes-C1DxDnDR.js.map} +1 -1
- package/dist/{src-Cum9kox5.js → src-CY0jBL-x.js} +111 -30
- package/dist/src-CY0jBL-x.js.map +1 -0
- package/dist/src-_qQ3RNCK.js.map +1 -1
- package/dist/utils/logging.d.ts +0 -4
- package/package.json +6 -6
- package/dist/admin_block-CcSGdH7M.js.map +0 -1
- package/dist/auth-CuC9M2x6.js.map +0 -1
- package/dist/cron-scheduler-B3RFt0HS.js.map +0 -1
- package/dist/cron-store-BywZsyfZ.js.map +0 -1
- package/dist/errors-CgkCzoj7.js.map +0 -1
- package/dist/openapi-generator-BEwyiaAr.js.map +0 -1
- 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 {
|
|
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-
|
|
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,
|
|
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,
|
|
12
|
-
import { i as generateDownloadToken, n as configureJwt } from "./jwt-
|
|
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-
|
|
21
|
-
import { t as createCronRoutes } from "./cron-routes-
|
|
22
|
-
import { t as createCronStore } from "./cron-store-
|
|
23
|
-
import { a as parseBackupTimestamp, i as parseBackupDestination, n as createBackupRoutes, o as readBackupBytes, r as listBackupObjects } from "./backup-
|
|
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
|
-
|
|
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
|
-
*
|
|
1059
|
-
* the admin table, realtime reconciliation, the offline
|
|
1060
|
-
* arrives without one is not a smaller row, it is an
|
|
1061
|
-
* `fields=title` and being unable to open the record
|
|
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
|
|
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
|
|
1165
|
-
if (!key || !await ensure()) return
|
|
1177
|
+
async claim(key, uid) {
|
|
1178
|
+
if (!key || !await ensure()) return { status: "claimed" };
|
|
1166
1179
|
try {
|
|
1167
|
-
|
|
1168
|
-
|
|
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
|
|
1198
|
+
async complete(key, uid, response) {
|
|
1174
1199
|
if (!key || !await ensure()) return;
|
|
1175
1200
|
try {
|
|
1176
|
-
await exec(`
|
|
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
|
-
|
|
1376
|
-
|
|
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
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
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
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
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
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
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:
|
|
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
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
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
|
-
|
|
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
|
|
1471
|
-
id: String(id),
|
|
1563
|
+
path,
|
|
1472
1564
|
values: body,
|
|
1473
1565
|
collection: resolvedCollection,
|
|
1474
|
-
status: "
|
|
1566
|
+
status: "new"
|
|
1475
1567
|
});
|
|
1476
|
-
|
|
1477
|
-
return c.json(response);
|
|
1568
|
+
response = this.formatResponse(entity);
|
|
1478
1569
|
} catch (error) {
|
|
1479
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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,
|
|
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
|
-
|
|
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(
|
|
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,
|
|
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
|
-
|
|
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 =
|
|
4648
|
-
const offset =
|
|
4881
|
+
const requestedLimit = window.limit;
|
|
4882
|
+
const offset = window.offset;
|
|
4649
4883
|
const rows = incomingRows;
|
|
4650
|
-
const
|
|
4651
|
-
|
|
4652
|
-
|
|
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
|
|
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 (
|
|
4664
|
-
|
|
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
|
-
|
|
6066
|
-
|
|
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
|
-
|
|
6991
|
-
else if (char === "
|
|
6992
|
-
|
|
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
|
-
/**
|
|
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
|
|
7361
|
+
const { limit, offset } = resolveFindWindow(params);
|
|
7127
7362
|
return {
|
|
7128
7363
|
limit,
|
|
7129
|
-
offset
|
|
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
|
-
|
|
7791
|
-
|
|
7792
|
-
|
|
7793
|
-
|
|
7794
|
-
|
|
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
|
-
|
|
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 ??
|
|
7955
|
-
offset: meta.offset ??
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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-
|
|
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-
|
|
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
|
-
|
|
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-
|
|
9560
|
-
const { createCronRoutes } = await import("./cron-routes-
|
|
9561
|
-
const { createCronStore } = await import("./cron-store-
|
|
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(
|
|
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-
|
|
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
|
|
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({
|
|
9735
|
-
*
|
|
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
|
|
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)
|
|
9955
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
12053
|
-
if (
|
|
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
|
|
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,
|
|
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
|