@rebasepro/server-postgres 0.13.1-canary.g18cfeb7 → 0.13.1-canary.g1bf9303
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/PostgresBootstrapper.d.ts +26 -0
- package/dist/auth/services.d.ts +21 -0
- package/dist/{auth-users-columns-Do9mw5Y5.js → auth-users-columns-BxT6FRyu.js} +819 -68
- package/dist/auth-users-columns-BxT6FRyu.js.map +1 -0
- package/dist/{backup-service-Bww-Lg0s.js → backup-service-BH0Dzo_h.js} +2 -3
- package/dist/{backup-service-Bww-Lg0s.js.map → backup-service-BH0Dzo_h.js.map} +1 -1
- package/dist/cli-helpers.d.ts +56 -0
- 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-CP5_YttS.js +124 -0
- package/dist/ensure-collection-policies-CP5_YttS.js.map +1 -0
- package/dist/{ensure-collection-tables-DSIxvLLD.js → ensure-collection-tables-DWVISfcC.js} +119 -13
- package/dist/ensure-collection-tables-DWVISfcC.js.map +1 -0
- package/dist/index.es.js +998 -600
- package/dist/index.es.js.map +1 -1
- package/dist/{rls-bootstrap-sql-Bpv3nUZo.js → rls-bootstrap-sql-69hYT8nr.js} +2 -2
- package/dist/{rls-bootstrap-sql-Bpv3nUZo.js.map → rls-bootstrap-sql-69hYT8nr.js.map} +1 -1
- package/dist/rls-enforcement-Ct9ab42o.js +425 -0
- package/dist/rls-enforcement-Ct9ab42o.js.map +1 -0
- package/dist/schema/auth-schema.d.ts +102 -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 +60 -6
- package/dist/schema/generate-drizzle-schema-logic.d.ts +9 -1
- package/dist/schema/generate-postgres-ddl-logic.d.ts +48 -0
- package/dist/schema/search-column.d.ts +248 -0
- package/dist/security/rls-enforcement.d.ts +8 -3
- package/dist/services/FetchService.d.ts +24 -0
- package/dist/services/RelationService.d.ts +30 -0
- package/dist/services/collection-helpers.d.ts +26 -0
- package/dist/services/dataService.d.ts +3 -0
- package/dist/services/realtimeService.d.ts +139 -2
- package/dist/{src-C_wvdMnl.js → src-DCdn3Val.js} +35 -3
- package/dist/src-DCdn3Val.js.map +1 -0
- package/dist/utils/drizzle-conditions.d.ts +124 -2
- package/dist/{websocket-D0TBU3ia.js → websocket-C8ZqVBiV.js} +75 -18
- package/dist/websocket-C8ZqVBiV.js.map +1 -0
- package/package.json +6 -6
- package/src/PostgresBackendDriver.ts +7 -3
- package/src/PostgresBootstrapper.ts +95 -9
- package/src/auth/ensure-tables.ts +27 -5
- 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 +110 -4
- package/src/cli-output.ts +43 -0
- package/src/cli.ts +210 -145
- package/src/collections/buildRegistry.ts +3 -1
- package/src/data-transformer.ts +107 -14
- package/src/history/ensure-history-table.ts +2 -2
- package/src/schema/auth-schema.ts +17 -1
- package/src/schema/doctor-cli.ts +14 -65
- package/src/schema/doctor-policy-checks.ts +105 -0
- package/src/schema/doctor.ts +149 -72
- package/src/schema/ensure-collection-policies.ts +99 -6
- package/src/schema/ensure-collection-tables.ts +286 -13
- package/src/schema/generate-drizzle-schema-logic.ts +126 -42
- package/src/schema/generate-drizzle-schema.ts +11 -10
- package/src/schema/generate-postgres-ddl-logic.ts +204 -10
- package/src/schema/generate-postgres-ddl.ts +38 -14
- package/src/schema/introspect-db-logic.ts +65 -33
- package/src/schema/introspect-db.ts +24 -24
- package/src/schema/search-column.ts +643 -0
- package/src/security/rls-enforcement.ts +9 -4
- package/src/services/FetchService.ts +229 -15
- package/src/services/PersistService.ts +24 -1
- package/src/services/RelationService.ts +119 -69
- package/src/services/cdc/trigger-cdc.ts +5 -1
- package/src/services/channel-history.ts +9 -3
- package/src/services/channel-presence.ts +10 -3
- package/src/services/collection-helpers.ts +54 -1
- package/src/services/dataService.ts +3 -0
- package/src/services/pg-notify-listener.ts +1 -1
- package/src/services/realtimeService.ts +347 -86
- package/src/utils/drizzle-conditions.ts +365 -23
- package/src/utils/pg-error-utils.ts +8 -3
- package/src/websocket.ts +113 -16
- package/dist/auth-users-columns-Do9mw5Y5.js.map +0 -1
- package/dist/ensure-collection-policies-DMUdRQdM.js +0 -57
- package/dist/ensure-collection-policies-DMUdRQdM.js.map +0 -1
- package/dist/ensure-collection-tables-DSIxvLLD.js.map +0 -1
- package/dist/policy-CPkCqVTz.js +0 -105
- package/dist/policy-CPkCqVTz.js.map +0 -1
- package/dist/src-C_wvdMnl.js.map +0 -1
- package/dist/websocket-D0TBU3ia.js.map +0 -1
|
@@ -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
|
|
@@ -55,25 +56,6 @@ function hasForeignKeyOnTarget(relation) {
|
|
|
55
56
|
function isManyToMany(relation) {
|
|
56
57
|
return relation.kind === "manyToMany";
|
|
57
58
|
}
|
|
58
|
-
/**
|
|
59
|
-
* Resolve a client-supplied list `limit` into a safe, always-defined value.
|
|
60
|
-
*
|
|
61
|
-
* - A provided limit is coerced to an integer and clamped to `[1, maxLimit]`,
|
|
62
|
-
* so `0`, negatives, and absurd values can never bypass the cap.
|
|
63
|
-
* - An absent / blank / non-numeric limit falls back to the mode default:
|
|
64
|
-
* `vectorDefaultLimit` for a vector search, otherwise `defaultLimit`.
|
|
65
|
-
*
|
|
66
|
-
* The return is never `undefined` — no ingress that routes its client limit
|
|
67
|
-
* through this can produce an unbounded read.
|
|
68
|
-
*/
|
|
69
|
-
function resolveClientListLimit(rawLimit, opts = {}) {
|
|
70
|
-
const maxLimit = opts.maxLimit ?? 1e3;
|
|
71
|
-
if (rawLimit != null && String(rawLimit).trim() !== "") {
|
|
72
|
-
const parsed = typeof rawLimit === "number" ? rawLimit : parseInt(String(rawLimit), 10);
|
|
73
|
-
if (Number.isFinite(parsed)) return Math.min(Math.max(1, Math.floor(parsed)), maxLimit);
|
|
74
|
-
}
|
|
75
|
-
return opts.vectorSearch ? opts.vectorDefaultLimit ?? 10 : opts.defaultLimit ?? 50;
|
|
76
|
-
}
|
|
77
59
|
//#endregion
|
|
78
60
|
//#region ../common/src/util/common.ts
|
|
79
61
|
var DEFAULT_ONE_OF_TYPE = "type";
|
|
@@ -1408,6 +1390,28 @@ function legacyForeignKeyName(name) {
|
|
|
1408
1390
|
const snake = toSnakeCase(name);
|
|
1409
1391
|
return `${snake.endsWith("s") ? snake.slice(0, -1) : snake}_id`;
|
|
1410
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
|
+
}
|
|
1411
1415
|
//#endregion
|
|
1412
1416
|
//#region ../common/src/util/entities.ts
|
|
1413
1417
|
/**
|
|
@@ -1517,6 +1521,25 @@ function createRelationRefWithData(id, path, data) {
|
|
|
1517
1521
|
data
|
|
1518
1522
|
};
|
|
1519
1523
|
}
|
|
1524
|
+
//#endregion
|
|
1525
|
+
//#region ../common/src/util/collections.ts
|
|
1526
|
+
/**
|
|
1527
|
+
* A copy of `collections` ordered by slug.
|
|
1528
|
+
*
|
|
1529
|
+
* Every generator that turns collections into a file is order-dependent, and
|
|
1530
|
+
* every one of them is compared against its own output — `rebase doctor`
|
|
1531
|
+
* regenerates in memory and diffs, `generate-sdk && git diff --exit-code` gates
|
|
1532
|
+
* CI. While only the *writers* sorted, a project whose `readdirSync` order
|
|
1533
|
+
* differed from its slug order was reported permanently out of date, and the
|
|
1534
|
+
* fix the message printed rewrote the file in the order it was already in. The
|
|
1535
|
+
* generators sort themselves now, so no caller can get this wrong.
|
|
1536
|
+
*
|
|
1537
|
+
* A slug-less collection is left to the generator's own validation, which names
|
|
1538
|
+
* the offending collection; sorting must not throw first.
|
|
1539
|
+
*/
|
|
1540
|
+
function sortCollectionsBySlug(collections) {
|
|
1541
|
+
return [...collections].sort((a, b) => (a.slug ?? "").localeCompare(b.slug ?? ""));
|
|
1542
|
+
}
|
|
1520
1543
|
/** The eight-four-four-four-twelve shape of a UUID, any version. */
|
|
1521
1544
|
var UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
1522
1545
|
/** Whether one address part can be a value of the column it addresses. */
|
|
@@ -2092,11 +2115,32 @@ function sqlToPolicy(sql) {
|
|
|
2092
2115
|
* is the more dangerous spelling, because it inverts a rule instead of
|
|
2093
2116
|
* emptying a table.
|
|
2094
2117
|
*/
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2118
|
+
/**
|
|
2119
|
+
* A `Map`, not an object literal.
|
|
2120
|
+
*
|
|
2121
|
+
* As `Record<string, string>` this was indexed with a literal taken straight
|
|
2122
|
+
* out of a policy, so every key on `Object.prototype` answered: a rule
|
|
2123
|
+
* comparing `rebase.uid()` to `"valueOf"`, `"toString"`, `"constructor"` or
|
|
2124
|
+
* `"hasOwnProperty"` found a truthy "platform" and reported an anonymous-grant
|
|
2125
|
+
* risk that does not exist — with the matched function interpolated into the
|
|
2126
|
+
* explanation as the platform's name. A security warning that fires on
|
|
2127
|
+
* innocent input is worse than none: it is what teaches people to skip the
|
|
2128
|
+
* warnings that are real.
|
|
2129
|
+
*
|
|
2130
|
+
* Same shape as the prototype-pollution class swept out of `setIn`, `getIn`,
|
|
2131
|
+
* `mergeDeep` and `unflattenObject` — a data-derived key reaching a plain
|
|
2132
|
+
* object. Found by a property test, on the input `"valueOf"`.
|
|
2133
|
+
*/
|
|
2134
|
+
var FOREIGN_CONVENTION_UIDS = /* @__PURE__ */ new Map([
|
|
2135
|
+
["anon", "Supabase"],
|
|
2136
|
+
["authenticated", "Supabase"],
|
|
2137
|
+
["service_role", "Supabase"]
|
|
2138
|
+
]);
|
|
2139
|
+
/**
|
|
2140
|
+
* The same foreign literals, as a pattern for SQL that could not be parsed
|
|
2141
|
+
* back into structure.
|
|
2142
|
+
*/
|
|
2143
|
+
var FOREIGN_UID_LITERAL_SQL = new RegExp(String.raw`rebase\.uid\(\)\s*=\s*'(${[...FOREIGN_CONVENTION_UIDS.keys()].join("|")})'`, "i");
|
|
2100
2144
|
/**
|
|
2101
2145
|
* `rebase.uid() IS NOT NULL` in raw SQL, the clause that is always true.
|
|
2102
2146
|
*
|
|
@@ -2143,17 +2187,27 @@ function findAnonymousGrants(expr) {
|
|
|
2143
2187
|
case "existsIn":
|
|
2144
2188
|
visit(e.where);
|
|
2145
2189
|
return;
|
|
2146
|
-
case "raw":
|
|
2190
|
+
case "raw": {
|
|
2147
2191
|
if (UID_NOT_NULL.test(e.sql)) found.push({
|
|
2148
2192
|
pattern: "uid-not-null",
|
|
2149
2193
|
detail: e.sql,
|
|
2150
2194
|
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".`
|
|
2151
2195
|
});
|
|
2196
|
+
const foreign = FOREIGN_UID_LITERAL_SQL.exec(e.sql);
|
|
2197
|
+
if (foreign) {
|
|
2198
|
+
const literal = foreign[1];
|
|
2199
|
+
found.push({
|
|
2200
|
+
pattern: "foreign-uid-literal",
|
|
2201
|
+
detail: literal,
|
|
2202
|
+
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".`
|
|
2203
|
+
});
|
|
2204
|
+
}
|
|
2152
2205
|
return;
|
|
2206
|
+
}
|
|
2153
2207
|
case "compare": {
|
|
2154
2208
|
const literal = [e.left, e.right].find((o) => o.kind === "literal");
|
|
2155
2209
|
if (!(e.left.kind === "authUid" || e.right.kind === "authUid") || typeof literal?.value !== "string") return;
|
|
2156
|
-
const platform = FOREIGN_CONVENTION_UIDS
|
|
2210
|
+
const platform = FOREIGN_CONVENTION_UIDS.get(literal.value);
|
|
2157
2211
|
if (!platform) return;
|
|
2158
2212
|
found.push({
|
|
2159
2213
|
pattern: "foreign-uid-literal",
|
|
@@ -2169,12 +2223,43 @@ function findAnonymousGrants(expr) {
|
|
|
2169
2223
|
return found;
|
|
2170
2224
|
}
|
|
2171
2225
|
function parseOperand(str) {
|
|
2172
|
-
if (
|
|
2173
|
-
const
|
|
2174
|
-
if (
|
|
2175
|
-
if (
|
|
2226
|
+
if (/^current_setting\s*\(\s*'app\.(uid|user_id)'\s*\)$/i.test(str) || /^rebase\.uid\(\)$/i.test(str)) return policy.authUid();
|
|
2227
|
+
const literal = parseSingleQuoted(str);
|
|
2228
|
+
if (literal !== null) return policy.literal(literal);
|
|
2229
|
+
if (/^-?\d+$/.test(str)) return policy.literal(Number(str));
|
|
2230
|
+
if (/^-?\d*\.\d+$/.test(str)) return policy.literal(Number(str));
|
|
2231
|
+
if (/^true$/i.test(str)) return policy.literal(true);
|
|
2232
|
+
if (/^false$/i.test(str)) return policy.literal(false);
|
|
2233
|
+
if (/^null$/i.test(str)) return policy.literal(null);
|
|
2234
|
+
if (/^\w+$/.test(str) && toSnakeCase(str) !== "") return policy.field(str);
|
|
2176
2235
|
return null;
|
|
2177
2236
|
}
|
|
2237
|
+
/**
|
|
2238
|
+
* Decode a single-quoted SQL literal, or null when `str` is not exactly one.
|
|
2239
|
+
*
|
|
2240
|
+
* Rejecting is as important as decoding: `'a' = 'b'` is two literals and an
|
|
2241
|
+
* operator, not one literal whose body contains a quote, and a regex anchored
|
|
2242
|
+
* on the outer quotes would happily read it as the latter. Every interior quote
|
|
2243
|
+
* must therefore be part of a `''` pair.
|
|
2244
|
+
*/
|
|
2245
|
+
function parseSingleQuoted(str) {
|
|
2246
|
+
if (str.length < 2 || !str.startsWith("'") || !str.endsWith("'")) return null;
|
|
2247
|
+
const body = str.slice(1, -1);
|
|
2248
|
+
let out = "";
|
|
2249
|
+
for (let i = 0; i < body.length; i++) {
|
|
2250
|
+
if (body[i] !== "'") {
|
|
2251
|
+
out += body[i];
|
|
2252
|
+
continue;
|
|
2253
|
+
}
|
|
2254
|
+
if (body[i + 1] === "'") {
|
|
2255
|
+
out += "'";
|
|
2256
|
+
i++;
|
|
2257
|
+
continue;
|
|
2258
|
+
}
|
|
2259
|
+
return null;
|
|
2260
|
+
}
|
|
2261
|
+
return out;
|
|
2262
|
+
}
|
|
2178
2263
|
//#endregion
|
|
2179
2264
|
//#region ../common/src/util/policy/securityRuleToConditions.ts
|
|
2180
2265
|
/**
|
|
@@ -2332,8 +2417,14 @@ function rolesArraySql(roles) {
|
|
|
2332
2417
|
* Rebase's enforcement model is unified: authenticated (user-context) requests
|
|
2333
2418
|
* run under the restricted `rebase_user` role, so Postgres RLS binds *every*
|
|
2334
2419
|
* statement — reads and writes. A collection's `securityRules` are the whole
|
|
2335
|
-
* authorization model. The server context (auth flows, migrations,
|
|
2336
|
-
* `
|
|
2420
|
+
* authorization model. The server context (auth flows, migrations, raw
|
|
2421
|
+
* `rebase.sql`) runs as the owner and bypasses RLS.
|
|
2422
|
+
*
|
|
2423
|
+
* `rebase.dataAsAdmin` is **not** in that set, despite the name: it is scoped as
|
|
2424
|
+
* `{ uid: "service", roles: ["admin"] }`, so it runs as `rebase_user` like any
|
|
2425
|
+
* other caller and clears the baseline below through the *admin* arm, not the
|
|
2426
|
+
* server arm. Which is why `disableDefaultPolicies` plus a lone
|
|
2427
|
+
* `policy.serverContext()` rule locks it out too.
|
|
2337
2428
|
*
|
|
2338
2429
|
* Because RLS default-denies, every collection is **locked by default**: with
|
|
2339
2430
|
* no rules, only the server context and admins can touch it. The generator
|
|
@@ -2391,11 +2482,28 @@ function getIdPropertyName$1(collection) {
|
|
|
2391
2482
|
*
|
|
2392
2483
|
* Collections that opt out via `disableDefaultPolicies` are returned unchanged.
|
|
2393
2484
|
*/
|
|
2485
|
+
/**
|
|
2486
|
+
* The restrictive write gate for an auth collection.
|
|
2487
|
+
*
|
|
2488
|
+
* Restrictive, so it is ANDed with everything else: whatever an author's
|
|
2489
|
+
* permissive rules allow, a write to this table still has to satisfy this too.
|
|
2490
|
+
* It is the only thing standing between "users may edit their own row" and
|
|
2491
|
+
* "users may grant themselves any role".
|
|
2492
|
+
*/
|
|
2493
|
+
function adminWriteGate(tableName) {
|
|
2494
|
+
return {
|
|
2495
|
+
name: `${tableName}_require_admin_write`,
|
|
2496
|
+
mode: "restrictive",
|
|
2497
|
+
operations: [...DEFAULT_GUARDED_OPS],
|
|
2498
|
+
condition: SERVER_OR_ADMIN_EXPR$1,
|
|
2499
|
+
check: SERVER_OR_ADMIN_EXPR$1
|
|
2500
|
+
};
|
|
2501
|
+
}
|
|
2394
2502
|
function getEffectiveSecurityRules(collection) {
|
|
2395
2503
|
const explicit = [...collection.securityRules ?? []];
|
|
2396
|
-
if (isPostgresCollectionConfig(collection) && collection.disableDefaultPolicies) return explicit;
|
|
2397
2504
|
const tableName = getTableName(collection);
|
|
2398
2505
|
const injected = [];
|
|
2506
|
+
if (isPostgresCollectionConfig(collection) && collection.disableDefaultPolicies) return isAuthCollection$1(collection) ? [...explicit, adminWriteGate(tableName)] : explicit;
|
|
2399
2507
|
injected.push({
|
|
2400
2508
|
name: `${tableName}_default_admin_read`,
|
|
2401
2509
|
operations: ["select"],
|
|
@@ -2413,13 +2521,7 @@ function getEffectiveSecurityRules(collection) {
|
|
|
2413
2521
|
operations: ["select"],
|
|
2414
2522
|
condition: policy.compare(policy.field(getIdPropertyName$1(collection)), "eq", policy.authUid())
|
|
2415
2523
|
});
|
|
2416
|
-
injected.push(
|
|
2417
|
-
name: `${tableName}_require_admin_write`,
|
|
2418
|
-
mode: "restrictive",
|
|
2419
|
-
operations: [...DEFAULT_GUARDED_OPS],
|
|
2420
|
-
condition: SERVER_OR_ADMIN_EXPR$1,
|
|
2421
|
-
check: SERVER_OR_ADMIN_EXPR$1
|
|
2422
|
-
});
|
|
2524
|
+
injected.push(adminWriteGate(tableName));
|
|
2423
2525
|
}
|
|
2424
2526
|
return [...explicit, ...injected];
|
|
2425
2527
|
}
|
|
@@ -3708,8 +3810,24 @@ var QueryBuilder = class {
|
|
|
3708
3810
|
/**
|
|
3709
3811
|
* Set a free-text search string if supported by the backend.
|
|
3710
3812
|
*/
|
|
3711
|
-
search(searchString) {
|
|
3813
|
+
search(searchString, options) {
|
|
3712
3814
|
this.params.searchString = searchString;
|
|
3815
|
+
if (options?.explain !== void 0) this.params.searchExplain = options.explain;
|
|
3816
|
+
return this;
|
|
3817
|
+
}
|
|
3818
|
+
/**
|
|
3819
|
+
* Order rows by nearest-neighbour distance to `vector`, closest first.
|
|
3820
|
+
*
|
|
3821
|
+
* Postgres only, over a property declared as `type: "vector"`. Rows come
|
|
3822
|
+
* back with a `_distance`; `where` filters before the ordering.
|
|
3823
|
+
*/
|
|
3824
|
+
vectorSearch(property, vector, options) {
|
|
3825
|
+
this.params.vectorSearch = {
|
|
3826
|
+
property,
|
|
3827
|
+
vector,
|
|
3828
|
+
...options?.distance !== void 0 && { distance: options.distance },
|
|
3829
|
+
...options?.threshold !== void 0 && { threshold: options.threshold }
|
|
3830
|
+
};
|
|
3713
3831
|
return this;
|
|
3714
3832
|
}
|
|
3715
3833
|
/**
|
|
@@ -3909,21 +4027,50 @@ async function collectAllPages(find, params, label = "collection") {
|
|
|
3909
4027
|
* metadata, so type coercion is the responsibility of the server-side data
|
|
3910
4028
|
* driver which has access to the collection schema.
|
|
3911
4029
|
*
|
|
3912
|
-
*
|
|
3913
|
-
*
|
|
4030
|
+
* Structural characters inside a value are backslash-escaped: `,` → `\,`,
|
|
4031
|
+
* `(` → `\(`, `)` → `\)`, and a literal backslash as `\\`. Decoding is
|
|
4032
|
+
* deliberately conservative — only those four sequences are decoded, so a
|
|
4033
|
+
* backslash that arrives unescaped from an older client survives intact.
|
|
3914
4034
|
*
|
|
3915
4035
|
* @module
|
|
3916
4036
|
*/
|
|
3917
4037
|
/**
|
|
3918
|
-
*
|
|
3919
|
-
*
|
|
4038
|
+
* Escape a value for the wire format: `\` → `\\`, `,` → `\,`, `(` → `\(`,
|
|
4039
|
+
* `)` → `\)`.
|
|
3920
4040
|
*/
|
|
3921
|
-
|
|
4041
|
+
/**
|
|
4042
|
+
* The wire spelling of an empty list.
|
|
4043
|
+
*
|
|
4044
|
+
* A lone backslash: unproducible by {@link escapeWireValue}, which doubles
|
|
4045
|
+
* every backslash it emits, so it cannot collide with any real item.
|
|
4046
|
+
*/
|
|
4047
|
+
var EMPTY_LIST_TOKEN = "\\";
|
|
4048
|
+
/**
|
|
4049
|
+
* Unescape a wire-format value.
|
|
4050
|
+
*
|
|
4051
|
+
* **Conservative**, and deliberately so: only the four sequences
|
|
4052
|
+
* {@link escapeWireValue} actually produces are decoded. A backslash followed
|
|
4053
|
+
* by anything else is left exactly as it is.
|
|
4054
|
+
*
|
|
4055
|
+
* This used to consume the backslash before *any* character, which is
|
|
4056
|
+
* indistinguishable for anything this codec emitted — it only ever emits those
|
|
4057
|
+
* four — but not for input arriving from elsewhere. A client on an older
|
|
4058
|
+
* release sends a Windows path or a LIKE pattern with a literal `C:\x`
|
|
4059
|
+
* unescaped, and greedy unescaping silently turned it into `C:x`, changing
|
|
4060
|
+
* which rows matched. Decoding only what the encoder can produce makes the two
|
|
4061
|
+
* directions agree across versions.
|
|
4062
|
+
*/
|
|
4063
|
+
function unescapeWireValue(value) {
|
|
3922
4064
|
let result = "";
|
|
3923
|
-
for (let i = 0; i < value.length; i++)
|
|
3924
|
-
|
|
3925
|
-
i
|
|
3926
|
-
|
|
4065
|
+
for (let i = 0; i < value.length; i++) {
|
|
4066
|
+
const next = value[i + 1];
|
|
4067
|
+
if (value[i] === "\\" && (next === "\\" || next === "," || next === "(" || next === ")")) {
|
|
4068
|
+
result += next;
|
|
4069
|
+
i++;
|
|
4070
|
+
continue;
|
|
4071
|
+
}
|
|
4072
|
+
result += value[i];
|
|
4073
|
+
}
|
|
3927
4074
|
return result;
|
|
3928
4075
|
}
|
|
3929
4076
|
/**
|
|
@@ -3941,13 +4088,205 @@ function splitListItems(inner) {
|
|
|
3941
4088
|
current += inner[i] + inner[i + 1];
|
|
3942
4089
|
i++;
|
|
3943
4090
|
} else if (inner[i] === ",") {
|
|
3944
|
-
items.push(
|
|
4091
|
+
items.push(unescapeWireValue(current));
|
|
3945
4092
|
current = "";
|
|
3946
4093
|
} else current += inner[i];
|
|
3947
|
-
items.push(
|
|
4094
|
+
items.push(unescapeWireValue(current));
|
|
3948
4095
|
return items;
|
|
3949
4096
|
}
|
|
3950
|
-
|
|
4097
|
+
/**
|
|
4098
|
+
* Operator tables as `Map`s, because the key comes off the wire.
|
|
4099
|
+
*
|
|
4100
|
+
* Indexed as plain objects, every `Object.prototype` member answered: a query
|
|
4101
|
+
* string of `?f=valueOf.x` found a truthy "operator" — the inherited function —
|
|
4102
|
+
* and `deserializeTuple` returned it *as the operator*, so a function object
|
|
4103
|
+
* travelled on into the compilers in place of a `WhereFilterOp`. The guard one
|
|
4104
|
+
* line below (`if (!canonicalOp)`) reads as though it rejects anything unknown,
|
|
4105
|
+
* and does not: `Object.prototype` is not unknown to a plain object.
|
|
4106
|
+
*
|
|
4107
|
+
* Same shape as the prototype-key defects swept out of `setIn`, `getIn`,
|
|
4108
|
+
* `mergeDeep`, `unflattenObject` and `FOREIGN_CONVENTION_UIDS`.
|
|
4109
|
+
*/
|
|
4110
|
+
var REST_OP_LOOKUP = new Map(Object.entries(REST_TO_CANONICAL));
|
|
4111
|
+
new Map(Object.entries(CANONICAL_TO_REST));
|
|
4112
|
+
/** The operator spellings a rejection lists back to the caller. */
|
|
4113
|
+
var VALID_OPERATOR_LIST = ALL_WHERE_FILTER_OPS.join(", ");
|
|
4114
|
+
/**
|
|
4115
|
+
* A filter condition named an operator this dialect does not have.
|
|
4116
|
+
*
|
|
4117
|
+
* ## Why this throws, rather than returning a typed rejection
|
|
4118
|
+
*
|
|
4119
|
+
* `deserializeFilter` is the *shared* codec: the REST ingress
|
|
4120
|
+
* (`packages/server/src/api/rest/query-parser.ts`), the browser SDK and the
|
|
4121
|
+
* admin panel (`buildRebaseData.ts`) all decode through it. Two constraints
|
|
4122
|
+
* follow.
|
|
4123
|
+
*
|
|
4124
|
+
* - It cannot throw the server's `ApiError`. `@rebasepro/common` does not
|
|
4125
|
+
* depend on `@rebasepro/server` (the dependency runs the other way), and a
|
|
4126
|
+
* browser client has no error handler to render an `ApiError` with. So the
|
|
4127
|
+
* rejection is this plain `Error` subclass, whose `message` reads correctly
|
|
4128
|
+
* wherever it surfaces — a rejected promise in an app, a 400 body over HTTP.
|
|
4129
|
+
* - It cannot be a returned rejection *value*. Every caller assigns the result
|
|
4130
|
+
* straight into a query it is about to run; a sentinel that none of them
|
|
4131
|
+
* check would be ignored, which is exactly the silently-wrong-filter failure
|
|
4132
|
+
* this exists to stop. Throwing is also what this file already does for the
|
|
4133
|
+
* sibling cases — `serializeTuple` on an unknown canonical operator,
|
|
4134
|
+
* `deserializeLogicalCondition` past the nesting bound — and the REST parser
|
|
4135
|
+
* already converts the latter into a 400.
|
|
4136
|
+
*
|
|
4137
|
+
* `statusCode`, `code` and `details` are carried as fields because the server's
|
|
4138
|
+
* Hono error handler duck-types those off any thrown error: a decode path that
|
|
4139
|
+
* forgets to convert still answers 400 with the canonical envelope instead of a
|
|
4140
|
+
* 500 that says "An unexpected error occurred". `query-parser.ts` converts
|
|
4141
|
+
* explicitly all the same — that is the path the contract is stated on, and an
|
|
4142
|
+
* incidental 400 is not a contract.
|
|
4143
|
+
*/
|
|
4144
|
+
var UnknownFilterOperatorError = class extends Error {
|
|
4145
|
+
/** The field the condition was written against. */
|
|
4146
|
+
field;
|
|
4147
|
+
/** The operator string as it arrived, verbatim. */
|
|
4148
|
+
operator;
|
|
4149
|
+
/** Every operator this dialect accepts, in canonical spelling. */
|
|
4150
|
+
validOperators = ALL_WHERE_FILTER_OPS;
|
|
4151
|
+
/** See the class docblock: read by the server's error handler. */
|
|
4152
|
+
statusCode = 400;
|
|
4153
|
+
code = "UNKNOWN_FILTER_OPERATOR";
|
|
4154
|
+
details;
|
|
4155
|
+
constructor(field, operator) {
|
|
4156
|
+
super(`Unknown filter operator '${operator}' on field '${field}'. Valid operators: ${VALID_OPERATOR_LIST}`);
|
|
4157
|
+
this.name = "UnknownFilterOperatorError";
|
|
4158
|
+
this.field = field;
|
|
4159
|
+
this.operator = operator;
|
|
4160
|
+
this.details = {
|
|
4161
|
+
field,
|
|
4162
|
+
operator,
|
|
4163
|
+
validOperators: ALL_WHERE_FILTER_OPS
|
|
4164
|
+
};
|
|
4165
|
+
}
|
|
4166
|
+
};
|
|
4167
|
+
/**
|
|
4168
|
+
* Two to three characters of ASCII punctuation and nothing else — the shape
|
|
4169
|
+
* every symbolic operator has (`==`, `>=`, `<>`, `~~`, `!!`, `>>`, `===`), and
|
|
4170
|
+
* one a column value effectively never has.
|
|
4171
|
+
*
|
|
4172
|
+
* Two characters minimum on purpose. A *single* punctuation character is a
|
|
4173
|
+
* perfectly ordinary value — `{ grade: ["-", "+"] }` is a two-item list, not a
|
|
4174
|
+
* condition — and the only single-character operator anyone actually mistypes
|
|
4175
|
+
* is `=`, which is named separately below. `<` and `>` need no special case:
|
|
4176
|
+
* they are real operators and resolve.
|
|
4177
|
+
*/
|
|
4178
|
+
var SYMBOLIC_OPERATOR = /^[^\p{L}\p{N}\s]{2,3}$/u;
|
|
4179
|
+
/** Lowercase, strip everything that is not a letter or digit. */
|
|
4180
|
+
function normalizeOperatorName(op) {
|
|
4181
|
+
return op.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
4182
|
+
}
|
|
4183
|
+
/**
|
|
4184
|
+
* Every real operator name with its case and separators removed, so a
|
|
4185
|
+
* respelling of one — `arrayContains`, `not_in`, `NOT-LIKE`, `isNull` — is
|
|
4186
|
+
* recognised as an attempt at an operator rather than read as a value.
|
|
4187
|
+
*
|
|
4188
|
+
* These are rejected rather than accepted: admitting a second spelling of an
|
|
4189
|
+
* operator would leave two wire spellings of one thing, and the rejection
|
|
4190
|
+
* message names the one that works.
|
|
4191
|
+
*/
|
|
4192
|
+
var RESPELLED_OPERATORS = new Set([...ALL_WHERE_FILTER_OPS, ...Object.keys(REST_TO_CANONICAL)].map(normalizeOperatorName));
|
|
4193
|
+
/**
|
|
4194
|
+
* Operator names *other* query dialects use, which this one does not have.
|
|
4195
|
+
*
|
|
4196
|
+
* This list is curated, and deliberately so. For a word-shaped string there is
|
|
4197
|
+
* no rule that separates "an operator the caller guessed" from "a value that
|
|
4198
|
+
* happens to be a word": `{ tags: ["a", "b"] }` has to keep meaning a two-item
|
|
4199
|
+
* `in` list, so the codec cannot simply refuse every unrecognised word in
|
|
4200
|
+
* position 0. The line is therefore drawn by name, and only around names whose
|
|
4201
|
+
* use as an operator is far more likely than their use as one of two sibling
|
|
4202
|
+
* values. `contains` is the motivating case — the first thing a developer
|
|
4203
|
+
* reaches for, and until now it compiled to `title IN ('contains', 'Hell')`.
|
|
4204
|
+
*
|
|
4205
|
+
* Genuinely ambiguous single words (`any`, `all`, `exists`, `search`, `not`)
|
|
4206
|
+
* are left off: as operators they are rare, and as enum values they are common.
|
|
4207
|
+
* Everywhere else the tie goes to *rejecting*, because a 400 naming the
|
|
4208
|
+
* supported set costs the caller one round trip, and the alternative — which is
|
|
4209
|
+
* what every name on this list used to produce — is a query that runs, returns
|
|
4210
|
+
* rows, and is wrong.
|
|
4211
|
+
*/
|
|
4212
|
+
var NEAR_MISS_OPERATORS = /* @__PURE__ */ new Set([
|
|
4213
|
+
"contains",
|
|
4214
|
+
"notcontains",
|
|
4215
|
+
"doesnotcontain",
|
|
4216
|
+
"doesnotcontains",
|
|
4217
|
+
"includes",
|
|
4218
|
+
"notincludes",
|
|
4219
|
+
"startswith",
|
|
4220
|
+
"notstartswith",
|
|
4221
|
+
"beginswith",
|
|
4222
|
+
"startingwith",
|
|
4223
|
+
"endswith",
|
|
4224
|
+
"notendswith",
|
|
4225
|
+
"matches",
|
|
4226
|
+
"notmatches",
|
|
4227
|
+
"regex",
|
|
4228
|
+
"regexp",
|
|
4229
|
+
"between",
|
|
4230
|
+
"notbetween",
|
|
4231
|
+
"equals",
|
|
4232
|
+
"notequals",
|
|
4233
|
+
"equalto",
|
|
4234
|
+
"isequalto",
|
|
4235
|
+
"isnotequalto",
|
|
4236
|
+
"greaterthan",
|
|
4237
|
+
"greaterthanorequal",
|
|
4238
|
+
"greaterthanorequalto",
|
|
4239
|
+
"lessthan",
|
|
4240
|
+
"lessthanorequal",
|
|
4241
|
+
"lessthanorequalto",
|
|
4242
|
+
"isempty",
|
|
4243
|
+
"isnotempty",
|
|
4244
|
+
"oneof",
|
|
4245
|
+
"noneof",
|
|
4246
|
+
"anyof",
|
|
4247
|
+
"allof",
|
|
4248
|
+
"null",
|
|
4249
|
+
"isnullorempty"
|
|
4250
|
+
]);
|
|
4251
|
+
/**
|
|
4252
|
+
* Was this string *meant* as an operator?
|
|
4253
|
+
*
|
|
4254
|
+
* Only consulted after {@link toCanonicalOp} has already failed to resolve it,
|
|
4255
|
+
* so a `true` here is always a rejection.
|
|
4256
|
+
*/
|
|
4257
|
+
function isOperatorShaped(op) {
|
|
4258
|
+
if (op === "=") return true;
|
|
4259
|
+
if (SYMBOLIC_OPERATOR.test(op)) return true;
|
|
4260
|
+
const normalized = normalizeOperatorName(op);
|
|
4261
|
+
if (!normalized) return false;
|
|
4262
|
+
return RESPELLED_OPERATORS.has(normalized) || NEAR_MISS_OPERATORS.has(normalized);
|
|
4263
|
+
}
|
|
4264
|
+
/**
|
|
4265
|
+
* Read a `[op, value]` tuple, if that is what this is.
|
|
4266
|
+
*
|
|
4267
|
+
* Three outcomes, and the middle one is the defect this function exists for:
|
|
4268
|
+
*
|
|
4269
|
+
* - the operator resolves (canonical *or* REST spelling) → the canonical tuple;
|
|
4270
|
+
* - the operator does not resolve but was plainly meant as one → throw;
|
|
4271
|
+
* - it does not look like an operator at all → `undefined`, and the caller
|
|
4272
|
+
* falls back to reading the array as a list of values.
|
|
4273
|
+
*
|
|
4274
|
+
* The old test was `toCanonicalOp(raw[0]) === raw[0]`, i.e. canonical spelling
|
|
4275
|
+
* only, with *everything else* — including every REST short-code — dropping
|
|
4276
|
+
* through to `["in", raw]`. So the operator string itself became a value in a
|
|
4277
|
+
* membership test: `["!!", "Hello"]` compiled to `title IN ('!!','Hello')`,
|
|
4278
|
+
* which matches, and the caller got back rows their filter was written to
|
|
4279
|
+
* exclude. `["eq", "active"]` had the same shape of failure.
|
|
4280
|
+
*/
|
|
4281
|
+
function readTuple(field, raw) {
|
|
4282
|
+
if (!Array.isArray(raw) || raw.length !== 2) return void 0;
|
|
4283
|
+
const [op, value] = raw;
|
|
4284
|
+
if (typeof op !== "string") return void 0;
|
|
4285
|
+
const canonical = toCanonicalOp(op);
|
|
4286
|
+
if (canonical) return [canonical, value];
|
|
4287
|
+
if (op.includes(".")) return void 0;
|
|
4288
|
+
if (isOperatorShaped(op)) throw new UnknownFilterOperatorError(field, op);
|
|
4289
|
+
}
|
|
3951
4290
|
/**
|
|
3952
4291
|
* Parse a single PostgREST dot-string into a `[WhereFilterOp, unknown]` tuple.
|
|
3953
4292
|
*
|
|
@@ -3964,10 +4303,13 @@ function deserializeSingle(raw) {
|
|
|
3964
4303
|
if (dotIndex === -1) return ["==", raw];
|
|
3965
4304
|
const prefix = raw.substring(0, dotIndex);
|
|
3966
4305
|
const rest = raw.substring(dotIndex + 1);
|
|
3967
|
-
const canonicalOp = REST_OP_LOOKUP
|
|
4306
|
+
const canonicalOp = REST_OP_LOOKUP.get(prefix);
|
|
3968
4307
|
if (!canonicalOp) return ["==", raw];
|
|
3969
4308
|
if (NULL_OPS.has(canonicalOp)) return [canonicalOp, null];
|
|
3970
|
-
if (rest.startsWith("(") && rest.endsWith(")"))
|
|
4309
|
+
if (rest.startsWith("(") && rest.endsWith(")")) {
|
|
4310
|
+
const inner = rest.slice(1, -1);
|
|
4311
|
+
return [canonicalOp, inner === EMPTY_LIST_TOKEN ? [] : splitListItems(inner)];
|
|
4312
|
+
}
|
|
3971
4313
|
return [canonicalOp, rest];
|
|
3972
4314
|
}
|
|
3973
4315
|
/**
|
|
@@ -3982,20 +4324,27 @@ function deserializeSingle(raw) {
|
|
|
3982
4324
|
*
|
|
3983
4325
|
* deserializeFilter({ age: ["gte.18", "lt.65"] })
|
|
3984
4326
|
* // → { age: [[">=", "18"], ["<", "65"]] }
|
|
4327
|
+
*
|
|
4328
|
+
* @throws {UnknownFilterOperatorError} when a condition names an operator this
|
|
4329
|
+
* dialect does not have. See that class for why a rejection here is a throw.
|
|
3985
4330
|
*/
|
|
3986
4331
|
function deserializeFilter(query) {
|
|
3987
4332
|
const result = {};
|
|
3988
4333
|
for (const [field, raw] of Object.entries(query)) {
|
|
3989
4334
|
if (raw === void 0) continue;
|
|
3990
|
-
|
|
3991
|
-
|
|
4335
|
+
const tuple = readTuple(field, raw);
|
|
4336
|
+
if (tuple) {
|
|
4337
|
+
result[field] = tuple;
|
|
3992
4338
|
continue;
|
|
3993
4339
|
}
|
|
3994
4340
|
if (Array.isArray(raw)) {
|
|
3995
4341
|
if (raw.length === 0) continue;
|
|
3996
|
-
if (Array.isArray(raw[0])
|
|
3997
|
-
|
|
3998
|
-
|
|
4342
|
+
if (Array.isArray(raw[0])) {
|
|
4343
|
+
const tuples = raw.map((item) => readTuple(field, item));
|
|
4344
|
+
if (tuples.every((t) => t !== void 0)) {
|
|
4345
|
+
result[field] = tuples;
|
|
4346
|
+
continue;
|
|
4347
|
+
}
|
|
3999
4348
|
}
|
|
4000
4349
|
if (raw.length === 1) result[field] = typeof raw[0] === "string" ? deserializeSingle(raw[0]) : ["==", raw[0]];
|
|
4001
4350
|
else if (typeof raw[0] === "string" && raw[0].includes(".")) result[field] = raw.map((r) => typeof r === "string" ? deserializeSingle(r) : ["==", r]);
|
|
@@ -4039,10 +4388,12 @@ function createPrimaryKeyResolver(options) {
|
|
|
4039
4388
|
* than postgres still serve rows with one, and this keeps them working.
|
|
4040
4389
|
*/
|
|
4041
4390
|
function rowToEntity(row, slug, primaryKeys = []) {
|
|
4391
|
+
const { _matches, ...values } = row;
|
|
4042
4392
|
return {
|
|
4043
4393
|
id: primaryKeys.length > 0 ? buildCompositeId(row, primaryKeys) : row.id,
|
|
4044
4394
|
path: slug,
|
|
4045
|
-
values
|
|
4395
|
+
values,
|
|
4396
|
+
..._matches ? { searchMatches: _matches } : {}
|
|
4046
4397
|
};
|
|
4047
4398
|
}
|
|
4048
4399
|
/**
|
|
@@ -4200,6 +4551,7 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
|
|
|
4200
4551
|
orderBy: params?.orderBy?.[0],
|
|
4201
4552
|
order: params?.orderBy?.[1],
|
|
4202
4553
|
searchString: params?.searchString,
|
|
4554
|
+
searchExplain: params?.searchExplain,
|
|
4203
4555
|
onUpdate: (entities) => {
|
|
4204
4556
|
onUpdate({
|
|
4205
4557
|
data: entities.map((row) => rowToEntity(normalize(row), slug, getPks())),
|
|
@@ -4237,8 +4589,11 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
|
|
|
4237
4589
|
offset(count) {
|
|
4238
4590
|
return new QueryBuilder(accessor).offset(count);
|
|
4239
4591
|
},
|
|
4240
|
-
search(searchString) {
|
|
4241
|
-
return new QueryBuilder(accessor).search(searchString);
|
|
4592
|
+
search(searchString, options) {
|
|
4593
|
+
return new QueryBuilder(accessor).search(searchString, options);
|
|
4594
|
+
},
|
|
4595
|
+
vectorSearch(property, vector, options) {
|
|
4596
|
+
return new QueryBuilder(accessor).vectorSearch(property, vector, options);
|
|
4242
4597
|
},
|
|
4243
4598
|
include(...relations) {
|
|
4244
4599
|
return new QueryBuilder(accessor).include(...relations);
|
|
@@ -4326,8 +4681,18 @@ var SdkQueryBuilder = class {
|
|
|
4326
4681
|
this.params.offset = count;
|
|
4327
4682
|
return this;
|
|
4328
4683
|
}
|
|
4329
|
-
search(searchString) {
|
|
4684
|
+
search(searchString, options) {
|
|
4330
4685
|
this.params.searchString = searchString;
|
|
4686
|
+
if (options?.explain !== void 0) this.params.searchExplain = options.explain;
|
|
4687
|
+
return this;
|
|
4688
|
+
}
|
|
4689
|
+
vectorSearch(property, vector, options) {
|
|
4690
|
+
this.params.vectorSearch = {
|
|
4691
|
+
property,
|
|
4692
|
+
vector,
|
|
4693
|
+
...options?.distance !== void 0 && { distance: options.distance },
|
|
4694
|
+
...options?.threshold !== void 0 && { threshold: options.threshold }
|
|
4695
|
+
};
|
|
4331
4696
|
return this;
|
|
4332
4697
|
}
|
|
4333
4698
|
include(...relations) {
|
|
@@ -4414,6 +4779,7 @@ function toSdkCollectionClient(snap, slug = "collection") {
|
|
|
4414
4779
|
limit: (count) => new SdkQueryBuilder(client).limit(count),
|
|
4415
4780
|
offset: (count) => new SdkQueryBuilder(client).offset(count),
|
|
4416
4781
|
search: (searchString) => new SdkQueryBuilder(client).search(searchString),
|
|
4782
|
+
vectorSearch: (property, vector, options) => new SdkQueryBuilder(client).vectorSearch(property, vector, options),
|
|
4417
4783
|
include: (...relations) => new SdkQueryBuilder(client).include(...relations)
|
|
4418
4784
|
};
|
|
4419
4785
|
return client;
|
|
@@ -4459,6 +4825,391 @@ function buildSdkData(driver) {
|
|
|
4459
4825
|
return wrapAsSdkData(buildRebaseData(driver));
|
|
4460
4826
|
}
|
|
4461
4827
|
//#endregion
|
|
4828
|
+
//#region src/schema/search-column.ts
|
|
4829
|
+
/**
|
|
4830
|
+
* The one place a collection's `search` block becomes SQL.
|
|
4831
|
+
*
|
|
4832
|
+
* Four things describe a Postgres table in this codebase — the DDL generator,
|
|
4833
|
+
* the Drizzle schema generator, the runtime table builder for BaaS mode, and
|
|
4834
|
+
* the boot-time schema ensure — and each of them has, at some point, described
|
|
4835
|
+
* a column differently from the others. The `varchar(255)` note in
|
|
4836
|
+
* `generate-postgres-ddl-logic` is one such scar: the same property produced a
|
|
4837
|
+
* capped column down one path and an uncapped one down the other, and nothing
|
|
4838
|
+
* failed until a user hit the cap.
|
|
4839
|
+
*
|
|
4840
|
+
* So the search column is not implemented four times. It is computed once,
|
|
4841
|
+
* here, and every generator renders the same {@link SearchColumnSpec}. There is
|
|
4842
|
+
* a test asserting exactly that (`search-column-contract.test.ts`); the point of
|
|
4843
|
+
* this module is that the test has something to assert *about*.
|
|
4844
|
+
*
|
|
4845
|
+
* ## Why the expressions look the way they do
|
|
4846
|
+
*
|
|
4847
|
+
* A `GENERATED ALWAYS AS … STORED` expression must be strictly IMMUTABLE, and
|
|
4848
|
+
* Postgres is stricter here than intuition. Verified against PostgreSQL 18:
|
|
4849
|
+
*
|
|
4850
|
+
* | expression | immutable |
|
|
4851
|
+
* |-----------------------------------------|-----------|
|
|
4852
|
+
* | `to_tsvector('spanish', col)` | yes |
|
|
4853
|
+
* | `to_tsvector(col)` (1-arg) | **no** — depends on `default_text_search_config` |
|
|
4854
|
+
* | `array_to_string(col, ' ')` | **no** |
|
|
4855
|
+
* | `col::text` on `text[]` | **no** |
|
|
4856
|
+
* | `to_jsonb(col)` | **no** |
|
|
4857
|
+
* | `unaccent(col)` | **no** — dictionary lookup is STABLE |
|
|
4858
|
+
* | `jsonb_to_tsvector('spanish', j, '["string"]')` | yes |
|
|
4859
|
+
* | `setweight(...) || setweight(...)` | yes |
|
|
4860
|
+
*
|
|
4861
|
+
* Three of the four things a real search column needs are therefore unavailable
|
|
4862
|
+
* directly, which is why {@link searchHelperFunctions} exists: each wraps a
|
|
4863
|
+
* stable built-in in an SQL function declared IMMUTABLE. That declaration is a
|
|
4864
|
+
* promise, and it is a true one for these three — array joining, JSON string
|
|
4865
|
+
* extraction and accent folding are all deterministic for a given input; the
|
|
4866
|
+
* built-ins are marked stable only because they must account for element types
|
|
4867
|
+
* and dictionaries in general.
|
|
4868
|
+
*
|
|
4869
|
+
* The alternative was to skip `unaccent` and text arrays entirely. That is not
|
|
4870
|
+
* a real option in an accented language: Postgres stems `auditoría` to
|
|
4871
|
+
* `auditor` and `auditoria` to `auditori` — *different lexemes* — so a query
|
|
4872
|
+
* typed without accents misses every row that carries them.
|
|
4873
|
+
*/
|
|
4874
|
+
/** Schema-qualified so a collection outside `public` still resolves them. */
|
|
4875
|
+
var HELPER_SCHEMA = "public";
|
|
4876
|
+
/**
|
|
4877
|
+
* Names of the helper functions. Frozen: they are recorded in the stored
|
|
4878
|
+
* generation expression of every search column ever created, so renaming one
|
|
4879
|
+
* orphans every table that already has a search column.
|
|
4880
|
+
*/
|
|
4881
|
+
var SEARCH_TEXT_FN = `${HELPER_SCHEMA}.rebase_search_text`;
|
|
4882
|
+
var SEARCH_UNACCENT_FN = `${HELPER_SCHEMA}.rebase_search_unaccent`;
|
|
4883
|
+
/** Raised when a `search` block names something that cannot be searched. */
|
|
4884
|
+
var SearchConfigError = class extends Error {
|
|
4885
|
+
constructor(message) {
|
|
4886
|
+
super(message);
|
|
4887
|
+
this.name = "SearchConfigError";
|
|
4888
|
+
}
|
|
4889
|
+
};
|
|
4890
|
+
/** The `search` block of a collection, or undefined when it has none. */
|
|
4891
|
+
var getSearchConfig = (collection) => isPostgresCollectionConfig(collection) ? collection.search : void 0;
|
|
4892
|
+
/**
|
|
4893
|
+
* Refuse a `search` block on a collection this engine does not store.
|
|
4894
|
+
*
|
|
4895
|
+
* The type only permits one on a `PostgresCollectionConfig`, so TypeScript
|
|
4896
|
+
* already stops the ordinary case. This catches the rest — a JS config, a cast,
|
|
4897
|
+
* a collection whose `engine` was changed after the block was written — because
|
|
4898
|
+
* the alternative is the exact failure the block exists to prevent: a developer
|
|
4899
|
+
* who declared what to index, saw no error, and got the substring fallback.
|
|
4900
|
+
*
|
|
4901
|
+
* Called with *every* collection, before the Postgres ones are filtered out.
|
|
4902
|
+
*/
|
|
4903
|
+
var assertSearchIsPostgresOnly = (collections) => {
|
|
4904
|
+
for (const collection of collections) {
|
|
4905
|
+
if (isPostgresCollectionConfig(collection)) continue;
|
|
4906
|
+
if (!collection.search) continue;
|
|
4907
|
+
const engine = collection.engine ?? "non-postgres";
|
|
4908
|
+
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.`);
|
|
4909
|
+
}
|
|
4910
|
+
};
|
|
4911
|
+
var columnNameOf = (propName, prop) => prop && "columnName" in prop && typeof prop.columnName === "string" ? prop.columnName : toSnakeCase(propName);
|
|
4912
|
+
/**
|
|
4913
|
+
* Classify a property for search purposes.
|
|
4914
|
+
*
|
|
4915
|
+
* Deliberately narrower than `getSqlColumnType`: search only cares whether a
|
|
4916
|
+
* value reaches text, and the mapping from property to *physical* type is
|
|
4917
|
+
* asserted against `getSqlColumnType` in the contract test rather than
|
|
4918
|
+
* duplicated here.
|
|
4919
|
+
*
|
|
4920
|
+
* Returns null for anything that is not text-bearing, which the caller turns
|
|
4921
|
+
* into a boot error naming the property.
|
|
4922
|
+
*/
|
|
4923
|
+
var classify = (prop) => {
|
|
4924
|
+
switch (prop.type) {
|
|
4925
|
+
case "string": {
|
|
4926
|
+
const sp = prop;
|
|
4927
|
+
if (sp.enum) return {
|
|
4928
|
+
kind: "text",
|
|
4929
|
+
reason: "enum"
|
|
4930
|
+
};
|
|
4931
|
+
if (sp.isId === "uuid" || sp.columnType === "uuid") return {
|
|
4932
|
+
kind: "text",
|
|
4933
|
+
reason: "uuid"
|
|
4934
|
+
};
|
|
4935
|
+
return { kind: "text" };
|
|
4936
|
+
}
|
|
4937
|
+
case "map":
|
|
4938
|
+
if (prop.columnType === "json") return {
|
|
4939
|
+
kind: "jsonb",
|
|
4940
|
+
reason: "json"
|
|
4941
|
+
};
|
|
4942
|
+
return { kind: "jsonb" };
|
|
4943
|
+
case "array": {
|
|
4944
|
+
const ap = prop;
|
|
4945
|
+
let colType = ap.columnType;
|
|
4946
|
+
if (!colType && ap.of && !Array.isArray(ap.of)) {
|
|
4947
|
+
const of = ap.of;
|
|
4948
|
+
if (of.type === "string") colType = "text[]";
|
|
4949
|
+
else if (of.type === "number") colType = of.validation?.integer ? "integer[]" : "numeric[]";
|
|
4950
|
+
else if (of.type === "boolean") colType = "boolean[]";
|
|
4951
|
+
}
|
|
4952
|
+
if (colType === "text[]") return { kind: "text_array" };
|
|
4953
|
+
if (colType === "json") return {
|
|
4954
|
+
kind: "jsonb",
|
|
4955
|
+
reason: "json"
|
|
4956
|
+
};
|
|
4957
|
+
if (colType === "integer[]" || colType === "boolean[]" || colType === "numeric[]") return {
|
|
4958
|
+
kind: "text_array",
|
|
4959
|
+
reason: "non_text_array"
|
|
4960
|
+
};
|
|
4961
|
+
return { kind: "jsonb" };
|
|
4962
|
+
}
|
|
4963
|
+
default: return null;
|
|
4964
|
+
}
|
|
4965
|
+
};
|
|
4966
|
+
var normalize = (inner, unaccent) => unaccent ? `${SEARCH_UNACCENT_FN}(${inner})` : inner;
|
|
4967
|
+
/** SQL reading one field as plain text, before normalization. */
|
|
4968
|
+
var rawTextSql = (field) => {
|
|
4969
|
+
const col = `"${field.column}"`;
|
|
4970
|
+
if (field.kind === "text") return `coalesce(${col}, '')`;
|
|
4971
|
+
if (field.kind === "text_array") return `${SEARCH_TEXT_FN}(coalesce(${col}, '{}'::text[]))`;
|
|
4972
|
+
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))`;
|
|
4973
|
+
};
|
|
4974
|
+
var quote = (v) => `'${v.replace(/'/g, "''")}'`;
|
|
4975
|
+
/**
|
|
4976
|
+
* Resolve and validate one declared field path.
|
|
4977
|
+
*
|
|
4978
|
+
* A path that does not resolve throws. The whole point of an explicit block is
|
|
4979
|
+
* that the author knows what is indexed; a silently dropped field would make it
|
|
4980
|
+
* a guess again, and the failure — a search that returns nothing for content
|
|
4981
|
+
* that is plainly in the row — is invisible from the outside.
|
|
4982
|
+
*/
|
|
4983
|
+
var resolveField = (entry, collection, cfg) => {
|
|
4984
|
+
const path = typeof entry === "string" ? entry : entry.path;
|
|
4985
|
+
const weight = (typeof entry === "string" ? void 0 : entry.weight) ?? "B";
|
|
4986
|
+
const where = `${collection.slug}.search`;
|
|
4987
|
+
if (!path || typeof path !== "string") throw new SearchConfigError(`${where}: every entry in \`fields\` needs a property path.`);
|
|
4988
|
+
const [head, ...rest] = path.split(".");
|
|
4989
|
+
const prop = collection.properties?.[head];
|
|
4990
|
+
if (!prop) throw new SearchConfigError(`${where}: "${path}" starts at property "${head}", which this collection does not declare. Known properties: ${Object.keys(collection.properties ?? {}).join(", ")}.`);
|
|
4991
|
+
const classified = classify(prop);
|
|
4992
|
+
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).`);
|
|
4993
|
+
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.`);
|
|
4994
|
+
if (classified.reason === "uuid") throw new SearchConfigError(`${where}: "${path}" is a UUID column. Look it up by id rather than searching it.`);
|
|
4995
|
+
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.`);
|
|
4996
|
+
if (classified.reason === "non_text_array") throw new SearchConfigError(`${where}: "${path}" is an array of numbers or booleans. Only \`string[]\` carries text to search.`);
|
|
4997
|
+
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.`);
|
|
4998
|
+
const column = columnNameOf(head, prop);
|
|
4999
|
+
const textSql = normalize(rawTextSql({
|
|
5000
|
+
column,
|
|
5001
|
+
jsonPath: rest,
|
|
5002
|
+
kind: classified.kind
|
|
5003
|
+
}), cfg.unaccent === true);
|
|
5004
|
+
const language = cfg.language ?? "simple";
|
|
5005
|
+
return {
|
|
5006
|
+
path,
|
|
5007
|
+
column,
|
|
5008
|
+
jsonPath: rest,
|
|
5009
|
+
kind: classified.kind,
|
|
5010
|
+
weight,
|
|
5011
|
+
sql: `setweight(to_tsvector(${quote(language)}, ${textSql}), ${quote(weight)})`,
|
|
5012
|
+
textSql
|
|
5013
|
+
};
|
|
5014
|
+
};
|
|
5015
|
+
/**
|
|
5016
|
+
* Build the full spec for a collection, or undefined when it has not opted in.
|
|
5017
|
+
*
|
|
5018
|
+
* Throws {@link SearchConfigError} on a config that cannot be honoured. Callers
|
|
5019
|
+
* at boot surface that as a startup failure — a search block that half-works is
|
|
5020
|
+
* worse than one that refuses.
|
|
5021
|
+
*/
|
|
5022
|
+
var buildSearchColumnSpec = (collection) => {
|
|
5023
|
+
const cfg = getSearchConfig(collection);
|
|
5024
|
+
if (!cfg) return void 0;
|
|
5025
|
+
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.`);
|
|
5026
|
+
const table = getTableName(collection);
|
|
5027
|
+
const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
|
|
5028
|
+
const column = cfg.column ?? "search_vector";
|
|
5029
|
+
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.`);
|
|
5030
|
+
const fields = cfg.fields.map((entry) => resolveField(entry, collection, cfg));
|
|
5031
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5032
|
+
for (const f of fields) {
|
|
5033
|
+
if (seen.has(f.path)) throw new SearchConfigError(`${collection.slug}.search: "${f.path}" is listed twice.`);
|
|
5034
|
+
seen.add(f.path);
|
|
5035
|
+
}
|
|
5036
|
+
const extensions = [];
|
|
5037
|
+
if (cfg.unaccent) extensions.push("unaccent");
|
|
5038
|
+
if (cfg.fuzzy) extensions.push("pg_trgm");
|
|
5039
|
+
const spec = {
|
|
5040
|
+
schema,
|
|
5041
|
+
table,
|
|
5042
|
+
column,
|
|
5043
|
+
language: cfg.language ?? "simple",
|
|
5044
|
+
unaccent: cfg.unaccent === true,
|
|
5045
|
+
fields,
|
|
5046
|
+
expression: fields.map((f) => f.sql).join(" || "),
|
|
5047
|
+
indexName: toPostgresIdentifier(`${table}_${column}_gin`),
|
|
5048
|
+
extensions
|
|
5049
|
+
};
|
|
5050
|
+
if (cfg.fuzzy) {
|
|
5051
|
+
const fuzzyColumn = `${column}_text`;
|
|
5052
|
+
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.`);
|
|
5053
|
+
spec.fuzzy = {
|
|
5054
|
+
column: fuzzyColumn,
|
|
5055
|
+
expression: fields.map((f) => f.textSql).join(" || ' ' || "),
|
|
5056
|
+
indexName: toPostgresIdentifier(`${table}_${fuzzyColumn}_trgm`),
|
|
5057
|
+
threshold: cfg.fuzzyThreshold ?? .3
|
|
5058
|
+
};
|
|
5059
|
+
}
|
|
5060
|
+
return spec;
|
|
5061
|
+
};
|
|
5062
|
+
/**
|
|
5063
|
+
* The IMMUTABLE wrappers the generated expressions call.
|
|
5064
|
+
*
|
|
5065
|
+
* `CREATE OR REPLACE` so a boot against an existing database is a no-op rather
|
|
5066
|
+
* than an error, and idempotent for the same reason every other boot-time DDL
|
|
5067
|
+
* statement here is.
|
|
5068
|
+
*
|
|
5069
|
+
* The bodies are stable built-ins wrapped in an immutable promise — see the
|
|
5070
|
+
* module comment for why that promise is sound. `STRICT` matters: it makes NULL
|
|
5071
|
+
* in mean NULL out without executing the body, which is what the `coalesce` at
|
|
5072
|
+
* each call site then absorbs.
|
|
5073
|
+
*/
|
|
5074
|
+
var searchHelperFunctions = (spec) => {
|
|
5075
|
+
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 $$;`];
|
|
5076
|
+
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) $$;`);
|
|
5077
|
+
return statements;
|
|
5078
|
+
};
|
|
5079
|
+
/**
|
|
5080
|
+
* `CREATE EXTENSION` statements the spec's expressions depend on.
|
|
5081
|
+
*
|
|
5082
|
+
* `WITH SCHEMA public` is load-bearing, not tidiness. An unqualified
|
|
5083
|
+
* `CREATE EXTENSION` installs into the first schema on `search_path`, which
|
|
5084
|
+
* defaults to `"$user", public` — and the scaffold's database role is named
|
|
5085
|
+
* `rebase`, the same as the schema the generator creates one statement earlier.
|
|
5086
|
+
* So the moment that schema exists, `CREATE EXTENSION unaccent` puts the
|
|
5087
|
+
* dictionary in `rebase`, and every reference to `public.unaccent` below fails
|
|
5088
|
+
* with "text search dictionary does not exist". Observed, not theorised.
|
|
5089
|
+
*/
|
|
5090
|
+
var searchExtensionStatements = (spec) => spec.extensions.map((e) => `CREATE EXTENSION IF NOT EXISTS ${e} WITH SCHEMA ${HELPER_SCHEMA};`);
|
|
5091
|
+
/**
|
|
5092
|
+
* Index statements for the spec.
|
|
5093
|
+
*
|
|
5094
|
+
* `CONCURRENTLY` is deliberately *not* used here. This form is emitted into a
|
|
5095
|
+
* SQL file replayed as one unit — a migration, or `search.sql` — where a
|
|
5096
|
+
* concurrent build is not allowed. The boot-time ensure path runs statement by
|
|
5097
|
+
* statement against tables that are live and populated, and uses the
|
|
5098
|
+
* concurrent form instead; see `ensureSearchColumns`.
|
|
5099
|
+
*/
|
|
5100
|
+
var searchIndexStatements = (spec) => {
|
|
5101
|
+
const statements = [`CREATE INDEX IF NOT EXISTS "${spec.indexName}" ON "${spec.schema}"."${spec.table}" USING GIN ("${spec.column}");`];
|
|
5102
|
+
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);`);
|
|
5103
|
+
return statements;
|
|
5104
|
+
};
|
|
5105
|
+
/**
|
|
5106
|
+
* Marker on the comment of every generated search column this module creates.
|
|
5107
|
+
*
|
|
5108
|
+
* Versioned because the fingerprint below is only comparable against itself: a
|
|
5109
|
+
* future change to how it is computed has to read as "not stamped by this
|
|
5110
|
+
* version" rather than as drift on every existing column.
|
|
5111
|
+
*/
|
|
5112
|
+
var SEARCH_STAMP_PREFIX = "rebase:search:v1:";
|
|
5113
|
+
/**
|
|
5114
|
+
* A stable fingerprint of one generated column's expression.
|
|
5115
|
+
*
|
|
5116
|
+
* Why a stamp rather than reading the expression back: Postgres stores a
|
|
5117
|
+
* generated column's expression *parsed*, and hands it back deparsed — casts
|
|
5118
|
+
* made explicit, identifiers requoted, schema qualifications added or dropped
|
|
5119
|
+
* according to `search_path`. Comparing that text to the text we generated
|
|
5120
|
+
* would report drift on wording, and this comparison decides whether a boot
|
|
5121
|
+
* refuses, so a false positive is an outage. The stamp is written by the same
|
|
5122
|
+
* code that writes the column, so equality means what it says.
|
|
5123
|
+
*/
|
|
5124
|
+
var searchExpressionFingerprint = (expression) => `${SEARCH_STAMP_PREFIX}${createHash("sha256").update(expression).digest("hex").slice(0, 16)}`;
|
|
5125
|
+
/**
|
|
5126
|
+
* The stamps for a spec's generated columns — one per column, never shared.
|
|
5127
|
+
*
|
|
5128
|
+
* Per column on purpose: turning `fuzzy` on adds a second column and changes
|
|
5129
|
+
* nothing about the first, and a spec-wide fingerprint would report the
|
|
5130
|
+
* untouched `tsvector` column as drifted and refuse a boot over a change that
|
|
5131
|
+
* is purely additive.
|
|
5132
|
+
*/
|
|
5133
|
+
var searchColumnStamps = (spec) => {
|
|
5134
|
+
const stamp = (column, expression) => {
|
|
5135
|
+
const fingerprint = searchExpressionFingerprint(expression);
|
|
5136
|
+
return {
|
|
5137
|
+
column,
|
|
5138
|
+
expression,
|
|
5139
|
+
fingerprint,
|
|
5140
|
+
sql: `COMMENT ON COLUMN "${spec.schema}"."${spec.table}"."${column}" IS ${quote(fingerprint)};`
|
|
5141
|
+
};
|
|
5142
|
+
};
|
|
5143
|
+
const stamps = [stamp(spec.column, spec.expression)];
|
|
5144
|
+
if (spec.fuzzy) stamps.push(stamp(spec.fuzzy.column, spec.fuzzy.expression));
|
|
5145
|
+
return stamps;
|
|
5146
|
+
};
|
|
5147
|
+
/**
|
|
5148
|
+
* The generated column names a collection's search block adds, if any.
|
|
5149
|
+
*
|
|
5150
|
+
* These are physical columns on the table, so `SELECT *` returns them. They are
|
|
5151
|
+
* an index in column form — a list of lexeme positions, or a concatenation of
|
|
5152
|
+
* every searchable field on the row — and nothing outside the query planner has
|
|
5153
|
+
* any use for them. Left in, every list response carries a second, larger copy
|
|
5154
|
+
* of the row's text.
|
|
5155
|
+
*/
|
|
5156
|
+
var searchColumnNames = (collection) => {
|
|
5157
|
+
let spec;
|
|
5158
|
+
try {
|
|
5159
|
+
spec = buildSearchColumnSpec(collection);
|
|
5160
|
+
} catch {
|
|
5161
|
+
return [];
|
|
5162
|
+
}
|
|
5163
|
+
if (!spec) return [];
|
|
5164
|
+
return spec.fuzzy ? [spec.column, spec.fuzzy.column] : [spec.column];
|
|
5165
|
+
};
|
|
5166
|
+
/**
|
|
5167
|
+
* True for a column whose type only ever holds a search index.
|
|
5168
|
+
*
|
|
5169
|
+
* Independent of any collection config on purpose: an introspected database
|
|
5170
|
+
* (BaaS mode) can carry a `tsvector` column this framework never created —
|
|
5171
|
+
* Pagila's `film.fulltext` is the canonical one — and it should not be returned
|
|
5172
|
+
* to callers either. `isDerivedIndexColumn` already keeps such a column out of
|
|
5173
|
+
* the *properties*; this keeps it out of the *rows*.
|
|
5174
|
+
*/
|
|
5175
|
+
var isSearchIndexColumn = (column) => {
|
|
5176
|
+
const sqlType = typeof column?.getSQLType === "function" ? column.getSQLType().toLowerCase() : "";
|
|
5177
|
+
return sqlType === "tsvector" || sqlType === "tsquery";
|
|
5178
|
+
};
|
|
5179
|
+
/**
|
|
5180
|
+
* A drizzle select projection over `table` with the search columns dropped.
|
|
5181
|
+
*
|
|
5182
|
+
* Returns undefined when nothing needs dropping, so the common case keeps using
|
|
5183
|
+
* a plain `select()` and this stays invisible in the generated SQL.
|
|
5184
|
+
*/
|
|
5185
|
+
var visibleColumnProjection = (tableColumns, collection) => {
|
|
5186
|
+
const excluded = excludedColumnNames(tableColumns, collection);
|
|
5187
|
+
if (!tableColumns || excluded.length === 0) return void 0;
|
|
5188
|
+
const projection = {};
|
|
5189
|
+
for (const [name, column] of Object.entries(tableColumns)) if (!excluded.includes(name)) projection[name] = column;
|
|
5190
|
+
return projection;
|
|
5191
|
+
};
|
|
5192
|
+
/** The same exclusion as a drizzle `db.query` `columns` denylist. */
|
|
5193
|
+
var hiddenColumnsOption = (tableColumns, collection) => {
|
|
5194
|
+
const excluded = excludedColumnNames(tableColumns, collection);
|
|
5195
|
+
if (excluded.length === 0) return void 0;
|
|
5196
|
+
return Object.fromEntries(excluded.map((name) => [name, false]));
|
|
5197
|
+
};
|
|
5198
|
+
/**
|
|
5199
|
+
* The columns to keep out of a response, by name.
|
|
5200
|
+
*
|
|
5201
|
+
* `tableColumns` is whatever `getTableColumns` returned, which is `undefined`
|
|
5202
|
+
* for anything that is not a real drizzle table — a stub in a test, a derived
|
|
5203
|
+
* or nested path with no table behind it. Nothing to exclude is the right
|
|
5204
|
+
* answer there, and it has to be an answer rather than a throw: this runs on
|
|
5205
|
+
* the read path of every collection, opted in or not.
|
|
5206
|
+
*/
|
|
5207
|
+
var excludedColumnNames = (tableColumns, collection) => {
|
|
5208
|
+
if (!tableColumns || typeof tableColumns !== "object") return [];
|
|
5209
|
+
const byName = new Set(collection ? searchColumnNames(collection) : []);
|
|
5210
|
+
return Object.keys(tableColumns).filter((name) => byName.has(name) || isSearchIndexColumn(tableColumns[name]));
|
|
5211
|
+
};
|
|
5212
|
+
//#endregion
|
|
4462
5213
|
//#region src/schema/auth-users-columns.ts
|
|
4463
5214
|
/**
|
|
4464
5215
|
* `email` is NOT NULL on purpose, and the anonymous sign-in route depends on it
|
|
@@ -4570,6 +5321,6 @@ function isAuthCollection(collection) {
|
|
|
4570
5321
|
return typeof auth === "object" && auth !== null && auth.enabled === true;
|
|
4571
5322
|
}
|
|
4572
5323
|
//#endregion
|
|
4573
|
-
export {
|
|
5324
|
+
export { isManyToMany as $, getTableName as A, normalizeToEntityRelation as B, getEffectiveSecurityRules as C, findRelation as D, findAnonymousGrants as E, isAddressableId as F, getPolicyNamesForRule as G, generateForeignKeyName as H, parseIdValues as I, camelCase as J, isPrototypePollutingKey as K, sortCollectionsBySlug as L, resolveCollectionRelations as M, buildCompositeId as N, getColumnName as O, getDeclaredPrimaryKeys as P, hasForeignKeyOnTarget as Q, createRelationRef as R, resolveJunctionSpecs as S, securityRuleToConditions as T, legacyForeignKeyName as U, updateDateAutoValues as V, toPostgresIdentifier as W, DEFAULT_ONE_OF_TYPE as X, toSnakeCase as Y, DEFAULT_ONE_OF_VALUE as Z, CollectionRegistry as _, SEARCH_STAMP_PREFIX as a, getJunctionCollectionConfig as b, assertSearchIsPostgresOnly as c, searchColumnStamps as d, Vector as et, searchExtensionStatements as f, buildSdkData as g, visibleColumnProjection as h, isAuthCollection as i, getTableVarName as j, getEnumVarName as k, buildSearchColumnSpec as l, searchIndexStatements as m, authUsersColumnDefinition as n, SEARCH_TEXT_FN as o, searchHelperFunctions as p, mergeDeep as q, authUsersColumnSql as r, SEARCH_UNACCENT_FN as s, AUTH_USERS_COLUMNS as t, hiddenColumnsOption as u, relationalCollections as v, policyToPostgres as w, getJunctionSecurityRules as x, resolveStringColumnLength as y, createRelationRefWithData as z };
|
|
4574
5325
|
|
|
4575
|
-
//# sourceMappingURL=auth-users-columns-
|
|
5326
|
+
//# sourceMappingURL=auth-users-columns-BxT6FRyu.js.map
|