@rebasepro/server-postgres 0.13.0 → 0.13.1-canary.g1822133
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/PostgresBackendDriver.d.ts +48 -1
- package/dist/PostgresBootstrapper.d.ts +26 -0
- package/dist/auth/services.d.ts +21 -0
- package/dist/{src-DlPBctw_.js → auth-users-columns-BfQHf9JE.js} +1222 -86
- package/dist/auth-users-columns-BfQHf9JE.js.map +1 -0
- package/dist/{backup-service-CD8o_1Sl.js → backup-service-BH0Dzo_h.js} +2 -3
- package/dist/{backup-service-CD8o_1Sl.js.map → backup-service-BH0Dzo_h.js.map} +1 -1
- package/dist/cli-helpers.d.ts +57 -1
- package/dist/cli-output.d.ts +34 -0
- package/dist/data-transformer.d.ts +7 -2
- package/dist/data_driver-ULAyJEi9.js +193 -0
- package/dist/data_driver-ULAyJEi9.js.map +1 -0
- package/dist/ensure-collection-policies-8vuu-n4r.js +124 -0
- package/dist/ensure-collection-policies-8vuu-n4r.js.map +1 -0
- package/dist/{ensure-collection-tables-CBQdOETu.js → ensure-collection-tables-CbvaGuVn.js} +170 -20
- package/dist/ensure-collection-tables-CbvaGuVn.js.map +1 -0
- package/dist/index.es.js +1815 -1023
- package/dist/index.es.js.map +1 -1
- package/dist/rls-bootstrap-sql-69hYT8nr.js +244 -0
- package/dist/rls-bootstrap-sql-69hYT8nr.js.map +1 -0
- package/dist/rls-enforcement-BJ_3wxwg.js +425 -0
- package/dist/rls-enforcement-BJ_3wxwg.js.map +1 -0
- package/dist/schema/auth-schema.d.ts +102 -0
- package/dist/schema/auth-users-columns.d.ts +97 -0
- package/dist/schema/doctor-policy-checks.d.ts +28 -0
- package/dist/schema/doctor.d.ts +41 -25
- package/dist/schema/ensure-collection-policies.d.ts +33 -9
- package/dist/schema/ensure-collection-tables.d.ts +61 -7
- package/dist/schema/generate-drizzle-schema-logic.d.ts +10 -2
- package/dist/schema/generate-postgres-ddl-logic.d.ts +53 -5
- package/dist/schema/generated-schema-staleness.d.ts +39 -0
- package/dist/schema/introspect-db-inference.d.ts +8 -1
- package/dist/schema/introspect-db-logic.d.ts +49 -0
- package/dist/schema/introspect-db-project.d.ts +21 -0
- package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
- package/dist/schema/search-column.d.ts +248 -0
- package/dist/security/policy-drift.d.ts +34 -0
- package/dist/security/rls-enforcement.d.ts +61 -5
- package/dist/services/FetchService.d.ts +34 -7
- package/dist/services/PersistService.d.ts +21 -17
- package/dist/services/RelationService.d.ts +9 -57
- package/dist/services/RelationWriteService.d.ts +82 -0
- package/dist/services/collection-helpers.d.ts +42 -0
- package/dist/services/dataService.d.ts +5 -0
- package/dist/services/junction-writes.d.ts +82 -0
- package/dist/services/realtimeService.d.ts +164 -23
- package/dist/services/write-denial.d.ts +36 -0
- package/dist/{src-DoU9yPqq.js → src-DCdn3Val.js} +124 -3
- package/dist/src-DCdn3Val.js.map +1 -0
- package/dist/utils/drizzle-conditions.d.ts +124 -2
- package/dist/{websocket-B2LsrINK.js → websocket-C8ZqVBiV.js} +75 -18
- package/dist/websocket-C8ZqVBiV.js.map +1 -0
- package/package.json +9 -8
- package/src/PostgresBackendDriver.ts +172 -6
- package/src/PostgresBootstrapper.ts +136 -11
- package/src/auth/ensure-tables.ts +212 -91
- package/src/auth/services.ts +82 -5
- package/src/backup/backup-cli.ts +59 -57
- package/src/cli-errors.ts +6 -6
- package/src/cli-helpers.ts +132 -13
- package/src/cli-output.ts +43 -0
- package/src/cli.ts +371 -161
- package/src/collections/buildRegistry.ts +3 -1
- package/src/collections/validate-relations.ts +124 -17
- package/src/data-transformer.ts +142 -28
- package/src/history/ensure-history-table.ts +9 -2
- package/src/schema/auth-schema.ts +17 -1
- package/src/schema/auth-users-columns.ts +131 -0
- package/src/schema/doctor-cli.ts +14 -65
- package/src/schema/doctor-policy-checks.ts +105 -0
- package/src/schema/doctor.ts +156 -77
- package/src/schema/ensure-collection-policies.ts +99 -6
- package/src/schema/ensure-collection-tables.ts +374 -32
- package/src/schema/generate-drizzle-schema-logic.ts +152 -66
- package/src/schema/generate-drizzle-schema.ts +11 -10
- package/src/schema/generate-postgres-ddl-logic.ts +294 -16
- package/src/schema/generate-postgres-ddl.ts +38 -14
- package/src/schema/generated-schema-staleness.ts +171 -0
- package/src/schema/introspect-db-inference.ts +9 -2
- package/src/schema/introspect-db-logic.ts +251 -75
- package/src/schema/introspect-db-project.ts +78 -0
- package/src/schema/introspect-db.ts +42 -25
- package/src/schema/introspect-runtime.ts +14 -2
- package/src/schema/non-sql-collections.test.ts +131 -0
- package/src/schema/rls-bootstrap-sql.ts +288 -0
- package/src/schema/search-column.ts +643 -0
- package/src/security/anonymous-grants.test.ts +4 -2
- package/src/security/policy-drift.test.ts +104 -3
- package/src/security/policy-drift.ts +129 -7
- package/src/security/rls-enforcement.ts +150 -7
- package/src/services/BranchService.ts +5 -0
- package/src/services/FetchService.ts +253 -115
- package/src/services/PersistService.ts +82 -43
- package/src/services/RelationService.ts +37 -696
- package/src/services/RelationWriteService.ts +653 -0
- package/src/services/cdc/trigger-cdc.ts +5 -1
- package/src/services/channel-history.ts +14 -0
- package/src/services/channel-presence.ts +13 -0
- package/src/services/collection-helpers.ts +89 -4
- package/src/services/dataService.ts +5 -0
- package/src/services/junction-writes.ts +295 -0
- package/src/services/pg-notify-listener.ts +1 -1
- package/src/services/realtimeService.ts +382 -118
- package/src/services/write-denial.ts +55 -0
- package/src/utils/drizzle-conditions.ts +433 -35
- package/src/utils/pg-error-utils.ts +8 -3
- package/src/websocket.ts +113 -16
- package/dist/ensure-collection-policies-ViG8XiPn.js +0 -57
- package/dist/ensure-collection-policies-ViG8XiPn.js.map +0 -1
- package/dist/ensure-collection-tables-CBQdOETu.js.map +0 -1
- package/dist/policy-CeA1JcxP.js +0 -105
- package/dist/policy-CeA1JcxP.js.map +0 -1
- package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
- package/dist/src-DlPBctw_.js.map +0 -1
- package/dist/src-DoU9yPqq.js.map +0 -1
- package/dist/websocket-B2LsrINK.js.map +0 -1
- package/src/schema/auth-bootstrap-sql.ts +0 -47
|
@@ -2,8 +2,9 @@ import { createRequire as __createRequire } from "module";
|
|
|
2
2
|
import "process";
|
|
3
3
|
__createRequire(import.meta.url);
|
|
4
4
|
import { l as __require, s as __commonJSMin } from "./connection-BuZ97wsr.js";
|
|
5
|
-
import { a as
|
|
6
|
-
import {
|
|
5
|
+
import { a as policy, i as ANONYMOUS_USER_IDS, r as ANONYMOUS_USER_ID } from "./data_driver-ULAyJEi9.js";
|
|
6
|
+
import { a as rewriteLegacyRlsFunctions, c as isPostgresCollectionConfig, d as getDataSourceCapabilities, f as ALL_WHERE_FILTER_OPS, g as toCanonicalOp, h as REST_TO_CANONICAL, i as RLS_UID_SQL, l as isRelationalCollectionConfig, m as NULL_OPS, p as CANONICAL_TO_REST, r as RLS_ROLES_SQL, s as getDeclaredSubcollections } from "./src-DCdn3Val.js";
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
7
8
|
//#region ../types/src/types/entities.ts
|
|
8
9
|
/**
|
|
9
10
|
* Class used to create a reference to a entity in a different path
|
|
@@ -1007,6 +1008,35 @@ function camelCase(str) {
|
|
|
1007
1008
|
});
|
|
1008
1009
|
})))();
|
|
1009
1010
|
/**
|
|
1011
|
+
* Segments that reach the prototype chain rather than a property of the object.
|
|
1012
|
+
*
|
|
1013
|
+
* The twin of this function in `@rebasepro/forms` could be made to write onto
|
|
1014
|
+
* `Object.prototype` through a path of `__proto__.x`. This copy survives the
|
|
1015
|
+
* write by accident — its `clone` always spreads into a fresh object, while the
|
|
1016
|
+
* form engine's has a "preserve class instances" branch that hands back
|
|
1017
|
+
* `Object.prototype` itself — but `getIn` still *reads* through the chain, and
|
|
1018
|
+
* handing back `Object.prototype` is how a polluted value is read out again.
|
|
1019
|
+
*
|
|
1020
|
+
* Closed on both sides here, so the two implementations agree.
|
|
1021
|
+
*/
|
|
1022
|
+
var UNSAFE_PATH_SEGMENTS = /* @__PURE__ */ new Set([
|
|
1023
|
+
"__proto__",
|
|
1024
|
+
"constructor",
|
|
1025
|
+
"prototype"
|
|
1026
|
+
]);
|
|
1027
|
+
/**
|
|
1028
|
+
* Whether writing this single key with `obj[key] = …` would reach the prototype
|
|
1029
|
+
* chain instead of creating a property.
|
|
1030
|
+
*
|
|
1031
|
+
* The single-key counterpart of {@link pathTraversesPrototype}, for the many
|
|
1032
|
+
* places that copy an object one key at a time. `JSON.parse` creates
|
|
1033
|
+
* `__proto__` as an *own* property, so it survives `hasOwnProperty` — and then
|
|
1034
|
+
* `target[key] = value` invokes the setter and replaces the target's prototype.
|
|
1035
|
+
*/
|
|
1036
|
+
function isPrototypePollutingKey(key) {
|
|
1037
|
+
return UNSAFE_PATH_SEGMENTS.has(key);
|
|
1038
|
+
}
|
|
1039
|
+
/**
|
|
1010
1040
|
* Deep clone a value, preserving function references and class instances.
|
|
1011
1041
|
* Unlike structuredClone, this handles objects that contain functions
|
|
1012
1042
|
* (e.g. CollectionConfig with target(), childCollections(), callbacks).
|
|
@@ -1360,6 +1390,95 @@ function legacyForeignKeyName(name) {
|
|
|
1360
1390
|
const snake = toSnakeCase(name);
|
|
1361
1391
|
return `${snake.endsWith("s") ? snake.slice(0, -1) : snake}_id`;
|
|
1362
1392
|
}
|
|
1393
|
+
/**
|
|
1394
|
+
* Truncate an identifier to what Postgres will actually store.
|
|
1395
|
+
*
|
|
1396
|
+
* Postgres silently truncates identifiers at NAMEDATALEN-1 = 63 **bytes**, so a
|
|
1397
|
+
* name generated longer than that is not the name the database ends up holding.
|
|
1398
|
+
* Anything that later looks the object up by the name it generated then misses.
|
|
1399
|
+
*
|
|
1400
|
+
* Byte length, not string length: NAMEDATALEN is a byte bound, and a multi-byte
|
|
1401
|
+
* character straddling the boundary would be cut mid-sequence by `slice(0, 63)`.
|
|
1402
|
+
*
|
|
1403
|
+
* `TextEncoder` rather than `Buffer`, which is not a matter of taste: `Buffer`
|
|
1404
|
+
* is a Node global, and this package is imported by browser-facing ones. It
|
|
1405
|
+
* typechecked only where `@types/node` happened to be in scope, so
|
|
1406
|
+
* `packages/codegen` — whose tsconfig is `lib: ["ESNext", "dom"]` — could not
|
|
1407
|
+
* compile the file at all, and both of its suites failed to run. `TextEncoder`
|
|
1408
|
+
* and `TextDecoder` are standard in both runtimes and need no ambient types.
|
|
1409
|
+
*/
|
|
1410
|
+
function toPostgresIdentifier(name) {
|
|
1411
|
+
const bytes = new TextEncoder().encode(name);
|
|
1412
|
+
if (bytes.byteLength <= 63) return name;
|
|
1413
|
+
return new TextDecoder("utf-8").decode(bytes.subarray(0, 63)).replace(/�+$/, "");
|
|
1414
|
+
}
|
|
1415
|
+
/**
|
|
1416
|
+
* The API name a database column is served under.
|
|
1417
|
+
*
|
|
1418
|
+
* The wire name of a field is its property key, and Rebase's property keys are
|
|
1419
|
+
* camelCase — `displayName`, `createdAt`, `photoURL`. Columns are snake_case,
|
|
1420
|
+
* because an unquoted Postgres identifier folds to lower case and a camelCase
|
|
1421
|
+
* column is therefore reachable only as `"authorId"` forever: in hand-written
|
|
1422
|
+
* SQL, in psql, in an RLS policy body, in a dump, and in every third-party tool
|
|
1423
|
+
* that ever touches the database. So the two conventions are both right, and
|
|
1424
|
+
* this is the function that crosses between them.
|
|
1425
|
+
*
|
|
1426
|
+
* It exists because two sources of field names never crossed: a foreign key
|
|
1427
|
+
* derived from a relation (`author_id`) and a column read back by introspection
|
|
1428
|
+
* (`user_id`) both landed on the wire under their column name, while every
|
|
1429
|
+
* hand-authored collection next to them used camelCase. One API, two
|
|
1430
|
+
* conventions, and no rule a caller could infer from outside — those names are
|
|
1431
|
+
* also the `where` and `orderBy` keys, so it was not a matter of taste.
|
|
1432
|
+
*
|
|
1433
|
+
* Rules, in the order they matter:
|
|
1434
|
+
*
|
|
1435
|
+
* - **A name with no separator is returned unchanged.** `photoURL` stays
|
|
1436
|
+
* `photoURL` and `id` stays `id`. Lower-casing a single token is what makes
|
|
1437
|
+
* a "camelCase" helper destructive — `camelCase("photoURL")` is `photourl` —
|
|
1438
|
+
* and this function is applied to names that are *already* keys.
|
|
1439
|
+
* - **Each following segment keeps its own casing** apart from an upper-cased
|
|
1440
|
+
* first letter, so `photo_URL` → `photoURL` rather than `photoUrl`.
|
|
1441
|
+
* - **The result may still not be a JavaScript identifier.** `2fa_enabled`
|
|
1442
|
+
* becomes `2faEnabled`, which is a perfectly good object key and still needs
|
|
1443
|
+
* quoting where one is written into generated source.
|
|
1444
|
+
*
|
|
1445
|
+
* Not the inverse of {@link toSnakeCase}: `toSnakeCase` tokenises on case
|
|
1446
|
+
* boundaries and would turn `photoURL` into `photo_url`. Round-tripping is not
|
|
1447
|
+
* a property either function promises, which is why a column name that a
|
|
1448
|
+
* property maps explicitly is always read off `columnName` rather than derived.
|
|
1449
|
+
*/
|
|
1450
|
+
function toWireKey(columnName) {
|
|
1451
|
+
if (!columnName) return columnName;
|
|
1452
|
+
const segments = columnName.split(/[-_ ]+/).filter(Boolean);
|
|
1453
|
+
if (segments.length <= 1) return columnName;
|
|
1454
|
+
return segments.map((segment, index) => index === 0 ? segment.charAt(0).toLowerCase() + segment.slice(1) : segment.charAt(0).toUpperCase() + segment.slice(1)).join("");
|
|
1455
|
+
}
|
|
1456
|
+
/**
|
|
1457
|
+
* The first candidate key not already used, or a numbered fallback.
|
|
1458
|
+
*
|
|
1459
|
+
* Introspection turns a set of column names into a set of object keys, and the
|
|
1460
|
+
* mapping is not injective: `user_id` and `userId` are two columns and one
|
|
1461
|
+
* {@link toWireKey}, and two foreign keys can strip to the same relation name.
|
|
1462
|
+
* A duplicate key in a generated object literal is a TypeScript error, so the
|
|
1463
|
+
* whole collection stops compiling — and a duplicate key in a `Record` built at
|
|
1464
|
+
* runtime is worse, because it silently drops a column instead.
|
|
1465
|
+
*
|
|
1466
|
+
* The numbered tail is what makes this total: a function that returns a key it
|
|
1467
|
+
* cannot guarantee is free has only moved the duplicate one line down.
|
|
1468
|
+
*
|
|
1469
|
+
* Structurally typed on `has` so a `Map` of emitted blocks and a `Set` of taken
|
|
1470
|
+
* names both satisfy it. Lives here, in the package both introspection
|
|
1471
|
+
* producers and the admin's table import can reach, because they must resolve a
|
|
1472
|
+
* collision the same way or one database describes itself three ways.
|
|
1473
|
+
*/
|
|
1474
|
+
function firstFreeKey(candidates, taken) {
|
|
1475
|
+
for (const candidate of candidates) if (!taken.has(candidate)) return candidate;
|
|
1476
|
+
const base = candidates[candidates.length - 1];
|
|
1477
|
+
for (let suffix = 2;; suffix++) {
|
|
1478
|
+
const candidate = `${base}_${suffix}`;
|
|
1479
|
+
if (!taken.has(candidate)) return candidate;
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1363
1482
|
//#endregion
|
|
1364
1483
|
//#region ../common/src/util/entities.ts
|
|
1365
1484
|
/**
|
|
@@ -1383,10 +1502,21 @@ function updateDateAutoValues({ inputValues, properties, status, timestampNowVal
|
|
|
1383
1502
|
* have `id` and `path` fields — these are relation-shaped objects from
|
|
1384
1503
|
* edge cases in the data pipeline (REST fallback, stale cache, custom data source).
|
|
1385
1504
|
*
|
|
1505
|
+
* When `targetPath` is given, also accepts a bare id. A relation column is a
|
|
1506
|
+
* foreign key, and the REST layer returns it as the scalar it is; only some
|
|
1507
|
+
* fetch paths hydrate it into an object. Which form a caller sees therefore
|
|
1508
|
+
* depends on how the row was loaded, and a caller that only accepted objects
|
|
1509
|
+
* reported half of its own data as a type error. The declared target is the
|
|
1510
|
+
* missing half: with it, an id is a relation that has not been fetched yet.
|
|
1511
|
+
*
|
|
1386
1512
|
* Returns null if the value cannot be coerced.
|
|
1387
1513
|
*/
|
|
1388
|
-
function normalizeToEntityRelation(value, propertyType) {
|
|
1514
|
+
function normalizeToEntityRelation(value, propertyType, targetPath) {
|
|
1389
1515
|
if (value instanceof EntityRelation) return value;
|
|
1516
|
+
if (targetPath && (typeof value === "string" || typeof value === "number")) {
|
|
1517
|
+
if (value === "") return null;
|
|
1518
|
+
return new EntityRelation(value, targetPath);
|
|
1519
|
+
}
|
|
1390
1520
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
1391
1521
|
const obj = value;
|
|
1392
1522
|
if (!(obj.__type === "relation" || obj.__type === "reference" || typeof obj.isEntityRelation === "function" && obj.isEntityRelation() || typeof obj.isEntityReference === "function" && obj.isEntityReference() || propertyType === "relation" && typeof obj.id !== "undefined" && typeof obj.path === "string")) return null;
|
|
@@ -1458,6 +1588,25 @@ function createRelationRefWithData(id, path, data) {
|
|
|
1458
1588
|
data
|
|
1459
1589
|
};
|
|
1460
1590
|
}
|
|
1591
|
+
//#endregion
|
|
1592
|
+
//#region ../common/src/util/collections.ts
|
|
1593
|
+
/**
|
|
1594
|
+
* A copy of `collections` ordered by slug.
|
|
1595
|
+
*
|
|
1596
|
+
* Every generator that turns collections into a file is order-dependent, and
|
|
1597
|
+
* every one of them is compared against its own output — `rebase doctor`
|
|
1598
|
+
* regenerates in memory and diffs, `generate-sdk && git diff --exit-code` gates
|
|
1599
|
+
* CI. While only the *writers* sorted, a project whose `readdirSync` order
|
|
1600
|
+
* differed from its slug order was reported permanently out of date, and the
|
|
1601
|
+
* fix the message printed rewrote the file in the order it was already in. The
|
|
1602
|
+
* generators sort themselves now, so no caller can get this wrong.
|
|
1603
|
+
*
|
|
1604
|
+
* A slug-less collection is left to the generator's own validation, which names
|
|
1605
|
+
* the offending collection; sorting must not throw first.
|
|
1606
|
+
*/
|
|
1607
|
+
function sortCollectionsBySlug(collections) {
|
|
1608
|
+
return [...collections].sort((a, b) => (a.slug ?? "").localeCompare(b.slug ?? ""));
|
|
1609
|
+
}
|
|
1461
1610
|
/** The eight-four-four-four-twelve shape of a UUID, any version. */
|
|
1462
1611
|
var UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
1463
1612
|
/** Whether one address part can be a value of the column it addresses. */
|
|
@@ -1794,6 +1943,48 @@ function getColumnName(fullColumn) {
|
|
|
1794
1943
|
return fullColumn.includes(".") ? fullColumn.split(".").pop() : fullColumn;
|
|
1795
1944
|
}
|
|
1796
1945
|
/**
|
|
1946
|
+
* The field key a database column is served and addressed under.
|
|
1947
|
+
*
|
|
1948
|
+
* A column has two names and they are not the same name. `author_id` is what
|
|
1949
|
+
* Postgres stores; `authorId` is the key on the JSON row, the key in the
|
|
1950
|
+
* generated Drizzle table, and the key a caller writes in `where` and
|
|
1951
|
+
* `orderBy`. Every place that starts from a column and has to reach a row, a
|
|
1952
|
+
* Drizzle table or a payload goes through here, so there is one answer rather
|
|
1953
|
+
* than one per call site — the two that disagreed put `displayName` and
|
|
1954
|
+
* `author_id` on the same API.
|
|
1955
|
+
*
|
|
1956
|
+
* A declared property is the authority when there is one, because its key *is*
|
|
1957
|
+
* the wire name and `columnName` is the only thing that ever renamed the
|
|
1958
|
+
* column:
|
|
1959
|
+
*
|
|
1960
|
+
* 1. an explicit `columnName` equal to this column;
|
|
1961
|
+
* 2. a property whose key is literally the column (an author who wrote
|
|
1962
|
+
* `author_id:` meant `author_id` on the wire, and gets it);
|
|
1963
|
+
* 3. a property whose key snake-cases to the column, which is the default
|
|
1964
|
+
* mapping — `authorId` → `author_id`.
|
|
1965
|
+
*
|
|
1966
|
+
* With no property in the way — a foreign key derived from a relation, which
|
|
1967
|
+
* usually has none — the name is derived: {@link toWireKey}.
|
|
1968
|
+
*
|
|
1969
|
+
* Note the fallback is *not* the column verbatim. That was the old behaviour
|
|
1970
|
+
* and it is precisely the defect: a derived foreign key reached the wire under
|
|
1971
|
+
* its column name while every hand-authored field beside it was camelCase.
|
|
1972
|
+
*/
|
|
1973
|
+
function fieldKeyForColumn(collection, column) {
|
|
1974
|
+
const properties = collection?.properties;
|
|
1975
|
+
if (properties) {
|
|
1976
|
+
for (const [key, prop] of Object.entries(properties)) {
|
|
1977
|
+
const columnName = prop?.columnName;
|
|
1978
|
+
if (typeof columnName === "string" && columnName === column) return key;
|
|
1979
|
+
}
|
|
1980
|
+
for (const key of Object.keys(properties)) {
|
|
1981
|
+
if (key === column) return key;
|
|
1982
|
+
if (toSnakeCase(key) === column) return key;
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
return toWireKey(column);
|
|
1986
|
+
}
|
|
1987
|
+
/**
|
|
1797
1988
|
* Look up a relation by key with forgiving normalization.
|
|
1798
1989
|
*
|
|
1799
1990
|
* `resolveCollectionRelations` stores each relation under a single canonical
|
|
@@ -1924,9 +2115,9 @@ function isKeywordAt(upper, i, keyword) {
|
|
|
1924
2115
|
*
|
|
1925
2116
|
* This used to be `sql.split(/ AND /i)`, which tore subqueries in half: the
|
|
1926
2117
|
* `AND` inside
|
|
1927
|
-
* `EXISTS (SELECT 1 FROM organization_members m WHERE m.org = t.org AND m.user_id =
|
|
2118
|
+
* `EXISTS (SELECT 1 FROM organization_members m WHERE m.org = t.org AND m.user_id = rebase.uid())`
|
|
1928
2119
|
* split the expression, and re-emitting the halves produced
|
|
1929
|
-
* `(EXISTS (...) AND m.user_id =
|
|
2120
|
+
* `(EXISTS (...) AND m.user_id = rebase.uid())`
|
|
1930
2121
|
* where `m` is no longer in scope — SQL that Postgres rejects outright with
|
|
1931
2122
|
* "missing FROM-clause entry for table". Returning null instead keeps such a
|
|
1932
2123
|
* clause as a `raw` expression, which round-trips verbatim.
|
|
@@ -2000,15 +2191,15 @@ function stripOuterParens(sql) {
|
|
|
2000
2191
|
}
|
|
2001
2192
|
}
|
|
2002
2193
|
function sqlToPolicy(sql) {
|
|
2003
|
-
const trimmed = stripOuterParens(sql.trim());
|
|
2194
|
+
const trimmed = stripOuterParens(rewriteLegacyRlsFunctions(sql).trim());
|
|
2004
2195
|
if (trimmed.toLowerCase() === "true") return policy.true();
|
|
2005
2196
|
if (trimmed.toLowerCase() === "false") return policy.false();
|
|
2006
|
-
const overlapMatch = trimmed.match(/^string_to_array\s*\(\s*
|
|
2197
|
+
const overlapMatch = trimmed.match(/^string_to_array\s*\(\s*rebase\.roles\(\)\s*,\s*','\s*\)\s*&&\s*ARRAY\s*\[(.+)\]$/i);
|
|
2007
2198
|
if (overlapMatch) {
|
|
2008
2199
|
const roles = overlapMatch[1].split(",").map((s) => s.trim().replace(/^'|'$/g, ""));
|
|
2009
2200
|
return policy.rolesOverlap(roles);
|
|
2010
2201
|
}
|
|
2011
|
-
const containMatch = trimmed.match(/^string_to_array\s*\(\s*
|
|
2202
|
+
const containMatch = trimmed.match(/^string_to_array\s*\(\s*rebase\.roles\(\)\s*,\s*','\s*\)\s*@>\s*ARRAY\s*\[(.+)\]$/i);
|
|
2012
2203
|
if (containMatch) {
|
|
2013
2204
|
const roles = containMatch[1].split(",").map((s) => s.trim().replace(/^'|'$/g, ""));
|
|
2014
2205
|
return policy.rolesContain(roles);
|
|
@@ -2024,32 +2215,60 @@ function sqlToPolicy(sql) {
|
|
|
2024
2215
|
const right = parseOperand(rightStr.trim());
|
|
2025
2216
|
if (left && right) return policy.compare(left, op === "=" ? "eq" : "neq", right);
|
|
2026
2217
|
}
|
|
2027
|
-
return policy.raw(
|
|
2218
|
+
return policy.raw(trimmed);
|
|
2028
2219
|
}
|
|
2029
2220
|
/**
|
|
2030
|
-
* Literals from other BaaS platforms that people compare `
|
|
2221
|
+
* Literals from other BaaS platforms that people compare `rebase.uid()` against
|
|
2031
2222
|
* out of habit. Mirrors the driver's `FOREIGN_CONVENTION_ROLES` guard on
|
|
2032
2223
|
* `pgRoles`, one surface over: the same muscle memory inside a `using:` string
|
|
2033
2224
|
* is the more dangerous spelling, because it inverts a rule instead of
|
|
2034
2225
|
* emptying a table.
|
|
2035
2226
|
*/
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2227
|
+
/**
|
|
2228
|
+
* A `Map`, not an object literal.
|
|
2229
|
+
*
|
|
2230
|
+
* As `Record<string, string>` this was indexed with a literal taken straight
|
|
2231
|
+
* out of a policy, so every key on `Object.prototype` answered: a rule
|
|
2232
|
+
* comparing `rebase.uid()` to `"valueOf"`, `"toString"`, `"constructor"` or
|
|
2233
|
+
* `"hasOwnProperty"` found a truthy "platform" and reported an anonymous-grant
|
|
2234
|
+
* risk that does not exist — with the matched function interpolated into the
|
|
2235
|
+
* explanation as the platform's name. A security warning that fires on
|
|
2236
|
+
* innocent input is worse than none: it is what teaches people to skip the
|
|
2237
|
+
* warnings that are real.
|
|
2238
|
+
*
|
|
2239
|
+
* Same shape as the prototype-pollution class swept out of `setIn`, `getIn`,
|
|
2240
|
+
* `mergeDeep` and `unflattenObject` — a data-derived key reaching a plain
|
|
2241
|
+
* object. Found by a property test, on the input `"valueOf"`.
|
|
2242
|
+
*/
|
|
2243
|
+
var FOREIGN_CONVENTION_UIDS = /* @__PURE__ */ new Map([
|
|
2244
|
+
["anon", "Supabase"],
|
|
2245
|
+
["authenticated", "Supabase"],
|
|
2246
|
+
["service_role", "Supabase"]
|
|
2247
|
+
]);
|
|
2248
|
+
/**
|
|
2249
|
+
* The same foreign literals, as a pattern for SQL that could not be parsed
|
|
2250
|
+
* back into structure.
|
|
2251
|
+
*/
|
|
2252
|
+
var FOREIGN_UID_LITERAL_SQL = new RegExp(String.raw`rebase\.uid\(\)\s*=\s*'(${[...FOREIGN_CONVENTION_UIDS.keys()].join("|")})'`, "i");
|
|
2253
|
+
/**
|
|
2254
|
+
* `rebase.uid() IS NOT NULL` in raw SQL, the clause that is always true.
|
|
2255
|
+
*
|
|
2256
|
+
* Both schema spellings, because this runs over policy bodies read back from a
|
|
2257
|
+
* database, and one migrated by a pre-1.0 release still holds `auth.uid()`.
|
|
2258
|
+
* A security check that stops recognising a dangerous clause because the
|
|
2259
|
+
* framework renamed a function is a check that silently turns off.
|
|
2260
|
+
*/
|
|
2261
|
+
var UID_NOT_NULL = /\b(?:rebase|auth)\.uid\(\)\s+IS\s+NOT\s+NULL/i;
|
|
2043
2262
|
/**
|
|
2044
2263
|
* Find clauses that read as "signed-in users only" but admit anonymous callers.
|
|
2045
2264
|
*
|
|
2046
|
-
* Both spellings come from the same place — Supabase, where `auth.uid()`
|
|
2047
|
-
* is NULL for an anonymous request. Rebase substitutes
|
|
2265
|
+
* Both spellings come from the same place — Supabase, where its own `auth.uid()`
|
|
2266
|
+
* really is NULL for an anonymous request. Rebase substitutes
|
|
2048
2267
|
* {@link ANONYMOUS_USER_ID} instead (a blank id would read back as NULL, which
|
|
2049
2268
|
* is how the trusted *server* context is recognised), so:
|
|
2050
2269
|
*
|
|
2051
|
-
* - `
|
|
2052
|
-
* - `
|
|
2270
|
+
* - `rebase.uid() IS NOT NULL` is a tautology on the user path, and
|
|
2271
|
+
* - `rebase.uid() != 'anon'` excludes one spelling of anonymous and admits the
|
|
2053
2272
|
* other. This one is not hypothetical and was not only a foreign habit:
|
|
2054
2273
|
* rebase's own request path reported `'anon'` while everything that compiled
|
|
2055
2274
|
* or checked a policy used `'anonymous'`, so whichever literal an author
|
|
@@ -2077,17 +2296,27 @@ function findAnonymousGrants(expr) {
|
|
|
2077
2296
|
case "existsIn":
|
|
2078
2297
|
visit(e.where);
|
|
2079
2298
|
return;
|
|
2080
|
-
case "raw":
|
|
2299
|
+
case "raw": {
|
|
2081
2300
|
if (UID_NOT_NULL.test(e.sql)) found.push({
|
|
2082
2301
|
pattern: "uid-not-null",
|
|
2083
2302
|
detail: e.sql,
|
|
2084
|
-
explanation: `\`
|
|
2303
|
+
explanation: `\`rebase.uid() IS NOT NULL\` is true for every request that came from a client, including anonymous ones — they carry '${ANONYMOUS_USER_ID}', not NULL. Use \`condition: policy.authenticated()\` to mean "signed in".`
|
|
2085
2304
|
});
|
|
2305
|
+
const foreign = FOREIGN_UID_LITERAL_SQL.exec(e.sql);
|
|
2306
|
+
if (foreign) {
|
|
2307
|
+
const literal = foreign[1];
|
|
2308
|
+
found.push({
|
|
2309
|
+
pattern: "foreign-uid-literal",
|
|
2310
|
+
detail: literal,
|
|
2311
|
+
explanation: `'${literal}' is a ${FOREIGN_CONVENTION_UIDS.get(literal)} convention. Rebase reports an anonymous request as '${ANONYMOUS_USER_ID}', so comparing against '${literal}' passes for every caller. Use \`condition: policy.authenticated()\` to mean "signed in".`
|
|
2312
|
+
});
|
|
2313
|
+
}
|
|
2086
2314
|
return;
|
|
2315
|
+
}
|
|
2087
2316
|
case "compare": {
|
|
2088
2317
|
const literal = [e.left, e.right].find((o) => o.kind === "literal");
|
|
2089
2318
|
if (!(e.left.kind === "authUid" || e.right.kind === "authUid") || typeof literal?.value !== "string") return;
|
|
2090
|
-
const platform = FOREIGN_CONVENTION_UIDS
|
|
2319
|
+
const platform = FOREIGN_CONVENTION_UIDS.get(literal.value);
|
|
2091
2320
|
if (!platform) return;
|
|
2092
2321
|
found.push({
|
|
2093
2322
|
pattern: "foreign-uid-literal",
|
|
@@ -2103,12 +2332,43 @@ function findAnonymousGrants(expr) {
|
|
|
2103
2332
|
return found;
|
|
2104
2333
|
}
|
|
2105
2334
|
function parseOperand(str) {
|
|
2106
|
-
if (
|
|
2107
|
-
const
|
|
2108
|
-
if (
|
|
2109
|
-
if (
|
|
2335
|
+
if (/^current_setting\s*\(\s*'app\.(uid|user_id)'\s*\)$/i.test(str) || /^rebase\.uid\(\)$/i.test(str)) return policy.authUid();
|
|
2336
|
+
const literal = parseSingleQuoted(str);
|
|
2337
|
+
if (literal !== null) return policy.literal(literal);
|
|
2338
|
+
if (/^-?\d+$/.test(str)) return policy.literal(Number(str));
|
|
2339
|
+
if (/^-?\d*\.\d+$/.test(str)) return policy.literal(Number(str));
|
|
2340
|
+
if (/^true$/i.test(str)) return policy.literal(true);
|
|
2341
|
+
if (/^false$/i.test(str)) return policy.literal(false);
|
|
2342
|
+
if (/^null$/i.test(str)) return policy.literal(null);
|
|
2343
|
+
if (/^\w+$/.test(str) && toSnakeCase(str) !== "") return policy.field(str);
|
|
2110
2344
|
return null;
|
|
2111
2345
|
}
|
|
2346
|
+
/**
|
|
2347
|
+
* Decode a single-quoted SQL literal, or null when `str` is not exactly one.
|
|
2348
|
+
*
|
|
2349
|
+
* Rejecting is as important as decoding: `'a' = 'b'` is two literals and an
|
|
2350
|
+
* operator, not one literal whose body contains a quote, and a regex anchored
|
|
2351
|
+
* on the outer quotes would happily read it as the latter. Every interior quote
|
|
2352
|
+
* must therefore be part of a `''` pair.
|
|
2353
|
+
*/
|
|
2354
|
+
function parseSingleQuoted(str) {
|
|
2355
|
+
if (str.length < 2 || !str.startsWith("'") || !str.endsWith("'")) return null;
|
|
2356
|
+
const body = str.slice(1, -1);
|
|
2357
|
+
let out = "";
|
|
2358
|
+
for (let i = 0; i < body.length; i++) {
|
|
2359
|
+
if (body[i] !== "'") {
|
|
2360
|
+
out += body[i];
|
|
2361
|
+
continue;
|
|
2362
|
+
}
|
|
2363
|
+
if (body[i + 1] === "'") {
|
|
2364
|
+
out += "'";
|
|
2365
|
+
i++;
|
|
2366
|
+
continue;
|
|
2367
|
+
}
|
|
2368
|
+
return null;
|
|
2369
|
+
}
|
|
2370
|
+
return out;
|
|
2371
|
+
}
|
|
2112
2372
|
//#endregion
|
|
2113
2373
|
//#region ../common/src/util/policy/securityRuleToConditions.ts
|
|
2114
2374
|
/**
|
|
@@ -2185,12 +2445,12 @@ function compile(expr, scope) {
|
|
|
2185
2445
|
const rightSql = castForAuthUid(expr.right, operandToSql(expr.right, scope), expr.left);
|
|
2186
2446
|
return `${leftSql} ${COMPARE_SQL[expr.op]} ${rightSql}`;
|
|
2187
2447
|
}
|
|
2188
|
-
case "rolesOverlap": return `string_to_array(
|
|
2189
|
-
case "rolesContain": return `string_to_array(
|
|
2190
|
-
case "authenticated": return
|
|
2191
|
-
case "serverContext": return
|
|
2448
|
+
case "rolesOverlap": return `string_to_array(${RLS_ROLES_SQL}, ',') && ${rolesArraySql(expr.roles)}`;
|
|
2449
|
+
case "rolesContain": return `string_to_array(${RLS_ROLES_SQL}, ',') @> ${rolesArraySql(expr.roles)}`;
|
|
2450
|
+
case "authenticated": return `${RLS_UID_SQL} IS NOT NULL AND ${RLS_UID_SQL} NOT IN (${ANONYMOUS_USER_IDS.map(quoteLiteral).join(", ")})`;
|
|
2451
|
+
case "serverContext": return `${RLS_UID_SQL} IS NULL`;
|
|
2192
2452
|
case "existsIn": return compileExistsIn(expr, scope);
|
|
2193
|
-
case "raw": return expr.sql.replace(/\{(\w+)\}/g, (_, col) => `${outerQualifier(scope)}${resolveColumnName(col, scope.outerCollection)}`);
|
|
2453
|
+
case "raw": return rewriteLegacyRlsFunctions(expr.sql).replace(/\{(\w+)\}/g, (_, col) => `${outerQualifier(scope)}${resolveColumnName(col, scope.outerCollection)}`);
|
|
2194
2454
|
}
|
|
2195
2455
|
}
|
|
2196
2456
|
/**
|
|
@@ -2227,8 +2487,8 @@ function operandToSql(operand, scope) {
|
|
|
2227
2487
|
case "field": return `${scope.fieldPrefix}${resolveColumnName(operand.name, scope.fieldCollection)}`;
|
|
2228
2488
|
case "outerField": return `${scope.outerPrefix}${resolveColumnName(operand.name, scope.outerCollection)}`;
|
|
2229
2489
|
case "literal": return quoteLiteral(operand.value);
|
|
2230
|
-
case "authUid": return
|
|
2231
|
-
case "authRoles": return
|
|
2490
|
+
case "authUid": return RLS_UID_SQL;
|
|
2491
|
+
case "authRoles": return `string_to_array(${RLS_ROLES_SQL}, ',')`;
|
|
2232
2492
|
}
|
|
2233
2493
|
}
|
|
2234
2494
|
/**
|
|
@@ -2266,8 +2526,14 @@ function rolesArraySql(roles) {
|
|
|
2266
2526
|
* Rebase's enforcement model is unified: authenticated (user-context) requests
|
|
2267
2527
|
* run under the restricted `rebase_user` role, so Postgres RLS binds *every*
|
|
2268
2528
|
* statement — reads and writes. A collection's `securityRules` are the whole
|
|
2269
|
-
* authorization model. The server context (auth flows, migrations,
|
|
2270
|
-
* `
|
|
2529
|
+
* authorization model. The server context (auth flows, migrations, raw
|
|
2530
|
+
* `rebase.sql`) runs as the owner and bypasses RLS.
|
|
2531
|
+
*
|
|
2532
|
+
* `rebase.dataAsAdmin` is **not** in that set, despite the name: it is scoped as
|
|
2533
|
+
* `{ uid: "service", roles: ["admin"] }`, so it runs as `rebase_user` like any
|
|
2534
|
+
* other caller and clears the baseline below through the *admin* arm, not the
|
|
2535
|
+
* server arm. Which is why `disableDefaultPolicies` plus a lone
|
|
2536
|
+
* `policy.serverContext()` rule locks it out too.
|
|
2271
2537
|
*
|
|
2272
2538
|
* Because RLS default-denies, every collection is **locked by default**: with
|
|
2273
2539
|
* no rules, only the server context and admins can touch it. The generator
|
|
@@ -2308,7 +2574,7 @@ var DEFAULT_GUARDED_OPS = [
|
|
|
2308
2574
|
"delete"
|
|
2309
2575
|
];
|
|
2310
2576
|
/** Whether a collection is flagged as an authentication collection. */
|
|
2311
|
-
function isAuthCollection(collection) {
|
|
2577
|
+
function isAuthCollection$1(collection) {
|
|
2312
2578
|
const auth = collection.auth;
|
|
2313
2579
|
return auth === true || typeof auth === "object" && auth?.enabled === true;
|
|
2314
2580
|
}
|
|
@@ -2325,11 +2591,28 @@ function getIdPropertyName$1(collection) {
|
|
|
2325
2591
|
*
|
|
2326
2592
|
* Collections that opt out via `disableDefaultPolicies` are returned unchanged.
|
|
2327
2593
|
*/
|
|
2594
|
+
/**
|
|
2595
|
+
* The restrictive write gate for an auth collection.
|
|
2596
|
+
*
|
|
2597
|
+
* Restrictive, so it is ANDed with everything else: whatever an author's
|
|
2598
|
+
* permissive rules allow, a write to this table still has to satisfy this too.
|
|
2599
|
+
* It is the only thing standing between "users may edit their own row" and
|
|
2600
|
+
* "users may grant themselves any role".
|
|
2601
|
+
*/
|
|
2602
|
+
function adminWriteGate(tableName) {
|
|
2603
|
+
return {
|
|
2604
|
+
name: `${tableName}_require_admin_write`,
|
|
2605
|
+
mode: "restrictive",
|
|
2606
|
+
operations: [...DEFAULT_GUARDED_OPS],
|
|
2607
|
+
condition: SERVER_OR_ADMIN_EXPR$1,
|
|
2608
|
+
check: SERVER_OR_ADMIN_EXPR$1
|
|
2609
|
+
};
|
|
2610
|
+
}
|
|
2328
2611
|
function getEffectiveSecurityRules(collection) {
|
|
2329
2612
|
const explicit = [...collection.securityRules ?? []];
|
|
2330
|
-
if (isPostgresCollectionConfig(collection) && collection.disableDefaultPolicies) return explicit;
|
|
2331
2613
|
const tableName = getTableName(collection);
|
|
2332
2614
|
const injected = [];
|
|
2615
|
+
if (isPostgresCollectionConfig(collection) && collection.disableDefaultPolicies) return isAuthCollection$1(collection) ? [...explicit, adminWriteGate(tableName)] : explicit;
|
|
2333
2616
|
injected.push({
|
|
2334
2617
|
name: `${tableName}_default_admin_read`,
|
|
2335
2618
|
operations: ["select"],
|
|
@@ -2341,19 +2624,13 @@ function getEffectiveSecurityRules(collection) {
|
|
|
2341
2624
|
condition: SERVER_OR_ADMIN_EXPR$1,
|
|
2342
2625
|
check: SERVER_OR_ADMIN_EXPR$1
|
|
2343
2626
|
});
|
|
2344
|
-
if (isAuthCollection(collection)) {
|
|
2627
|
+
if (isAuthCollection$1(collection)) {
|
|
2345
2628
|
injected.push({
|
|
2346
2629
|
name: `${tableName}_default_self_read`,
|
|
2347
2630
|
operations: ["select"],
|
|
2348
2631
|
condition: policy.compare(policy.field(getIdPropertyName$1(collection)), "eq", policy.authUid())
|
|
2349
2632
|
});
|
|
2350
|
-
injected.push(
|
|
2351
|
-
name: `${tableName}_require_admin_write`,
|
|
2352
|
-
mode: "restrictive",
|
|
2353
|
-
operations: [...DEFAULT_GUARDED_OPS],
|
|
2354
|
-
condition: SERVER_OR_ADMIN_EXPR$1,
|
|
2355
|
-
check: SERVER_OR_ADMIN_EXPR$1
|
|
2356
|
-
});
|
|
2633
|
+
injected.push(adminWriteGate(tableName));
|
|
2357
2634
|
}
|
|
2358
2635
|
return [...explicit, ...injected];
|
|
2359
2636
|
}
|
|
@@ -3308,6 +3585,46 @@ function resolveDataSource(collection, registry) {
|
|
|
3308
3585
|
capabilities: getDataSourceCapabilities(engine)
|
|
3309
3586
|
};
|
|
3310
3587
|
}
|
|
3588
|
+
/**
|
|
3589
|
+
* Does a SQL toolchain own this collection's storage?
|
|
3590
|
+
*
|
|
3591
|
+
* "Owns the storage" means: something generates a table for it, pushes that
|
|
3592
|
+
* table to a database, plans its RLS policies, and reports it as drifted when
|
|
3593
|
+
* the two disagree. That is true of a Postgres collection and false of a
|
|
3594
|
+
* Firestore or MongoDB one, whose documents live in a store Rebase never
|
|
3595
|
+
* migrates — and the two were never told apart. Every stage of the SQL
|
|
3596
|
+
* toolchain took "the collections" to mean *all* of them, so a Firestore
|
|
3597
|
+
* collection declared next to the Postgres ones got a `pgTable` in the
|
|
3598
|
+
* generated schema, a `CREATE TABLE` at boot, RLS policies, and a place in the
|
|
3599
|
+
* `db push` include list — where its name shielding a same-named real table
|
|
3600
|
+
* from Atlas's exclude list is the one that can lose data.
|
|
3601
|
+
*
|
|
3602
|
+
* The answer is the resolved engine's {@link DataSourceCapabilities}, not a
|
|
3603
|
+
* name check: an engine registered through `registerDataSourceCapabilities`
|
|
3604
|
+
* gets the same treatment as the built-in ones.
|
|
3605
|
+
*
|
|
3606
|
+
* Deliberately answers **true** for an engine nobody has heard of. Build-time
|
|
3607
|
+
* tooling (the CLI, the schema generator) has no data-source registry to
|
|
3608
|
+
* resolve a `dataSource` key against, so an unknown key resolves to an unknown
|
|
3609
|
+
* engine — and the cost of the two mistakes is not symmetric. Wrongly
|
|
3610
|
+
* including a collection generates a table nothing writes to; wrongly excluding
|
|
3611
|
+
* one silently stops generating a table the app is serving from. Declare
|
|
3612
|
+
* `engine` on a collection that is not SQL-backed and this is exact.
|
|
3613
|
+
*/
|
|
3614
|
+
function isRelationalCollection(collection, registry) {
|
|
3615
|
+
return getDataSourceCapabilities(collection?.engine ?? (collection?.dataSource ? resolveDataSource(collection, registry).engine : void 0)).supportsRelations;
|
|
3616
|
+
}
|
|
3617
|
+
/**
|
|
3618
|
+
* The subset of `collections` a SQL toolchain owns — see
|
|
3619
|
+
* {@link isRelationalCollection}.
|
|
3620
|
+
*
|
|
3621
|
+
* Every stage that generates SQL from collections starts by calling this, so
|
|
3622
|
+
* the rule lives in one place rather than being re-decided per generator. It
|
|
3623
|
+
* keeps the input order.
|
|
3624
|
+
*/
|
|
3625
|
+
function relationalCollections(collections, registry) {
|
|
3626
|
+
return collections.filter((collection) => isRelationalCollection(collection, registry));
|
|
3627
|
+
}
|
|
3311
3628
|
//#endregion
|
|
3312
3629
|
//#region ../common/src/collections/CollectionRegistry.ts
|
|
3313
3630
|
var CollectionRegistry = class {
|
|
@@ -3602,8 +3919,24 @@ var QueryBuilder = class {
|
|
|
3602
3919
|
/**
|
|
3603
3920
|
* Set a free-text search string if supported by the backend.
|
|
3604
3921
|
*/
|
|
3605
|
-
search(searchString) {
|
|
3922
|
+
search(searchString, options) {
|
|
3606
3923
|
this.params.searchString = searchString;
|
|
3924
|
+
if (options?.explain !== void 0) this.params.searchExplain = options.explain;
|
|
3925
|
+
return this;
|
|
3926
|
+
}
|
|
3927
|
+
/**
|
|
3928
|
+
* Order rows by nearest-neighbour distance to `vector`, closest first.
|
|
3929
|
+
*
|
|
3930
|
+
* Postgres only, over a property declared as `type: "vector"`. Rows come
|
|
3931
|
+
* back with a `_distance`; `where` filters before the ordering.
|
|
3932
|
+
*/
|
|
3933
|
+
vectorSearch(property, vector, options) {
|
|
3934
|
+
this.params.vectorSearch = {
|
|
3935
|
+
property,
|
|
3936
|
+
vector,
|
|
3937
|
+
...options?.distance !== void 0 && { distance: options.distance },
|
|
3938
|
+
...options?.threshold !== void 0 && { threshold: options.threshold }
|
|
3939
|
+
};
|
|
3607
3940
|
return this;
|
|
3608
3941
|
}
|
|
3609
3942
|
/**
|
|
@@ -3660,6 +3993,30 @@ var RebasePaginationError = class RebasePaginationError extends Error {
|
|
|
3660
3993
|
Object.setPrototypeOf(this, RebasePaginationError.prototype);
|
|
3661
3994
|
}
|
|
3662
3995
|
};
|
|
3996
|
+
/**
|
|
3997
|
+
* Resolve `limit`/`offset`/`page` into the window a read will actually use.
|
|
3998
|
+
*
|
|
3999
|
+
* Lives here, next to the walk, for the reason at the top of this file: every
|
|
4000
|
+
* transport has to mean the same thing by "page two". Four of them did not —
|
|
4001
|
+
* the REST layer strode by {@link DEFAULT_LIST_LIMIT}, the local-first
|
|
4002
|
+
* evaluator by {@link DEFAULT_PAGE_SIZE}, the in-process accessor by 20, and
|
|
4003
|
+
* the published type documented a fourth number. Pages that overlap or skip
|
|
4004
|
+
* rows are the mildest of those outcomes.
|
|
4005
|
+
*
|
|
4006
|
+
* `page` wins over `offset`, as {@link FindParams} documents. `driverOffset`
|
|
4007
|
+
* is the value to hand a driver: it stays `undefined` when the caller named no
|
|
4008
|
+
* offset, because keyset pagination seeks with a `where` clause and must not
|
|
4009
|
+
* look like it is paging by offset.
|
|
4010
|
+
*/
|
|
4011
|
+
function resolveFindWindow(params) {
|
|
4012
|
+
const limit = params?.limit ?? 50;
|
|
4013
|
+
const offset = params?.page != null ? Math.max(0, (params.page - 1) * limit) : params?.offset ?? 0;
|
|
4014
|
+
return {
|
|
4015
|
+
limit,
|
|
4016
|
+
offset,
|
|
4017
|
+
driverOffset: params?.page != null ? offset : params?.offset
|
|
4018
|
+
};
|
|
4019
|
+
}
|
|
3663
4020
|
function normalizePageSize(raw) {
|
|
3664
4021
|
if (raw === void 0 || !Number.isFinite(raw)) return 200;
|
|
3665
4022
|
return Math.max(1, Math.floor(raw));
|
|
@@ -3779,21 +4136,50 @@ async function collectAllPages(find, params, label = "collection") {
|
|
|
3779
4136
|
* metadata, so type coercion is the responsibility of the server-side data
|
|
3780
4137
|
* driver which has access to the collection schema.
|
|
3781
4138
|
*
|
|
3782
|
-
*
|
|
3783
|
-
*
|
|
4139
|
+
* Structural characters inside a value are backslash-escaped: `,` → `\,`,
|
|
4140
|
+
* `(` → `\(`, `)` → `\)`, and a literal backslash as `\\`. Decoding is
|
|
4141
|
+
* deliberately conservative — only those four sequences are decoded, so a
|
|
4142
|
+
* backslash that arrives unescaped from an older client survives intact.
|
|
3784
4143
|
*
|
|
3785
4144
|
* @module
|
|
3786
4145
|
*/
|
|
3787
4146
|
/**
|
|
3788
|
-
*
|
|
3789
|
-
*
|
|
4147
|
+
* Escape a value for the wire format: `\` → `\\`, `,` → `\,`, `(` → `\(`,
|
|
4148
|
+
* `)` → `\)`.
|
|
3790
4149
|
*/
|
|
3791
|
-
|
|
4150
|
+
/**
|
|
4151
|
+
* The wire spelling of an empty list.
|
|
4152
|
+
*
|
|
4153
|
+
* A lone backslash: unproducible by {@link escapeWireValue}, which doubles
|
|
4154
|
+
* every backslash it emits, so it cannot collide with any real item.
|
|
4155
|
+
*/
|
|
4156
|
+
var EMPTY_LIST_TOKEN = "\\";
|
|
4157
|
+
/**
|
|
4158
|
+
* Unescape a wire-format value.
|
|
4159
|
+
*
|
|
4160
|
+
* **Conservative**, and deliberately so: only the four sequences
|
|
4161
|
+
* {@link escapeWireValue} actually produces are decoded. A backslash followed
|
|
4162
|
+
* by anything else is left exactly as it is.
|
|
4163
|
+
*
|
|
4164
|
+
* This used to consume the backslash before *any* character, which is
|
|
4165
|
+
* indistinguishable for anything this codec emitted — it only ever emits those
|
|
4166
|
+
* four — but not for input arriving from elsewhere. A client on an older
|
|
4167
|
+
* release sends a Windows path or a LIKE pattern with a literal `C:\x`
|
|
4168
|
+
* unescaped, and greedy unescaping silently turned it into `C:x`, changing
|
|
4169
|
+
* which rows matched. Decoding only what the encoder can produce makes the two
|
|
4170
|
+
* directions agree across versions.
|
|
4171
|
+
*/
|
|
4172
|
+
function unescapeWireValue(value) {
|
|
3792
4173
|
let result = "";
|
|
3793
|
-
for (let i = 0; i < value.length; i++)
|
|
3794
|
-
|
|
3795
|
-
i
|
|
3796
|
-
|
|
4174
|
+
for (let i = 0; i < value.length; i++) {
|
|
4175
|
+
const next = value[i + 1];
|
|
4176
|
+
if (value[i] === "\\" && (next === "\\" || next === "," || next === "(" || next === ")")) {
|
|
4177
|
+
result += next;
|
|
4178
|
+
i++;
|
|
4179
|
+
continue;
|
|
4180
|
+
}
|
|
4181
|
+
result += value[i];
|
|
4182
|
+
}
|
|
3797
4183
|
return result;
|
|
3798
4184
|
}
|
|
3799
4185
|
/**
|
|
@@ -3811,13 +4197,205 @@ function splitListItems(inner) {
|
|
|
3811
4197
|
current += inner[i] + inner[i + 1];
|
|
3812
4198
|
i++;
|
|
3813
4199
|
} else if (inner[i] === ",") {
|
|
3814
|
-
items.push(
|
|
4200
|
+
items.push(unescapeWireValue(current));
|
|
3815
4201
|
current = "";
|
|
3816
4202
|
} else current += inner[i];
|
|
3817
|
-
items.push(
|
|
4203
|
+
items.push(unescapeWireValue(current));
|
|
3818
4204
|
return items;
|
|
3819
4205
|
}
|
|
3820
|
-
|
|
4206
|
+
/**
|
|
4207
|
+
* Operator tables as `Map`s, because the key comes off the wire.
|
|
4208
|
+
*
|
|
4209
|
+
* Indexed as plain objects, every `Object.prototype` member answered: a query
|
|
4210
|
+
* string of `?f=valueOf.x` found a truthy "operator" — the inherited function —
|
|
4211
|
+
* and `deserializeTuple` returned it *as the operator*, so a function object
|
|
4212
|
+
* travelled on into the compilers in place of a `WhereFilterOp`. The guard one
|
|
4213
|
+
* line below (`if (!canonicalOp)`) reads as though it rejects anything unknown,
|
|
4214
|
+
* and does not: `Object.prototype` is not unknown to a plain object.
|
|
4215
|
+
*
|
|
4216
|
+
* Same shape as the prototype-key defects swept out of `setIn`, `getIn`,
|
|
4217
|
+
* `mergeDeep`, `unflattenObject` and `FOREIGN_CONVENTION_UIDS`.
|
|
4218
|
+
*/
|
|
4219
|
+
var REST_OP_LOOKUP = new Map(Object.entries(REST_TO_CANONICAL));
|
|
4220
|
+
new Map(Object.entries(CANONICAL_TO_REST));
|
|
4221
|
+
/** The operator spellings a rejection lists back to the caller. */
|
|
4222
|
+
var VALID_OPERATOR_LIST = ALL_WHERE_FILTER_OPS.join(", ");
|
|
4223
|
+
/**
|
|
4224
|
+
* A filter condition named an operator this dialect does not have.
|
|
4225
|
+
*
|
|
4226
|
+
* ## Why this throws, rather than returning a typed rejection
|
|
4227
|
+
*
|
|
4228
|
+
* `deserializeFilter` is the *shared* codec: the REST ingress
|
|
4229
|
+
* (`packages/server/src/api/rest/query-parser.ts`), the browser SDK and the
|
|
4230
|
+
* admin panel (`buildRebaseData.ts`) all decode through it. Two constraints
|
|
4231
|
+
* follow.
|
|
4232
|
+
*
|
|
4233
|
+
* - It cannot throw the server's `ApiError`. `@rebasepro/common` does not
|
|
4234
|
+
* depend on `@rebasepro/server` (the dependency runs the other way), and a
|
|
4235
|
+
* browser client has no error handler to render an `ApiError` with. So the
|
|
4236
|
+
* rejection is this plain `Error` subclass, whose `message` reads correctly
|
|
4237
|
+
* wherever it surfaces — a rejected promise in an app, a 400 body over HTTP.
|
|
4238
|
+
* - It cannot be a returned rejection *value*. Every caller assigns the result
|
|
4239
|
+
* straight into a query it is about to run; a sentinel that none of them
|
|
4240
|
+
* check would be ignored, which is exactly the silently-wrong-filter failure
|
|
4241
|
+
* this exists to stop. Throwing is also what this file already does for the
|
|
4242
|
+
* sibling cases — `serializeTuple` on an unknown canonical operator,
|
|
4243
|
+
* `deserializeLogicalCondition` past the nesting bound — and the REST parser
|
|
4244
|
+
* already converts the latter into a 400.
|
|
4245
|
+
*
|
|
4246
|
+
* `statusCode`, `code` and `details` are carried as fields because the server's
|
|
4247
|
+
* Hono error handler duck-types those off any thrown error: a decode path that
|
|
4248
|
+
* forgets to convert still answers 400 with the canonical envelope instead of a
|
|
4249
|
+
* 500 that says "An unexpected error occurred". `query-parser.ts` converts
|
|
4250
|
+
* explicitly all the same — that is the path the contract is stated on, and an
|
|
4251
|
+
* incidental 400 is not a contract.
|
|
4252
|
+
*/
|
|
4253
|
+
var UnknownFilterOperatorError = class extends Error {
|
|
4254
|
+
/** The field the condition was written against. */
|
|
4255
|
+
field;
|
|
4256
|
+
/** The operator string as it arrived, verbatim. */
|
|
4257
|
+
operator;
|
|
4258
|
+
/** Every operator this dialect accepts, in canonical spelling. */
|
|
4259
|
+
validOperators = ALL_WHERE_FILTER_OPS;
|
|
4260
|
+
/** See the class docblock: read by the server's error handler. */
|
|
4261
|
+
statusCode = 400;
|
|
4262
|
+
code = "UNKNOWN_FILTER_OPERATOR";
|
|
4263
|
+
details;
|
|
4264
|
+
constructor(field, operator) {
|
|
4265
|
+
super(`Unknown filter operator '${operator}' on field '${field}'. Valid operators: ${VALID_OPERATOR_LIST}`);
|
|
4266
|
+
this.name = "UnknownFilterOperatorError";
|
|
4267
|
+
this.field = field;
|
|
4268
|
+
this.operator = operator;
|
|
4269
|
+
this.details = {
|
|
4270
|
+
field,
|
|
4271
|
+
operator,
|
|
4272
|
+
validOperators: ALL_WHERE_FILTER_OPS
|
|
4273
|
+
};
|
|
4274
|
+
}
|
|
4275
|
+
};
|
|
4276
|
+
/**
|
|
4277
|
+
* Two to three characters of ASCII punctuation and nothing else — the shape
|
|
4278
|
+
* every symbolic operator has (`==`, `>=`, `<>`, `~~`, `!!`, `>>`, `===`), and
|
|
4279
|
+
* one a column value effectively never has.
|
|
4280
|
+
*
|
|
4281
|
+
* Two characters minimum on purpose. A *single* punctuation character is a
|
|
4282
|
+
* perfectly ordinary value — `{ grade: ["-", "+"] }` is a two-item list, not a
|
|
4283
|
+
* condition — and the only single-character operator anyone actually mistypes
|
|
4284
|
+
* is `=`, which is named separately below. `<` and `>` need no special case:
|
|
4285
|
+
* they are real operators and resolve.
|
|
4286
|
+
*/
|
|
4287
|
+
var SYMBOLIC_OPERATOR = /^[^\p{L}\p{N}\s]{2,3}$/u;
|
|
4288
|
+
/** Lowercase, strip everything that is not a letter or digit. */
|
|
4289
|
+
function normalizeOperatorName(op) {
|
|
4290
|
+
return op.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
4291
|
+
}
|
|
4292
|
+
/**
|
|
4293
|
+
* Every real operator name with its case and separators removed, so a
|
|
4294
|
+
* respelling of one — `arrayContains`, `not_in`, `NOT-LIKE`, `isNull` — is
|
|
4295
|
+
* recognised as an attempt at an operator rather than read as a value.
|
|
4296
|
+
*
|
|
4297
|
+
* These are rejected rather than accepted: admitting a second spelling of an
|
|
4298
|
+
* operator would leave two wire spellings of one thing, and the rejection
|
|
4299
|
+
* message names the one that works.
|
|
4300
|
+
*/
|
|
4301
|
+
var RESPELLED_OPERATORS = new Set([...ALL_WHERE_FILTER_OPS, ...Object.keys(REST_TO_CANONICAL)].map(normalizeOperatorName));
|
|
4302
|
+
/**
|
|
4303
|
+
* Operator names *other* query dialects use, which this one does not have.
|
|
4304
|
+
*
|
|
4305
|
+
* This list is curated, and deliberately so. For a word-shaped string there is
|
|
4306
|
+
* no rule that separates "an operator the caller guessed" from "a value that
|
|
4307
|
+
* happens to be a word": `{ tags: ["a", "b"] }` has to keep meaning a two-item
|
|
4308
|
+
* `in` list, so the codec cannot simply refuse every unrecognised word in
|
|
4309
|
+
* position 0. The line is therefore drawn by name, and only around names whose
|
|
4310
|
+
* use as an operator is far more likely than their use as one of two sibling
|
|
4311
|
+
* values. `contains` is the motivating case — the first thing a developer
|
|
4312
|
+
* reaches for, and until now it compiled to `title IN ('contains', 'Hell')`.
|
|
4313
|
+
*
|
|
4314
|
+
* Genuinely ambiguous single words (`any`, `all`, `exists`, `search`, `not`)
|
|
4315
|
+
* are left off: as operators they are rare, and as enum values they are common.
|
|
4316
|
+
* Everywhere else the tie goes to *rejecting*, because a 400 naming the
|
|
4317
|
+
* supported set costs the caller one round trip, and the alternative — which is
|
|
4318
|
+
* what every name on this list used to produce — is a query that runs, returns
|
|
4319
|
+
* rows, and is wrong.
|
|
4320
|
+
*/
|
|
4321
|
+
var NEAR_MISS_OPERATORS = /* @__PURE__ */ new Set([
|
|
4322
|
+
"contains",
|
|
4323
|
+
"notcontains",
|
|
4324
|
+
"doesnotcontain",
|
|
4325
|
+
"doesnotcontains",
|
|
4326
|
+
"includes",
|
|
4327
|
+
"notincludes",
|
|
4328
|
+
"startswith",
|
|
4329
|
+
"notstartswith",
|
|
4330
|
+
"beginswith",
|
|
4331
|
+
"startingwith",
|
|
4332
|
+
"endswith",
|
|
4333
|
+
"notendswith",
|
|
4334
|
+
"matches",
|
|
4335
|
+
"notmatches",
|
|
4336
|
+
"regex",
|
|
4337
|
+
"regexp",
|
|
4338
|
+
"between",
|
|
4339
|
+
"notbetween",
|
|
4340
|
+
"equals",
|
|
4341
|
+
"notequals",
|
|
4342
|
+
"equalto",
|
|
4343
|
+
"isequalto",
|
|
4344
|
+
"isnotequalto",
|
|
4345
|
+
"greaterthan",
|
|
4346
|
+
"greaterthanorequal",
|
|
4347
|
+
"greaterthanorequalto",
|
|
4348
|
+
"lessthan",
|
|
4349
|
+
"lessthanorequal",
|
|
4350
|
+
"lessthanorequalto",
|
|
4351
|
+
"isempty",
|
|
4352
|
+
"isnotempty",
|
|
4353
|
+
"oneof",
|
|
4354
|
+
"noneof",
|
|
4355
|
+
"anyof",
|
|
4356
|
+
"allof",
|
|
4357
|
+
"null",
|
|
4358
|
+
"isnullorempty"
|
|
4359
|
+
]);
|
|
4360
|
+
/**
|
|
4361
|
+
* Was this string *meant* as an operator?
|
|
4362
|
+
*
|
|
4363
|
+
* Only consulted after {@link toCanonicalOp} has already failed to resolve it,
|
|
4364
|
+
* so a `true` here is always a rejection.
|
|
4365
|
+
*/
|
|
4366
|
+
function isOperatorShaped(op) {
|
|
4367
|
+
if (op === "=") return true;
|
|
4368
|
+
if (SYMBOLIC_OPERATOR.test(op)) return true;
|
|
4369
|
+
const normalized = normalizeOperatorName(op);
|
|
4370
|
+
if (!normalized) return false;
|
|
4371
|
+
return RESPELLED_OPERATORS.has(normalized) || NEAR_MISS_OPERATORS.has(normalized);
|
|
4372
|
+
}
|
|
4373
|
+
/**
|
|
4374
|
+
* Read a `[op, value]` tuple, if that is what this is.
|
|
4375
|
+
*
|
|
4376
|
+
* Three outcomes, and the middle one is the defect this function exists for:
|
|
4377
|
+
*
|
|
4378
|
+
* - the operator resolves (canonical *or* REST spelling) → the canonical tuple;
|
|
4379
|
+
* - the operator does not resolve but was plainly meant as one → throw;
|
|
4380
|
+
* - it does not look like an operator at all → `undefined`, and the caller
|
|
4381
|
+
* falls back to reading the array as a list of values.
|
|
4382
|
+
*
|
|
4383
|
+
* The old test was `toCanonicalOp(raw[0]) === raw[0]`, i.e. canonical spelling
|
|
4384
|
+
* only, with *everything else* — including every REST short-code — dropping
|
|
4385
|
+
* through to `["in", raw]`. So the operator string itself became a value in a
|
|
4386
|
+
* membership test: `["!!", "Hello"]` compiled to `title IN ('!!','Hello')`,
|
|
4387
|
+
* which matches, and the caller got back rows their filter was written to
|
|
4388
|
+
* exclude. `["eq", "active"]` had the same shape of failure.
|
|
4389
|
+
*/
|
|
4390
|
+
function readTuple(field, raw) {
|
|
4391
|
+
if (!Array.isArray(raw) || raw.length !== 2) return void 0;
|
|
4392
|
+
const [op, value] = raw;
|
|
4393
|
+
if (typeof op !== "string") return void 0;
|
|
4394
|
+
const canonical = toCanonicalOp(op);
|
|
4395
|
+
if (canonical) return [canonical, value];
|
|
4396
|
+
if (op.includes(".")) return void 0;
|
|
4397
|
+
if (isOperatorShaped(op)) throw new UnknownFilterOperatorError(field, op);
|
|
4398
|
+
}
|
|
3821
4399
|
/**
|
|
3822
4400
|
* Parse a single PostgREST dot-string into a `[WhereFilterOp, unknown]` tuple.
|
|
3823
4401
|
*
|
|
@@ -3834,10 +4412,13 @@ function deserializeSingle(raw) {
|
|
|
3834
4412
|
if (dotIndex === -1) return ["==", raw];
|
|
3835
4413
|
const prefix = raw.substring(0, dotIndex);
|
|
3836
4414
|
const rest = raw.substring(dotIndex + 1);
|
|
3837
|
-
const canonicalOp = REST_OP_LOOKUP
|
|
4415
|
+
const canonicalOp = REST_OP_LOOKUP.get(prefix);
|
|
3838
4416
|
if (!canonicalOp) return ["==", raw];
|
|
3839
4417
|
if (NULL_OPS.has(canonicalOp)) return [canonicalOp, null];
|
|
3840
|
-
if (rest.startsWith("(") && rest.endsWith(")"))
|
|
4418
|
+
if (rest.startsWith("(") && rest.endsWith(")")) {
|
|
4419
|
+
const inner = rest.slice(1, -1);
|
|
4420
|
+
return [canonicalOp, inner === EMPTY_LIST_TOKEN ? [] : splitListItems(inner)];
|
|
4421
|
+
}
|
|
3841
4422
|
return [canonicalOp, rest];
|
|
3842
4423
|
}
|
|
3843
4424
|
/**
|
|
@@ -3852,20 +4433,27 @@ function deserializeSingle(raw) {
|
|
|
3852
4433
|
*
|
|
3853
4434
|
* deserializeFilter({ age: ["gte.18", "lt.65"] })
|
|
3854
4435
|
* // → { age: [[">=", "18"], ["<", "65"]] }
|
|
4436
|
+
*
|
|
4437
|
+
* @throws {UnknownFilterOperatorError} when a condition names an operator this
|
|
4438
|
+
* dialect does not have. See that class for why a rejection here is a throw.
|
|
3855
4439
|
*/
|
|
3856
4440
|
function deserializeFilter(query) {
|
|
3857
4441
|
const result = {};
|
|
3858
4442
|
for (const [field, raw] of Object.entries(query)) {
|
|
3859
4443
|
if (raw === void 0) continue;
|
|
3860
|
-
|
|
3861
|
-
|
|
4444
|
+
const tuple = readTuple(field, raw);
|
|
4445
|
+
if (tuple) {
|
|
4446
|
+
result[field] = tuple;
|
|
3862
4447
|
continue;
|
|
3863
4448
|
}
|
|
3864
4449
|
if (Array.isArray(raw)) {
|
|
3865
4450
|
if (raw.length === 0) continue;
|
|
3866
|
-
if (Array.isArray(raw[0])
|
|
3867
|
-
|
|
3868
|
-
|
|
4451
|
+
if (Array.isArray(raw[0])) {
|
|
4452
|
+
const tuples = raw.map((item) => readTuple(field, item));
|
|
4453
|
+
if (tuples.every((t) => t !== void 0)) {
|
|
4454
|
+
result[field] = tuples;
|
|
4455
|
+
continue;
|
|
4456
|
+
}
|
|
3869
4457
|
}
|
|
3870
4458
|
if (raw.length === 1) result[field] = typeof raw[0] === "string" ? deserializeSingle(raw[0]) : ["==", raw[0]];
|
|
3871
4459
|
else if (typeof raw[0] === "string" && raw[0].includes(".")) result[field] = raw.map((r) => typeof r === "string" ? deserializeSingle(r) : ["==", r]);
|
|
@@ -3909,10 +4497,12 @@ function createPrimaryKeyResolver(options) {
|
|
|
3909
4497
|
* than postgres still serve rows with one, and this keeps them working.
|
|
3910
4498
|
*/
|
|
3911
4499
|
function rowToEntity(row, slug, primaryKeys = []) {
|
|
4500
|
+
const { _matches, ...values } = row;
|
|
3912
4501
|
return {
|
|
3913
4502
|
id: primaryKeys.length > 0 ? buildCompositeId(row, primaryKeys) : row.id,
|
|
3914
4503
|
path: slug,
|
|
3915
|
-
values
|
|
4504
|
+
values,
|
|
4505
|
+
..._matches ? { searchMatches: _matches } : {}
|
|
3916
4506
|
};
|
|
3917
4507
|
}
|
|
3918
4508
|
/**
|
|
@@ -3955,21 +4545,22 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
|
|
|
3955
4545
|
const accessor = {
|
|
3956
4546
|
async find(params) {
|
|
3957
4547
|
const filter = params?.where ? deserializeFilter(params.where) : void 0;
|
|
3958
|
-
const limit = params
|
|
3959
|
-
const offset = params?.offset ?? 0;
|
|
4548
|
+
const { limit, offset, driverOffset } = resolveFindWindow(params);
|
|
3960
4549
|
const fetchService = driver.restFetchService;
|
|
3961
4550
|
const rows = fetchService ? await fetchService.fetchCollectionForRest(slug, {
|
|
3962
4551
|
filter,
|
|
3963
|
-
|
|
3964
|
-
|
|
4552
|
+
logical: params?.logical,
|
|
4553
|
+
limit,
|
|
4554
|
+
offset: driverOffset,
|
|
3965
4555
|
orderBy: params?.orderBy?.[0],
|
|
3966
4556
|
order: params?.orderBy?.[1],
|
|
3967
4557
|
searchString: params?.searchString
|
|
3968
4558
|
}, params?.include) : await driver.fetchCollection({
|
|
3969
4559
|
path: slug,
|
|
3970
|
-
limit
|
|
3971
|
-
offset:
|
|
4560
|
+
limit,
|
|
4561
|
+
offset: driverOffset,
|
|
3972
4562
|
filter,
|
|
4563
|
+
logical: params?.logical,
|
|
3973
4564
|
orderBy: params?.orderBy?.[0],
|
|
3974
4565
|
order: params?.orderBy?.[1],
|
|
3975
4566
|
searchString: params?.searchString
|
|
@@ -3979,7 +4570,9 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
|
|
|
3979
4570
|
if (driver.count) {
|
|
3980
4571
|
total = await driver.count({
|
|
3981
4572
|
path: slug,
|
|
3982
|
-
filter
|
|
4573
|
+
filter,
|
|
4574
|
+
logical: params?.logical,
|
|
4575
|
+
searchString: params?.searchString
|
|
3983
4576
|
});
|
|
3984
4577
|
hasMore = offset + rows.length < total;
|
|
3985
4578
|
}
|
|
@@ -4031,30 +4624,48 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
|
|
|
4031
4624
|
values: {}
|
|
4032
4625
|
} });
|
|
4033
4626
|
},
|
|
4627
|
+
updateMany: driver.updateMany ? async (updates) => {
|
|
4628
|
+
return (await driver.updateMany({
|
|
4629
|
+
path: slug,
|
|
4630
|
+
updates: updates.map((u) => ({
|
|
4631
|
+
id: u.id,
|
|
4632
|
+
values: u.data
|
|
4633
|
+
}))
|
|
4634
|
+
})).map((row) => rowToEntity(row, slug, getPks()));
|
|
4635
|
+
} : void 0,
|
|
4636
|
+
deleteMany: driver.deleteMany ? async (ids) => {
|
|
4637
|
+
await driver.deleteMany({
|
|
4638
|
+
path: slug,
|
|
4639
|
+
ids
|
|
4640
|
+
});
|
|
4641
|
+
} : void 0,
|
|
4034
4642
|
count: driver.count ? async (params) => {
|
|
4035
4643
|
const filter = params?.where ? deserializeFilter(params.where) : void 0;
|
|
4036
4644
|
return driver.count({
|
|
4037
4645
|
path: slug,
|
|
4038
|
-
filter
|
|
4646
|
+
filter,
|
|
4647
|
+
logical: params?.logical,
|
|
4648
|
+
searchString: params?.searchString
|
|
4039
4649
|
});
|
|
4040
4650
|
} : void 0,
|
|
4041
4651
|
listen: driver.listenCollection ? (params, onUpdate, onError) => {
|
|
4042
|
-
const limit = params
|
|
4043
|
-
const offset = params?.offset ?? 0;
|
|
4652
|
+
const { limit, offset, driverOffset } = resolveFindWindow(params);
|
|
4044
4653
|
const normalize = driver.restFetchService ? inlineRelationRefs : (row) => row;
|
|
4045
4654
|
return driver.listenCollection({
|
|
4046
4655
|
path: slug,
|
|
4047
|
-
limit
|
|
4048
|
-
offset:
|
|
4656
|
+
limit,
|
|
4657
|
+
offset: driverOffset,
|
|
4049
4658
|
filter: params?.where,
|
|
4659
|
+
logical: params?.logical,
|
|
4050
4660
|
orderBy: params?.orderBy?.[0],
|
|
4051
4661
|
order: params?.orderBy?.[1],
|
|
4052
4662
|
searchString: params?.searchString,
|
|
4663
|
+
searchExplain: params?.searchExplain,
|
|
4053
4664
|
onUpdate: (entities) => {
|
|
4054
4665
|
onUpdate({
|
|
4055
4666
|
data: entities.map((row) => rowToEntity(normalize(row), slug, getPks())),
|
|
4056
4667
|
meta: {
|
|
4057
|
-
total: entities.length,
|
|
4668
|
+
total: offset + entities.length,
|
|
4058
4669
|
limit,
|
|
4059
4670
|
offset,
|
|
4060
4671
|
hasMore: entities.length >= limit
|
|
@@ -4087,8 +4698,11 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
|
|
|
4087
4698
|
offset(count) {
|
|
4088
4699
|
return new QueryBuilder(accessor).offset(count);
|
|
4089
4700
|
},
|
|
4090
|
-
search(searchString) {
|
|
4091
|
-
return new QueryBuilder(accessor).search(searchString);
|
|
4701
|
+
search(searchString, options) {
|
|
4702
|
+
return new QueryBuilder(accessor).search(searchString, options);
|
|
4703
|
+
},
|
|
4704
|
+
vectorSearch(property, vector, options) {
|
|
4705
|
+
return new QueryBuilder(accessor).vectorSearch(property, vector, options);
|
|
4092
4706
|
},
|
|
4093
4707
|
include(...relations) {
|
|
4094
4708
|
return new QueryBuilder(accessor).include(...relations);
|
|
@@ -4176,8 +4790,18 @@ var SdkQueryBuilder = class {
|
|
|
4176
4790
|
this.params.offset = count;
|
|
4177
4791
|
return this;
|
|
4178
4792
|
}
|
|
4179
|
-
search(searchString) {
|
|
4793
|
+
search(searchString, options) {
|
|
4180
4794
|
this.params.searchString = searchString;
|
|
4795
|
+
if (options?.explain !== void 0) this.params.searchExplain = options.explain;
|
|
4796
|
+
return this;
|
|
4797
|
+
}
|
|
4798
|
+
vectorSearch(property, vector, options) {
|
|
4799
|
+
this.params.vectorSearch = {
|
|
4800
|
+
property,
|
|
4801
|
+
vector,
|
|
4802
|
+
...options?.distance !== void 0 && { distance: options.distance },
|
|
4803
|
+
...options?.threshold !== void 0 && { threshold: options.threshold }
|
|
4804
|
+
};
|
|
4181
4805
|
return this;
|
|
4182
4806
|
}
|
|
4183
4807
|
include(...relations) {
|
|
@@ -4231,9 +4855,24 @@ function toSdkCollectionClient(snap, slug = "collection") {
|
|
|
4231
4855
|
async update(id, data) {
|
|
4232
4856
|
return entityToRow(await snap.update(id, data));
|
|
4233
4857
|
},
|
|
4858
|
+
async updateMany(updates) {
|
|
4859
|
+
if (!Array.isArray(updates)) throw new TypeError("updateMany expects an array of { id, data } entries.");
|
|
4860
|
+
if (updates.length === 0) return [];
|
|
4861
|
+
if (!snap.updateMany) throw new Error("Bulk updates are not supported by this collection's data source. Fall back to update() per record.");
|
|
4862
|
+
return (await snap.updateMany(updates.map((u) => ({
|
|
4863
|
+
id: u.id,
|
|
4864
|
+
data: u.data
|
|
4865
|
+
})))).map(entityToRow);
|
|
4866
|
+
},
|
|
4234
4867
|
delete(id) {
|
|
4235
4868
|
return snap.delete(id);
|
|
4236
4869
|
},
|
|
4870
|
+
async deleteMany(ids) {
|
|
4871
|
+
if (!Array.isArray(ids)) throw new TypeError("deleteMany expects an array of ids.");
|
|
4872
|
+
if (ids.length === 0) return;
|
|
4873
|
+
if (!snap.deleteMany) throw new Error("Bulk deletes are not supported by this collection's data source. Fall back to delete() per record.");
|
|
4874
|
+
await snap.deleteMany(ids);
|
|
4875
|
+
},
|
|
4237
4876
|
count: snap.count ? (params) => snap.count(params) : void 0,
|
|
4238
4877
|
listen: snap.listen ? (params, onUpdate, onError) => snap.listen(params, (res) => onUpdate({
|
|
4239
4878
|
data: res.data.map(entityToRow),
|
|
@@ -4249,6 +4888,7 @@ function toSdkCollectionClient(snap, slug = "collection") {
|
|
|
4249
4888
|
limit: (count) => new SdkQueryBuilder(client).limit(count),
|
|
4250
4889
|
offset: (count) => new SdkQueryBuilder(client).offset(count),
|
|
4251
4890
|
search: (searchString) => new SdkQueryBuilder(client).search(searchString),
|
|
4891
|
+
vectorSearch: (property, vector, options) => new SdkQueryBuilder(client).vectorSearch(property, vector, options),
|
|
4252
4892
|
include: (...relations) => new SdkQueryBuilder(client).include(...relations)
|
|
4253
4893
|
};
|
|
4254
4894
|
return client;
|
|
@@ -4294,6 +4934,502 @@ function buildSdkData(driver) {
|
|
|
4294
4934
|
return wrapAsSdkData(buildRebaseData(driver));
|
|
4295
4935
|
}
|
|
4296
4936
|
//#endregion
|
|
4297
|
-
|
|
4937
|
+
//#region src/schema/search-column.ts
|
|
4938
|
+
/**
|
|
4939
|
+
* The one place a collection's `search` block becomes SQL.
|
|
4940
|
+
*
|
|
4941
|
+
* Four things describe a Postgres table in this codebase — the DDL generator,
|
|
4942
|
+
* the Drizzle schema generator, the runtime table builder for BaaS mode, and
|
|
4943
|
+
* the boot-time schema ensure — and each of them has, at some point, described
|
|
4944
|
+
* a column differently from the others. The `varchar(255)` note in
|
|
4945
|
+
* `generate-postgres-ddl-logic` is one such scar: the same property produced a
|
|
4946
|
+
* capped column down one path and an uncapped one down the other, and nothing
|
|
4947
|
+
* failed until a user hit the cap.
|
|
4948
|
+
*
|
|
4949
|
+
* So the search column is not implemented four times. It is computed once,
|
|
4950
|
+
* here, and every generator renders the same {@link SearchColumnSpec}. There is
|
|
4951
|
+
* a test asserting exactly that (`search-column-contract.test.ts`); the point of
|
|
4952
|
+
* this module is that the test has something to assert *about*.
|
|
4953
|
+
*
|
|
4954
|
+
* ## Why the expressions look the way they do
|
|
4955
|
+
*
|
|
4956
|
+
* A `GENERATED ALWAYS AS … STORED` expression must be strictly IMMUTABLE, and
|
|
4957
|
+
* Postgres is stricter here than intuition. Verified against PostgreSQL 18:
|
|
4958
|
+
*
|
|
4959
|
+
* | expression | immutable |
|
|
4960
|
+
* |-----------------------------------------|-----------|
|
|
4961
|
+
* | `to_tsvector('spanish', col)` | yes |
|
|
4962
|
+
* | `to_tsvector(col)` (1-arg) | **no** — depends on `default_text_search_config` |
|
|
4963
|
+
* | `array_to_string(col, ' ')` | **no** |
|
|
4964
|
+
* | `col::text` on `text[]` | **no** |
|
|
4965
|
+
* | `to_jsonb(col)` | **no** |
|
|
4966
|
+
* | `unaccent(col)` | **no** — dictionary lookup is STABLE |
|
|
4967
|
+
* | `jsonb_to_tsvector('spanish', j, '["string"]')` | yes |
|
|
4968
|
+
* | `setweight(...) || setweight(...)` | yes |
|
|
4969
|
+
*
|
|
4970
|
+
* Three of the four things a real search column needs are therefore unavailable
|
|
4971
|
+
* directly, which is why {@link searchHelperFunctions} exists: each wraps a
|
|
4972
|
+
* stable built-in in an SQL function declared IMMUTABLE. That declaration is a
|
|
4973
|
+
* promise, and it is a true one for these three — array joining, JSON string
|
|
4974
|
+
* extraction and accent folding are all deterministic for a given input; the
|
|
4975
|
+
* built-ins are marked stable only because they must account for element types
|
|
4976
|
+
* and dictionaries in general.
|
|
4977
|
+
*
|
|
4978
|
+
* The alternative was to skip `unaccent` and text arrays entirely. That is not
|
|
4979
|
+
* a real option in an accented language: Postgres stems `auditoría` to
|
|
4980
|
+
* `auditor` and `auditoria` to `auditori` — *different lexemes* — so a query
|
|
4981
|
+
* typed without accents misses every row that carries them.
|
|
4982
|
+
*/
|
|
4983
|
+
/** Schema-qualified so a collection outside `public` still resolves them. */
|
|
4984
|
+
var HELPER_SCHEMA = "public";
|
|
4985
|
+
/**
|
|
4986
|
+
* Names of the helper functions. Frozen: they are recorded in the stored
|
|
4987
|
+
* generation expression of every search column ever created, so renaming one
|
|
4988
|
+
* orphans every table that already has a search column.
|
|
4989
|
+
*/
|
|
4990
|
+
var SEARCH_TEXT_FN = `${HELPER_SCHEMA}.rebase_search_text`;
|
|
4991
|
+
var SEARCH_UNACCENT_FN = `${HELPER_SCHEMA}.rebase_search_unaccent`;
|
|
4992
|
+
/** Raised when a `search` block names something that cannot be searched. */
|
|
4993
|
+
var SearchConfigError = class extends Error {
|
|
4994
|
+
constructor(message) {
|
|
4995
|
+
super(message);
|
|
4996
|
+
this.name = "SearchConfigError";
|
|
4997
|
+
}
|
|
4998
|
+
};
|
|
4999
|
+
/** The `search` block of a collection, or undefined when it has none. */
|
|
5000
|
+
var getSearchConfig = (collection) => isPostgresCollectionConfig(collection) ? collection.search : void 0;
|
|
5001
|
+
/**
|
|
5002
|
+
* Refuse a `search` block on a collection this engine does not store.
|
|
5003
|
+
*
|
|
5004
|
+
* The type only permits one on a `PostgresCollectionConfig`, so TypeScript
|
|
5005
|
+
* already stops the ordinary case. This catches the rest — a JS config, a cast,
|
|
5006
|
+
* a collection whose `engine` was changed after the block was written — because
|
|
5007
|
+
* the alternative is the exact failure the block exists to prevent: a developer
|
|
5008
|
+
* who declared what to index, saw no error, and got the substring fallback.
|
|
5009
|
+
*
|
|
5010
|
+
* Called with *every* collection, before the Postgres ones are filtered out.
|
|
5011
|
+
*/
|
|
5012
|
+
var assertSearchIsPostgresOnly = (collections) => {
|
|
5013
|
+
for (const collection of collections) {
|
|
5014
|
+
if (isPostgresCollectionConfig(collection)) continue;
|
|
5015
|
+
if (!collection.search) continue;
|
|
5016
|
+
const engine = collection.engine ?? "non-postgres";
|
|
5017
|
+
throw new SearchConfigError(`${collection.slug}.search: full-text search is a Postgres feature, and this collection is served by \`${engine}\`. Remove the block — it would otherwise look configured while \`.search()\` kept using the default substring match.`);
|
|
5018
|
+
}
|
|
5019
|
+
};
|
|
5020
|
+
var columnNameOf = (propName, prop) => prop && "columnName" in prop && typeof prop.columnName === "string" ? prop.columnName : toSnakeCase(propName);
|
|
5021
|
+
/**
|
|
5022
|
+
* Classify a property for search purposes.
|
|
5023
|
+
*
|
|
5024
|
+
* Deliberately narrower than `getSqlColumnType`: search only cares whether a
|
|
5025
|
+
* value reaches text, and the mapping from property to *physical* type is
|
|
5026
|
+
* asserted against `getSqlColumnType` in the contract test rather than
|
|
5027
|
+
* duplicated here.
|
|
5028
|
+
*
|
|
5029
|
+
* Returns null for anything that is not text-bearing, which the caller turns
|
|
5030
|
+
* into a boot error naming the property.
|
|
5031
|
+
*/
|
|
5032
|
+
var classify = (prop) => {
|
|
5033
|
+
switch (prop.type) {
|
|
5034
|
+
case "string": {
|
|
5035
|
+
const sp = prop;
|
|
5036
|
+
if (sp.enum) return {
|
|
5037
|
+
kind: "text",
|
|
5038
|
+
reason: "enum"
|
|
5039
|
+
};
|
|
5040
|
+
if (sp.isId === "uuid" || sp.columnType === "uuid") return {
|
|
5041
|
+
kind: "text",
|
|
5042
|
+
reason: "uuid"
|
|
5043
|
+
};
|
|
5044
|
+
return { kind: "text" };
|
|
5045
|
+
}
|
|
5046
|
+
case "map":
|
|
5047
|
+
if (prop.columnType === "json") return {
|
|
5048
|
+
kind: "jsonb",
|
|
5049
|
+
reason: "json"
|
|
5050
|
+
};
|
|
5051
|
+
return { kind: "jsonb" };
|
|
5052
|
+
case "array": {
|
|
5053
|
+
const ap = prop;
|
|
5054
|
+
let colType = ap.columnType;
|
|
5055
|
+
if (!colType && ap.of && !Array.isArray(ap.of)) {
|
|
5056
|
+
const of = ap.of;
|
|
5057
|
+
if (of.type === "string") colType = "text[]";
|
|
5058
|
+
else if (of.type === "number") colType = of.validation?.integer ? "integer[]" : "numeric[]";
|
|
5059
|
+
else if (of.type === "boolean") colType = "boolean[]";
|
|
5060
|
+
}
|
|
5061
|
+
if (colType === "text[]") return { kind: "text_array" };
|
|
5062
|
+
if (colType === "json") return {
|
|
5063
|
+
kind: "jsonb",
|
|
5064
|
+
reason: "json"
|
|
5065
|
+
};
|
|
5066
|
+
if (colType === "integer[]" || colType === "boolean[]" || colType === "numeric[]") return {
|
|
5067
|
+
kind: "text_array",
|
|
5068
|
+
reason: "non_text_array"
|
|
5069
|
+
};
|
|
5070
|
+
return { kind: "jsonb" };
|
|
5071
|
+
}
|
|
5072
|
+
default: return null;
|
|
5073
|
+
}
|
|
5074
|
+
};
|
|
5075
|
+
var normalize = (inner, unaccent) => unaccent ? `${SEARCH_UNACCENT_FN}(${inner})` : inner;
|
|
5076
|
+
/** SQL reading one field as plain text, before normalization. */
|
|
5077
|
+
var rawTextSql = (field) => {
|
|
5078
|
+
const col = `"${field.column}"`;
|
|
5079
|
+
if (field.kind === "text") return `coalesce(${col}, '')`;
|
|
5080
|
+
if (field.kind === "text_array") return `${SEARCH_TEXT_FN}(coalesce(${col}, '{}'::text[]))`;
|
|
5081
|
+
return `${SEARCH_TEXT_FN}(coalesce(${field.jsonPath.length === 0 ? col : field.jsonPath.length === 1 ? `${col} -> ${quote(field.jsonPath[0])}` : `${col} #> ${quote(`{${field.jsonPath.join(",")}}`)}`}, '{}'::jsonb))`;
|
|
5082
|
+
};
|
|
5083
|
+
var quote = (v) => `'${v.replace(/'/g, "''")}'`;
|
|
5084
|
+
/**
|
|
5085
|
+
* Resolve and validate one declared field path.
|
|
5086
|
+
*
|
|
5087
|
+
* A path that does not resolve throws. The whole point of an explicit block is
|
|
5088
|
+
* that the author knows what is indexed; a silently dropped field would make it
|
|
5089
|
+
* a guess again, and the failure — a search that returns nothing for content
|
|
5090
|
+
* that is plainly in the row — is invisible from the outside.
|
|
5091
|
+
*/
|
|
5092
|
+
var resolveField = (entry, collection, cfg) => {
|
|
5093
|
+
const path = typeof entry === "string" ? entry : entry.path;
|
|
5094
|
+
const weight = (typeof entry === "string" ? void 0 : entry.weight) ?? "B";
|
|
5095
|
+
const where = `${collection.slug}.search`;
|
|
5096
|
+
if (!path || typeof path !== "string") throw new SearchConfigError(`${where}: every entry in \`fields\` needs a property path.`);
|
|
5097
|
+
const [head, ...rest] = path.split(".");
|
|
5098
|
+
const prop = collection.properties?.[head];
|
|
5099
|
+
if (!prop) throw new SearchConfigError(`${where}: "${path}" starts at property "${head}", which this collection does not declare. Known properties: ${Object.keys(collection.properties ?? {}).join(", ")}.`);
|
|
5100
|
+
const classified = classify(prop);
|
|
5101
|
+
if (!classified) throw new SearchConfigError(`${where}: "${path}" is a \`${prop.type}\` property, which holds no text to search. Searchable kinds are \`string\`, \`string[]\` and \`map\` (or a path inside one).`);
|
|
5102
|
+
if (classified.reason === "enum") throw new SearchConfigError(`${where}: "${path}" is an enum. Enums are a fixed vocabulary — filter on them with \`where\` instead, which is exact and uses an index.`);
|
|
5103
|
+
if (classified.reason === "uuid") throw new SearchConfigError(`${where}: "${path}" is a UUID column. Look it up by id rather than searching it.`);
|
|
5104
|
+
if (classified.reason === "json") throw new SearchConfigError(`${where}: "${path}" is a \`json\` column, and the cast from \`json\` to \`jsonb\` is not immutable, so it cannot feed a generated column. Declare the property as \`jsonb\` (the default) to search it.`);
|
|
5105
|
+
if (classified.reason === "non_text_array") throw new SearchConfigError(`${where}: "${path}" is an array of numbers or booleans. Only \`string[]\` carries text to search.`);
|
|
5106
|
+
if (rest.length > 0 && classified.kind !== "jsonb") throw new SearchConfigError(`${where}: "${path}" addresses a path inside "${head}", but "${head}" is a \`${prop.type}\` property, not a \`map\`. Only map properties have paths inside them.`);
|
|
5107
|
+
const column = columnNameOf(head, prop);
|
|
5108
|
+
const textSql = normalize(rawTextSql({
|
|
5109
|
+
column,
|
|
5110
|
+
jsonPath: rest,
|
|
5111
|
+
kind: classified.kind
|
|
5112
|
+
}), cfg.unaccent === true);
|
|
5113
|
+
const language = cfg.language ?? "simple";
|
|
5114
|
+
return {
|
|
5115
|
+
path,
|
|
5116
|
+
column,
|
|
5117
|
+
jsonPath: rest,
|
|
5118
|
+
kind: classified.kind,
|
|
5119
|
+
weight,
|
|
5120
|
+
sql: `setweight(to_tsvector(${quote(language)}, ${textSql}), ${quote(weight)})`,
|
|
5121
|
+
textSql
|
|
5122
|
+
};
|
|
5123
|
+
};
|
|
5124
|
+
/**
|
|
5125
|
+
* Build the full spec for a collection, or undefined when it has not opted in.
|
|
5126
|
+
*
|
|
5127
|
+
* Throws {@link SearchConfigError} on a config that cannot be honoured. Callers
|
|
5128
|
+
* at boot surface that as a startup failure — a search block that half-works is
|
|
5129
|
+
* worse than one that refuses.
|
|
5130
|
+
*/
|
|
5131
|
+
var buildSearchColumnSpec = (collection) => {
|
|
5132
|
+
const cfg = getSearchConfig(collection);
|
|
5133
|
+
if (!cfg) return void 0;
|
|
5134
|
+
if (!Array.isArray(cfg.fields) || cfg.fields.length === 0) throw new SearchConfigError(`${collection.slug}.search: \`fields\` is empty. Name the properties to index, or remove the \`search\` block to keep the default ILIKE behaviour.`);
|
|
5135
|
+
const table = getTableName(collection);
|
|
5136
|
+
const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
|
|
5137
|
+
const column = cfg.column ?? "search_vector";
|
|
5138
|
+
if (collection.properties?.[column]) throw new SearchConfigError(`${collection.slug}.search: the generated column "${column}" collides with a declared property of the same name. Set \`search.column\` to something else.`);
|
|
5139
|
+
const fields = cfg.fields.map((entry) => resolveField(entry, collection, cfg));
|
|
5140
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5141
|
+
for (const f of fields) {
|
|
5142
|
+
if (seen.has(f.path)) throw new SearchConfigError(`${collection.slug}.search: "${f.path}" is listed twice.`);
|
|
5143
|
+
seen.add(f.path);
|
|
5144
|
+
}
|
|
5145
|
+
const extensions = [];
|
|
5146
|
+
if (cfg.unaccent) extensions.push("unaccent");
|
|
5147
|
+
if (cfg.fuzzy) extensions.push("pg_trgm");
|
|
5148
|
+
const spec = {
|
|
5149
|
+
schema,
|
|
5150
|
+
table,
|
|
5151
|
+
column,
|
|
5152
|
+
language: cfg.language ?? "simple",
|
|
5153
|
+
unaccent: cfg.unaccent === true,
|
|
5154
|
+
fields,
|
|
5155
|
+
expression: fields.map((f) => f.sql).join(" || "),
|
|
5156
|
+
indexName: toPostgresIdentifier(`${table}_${column}_gin`),
|
|
5157
|
+
extensions
|
|
5158
|
+
};
|
|
5159
|
+
if (cfg.fuzzy) {
|
|
5160
|
+
const fuzzyColumn = `${column}_text`;
|
|
5161
|
+
if (collection.properties?.[fuzzyColumn]) throw new SearchConfigError(`${collection.slug}.search: \`fuzzy\` needs the column "${fuzzyColumn}", which collides with a declared property. Set \`search.column\` to something else.`);
|
|
5162
|
+
spec.fuzzy = {
|
|
5163
|
+
column: fuzzyColumn,
|
|
5164
|
+
expression: fields.map((f) => f.textSql).join(" || ' ' || "),
|
|
5165
|
+
indexName: toPostgresIdentifier(`${table}_${fuzzyColumn}_trgm`),
|
|
5166
|
+
threshold: cfg.fuzzyThreshold ?? .3
|
|
5167
|
+
};
|
|
5168
|
+
}
|
|
5169
|
+
return spec;
|
|
5170
|
+
};
|
|
5171
|
+
/**
|
|
5172
|
+
* The IMMUTABLE wrappers the generated expressions call.
|
|
5173
|
+
*
|
|
5174
|
+
* `CREATE OR REPLACE` so a boot against an existing database is a no-op rather
|
|
5175
|
+
* than an error, and idempotent for the same reason every other boot-time DDL
|
|
5176
|
+
* statement here is.
|
|
5177
|
+
*
|
|
5178
|
+
* The bodies are stable built-ins wrapped in an immutable promise — see the
|
|
5179
|
+
* module comment for why that promise is sound. `STRICT` matters: it makes NULL
|
|
5180
|
+
* in mean NULL out without executing the body, which is what the `coalesce` at
|
|
5181
|
+
* each call site then absorbs.
|
|
5182
|
+
*/
|
|
5183
|
+
var searchHelperFunctions = (spec) => {
|
|
5184
|
+
const statements = [`CREATE OR REPLACE FUNCTION ${SEARCH_TEXT_FN}(text[]) RETURNS text\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE AS\n $$ SELECT array_to_string($1, ' ') $$;`, `CREATE OR REPLACE FUNCTION ${SEARCH_TEXT_FN}(jsonb) RETURNS text\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE AS\n $$ SELECT coalesce(string_agg(v, ' '), '')\n FROM jsonb_array_elements_text(jsonb_path_query_array($1, 'strict $.**?(@.type() == "string")')) AS v $$;`];
|
|
5185
|
+
if (spec.unaccent) statements.push(`CREATE OR REPLACE FUNCTION ${SEARCH_UNACCENT_FN}(text) RETURNS text\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE AS\n $$ SELECT ${HELPER_SCHEMA}.unaccent('${HELPER_SCHEMA}.unaccent'::regdictionary, $1) $$;`);
|
|
5186
|
+
return statements;
|
|
5187
|
+
};
|
|
5188
|
+
/**
|
|
5189
|
+
* `CREATE EXTENSION` statements the spec's expressions depend on.
|
|
5190
|
+
*
|
|
5191
|
+
* `WITH SCHEMA public` is load-bearing, not tidiness. An unqualified
|
|
5192
|
+
* `CREATE EXTENSION` installs into the first schema on `search_path`, which
|
|
5193
|
+
* defaults to `"$user", public` — and the scaffold's database role is named
|
|
5194
|
+
* `rebase`, the same as the schema the generator creates one statement earlier.
|
|
5195
|
+
* So the moment that schema exists, `CREATE EXTENSION unaccent` puts the
|
|
5196
|
+
* dictionary in `rebase`, and every reference to `public.unaccent` below fails
|
|
5197
|
+
* with "text search dictionary does not exist". Observed, not theorised.
|
|
5198
|
+
*/
|
|
5199
|
+
var searchExtensionStatements = (spec) => spec.extensions.map((e) => `CREATE EXTENSION IF NOT EXISTS ${e} WITH SCHEMA ${HELPER_SCHEMA};`);
|
|
5200
|
+
/**
|
|
5201
|
+
* Index statements for the spec.
|
|
5202
|
+
*
|
|
5203
|
+
* `CONCURRENTLY` is deliberately *not* used here. This form is emitted into a
|
|
5204
|
+
* SQL file replayed as one unit — a migration, or `search.sql` — where a
|
|
5205
|
+
* concurrent build is not allowed. The boot-time ensure path runs statement by
|
|
5206
|
+
* statement against tables that are live and populated, and uses the
|
|
5207
|
+
* concurrent form instead; see `ensureSearchColumns`.
|
|
5208
|
+
*/
|
|
5209
|
+
var searchIndexStatements = (spec) => {
|
|
5210
|
+
const statements = [`CREATE INDEX IF NOT EXISTS "${spec.indexName}" ON "${spec.schema}"."${spec.table}" USING GIN ("${spec.column}");`];
|
|
5211
|
+
if (spec.fuzzy) statements.push(`CREATE INDEX IF NOT EXISTS "${spec.fuzzy.indexName}" ON "${spec.schema}"."${spec.table}" USING GIN ("${spec.fuzzy.column}" ${HELPER_SCHEMA}.gin_trgm_ops);`);
|
|
5212
|
+
return statements;
|
|
5213
|
+
};
|
|
5214
|
+
/**
|
|
5215
|
+
* Marker on the comment of every generated search column this module creates.
|
|
5216
|
+
*
|
|
5217
|
+
* Versioned because the fingerprint below is only comparable against itself: a
|
|
5218
|
+
* future change to how it is computed has to read as "not stamped by this
|
|
5219
|
+
* version" rather than as drift on every existing column.
|
|
5220
|
+
*/
|
|
5221
|
+
var SEARCH_STAMP_PREFIX = "rebase:search:v1:";
|
|
5222
|
+
/**
|
|
5223
|
+
* A stable fingerprint of one generated column's expression.
|
|
5224
|
+
*
|
|
5225
|
+
* Why a stamp rather than reading the expression back: Postgres stores a
|
|
5226
|
+
* generated column's expression *parsed*, and hands it back deparsed — casts
|
|
5227
|
+
* made explicit, identifiers requoted, schema qualifications added or dropped
|
|
5228
|
+
* according to `search_path`. Comparing that text to the text we generated
|
|
5229
|
+
* would report drift on wording, and this comparison decides whether a boot
|
|
5230
|
+
* refuses, so a false positive is an outage. The stamp is written by the same
|
|
5231
|
+
* code that writes the column, so equality means what it says.
|
|
5232
|
+
*/
|
|
5233
|
+
var searchExpressionFingerprint = (expression) => `${SEARCH_STAMP_PREFIX}${createHash("sha256").update(expression).digest("hex").slice(0, 16)}`;
|
|
5234
|
+
/**
|
|
5235
|
+
* The stamps for a spec's generated columns — one per column, never shared.
|
|
5236
|
+
*
|
|
5237
|
+
* Per column on purpose: turning `fuzzy` on adds a second column and changes
|
|
5238
|
+
* nothing about the first, and a spec-wide fingerprint would report the
|
|
5239
|
+
* untouched `tsvector` column as drifted and refuse a boot over a change that
|
|
5240
|
+
* is purely additive.
|
|
5241
|
+
*/
|
|
5242
|
+
var searchColumnStamps = (spec) => {
|
|
5243
|
+
const stamp = (column, expression) => {
|
|
5244
|
+
const fingerprint = searchExpressionFingerprint(expression);
|
|
5245
|
+
return {
|
|
5246
|
+
column,
|
|
5247
|
+
expression,
|
|
5248
|
+
fingerprint,
|
|
5249
|
+
sql: `COMMENT ON COLUMN "${spec.schema}"."${spec.table}"."${column}" IS ${quote(fingerprint)};`
|
|
5250
|
+
};
|
|
5251
|
+
};
|
|
5252
|
+
const stamps = [stamp(spec.column, spec.expression)];
|
|
5253
|
+
if (spec.fuzzy) stamps.push(stamp(spec.fuzzy.column, spec.fuzzy.expression));
|
|
5254
|
+
return stamps;
|
|
5255
|
+
};
|
|
5256
|
+
/**
|
|
5257
|
+
* The generated column names a collection's search block adds, if any.
|
|
5258
|
+
*
|
|
5259
|
+
* These are physical columns on the table, so `SELECT *` returns them. They are
|
|
5260
|
+
* an index in column form — a list of lexeme positions, or a concatenation of
|
|
5261
|
+
* every searchable field on the row — and nothing outside the query planner has
|
|
5262
|
+
* any use for them. Left in, every list response carries a second, larger copy
|
|
5263
|
+
* of the row's text.
|
|
5264
|
+
*/
|
|
5265
|
+
var searchColumnNames = (collection) => {
|
|
5266
|
+
let spec;
|
|
5267
|
+
try {
|
|
5268
|
+
spec = buildSearchColumnSpec(collection);
|
|
5269
|
+
} catch {
|
|
5270
|
+
return [];
|
|
5271
|
+
}
|
|
5272
|
+
if (!spec) return [];
|
|
5273
|
+
return spec.fuzzy ? [spec.column, spec.fuzzy.column] : [spec.column];
|
|
5274
|
+
};
|
|
5275
|
+
/**
|
|
5276
|
+
* True for a column whose type only ever holds a search index.
|
|
5277
|
+
*
|
|
5278
|
+
* Independent of any collection config on purpose: an introspected database
|
|
5279
|
+
* (BaaS mode) can carry a `tsvector` column this framework never created —
|
|
5280
|
+
* Pagila's `film.fulltext` is the canonical one — and it should not be returned
|
|
5281
|
+
* to callers either. `isDerivedIndexColumn` already keeps such a column out of
|
|
5282
|
+
* the *properties*; this keeps it out of the *rows*.
|
|
5283
|
+
*/
|
|
5284
|
+
var isSearchIndexColumn = (column) => {
|
|
5285
|
+
const sqlType = typeof column?.getSQLType === "function" ? column.getSQLType().toLowerCase() : "";
|
|
5286
|
+
return sqlType === "tsvector" || sqlType === "tsquery";
|
|
5287
|
+
};
|
|
5288
|
+
/**
|
|
5289
|
+
* A drizzle select projection over `table` with the search columns dropped.
|
|
5290
|
+
*
|
|
5291
|
+
* Returns undefined when nothing needs dropping, so the common case keeps using
|
|
5292
|
+
* a plain `select()` and this stays invisible in the generated SQL.
|
|
5293
|
+
*/
|
|
5294
|
+
var visibleColumnProjection = (tableColumns, collection) => {
|
|
5295
|
+
const excluded = excludedColumnNames(tableColumns, collection);
|
|
5296
|
+
if (!tableColumns || excluded.length === 0) return void 0;
|
|
5297
|
+
const projection = {};
|
|
5298
|
+
for (const [name, column] of Object.entries(tableColumns)) if (!excluded.includes(name)) projection[name] = column;
|
|
5299
|
+
return projection;
|
|
5300
|
+
};
|
|
5301
|
+
/** The same exclusion as a drizzle `db.query` `columns` denylist. */
|
|
5302
|
+
var hiddenColumnsOption = (tableColumns, collection) => {
|
|
5303
|
+
const excluded = excludedColumnNames(tableColumns, collection);
|
|
5304
|
+
if (excluded.length === 0) return void 0;
|
|
5305
|
+
return Object.fromEntries(excluded.map((name) => [name, false]));
|
|
5306
|
+
};
|
|
5307
|
+
/**
|
|
5308
|
+
* The columns to keep out of a response, by name.
|
|
5309
|
+
*
|
|
5310
|
+
* `tableColumns` is whatever `getTableColumns` returned, which is `undefined`
|
|
5311
|
+
* for anything that is not a real drizzle table — a stub in a test, a derived
|
|
5312
|
+
* or nested path with no table behind it. Nothing to exclude is the right
|
|
5313
|
+
* answer there, and it has to be an answer rather than a throw: this runs on
|
|
5314
|
+
* the read path of every collection, opted in or not.
|
|
5315
|
+
*/
|
|
5316
|
+
var excludedColumnNames = (tableColumns, collection) => {
|
|
5317
|
+
if (!tableColumns || typeof tableColumns !== "object") return [];
|
|
5318
|
+
const byName = new Set(collection ? searchColumnNames(collection) : []);
|
|
5319
|
+
return Object.keys(tableColumns).filter((name) => byName.has(name) || isSearchIndexColumn(tableColumns[name]));
|
|
5320
|
+
};
|
|
5321
|
+
//#endregion
|
|
5322
|
+
//#region src/schema/auth-users-columns.ts
|
|
5323
|
+
/**
|
|
5324
|
+
* `email` is NOT NULL on purpose, and the anonymous sign-in route depends on it
|
|
5325
|
+
* — it synthesizes `anon_<32 hex>@anonymous.local` rather than inserting NULL.
|
|
5326
|
+
* The 320-char bound (RFC 5321) is a CHECK rather than a `VARCHAR(n)`, added
|
|
5327
|
+
* separately by `ensureAuthTablesExist` so it can be `NOT VALID` on an adopted
|
|
5328
|
+
* table that already holds a longer row.
|
|
5329
|
+
*/
|
|
5330
|
+
var AUTH_USERS_COLUMNS = [
|
|
5331
|
+
{
|
|
5332
|
+
column: "email",
|
|
5333
|
+
type: "TEXT",
|
|
5334
|
+
notNull: true
|
|
5335
|
+
},
|
|
5336
|
+
{
|
|
5337
|
+
column: "display_name",
|
|
5338
|
+
type: "TEXT"
|
|
5339
|
+
},
|
|
5340
|
+
{
|
|
5341
|
+
column: "photo_url",
|
|
5342
|
+
type: "TEXT"
|
|
5343
|
+
},
|
|
5344
|
+
{
|
|
5345
|
+
column: "roles",
|
|
5346
|
+
type: "TEXT[]",
|
|
5347
|
+
default: "'{}'",
|
|
5348
|
+
notNull: true
|
|
5349
|
+
},
|
|
5350
|
+
{
|
|
5351
|
+
column: "password_hash",
|
|
5352
|
+
type: "TEXT"
|
|
5353
|
+
},
|
|
5354
|
+
{
|
|
5355
|
+
column: "email_verified",
|
|
5356
|
+
type: "BOOLEAN",
|
|
5357
|
+
default: "FALSE",
|
|
5358
|
+
notNull: true
|
|
5359
|
+
},
|
|
5360
|
+
{
|
|
5361
|
+
column: "email_verification_token",
|
|
5362
|
+
type: "TEXT"
|
|
5363
|
+
},
|
|
5364
|
+
{
|
|
5365
|
+
column: "email_verification_sent_at",
|
|
5366
|
+
type: "TIMESTAMP WITH TIME ZONE"
|
|
5367
|
+
},
|
|
5368
|
+
{
|
|
5369
|
+
column: "is_anonymous",
|
|
5370
|
+
type: "BOOLEAN",
|
|
5371
|
+
default: "FALSE",
|
|
5372
|
+
notNull: true
|
|
5373
|
+
},
|
|
5374
|
+
{
|
|
5375
|
+
column: "metadata",
|
|
5376
|
+
type: "JSONB",
|
|
5377
|
+
default: "'{}'",
|
|
5378
|
+
notNull: true
|
|
5379
|
+
},
|
|
5380
|
+
{
|
|
5381
|
+
column: "tokens_valid_after",
|
|
5382
|
+
type: "TIMESTAMP WITH TIME ZONE"
|
|
5383
|
+
},
|
|
5384
|
+
{
|
|
5385
|
+
column: "created_at",
|
|
5386
|
+
type: "TIMESTAMP WITH TIME ZONE",
|
|
5387
|
+
default: "NOW()",
|
|
5388
|
+
notNull: true
|
|
5389
|
+
},
|
|
5390
|
+
{
|
|
5391
|
+
column: "updated_at",
|
|
5392
|
+
type: "TIMESTAMP WITH TIME ZONE",
|
|
5393
|
+
default: "NOW()",
|
|
5394
|
+
notNull: true
|
|
5395
|
+
}
|
|
5396
|
+
];
|
|
5397
|
+
var BY_COLUMN = new Map(AUTH_USERS_COLUMNS.map((c) => [c.column, c]));
|
|
5398
|
+
/** Type + inline constraints, as they appear after the column name. */
|
|
5399
|
+
function authUsersColumnSql(spec) {
|
|
5400
|
+
return [
|
|
5401
|
+
spec.type,
|
|
5402
|
+
spec.default !== void 0 ? `DEFAULT ${spec.default}` : "",
|
|
5403
|
+
spec.notNull ? "NOT NULL" : ""
|
|
5404
|
+
].filter(Boolean).join(" ");
|
|
5405
|
+
}
|
|
5406
|
+
/**
|
|
5407
|
+
* The auth-owned definition for a physical column name, or `undefined` when
|
|
5408
|
+
* auth does not own it.
|
|
5409
|
+
*
|
|
5410
|
+
* Callers pass the RESOLVED column name (after `columnName` mapping), because
|
|
5411
|
+
* that is the only name the three creators agree on: the scaffold's users
|
|
5412
|
+
* collection spells the property `displayName` and the column `display_name`.
|
|
5413
|
+
*/
|
|
5414
|
+
function authUsersColumnDefinition(column) {
|
|
5415
|
+
const spec = BY_COLUMN.get(column);
|
|
5416
|
+
return spec ? authUsersColumnSql(spec) : void 0;
|
|
5417
|
+
}
|
|
5418
|
+
/**
|
|
5419
|
+
* Whether a collection is an auth collection, i.e. whether the definitions in
|
|
5420
|
+
* this module apply to its table at all.
|
|
5421
|
+
*
|
|
5422
|
+
* Duplicated in shape from `@rebasepro/common`'s policy defaults on purpose:
|
|
5423
|
+
* that one takes a `CollectionConfig`, this one is called from DDL code paths
|
|
5424
|
+
* that hold looser objects, and both spellings must accept `auth: true` as well
|
|
5425
|
+
* as `auth: { enabled: true }`.
|
|
5426
|
+
*/
|
|
5427
|
+
function isAuthCollection(collection) {
|
|
5428
|
+
const auth = collection?.auth;
|
|
5429
|
+
if (auth === true) return true;
|
|
5430
|
+
return typeof auth === "object" && auth !== null && auth.enabled === true;
|
|
5431
|
+
}
|
|
5432
|
+
//#endregion
|
|
5433
|
+
export { DEFAULT_ONE_OF_TYPE as $, getEnumVarName as A, createRelationRefWithData as B, getEffectiveSecurityRules as C, fieldKeyForColumn as D, findAnonymousGrants as E, getDeclaredPrimaryKeys as F, legacyForeignKeyName as G, updateDateAutoValues as H, isAddressableId as I, getPolicyNamesForRule as J, toPostgresIdentifier as K, parseIdValues as L, getTableVarName as M, resolveCollectionRelations as N, findRelation as O, buildCompositeId as P, toSnakeCase as Q, sortCollectionsBySlug as R, resolveJunctionSpecs as S, securityRuleToConditions as T, firstFreeKey as U, normalizeToEntityRelation as V, generateForeignKeyName as W, mergeDeep as X, isPrototypePollutingKey as Y, camelCase as Z, CollectionRegistry as _, SEARCH_STAMP_PREFIX as a, getJunctionCollectionConfig as b, assertSearchIsPostgresOnly as c, searchColumnStamps as d, DEFAULT_ONE_OF_VALUE as et, searchExtensionStatements as f, buildSdkData as g, visibleColumnProjection as h, isAuthCollection as i, getTableName as j, getColumnName as k, buildSearchColumnSpec as l, searchIndexStatements as m, authUsersColumnDefinition as n, isManyToMany as nt, SEARCH_TEXT_FN as o, searchHelperFunctions as p, toWireKey as q, authUsersColumnSql as r, Vector as rt, SEARCH_UNACCENT_FN as s, AUTH_USERS_COLUMNS as t, hasForeignKeyOnTarget as tt, hiddenColumnsOption as u, relationalCollections as v, policyToPostgres as w, getJunctionSecurityRules as x, resolveStringColumnLength as y, createRelationRef as z };
|
|
4298
5434
|
|
|
4299
|
-
//# sourceMappingURL=
|
|
5435
|
+
//# sourceMappingURL=auth-users-columns-BfQHf9JE.js.map
|