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