@rebasepro/server-postgres 0.21.1 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/{backup-cli-CkjsJEcu.js → backup-cli-DqBakiMO.js} +2 -2
  2. package/dist/{backup-cli-CkjsJEcu.js.map → backup-cli-DqBakiMO.js.map} +1 -1
  3. package/dist/{backup-service-BNLwvxuy.js → backup-service-CaMOS76G.js} +2 -2
  4. package/dist/{backup-service-BNLwvxuy.js.map → backup-service-CaMOS76G.js.map} +1 -1
  5. package/dist/cli.js +9 -9
  6. package/dist/{doctor-Gzzd8wTq.js → doctor-CU9IogdL.js} +3 -3
  7. package/dist/{doctor-Gzzd8wTq.js.map → doctor-CU9IogdL.js.map} +1 -1
  8. package/dist/{ensure-collection-policies-B4IDFtQ-.js → ensure-collection-policies-Dzd-2S81.js} +4 -4
  9. package/dist/{ensure-collection-policies-B4IDFtQ-.js.map → ensure-collection-policies-Dzd-2S81.js.map} +1 -1
  10. package/dist/{ensure-collection-tables-Cr2ye5JC.js → ensure-collection-tables-CpAgy51F.js} +3 -3
  11. package/dist/{ensure-collection-tables-Cr2ye5JC.js.map → ensure-collection-tables-CpAgy51F.js.map} +1 -1
  12. package/dist/{ensure-tables-BnEvEJPr.js → ensure-tables-Cr5B4UmH.js} +2 -2
  13. package/dist/{ensure-tables-BnEvEJPr.js.map → ensure-tables-Cr5B4UmH.js.map} +1 -1
  14. package/dist/{generate-drizzle-schema-D2MDdJt-.js → generate-drizzle-schema-B537GIvz.js} +2 -2
  15. package/dist/{generate-drizzle-schema-D2MDdJt-.js.map → generate-drizzle-schema-B537GIvz.js.map} +1 -1
  16. package/dist/{generate-drizzle-schema-logic-DFa9qy9u.js → generate-drizzle-schema-logic-BcMl7VSy.js} +2 -2
  17. package/dist/{generate-drizzle-schema-logic-DFa9qy9u.js.map → generate-drizzle-schema-logic-BcMl7VSy.js.map} +1 -1
  18. package/dist/{generate-postgres-ddl-logic-CR2xcS7e.js → generate-postgres-ddl-logic-D7imhYV8.js} +2 -2
  19. package/dist/{generate-postgres-ddl-logic-CR2xcS7e.js.map → generate-postgres-ddl-logic-D7imhYV8.js.map} +1 -1
  20. package/dist/index.es.js +391 -54
  21. package/dist/index.es.js.map +1 -1
  22. package/dist/{plan-schema-Hgl62S-w.js → plan-schema-CboAIwLN.js} +13 -8
  23. package/dist/plan-schema-CboAIwLN.js.map +1 -0
  24. package/dist/{policy-drift-B0GDRh7_.js → policy-drift-xJfy9xG7.js} +2 -2
  25. package/dist/{policy-drift-B0GDRh7_.js.map → policy-drift-xJfy9xG7.js.map} +1 -1
  26. package/dist/{rls-bootstrap-sql-BHA6jLtj.js → rls-bootstrap-sql-H3rCFi3F.js} +5 -5
  27. package/dist/{rls-bootstrap-sql-BHA6jLtj.js.map → rls-bootstrap-sql-H3rCFi3F.js.map} +1 -1
  28. package/dist/schema/doctor-cli.js +2 -2
  29. package/dist/schema/generate-drizzle-schema.js +1 -1
  30. package/dist/schema/generate-postgres-ddl.js +1 -1
  31. package/dist/schema/search-column.d.ts +21 -1
  32. package/dist/services/FetchService.d.ts +50 -2
  33. package/dist/services/PersistService.d.ts +16 -1
  34. package/dist/services/RelationService.d.ts +31 -1
  35. package/dist/services/RelationWriteService.d.ts +16 -1
  36. package/dist/services/dataService.d.ts +16 -1
  37. package/dist/services/read-scope.d.ts +100 -0
  38. package/dist/utils/drizzle-conditions.d.ts +56 -6
  39. package/package.json +7 -7
  40. package/dist/plan-schema-Hgl62S-w.js.map +0 -1
package/dist/index.es.js CHANGED
@@ -2,25 +2,25 @@ import { createRequire as __createRequire } from "module";
2
2
  __createRequire(import.meta.url);
3
3
  import { r as __exportAll } from "./__vite-browser-external-BnuHet1e.js";
4
4
  import { a as createDirectDatabaseConnection, c as guardPoolAgainstDirtyRelease, l as pinSearchPath, o as createPostgresDatabaseConnection, r as cappedPoolMax, s as createReadReplicaConnection, t as DatabasePoolManager, u as poolMaxCeiling } from "./databasePoolManager-Bj5FbeAs.js";
5
- import { f as hiddenColumnsOption, l as SEARCH_UNACCENT_FN, m as searchColumnNames, o as sharedRelationName, u as buildSearchColumnSpec, x as visibleColumnProjection } from "./plan-schema-Hgl62S-w.js";
5
+ import { f as hiddenColumnsOption, l as SEARCH_UNACCENT_FN, m as searchColumnNames, o as sharedRelationName, u as buildSearchColumnSpec, x as visibleColumnProjection } from "./plan-schema-CboAIwLN.js";
6
6
  import { d as extractPgError, f as isRoleSwitchingPermissionError, g as sanitizeErrorForClient, h as reachedDatabase, i as diagnoseDbError, l as classifyConnectFailure, m as pgErrorToFriendlyMessage, n as deepestErrorMessage, o as isEconnrefused, p as isRowLevelSecurityDenial, s as parseHostInfo, u as extractCauseMessage } from "./cli-errors-Dka89exj.js";
7
7
  import { r as BranchingUnsupportedError, t as BranchService } from "./BranchService-CucnFcSE.js";
8
8
  import { c as warnOnLegacyRlsFunctions, i as ensureAppRole, l as warnOnRoleSchemaCollision, n as applyAuthContext, o as validatePolicyPgRoles, r as detectConnectionPosture, s as warnOnAnonymousGrants, t as REBASE_USER_ROLE } from "./rls-enforcement-C6Xk0lA6.js";
9
9
  import { l as resolveColumnName } from "./column-plan-helpers-CpILzHJS.js";
10
10
  import { i as isAuthCollection, t as AUTH_USERS_COLUMNS } from "./auth-users-columns-D2LBFrMH.js";
11
- import { a as generatePostgresVectorDdl, i as generatePostgresTriggersDdl, n as generatePostgresPoliciesDdl, r as generatePostgresSearchDdl, t as generatePostgresDdl } from "./generate-postgres-ddl-logic-CR2xcS7e.js";
12
- import { t as generateSchema } from "./generate-drizzle-schema-logic-DFa9qy9u.js";
13
- import { a as readSchemaFactsFor, r as planCollectionSchemaEnsure } from "./ensure-collection-tables-Cr2ye5JC.js";
14
- import { t as main } from "./generate-drizzle-schema-D2MDdJt-.js";
15
- import { A as parsePgToolMajor, C as checkToolServerCompatibility, D as parseBackupDestination, E as joinStorageKey, M as serverVersionNumToMajor, N as splitGlobalsStatements, O as parseBackupTimestamp, P as withDatabaseName, S as buildRowSecurityPgOptions, T as globalsFileForDump, _ as buildBackupFilename, a as detectToolMajor, b as buildPgRestoreArgs, c as getServerVersionMajor, d as pruneBackups, f as resolvePgBinary, g as selectBackupsToPrune, h as validateDump, i as createDump, j as resolveConnectionString, k as parseDbNameFromUrl, l as listBackups, m as uploadBackup, n as applyGlobals, o as discardPartialDump, p as restoreDump, s as ensureDatabaseExists, t as BackupToolError, u as preflight, v as buildPgDumpArgs, w as diagnoseRowSecurityDumpFailure, x as buildPgRestoreListArgs, y as buildPgDumpallGlobalsArgs } from "./backup-service-BNLwvxuy.js";
16
- import { i as resolveAuthSchema, r as probeAuthSchema, t as ensureAuthTablesExist } from "./ensure-tables-BnEvEJPr.js";
11
+ import { a as generatePostgresVectorDdl, i as generatePostgresTriggersDdl, n as generatePostgresPoliciesDdl, r as generatePostgresSearchDdl, t as generatePostgresDdl } from "./generate-postgres-ddl-logic-D7imhYV8.js";
12
+ import { t as generateSchema } from "./generate-drizzle-schema-logic-BcMl7VSy.js";
13
+ import { a as readSchemaFactsFor, r as planCollectionSchemaEnsure } from "./ensure-collection-tables-CpAgy51F.js";
14
+ import { t as main } from "./generate-drizzle-schema-B537GIvz.js";
15
+ import { A as parsePgToolMajor, C as checkToolServerCompatibility, D as parseBackupDestination, E as joinStorageKey, M as serverVersionNumToMajor, N as splitGlobalsStatements, O as parseBackupTimestamp, P as withDatabaseName, S as buildRowSecurityPgOptions, T as globalsFileForDump, _ as buildBackupFilename, a as detectToolMajor, b as buildPgRestoreArgs, c as getServerVersionMajor, d as pruneBackups, f as resolvePgBinary, g as selectBackupsToPrune, h as validateDump, i as createDump, j as resolveConnectionString, k as parseDbNameFromUrl, l as listBackups, m as uploadBackup, n as applyGlobals, o as discardPartialDump, p as restoreDump, s as ensureDatabaseExists, t as BackupToolError, u as preflight, v as buildPgDumpArgs, w as diagnoseRowSecurityDumpFailure, x as buildPgRestoreListArgs, y as buildPgDumpallGlobalsArgs } from "./backup-service-CaMOS76G.js";
16
+ import { i as resolveAuthSchema, r as probeAuthSchema, t as ensureAuthTablesExist } from "./ensure-tables-Cr5B4UmH.js";
17
17
  import { c as identifyJoinTables, d as singularize, h as humanize, i as buildTablesMap, m as mapPgType, r as buildEnumMap, s as getIconForTable } from "./introspect-db-logic-C6LQdTxj.js";
18
18
  import { Client } from "pg";
19
19
  import { ApiError, assertFieldOpsValid, assertWriteRequestValid, createDdlBootstrapper, createEmailService, declaredErrorAnswer, extractUserFromToken, logger, rawQueryLoggingEnabled, resolveBatchRefs, resolveRequireAuth, safeCompare, splitFieldOps } from "@rebasepro/server";
20
20
  import { and, asc, count, desc, eq, getTableColumns, getTableName, gt, ilike, inArray, isNotNull, isNull, isTable, lt, not, notInArray, or, relations, sql } from "drizzle-orm";
21
21
  import { ALL_WHERE_FILTER_OPS, ANONYMOUS_USER_ID, DEFAULT_COMMIT_PATHS, DEFAULT_DATA_SOURCE_KEY, JUNCTION_PIVOT_KEY, ListLimitError, MAX_INCLUDE_DEPTH, Vector, declaredDatabaseExtensions, encodeRelationAggregateSort, hasForeignKeyOnTarget, isChannelBusInstance, isChannelBusInstance as isChannelBusInstance$1, isManyToMany, isRelationalCollectionConfig, isSQLAdmin, isSchemaAdmin, parseEnvBoolean, parseRelationAggregateSort, resolveClientListLimit } from "@rebasepro/types";
22
22
  import { COMPOSITE_ID_SEPARATOR, COMPOSITE_ID_SEPARATOR as COMPOSITE_ID_SEPARATOR$1, CollectionRegistry, DEFAULT_ONE_OF_TYPE, DEFAULT_ONE_OF_VALUE, OrderBySpecError, REBASE_USER_ROLE as REBASE_USER_ROLE$1, applyDefaultValuesOnCreate, buildCompositeId, buildCompositeId as buildCompositeId$1, buildPropertyCallbacks, buildSdkData, callbackRefusal, canReadField, classifyTable, createRelationRef, createRelationRefWithData, detectJunctionTables, encodeCursor, fieldKeyForColumn, findRelation, firstSqlRow, getColumnName, getDeclaredPrimaryKeys, getJunctionCollectionConfig, getJunctionConfigForRelation, getTableName as getTableName$1, getTenantConfig, isAddressableId, normalizeDriverOrderBy, normalizeEmail, normalizeInclude, normalizeToEntityRelation, parseIdValues, parseOrderBySpecStrict, requireCallbackClient, requireCallbackCollection, resolveCollectionRelations, resolveJunctionSpecs, resolveTenantWrite, revokeInternalTableSql, sqlRows, tenantBypassRoles, toCallbackError, toFilterTuples, updateDateAutoValues, updateUserAutoValues } from "@rebasepro/common";
23
- import { camelCase, firstFreeKey, generateForeignKeyName, isPrototypePollutingKey, legacyForeignKeyName, mergeDeep, toSnakeCase, toWireKey, unref } from "@rebasepro/utils";
23
+ import { camelCase, firstFreeKey, generateForeignKeyName, isPrototypePollutingKey, legacyForeignKeyName, mergeDeep, splitSearchTerms, toSnakeCase, toWireKey, unref } from "@rebasepro/utils";
24
24
  import { AsyncLocalStorage } from "node:async_hooks";
25
25
  import { PgArray, PgNumeric, PgTable, bigint, boolean, char, cidr, customType, date, doublePrecision, geometry, getTableConfig, index, inet, integer, interval, json, jsonb, line, macaddr, macaddr8, numeric, pgSchema, pgTable, point, primaryKey, real, smallint, text, time, timestamp, unique, uuid, varchar, vector } from "drizzle-orm/pg-core";
26
26
  import fs from "fs";
@@ -408,6 +408,15 @@ function deriveRowAddress(row, collection, registry) {
408
408
  */
409
409
  var PG_TRGM_WORD_SIMILARITY_DEFAULT = .6;
410
410
  /**
411
+ * What a substring-only hit contributes to `_score` under
412
+ * {@link SearchMode} `"hybrid"`.
413
+ *
414
+ * Below the smallest `ts_rank` a real lexeme match can produce (a single
415
+ * D-weighted hit is 0.1 × 0.0607927 ≈ 0.006), so a row found by the indexed
416
+ * half always outranks one found only by substring.
417
+ */
418
+ var HYBRID_SUBSTRING_SCORE = .001;
419
+ /**
411
420
  * A user's search term, made safe to drop inside a `%…%` LIKE pattern.
412
421
  *
413
422
  * The term is already a bind parameter, so this is not about injection. It is
@@ -1693,13 +1702,20 @@ var DrizzleConditionBuilder = class DrizzleConditionBuilder {
1693
1702
  * - **Declared** — one `@@ websearch_to_tsquery` against the generated
1694
1703
  * `tsvector` column. Stems, drops stopwords, AND-es the terms, reaches
1695
1704
  * inside JSONB and arrays, and uses the GIN index.
1696
- * - **Not declared** — the original `ILIKE '%term%'` OR-ed across top-level
1697
- * string properties, with the term escaped (see {@link escapeLikePattern})
1698
- * so it is matched as the literal text the user typed.
1705
+ * - **Not declared** — `ILIKE '%term%'` per term, OR-ed across top-level
1706
+ * string properties and AND-ed across the terms, with each term escaped
1707
+ * (see {@link escapeLikePattern}) so it is matched as the literal text
1708
+ * the user typed.
1699
1709
  *
1700
- * The second is the default and stays the default. A collection that has
1701
- * not opted in compiles to exactly the SQL it compiled to before this
1702
- * branch existed, which is the only reason it is safe to have added it.
1710
+ * The second is the default and stays the default, and it AND-es terms for
1711
+ * the same reason `websearch_to_tsquery` does: a person typing two words
1712
+ * into a search box means both, and a name lives in two columns. Matching
1713
+ * the whole string per column instead — which is what this did — cannot
1714
+ * find `sebastian melendez` on a row with `first_name`/`last_name`, and
1715
+ * could not find `sebastian ` at all.
1716
+ *
1717
+ * Returns at most one condition. Every caller ORs what it gets back, which
1718
+ * is right across columns and wrong across terms, so the AND is built here.
1703
1719
  *
1704
1720
  * `collection` is optional so that the callers which genuinely have no
1705
1721
  * collection in hand — nested paths, derived views — keep working; without
@@ -1711,16 +1727,24 @@ var DrizzleConditionBuilder = class DrizzleConditionBuilder {
1711
1727
  if (ftsCondition) return [ftsCondition];
1712
1728
  let declaredStringProperties = 0;
1713
1729
  const viewer = currentFieldViewer();
1730
+ const searchableColumns = [];
1714
1731
  for (const [key, prop] of Object.entries(properties)) {
1715
1732
  const p = prop;
1716
1733
  if (!canReadField(prop, viewer)) continue;
1717
1734
  if (p.type === "string" && !p.enum && p.isId !== "uuid") {
1718
1735
  declaredStringProperties++;
1719
1736
  const fieldColumn = table[key];
1720
- if (fieldColumn && supportsILike(fieldColumn)) searchConditions.push(ilike(fieldColumn, `%${escapeLikePattern(searchString)}%`));
1737
+ if (fieldColumn && supportsILike(fieldColumn)) searchableColumns.push(fieldColumn);
1721
1738
  }
1722
1739
  }
1723
- if (declaredStringProperties > 0 && searchConditions.length === 0) logger.warn(`[search] "${collection?.slug ?? "collection"}" declares ${declaredStringProperties} string property(ies) but none compiled to a searchable column, so this search can only return nothing. Check that the generated schema's column types are text/varchar/char.`);
1740
+ if (declaredStringProperties > 0 && searchableColumns.length === 0) logger.warn(`[search] "${collection?.slug ?? "collection"}" declares ${declaredStringProperties} string property(ies) but none compiled to a searchable column, so this search can only return nothing. Check that the generated schema's column types are text/varchar/char.`);
1741
+ if (searchableColumns.length === 0) return [];
1742
+ const terms = splitSearchTerms(searchString);
1743
+ const perTerm = (terms.length > 0 ? terms : [searchString]).map((term) => {
1744
+ const pattern = `%${escapeLikePattern(term)}%`;
1745
+ return DrizzleConditionBuilder.combineConditionsWithOr(searchableColumns.map((column) => ilike(column, pattern)));
1746
+ });
1747
+ searchConditions.push(DrizzleConditionBuilder.combineConditionsWithAnd(perTerm));
1724
1748
  return searchConditions;
1725
1749
  }
1726
1750
  /**
@@ -1748,12 +1772,66 @@ var DrizzleConditionBuilder = class DrizzleConditionBuilder {
1748
1772
  const column = table[spec.column];
1749
1773
  if (!column) return;
1750
1774
  const exact = sql`${column} @@ ${DrizzleConditionBuilder.normalizedTsQuery(searchString, spec)}`;
1751
- if (!spec.fuzzy) return exact;
1775
+ const substring = spec.mode === "hybrid" ? DrizzleConditionBuilder.buildFoldedSubstringCondition(searchString, spec) : void 0;
1776
+ const matched = substring ? sql`(${exact} OR ${substring})` : exact;
1777
+ if (!spec.fuzzy) return matched;
1752
1778
  const fuzzyColumn = table[spec.fuzzy.column];
1753
- if (!fuzzyColumn) return exact;
1779
+ if (!fuzzyColumn) return matched;
1754
1780
  const needle = spec.unaccent ? sql`${sql.raw(SEARCH_UNACCENT_FN)}(${searchString})` : sql`${searchString}`;
1755
1781
  const similar = sql`public.word_similarity(${needle}, ${fuzzyColumn}) >= ${spec.fuzzy.threshold}`;
1756
- return sql`(${exact} OR ${spec.fuzzy.threshold > PG_TRGM_WORD_SIMILARITY_DEFAULT ? sql`(${needle} OPERATOR(public.<%) ${fuzzyColumn} AND ${similar})` : similar})`;
1782
+ return sql`(${matched} OR ${spec.fuzzy.threshold > PG_TRGM_WORD_SIMILARITY_DEFAULT ? sql`(${needle} OPERATOR(public.<%) ${fuzzyColumn} AND ${similar})` : similar})`;
1783
+ }
1784
+ /**
1785
+ * The substring half of {@link SearchMode} `"hybrid"`: every term found
1786
+ * somewhere in the declared fields, with accents folded on both sides.
1787
+ *
1788
+ * Terms are AND-ed and fields OR-ed, exactly as the ILIKE default does and
1789
+ * for the same reason — a person typing two words means both, and a name
1790
+ * lives in two columns (`splitSearchTerms`). What it does *not* share with
1791
+ * the default is its reach: these are the declared `search.fields`, so it
1792
+ * folds accents, joins `text[]` and walks into JSONB through the same
1793
+ * IMMUTABLE helpers the generated column uses.
1794
+ *
1795
+ * The needle is folded with the same function as the document, so
1796
+ * `munoz` and `Muñoz` meet in the middle rather than one side being
1797
+ * normalized and the other not — the subtle way to build a search that
1798
+ * matches nothing.
1799
+ *
1800
+ * Interpolated as raw SQL because the field expressions are generated (they
1801
+ * name columns and helper functions, which are identifiers, not values);
1802
+ * the *term* is a bind parameter, escaped for LIKE first — see
1803
+ * {@link escapeLikePattern} for what that escape is for.
1804
+ */
1805
+ static buildFoldedSubstringCondition(searchString, spec) {
1806
+ if (spec.fields.length === 0) return void 0;
1807
+ const perTerm = DrizzleConditionBuilder.foldedNeedles(searchString).map((needle) => DrizzleConditionBuilder.combineConditionsWithOr(spec.fields.map((f) => sql`${sql.raw(f.foldedTextSql)} ILIKE ${needle}`)));
1808
+ return DrizzleConditionBuilder.combineConditionsWithAnd(perTerm);
1809
+ }
1810
+ /**
1811
+ * Whether one text expression holds **any** term of the search string.
1812
+ *
1813
+ * The per-field question, where {@link buildFoldedSubstringCondition} asks
1814
+ * the per-row one: `OR` across terms rather than `AND`, because a two-word
1815
+ * query satisfied across two fields has each field carrying one term. Used
1816
+ * by `_matches` to name the fields that caused a substring hit.
1817
+ */
1818
+ static foldedSubstringOf(searchString, text) {
1819
+ return DrizzleConditionBuilder.combineConditionsWithOr(DrizzleConditionBuilder.foldedNeedles(searchString).map((needle) => sql`${text} ILIKE ${needle}`));
1820
+ }
1821
+ /**
1822
+ * One accent-folded `%term%` bind parameter per search term.
1823
+ *
1824
+ * The needle is folded with the same function as the document, so `munoz`
1825
+ * and `Muñoz` meet in the middle instead of one side being normalized and
1826
+ * the other not — the subtle way to build a search that matches nothing.
1827
+ *
1828
+ * A string with no terms at all is only whitespace, and stays the single
1829
+ * literal pattern it is on the ILIKE path rather than becoming "no rows",
1830
+ * so both paths answer a stray space the same way.
1831
+ */
1832
+ static foldedNeedles(searchString) {
1833
+ const terms = splitSearchTerms(searchString);
1834
+ return (terms.length > 0 ? terms : [searchString]).map((term) => sql`${sql.raw(SEARCH_UNACCENT_FN)}(${`%${escapeLikePattern(term)}%`})`);
1757
1835
  }
1758
1836
  /**
1759
1837
  * `websearch_to_tsquery(<config>, <normalized search string>)`.
@@ -1800,17 +1878,18 @@ var DrizzleConditionBuilder = class DrizzleConditionBuilder {
1800
1878
  if (!table[spec.column]) return void 0;
1801
1879
  const query = DrizzleConditionBuilder.normalizedTsQuery(searchString, spec);
1802
1880
  const config = sql`${spec.language}`;
1803
- const rows = spec.fields.map((f, i) => sql`(${i}, ${f.path}, ${sql.raw(f.textSql)})`);
1881
+ const rows = spec.fields.map((f, i) => sql`(${i}, ${f.path}, ${sql.raw(f.textSql)}, ${sql.raw(f.foldedTextSql)})`);
1882
+ const matchedField = spec.mode === "hybrid" ? sql`(to_tsvector(${config}::regconfig, f.txt) @@ ${query} OR ${DrizzleConditionBuilder.foldedSubstringOf(searchString, sql`f.folded`)})` : sql`to_tsvector(${config}::regconfig, f.txt) @@ ${query}`;
1804
1883
  return sql`(
1805
1884
  SELECT coalesce(jsonb_agg(s.m ORDER BY f.ord), '[]'::jsonb)
1806
- FROM (VALUES ${sql.join(rows, sql`, `)}) AS f(ord, path, txt)
1885
+ FROM (VALUES ${sql.join(rows, sql`, `)}) AS f(ord, path, txt, folded)
1807
1886
  CROSS JOIN LATERAL (
1808
1887
  SELECT jsonb_build_object(
1809
1888
  'field', f.path,
1810
1889
  'snippet', ts_headline(${config}::regconfig, f.txt, ${query},
1811
1890
  'StartSel=<mark>,StopSel=</mark>,MaxWords=14,MinWords=1,MaxFragments=1,FragmentDelimiter= … ')
1812
1891
  ) AS m
1813
- WHERE to_tsvector(${config}::regconfig, f.txt) @@ ${query}
1892
+ WHERE ${matchedField}
1814
1893
  ) s
1815
1894
  )`;
1816
1895
  }
@@ -1828,7 +1907,11 @@ var DrizzleConditionBuilder = class DrizzleConditionBuilder {
1828
1907
  if (!spec) return void 0;
1829
1908
  const column = table[spec.column];
1830
1909
  if (!column) return void 0;
1831
- const rank = sql`ts_rank(${column}, ${DrizzleConditionBuilder.normalizedTsQuery(searchString, spec)})`;
1910
+ const base = sql`ts_rank(${column}, ${DrizzleConditionBuilder.normalizedTsQuery(searchString, spec)})`;
1911
+ const rank = spec.mode === "hybrid" ? (() => {
1912
+ const substring = DrizzleConditionBuilder.buildFoldedSubstringCondition(searchString, spec);
1913
+ return substring ? sql`(${base} + CASE WHEN ${substring} THEN ${sql.raw(`${HYBRID_SUBSTRING_SCORE}::real`)} ELSE 0 END)` : base;
1914
+ })() : base;
1832
1915
  if (!spec.fuzzy) return rank;
1833
1916
  const fuzzyColumn = table[spec.fuzzy.column];
1834
1917
  if (!fuzzyColumn) return rank;
@@ -2488,6 +2571,124 @@ function andSoftDelete(where, collection, table, withDeleted) {
2488
2571
  return where ? and(where, condition) : condition;
2489
2572
  }
2490
2573
  //#endregion
2574
+ //#region src/services/read-scope.ts
2575
+ /**
2576
+ * `beforeQuery`: the one place a collection callback can change *which* rows a
2577
+ * read asks for.
2578
+ *
2579
+ * `afterRead` runs over rows that have already been fetched, so until this
2580
+ * existed an application developer who needed the read itself narrowed — a
2581
+ * tenant scope, a visibility window, a per-role row filter — had two options:
2582
+ * patch this driver, or `rebase eject`.
2583
+ *
2584
+ * Two properties are load-bearing, and both belong to the *shape* of the thing
2585
+ * rather than to how carefully each call site was written.
2586
+ *
2587
+ * ## It can only narrow
2588
+ *
2589
+ * A hook returns a {@link QueryNarrowing} — a declarative filter, optionally a
2590
+ * logical group — which is AND-ed into the query the caller sent. `AND(q, c)`
2591
+ * is a subset of `q` for every `c`, so there is no `c` a hook can return that
2592
+ * widens a read. The obvious alternative shape, `(query) => query`, can: a
2593
+ * hook that drops a condition on the way through runs the query without it,
2594
+ * and on an RLS data plane a dropped condition returns everything the policies
2595
+ * happen to allow. That is the same reasoning behind
2596
+ * `UnknownFilterFieldsMode` defaulting to `"error"`, and it is why that mode is
2597
+ * **forced** to `"error"` for a hook's own filter here whatever the
2598
+ * process-wide setting is: a scope condition naming a renamed column has to
2599
+ * refuse the request, never be dropped from it.
2600
+ *
2601
+ * ## It cannot be skipped
2602
+ *
2603
+ * The hooks are resolved *by the read path itself*, out of the registry that
2604
+ * read already holds — not handed to it by a caller who might forget. So a
2605
+ * read that compiles a WHERE compiles this one too, or it throws.
2606
+ *
2607
+ * The one thing a read path cannot derive is the identity the hook runs as:
2608
+ * `FetchService`, `RelationService` and `DataService` carry no user. That
2609
+ * arrives as a {@link ReadCallContextProvider}, threaded from the driver that
2610
+ * constructed them — the driver is the only object that knows both the caller
2611
+ * and the connection, and it constructs a service per transaction anyway, so
2612
+ * there is exactly one place per instance to pass it.
2613
+ *
2614
+ * Absence is the interesting case, and it fails closed: a service built with no
2615
+ * provider refuses any read of a collection that declares `beforeQuery`, with
2616
+ * a message naming the collection. So a future construction site that forgets
2617
+ * the provider breaks loudly on the first scoped collection instead of quietly
2618
+ * serving every row — which is the failure mode this whole module exists to
2619
+ * rule out. A deployment with no `beforeQuery` anywhere never reaches any of
2620
+ * this: the check is one map lookup and the compiled SQL is byte-identical.
2621
+ *
2622
+ * @module
2623
+ */
2624
+ /**
2625
+ * The `beforeQuery` hooks that apply to one collection, in the order they run.
2626
+ *
2627
+ * Global first, then the collection's own — the same order `afterRead` and
2628
+ * `beforeSave` use. The order changes nothing about the outcome here (both are
2629
+ * AND-ed in, so neither can relax the other) and is kept because a hook that
2630
+ * logs or measures should see the same sequence everywhere.
2631
+ *
2632
+ * There is no property tier: a property callback shapes one value on a row, and
2633
+ * a row filter is not a value.
2634
+ */
2635
+ function resolveBeforeQuery(registry, collection) {
2636
+ const hooks = [];
2637
+ const global = registry?.getGlobalCallbacks();
2638
+ if (global?.beforeQuery) hooks.push(global.beforeQuery);
2639
+ const own = collection?.callbacks;
2640
+ if (own?.beforeQuery) hooks.push(own.beforeQuery);
2641
+ return hooks;
2642
+ }
2643
+ /**
2644
+ * The conditions every `beforeQuery` hook for this collection asks to add.
2645
+ *
2646
+ * Returned as a list rather than pre-combined so a caller pushes them onto the
2647
+ * same `allConditions` array its own filter goes into. Every caller AND-es
2648
+ * that array, which is what makes these additive — the hook is not trusted to
2649
+ * narrow, it is only ever given a way to.
2650
+ */
2651
+ async function beforeQueryConditions(env, collection, path, table, description, filterContext) {
2652
+ const hooks = resolveBeforeQuery(env.registry, collection);
2653
+ if (hooks.length === 0) return [];
2654
+ if (!collection) throw new Error(`[beforeQuery] "${path}" resolves to no registered collection, so a global \`beforeQuery\` cannot be compiled against it. Register the collection, or narrow the hook to the collections it applies to.`);
2655
+ if (!env.callContext) throw new Error(`[beforeQuery] "${collection.slug}" declares a \`beforeQuery\` hook, and this read was compiled by a service that was built without a call-context provider, so the hook could not run. Serving the read unnarrowed is not an option — a hook that does not run widens the result set. Construct the service through the driver (which passes one), or pass a provider explicitly.`);
2656
+ const context = env.callContext();
2657
+ const unknownFields = "error";
2658
+ const options = {
2659
+ ...filterContext,
2660
+ unknownFields
2661
+ };
2662
+ const conditions = [];
2663
+ for (const hook of hooks) {
2664
+ const narrowing = await hook({
2665
+ collection,
2666
+ path,
2667
+ operation: description.operation,
2668
+ query: description.query,
2669
+ context
2670
+ });
2671
+ if (!narrowing) continue;
2672
+ if (!narrowing.filter && !narrowing.logical) throw new Error(`[beforeQuery] "${collection.slug}" returned an object with neither \`filter\` nor \`logical\`. Return nothing (or \`undefined\`) to add no conditions; an empty object reads as a scope that was meant to be built and was not.`);
2673
+ if (narrowing.filter) conditions.push(...DrizzleConditionBuilder.buildFilterConditions(narrowing.filter, table, path, options));
2674
+ if (narrowing.logical) {
2675
+ const logical = DrizzleConditionBuilder.buildLogicalConditions(narrowing.logical, table, path, options);
2676
+ if (logical) conditions.push(logical);
2677
+ }
2678
+ }
2679
+ return conditions;
2680
+ }
2681
+ /**
2682
+ * {@link beforeQueryConditions}, pre-combined, for the read paths that hold a
2683
+ * single `SQL` rather than a list of them — a single get, a relation loader.
2684
+ */
2685
+ async function beforeQueryCondition(env, collection, path, table, description, filterContext) {
2686
+ const conditions = await beforeQueryConditions(env, collection, path, table, description, filterContext);
2687
+ if (conditions.length === 0) return void 0;
2688
+ if (conditions.length === 1) return conditions[0];
2689
+ return and(...conditions);
2690
+ }
2691
+ //#endregion
2491
2692
  //#region src/services/RelationService.ts
2492
2693
  /**
2493
2694
  * Typed wrapper for Drizzle dynamic query innerJoin.
@@ -2522,6 +2723,17 @@ function narrowed(base, narrow) {
2522
2723
  return narrow ? and(base, narrow) : base;
2523
2724
  }
2524
2725
  /**
2726
+ * Two optional conditions, AND-ed: either, both, or neither.
2727
+ *
2728
+ * The include's own `where` and the target collection's `beforeQuery` arrive
2729
+ * separately and are both optional, and they have to reach the loaders as one
2730
+ * condition because that is the shape every relation kind threads through.
2731
+ */
2732
+ function narrowBoth(a, b) {
2733
+ if (a && b) return and(a, b);
2734
+ return a ?? b;
2735
+ }
2736
+ /**
2525
2737
  * The registry has no table for a collection a relation just asked about.
2526
2738
  *
2527
2739
  * "Parent table not found" was the whole message, four times over, and it names
@@ -2538,9 +2750,43 @@ function unregisteredTable(collectionSlug, table, role) {
2538
2750
  var RelationService = class {
2539
2751
  db;
2540
2752
  registry;
2541
- constructor(db, registry) {
2753
+ callContext;
2754
+ constructor(db, registry, callContext) {
2542
2755
  this.db = db;
2543
2756
  this.registry = registry;
2757
+ this.callContext = callContext;
2758
+ }
2759
+ /**
2760
+ * The **target** collection's `beforeQuery` narrowing, for rows reached
2761
+ * through a relation.
2762
+ *
2763
+ * The target's, not the parent's, because these are the target's rows: a
2764
+ * scope on `comments` has to hold whether they are listed at
2765
+ * `/comments` or loaded as `post.comments`. Without this an `include` is a
2766
+ * way around every row filter a collection declares — the exact hole
2767
+ * `stripUnreadable` was once missing from the relation-ref branch.
2768
+ *
2769
+ * Folded into the `narrow` condition the loaders already thread through
2770
+ * every relation kind, so a `via` join, a junction, a foreign key and the
2771
+ * dynamic relation builder all apply it or none of them do.
2772
+ */
2773
+ narrowTargetRead(parentCollection, relation, parentId) {
2774
+ const targetCollection = relation.target();
2775
+ const targetTable = getTableForCollection(targetCollection, this.registry);
2776
+ return beforeQueryCondition({
2777
+ registry: this.registry,
2778
+ callContext: this.callContext
2779
+ }, targetCollection, targetCollection.slug, targetTable, {
2780
+ operation: "relation",
2781
+ query: { relatedTo: {
2782
+ parentSlug: parentCollection.slug,
2783
+ parentId,
2784
+ relationName: relation.relationName
2785
+ } }
2786
+ }, {
2787
+ collection: targetCollection,
2788
+ registry: this.registry
2789
+ });
2544
2790
  }
2545
2791
  /**
2546
2792
  * One target row, as the {@link RelatedRow} everything here returns.
@@ -2711,6 +2957,7 @@ var RelationService = class {
2711
2957
  const parentTable = this.registry.getTable(parentTableName);
2712
2958
  if (!parentTable) throw unregisteredTable(parentCollection.slug, parentTableName, "parent");
2713
2959
  const parentIdCol = parentTable[parentIdInfo.fieldName];
2960
+ const relatedNarrowing = await this.narrowTargetRead(parentCollection, relation, parentId);
2714
2961
  if (relation.kind === "via") {
2715
2962
  let query = this.db.select().from(parentTable).$dynamic();
2716
2963
  let currentTable = parentTable;
@@ -2730,7 +2977,7 @@ var RelationService = class {
2730
2977
  currentTable = joinTable;
2731
2978
  }
2732
2979
  const parentIdField = parentTable[requirePrimaryKeys(parentCollection, this.registry)[0].fieldName];
2733
- query = query.where(eq(parentIdField, parsedParentId));
2980
+ query = query.where(narrowed(eq(parentIdField, parsedParentId), relatedNarrowing));
2734
2981
  if (options.limit) query = query.limit(options.limit);
2735
2982
  const results = await query;
2736
2983
  const targetTableName = relation.joinPath[relation.joinPath.length - 1].table;
@@ -2747,6 +2994,7 @@ var RelationService = class {
2747
2994
  const additionalFilters = [];
2748
2995
  const relatedSoftDelete = softDeleteCondition(targetCollection, targetTable);
2749
2996
  if (relatedSoftDelete) additionalFilters.push(relatedSoftDelete);
2997
+ if (relatedNarrowing) additionalFilters.push(relatedNarrowing);
2750
2998
  if (options.searchString) {
2751
2999
  const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, targetCollection.properties, targetTable, targetCollection);
2752
3000
  if (searchConditions.length === 0) return [];
@@ -2801,6 +3049,8 @@ var RelationService = class {
2801
3049
  if (matchValue === void 0) return 0;
2802
3050
  const countSoftDelete = softDeleteCondition(targetCollection, targetTable);
2803
3051
  if (countSoftDelete) additionalFilters = [...additionalFilters, countSoftDelete];
3052
+ const countNarrowing = await this.narrowTargetRead(parentCollection, relation, parentId);
3053
+ if (countNarrowing) additionalFilters = [...additionalFilters, countNarrowing];
2804
3054
  let query = this.db.select({ count: sql`count(distinct ${targetIdField})` }).from(targetTable).$dynamic();
2805
3055
  query = DrizzleConditionBuilder.buildRelationCountQuery(query, relation, matchValue, targetTable, parentTable, parentIdCol, targetIdField, this.registry, additionalFilters);
2806
3056
  const result = await query;
@@ -2848,7 +3098,7 @@ var RelationService = class {
2848
3098
  if (!parentTable) throw unregisteredTable(parentCollection.slug, parentTableName, "parent");
2849
3099
  const parentIdCol = parentTable[parentIdInfo.fieldName];
2850
3100
  const parsedParentIds = parentIds.map((id) => parseIdValues(id, parentPks)[parentIdInfo.fieldName]);
2851
- const narrowTarget = andSoftDelete(narrow, targetCollection, targetTable);
3101
+ const narrowTarget = andSoftDelete(narrowBoth(narrow, await this.narrowTargetRead(parentCollection, relation)), targetCollection, targetTable);
2852
3102
  if (relation.kind === "via") {
2853
3103
  let query = this.db.select().from(parentTable).$dynamic();
2854
3104
  let currentTable = parentTable;
@@ -2952,7 +3202,7 @@ var RelationService = class {
2952
3202
  if (!parentTable) throw unregisteredTable(parentCollection.slug, parentTableName, "parent");
2953
3203
  const parentIdCol = parentTable[parentIdInfo.fieldName];
2954
3204
  const parsedParentIds = parentIds.map((id) => parseIdValues(id, parentPks)[parentIdInfo.fieldName]);
2955
- const narrowTarget = andSoftDelete(narrow, targetCollection, targetTable);
3205
+ const narrowTarget = andSoftDelete(narrowBoth(narrow, await this.narrowTargetRead(parentCollection, relation)), targetCollection, targetTable);
2956
3206
  if (relation.kind === "via") {
2957
3207
  let query = this.db.select().from(parentTable).$dynamic();
2958
3208
  let currentTable = parentTable;
@@ -3652,11 +3902,13 @@ function numericAggregateAliases(aggregates, properties) {
3652
3902
  var FetchService = class FetchService {
3653
3903
  db;
3654
3904
  registry;
3905
+ callContext;
3655
3906
  relationService;
3656
- constructor(db, registry) {
3907
+ constructor(db, registry, callContext) {
3657
3908
  this.db = db;
3658
3909
  this.registry = registry;
3659
- this.relationService = new RelationService(db, registry);
3910
+ this.callContext = callContext;
3911
+ this.relationService = new RelationService(db, registry, callContext);
3660
3912
  }
3661
3913
  /**
3662
3914
  * Get the relational query builder for a given table name.
@@ -3690,6 +3942,63 @@ var FetchService = class FetchService {
3690
3942
  };
3691
3943
  }
3692
3944
  /**
3945
+ * The conditions this collection's `beforeQuery` hooks ask to add, for a
3946
+ * read that is about to be compiled.
3947
+ *
3948
+ * Resolved here, from the registry this service already holds, rather than
3949
+ * handed in by a caller — so a read path that builds a WHERE cannot serve
3950
+ * one of these collections without the hook. `[]` when nothing is declared,
3951
+ * which leaves the compiled SQL byte-identical.
3952
+ *
3953
+ * Every caller pushes the result onto the same `allConditions` array its
3954
+ * own filter goes into, and every one of those arrays is AND-ed. That is
3955
+ * what keeps a hook additive: it is not trusted to narrow, it is only ever
3956
+ * given a way to.
3957
+ */
3958
+ narrowRead(collectionPath, collection, table, operation, query) {
3959
+ return beforeQueryConditions({
3960
+ registry: this.registry,
3961
+ callContext: this.callContext
3962
+ }, collection, collectionPath, table, {
3963
+ operation,
3964
+ query
3965
+ }, this.filterContext(collectionPath, table));
3966
+ }
3967
+ /** {@link narrowRead}, pre-combined, for the reads that hold one `SQL`. */
3968
+ narrowReadCondition(collectionPath, collection, table, operation, query) {
3969
+ return beforeQueryCondition({
3970
+ registry: this.registry,
3971
+ callContext: this.callContext
3972
+ }, collection, collectionPath, table, {
3973
+ operation,
3974
+ query
3975
+ }, this.filterContext(collectionPath, table));
3976
+ }
3977
+ /**
3978
+ * The read the hooks are shown, from the options a read path was given.
3979
+ *
3980
+ * Transport is left out on purpose — a `databaseId`, an `include` tree, a
3981
+ * `withDeleted` flag are not part of "which rows". What is in is what a
3982
+ * scope might reasonably branch on.
3983
+ */
3984
+ static describeRead(options) {
3985
+ const orderBy = normalizeDriverOrderBy(options.orderBy, options.order);
3986
+ return {
3987
+ filter: options.filter,
3988
+ logical: options.logical,
3989
+ searchString: options.searchString,
3990
+ limit: options.limit,
3991
+ offset: options.offset,
3992
+ orderBy: orderBy && orderBy.length > 0 ? orderBy : void 0,
3993
+ fields: options.fields,
3994
+ relatedTo: options.relatedTo ? {
3995
+ parentSlug: options.relatedTo.parentCollection.slug,
3996
+ parentId: options.relatedTo.parentId,
3997
+ relationName: options.relatedTo.relation.relationName
3998
+ } : void 0
3999
+ };
4000
+ }
4001
+ /**
3693
4002
  * The table column this collection's rows are keyed by, or `undefined`.
3694
4003
  *
3695
4004
  * `getPrimaryKeys` rather than `requirePrimaryKeys`: a collection with no
@@ -4282,13 +4591,15 @@ var FetchService = class FetchService {
4282
4591
  if (!idField) throw new Error(`ID field '${idInfo.fieldName}' not found in table for collection '${collectionPath}'`);
4283
4592
  if (!idCanAddressTable(id, table, idInfoArray)) return void 0;
4284
4593
  const parsedId = parseIdValues(id, idInfoArray)[idInfo.fieldName];
4594
+ const narrowing = await this.narrowReadCondition(collectionPath, collection, table, "get", {});
4595
+ const identity = narrowing ? and(eq(idField, parsedId), narrowing) : eq(idField, parsedId);
4285
4596
  const tableName = getTableName(table);
4286
4597
  const qb = this.getQueryBuilder(tableName);
4287
4598
  if (qb) try {
4288
4599
  const withConfig = this.buildWithConfig(collection);
4289
4600
  const hidden = hiddenColumnsOption(getTableColumns(table), collection);
4290
4601
  const row = await qb.findFirst({
4291
- where: andSoftDelete(eq(idField, parsedId), collection, table, withDeleted),
4602
+ where: andSoftDelete(identity, collection, table, withDeleted),
4292
4603
  with: withConfig,
4293
4604
  ...hidden ? { columns: hidden } : {}
4294
4605
  });
@@ -4305,7 +4616,7 @@ var FetchService = class FetchService {
4305
4616
  logger.warn(`[FetchService] db.query.findFirst failed for ${collectionPath}, falling back to db.select`, { error: e });
4306
4617
  }
4307
4618
  const visibleOne = visibleColumnProjection(getTableColumns(table), collection);
4308
- const result = await this.db.select(visibleOne).from(table).where(andSoftDelete(eq(idField, parsedId), collection, table, withDeleted)).limit(1);
4619
+ const result = await this.db.select(visibleOne).from(table).where(andSoftDelete(identity, collection, table, withDeleted)).limit(1);
4309
4620
  if (result.length === 0) return void 0;
4310
4621
  const raw = result[0];
4311
4622
  const values = await parseDataFromServer(raw, collection, this.db, this.registry);
@@ -4406,6 +4717,10 @@ var FetchService = class FetchService {
4406
4717
  const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(options.logical, table, effectivePath, this.filterContext(effectivePath, table));
4407
4718
  if (logicalCondition) allConditions.push(logicalCondition);
4408
4719
  }
4720
+ allConditions.push(...await this.narrowRead(effectivePath, collection, table, "count", FetchService.describeRead({
4721
+ ...options,
4722
+ relatedTo: hop
4723
+ })));
4409
4724
  if (options.vectorSearch) {
4410
4725
  const vectorMeta = DrizzleConditionBuilder.buildVectorSearchConditions(table, options.vectorSearch);
4411
4726
  if (vectorMeta.filter) allConditions.push(vectorMeta.filter);
@@ -4484,6 +4799,7 @@ var FetchService = class FetchService {
4484
4799
  const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(options.logical, table, collectionPath, this.filterContext(collectionPath, table));
4485
4800
  if (logicalCondition) conditions.push(logicalCondition);
4486
4801
  }
4802
+ conditions.push(...await this.narrowRead(collectionPath, collection, table, "aggregate", FetchService.describeRead(options)));
4487
4803
  if (conditions.length > 0) {
4488
4804
  const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(conditions);
4489
4805
  if (finalCondition) query = query.where(finalCondition);
@@ -4505,7 +4821,15 @@ var FetchService = class FetchService {
4505
4821
  });
4506
4822
  }
4507
4823
  /**
4508
- * Check if a field value is unique
4824
+ * Check if a field value is unique.
4825
+ *
4826
+ * Deliberately **not** narrowed by `beforeQuery`, and it is the only read
4827
+ * here that is not. The question is whether the value exists anywhere in
4828
+ * the table, not whether it exists among the rows this caller can see:
4829
+ * narrowed, it would answer "unique" for a value a hidden row already
4830
+ * holds, and the insert would then be rejected by the unique constraint
4831
+ * with a message about a row the caller has no way to find. Uniqueness is a
4832
+ * property of the table.
4509
4833
  */
4510
4834
  async checkUniqueField(collectionPath, fieldName, value, excludeEntityId, _databaseId) {
4511
4835
  if (value === void 0 || value === null) return true;
@@ -4583,7 +4907,9 @@ var FetchService = class FetchService {
4583
4907
  const parsedId = parseIdValues(id, idInfoArray)[idInfo.fieldName];
4584
4908
  const withDeleted = options?.withDeleted;
4585
4909
  const projection = this.columnProjection(table, collection, options?.fields, idInfoArray);
4586
- const result = await this.db.select(projection).from(table).where(andSoftDelete(eq(idField, parsedId), collection, table, withDeleted)).limit(1);
4910
+ const narrowing = await this.narrowReadCondition(collectionPath, collection, table, "get", FetchService.describeRead({ fields: options?.fields }));
4911
+ const identity = narrowing ? and(eq(idField, parsedId), narrowing) : eq(idField, parsedId);
4912
+ const result = await this.db.select(projection).from(table).where(andSoftDelete(identity, collection, table, withDeleted)).limit(1);
4587
4913
  if (result.length === 0) return null;
4588
4914
  const row = toRestRow(result[0], collection, this.registry);
4589
4915
  const normalizedInclude = normalizeInclude(include);
@@ -4634,6 +4960,7 @@ var FetchService = class FetchService {
4634
4960
  const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(options.logical, table, collectionPath, this.filterContext(collectionPath, table));
4635
4961
  if (logicalCondition) allConditions.push(logicalCondition);
4636
4962
  }
4963
+ allConditions.push(...await this.narrowRead(collectionPath, collection, table, "list", FetchService.describeRead(options)));
4637
4964
  if (vectorMeta?.filter) allConditions.push(vectorMeta.filter);
4638
4965
  if (allConditions.length > 0) {
4639
4966
  const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
@@ -4738,11 +5065,13 @@ function serializePivot(pivot, payload, label) {
4738
5065
  var RelationWriteService = class {
4739
5066
  db;
4740
5067
  registry;
5068
+ callContext;
4741
5069
  reads;
4742
- constructor(db, registry) {
5070
+ constructor(db, registry, callContext) {
4743
5071
  this.db = db;
4744
5072
  this.registry = registry;
4745
- this.reads = new RelationService(db, registry);
5073
+ this.callContext = callContext;
5074
+ this.reads = new RelationService(db, registry, callContext);
4746
5075
  }
4747
5076
  /**
4748
5077
  * Remove the junction row linking a parent to `targetId`, leaving the target
@@ -5109,17 +5438,19 @@ function compileFieldOps(table, fieldOps, context) {
5109
5438
  var PersistService = class {
5110
5439
  db;
5111
5440
  registry;
5441
+ callContext;
5112
5442
  /** Reads: whether a row is under a parent, the key a link joins on. */
5113
5443
  relationService;
5114
5444
  /** Writes: junction membership, foreign-key stamping, links. */
5115
5445
  relationWrites;
5116
5446
  fetchService;
5117
- constructor(db, registry) {
5447
+ constructor(db, registry, callContext) {
5118
5448
  this.db = db;
5119
5449
  this.registry = registry;
5120
- this.relationService = new RelationService(db, registry);
5121
- this.relationWrites = new RelationWriteService(db, registry);
5122
- this.fetchService = new FetchService(db, registry);
5450
+ this.callContext = callContext;
5451
+ this.relationService = new RelationService(db, registry, callContext);
5452
+ this.relationWrites = new RelationWriteService(db, registry, callContext);
5453
+ this.fetchService = new FetchService(db, registry, callContext);
5123
5454
  }
5124
5455
  /**
5125
5456
  * Set the columns of one many-to-many link, without touching the membership.
@@ -5413,13 +5744,15 @@ var PersistService = class {
5413
5744
  var DataService = class {
5414
5745
  db;
5415
5746
  registry;
5747
+ callContext;
5416
5748
  fetchService;
5417
5749
  persistService;
5418
- constructor(db, registry) {
5750
+ constructor(db, registry, callContext) {
5419
5751
  this.db = db;
5420
5752
  this.registry = registry;
5421
- this.fetchService = new FetchService(db, registry);
5422
- this.persistService = new PersistService(db, registry);
5753
+ this.callContext = callContext;
5754
+ this.fetchService = new FetchService(db, registry, callContext);
5755
+ this.persistService = new PersistService(db, registry, callContext);
5423
5756
  }
5424
5757
  /**
5425
5758
  * Fetch a single row by ID
@@ -6173,7 +6506,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
6173
6506
  this.db = db;
6174
6507
  this.registry = registry;
6175
6508
  this.poolManager = poolManager;
6176
- this.dataService = new DataService(db, registry);
6509
+ this.dataService = new DataService(db, registry, () => this.buildCallContext());
6177
6510
  this.realtimeService = realtimeService;
6178
6511
  this.historyService = historyService;
6179
6512
  this.user = user;
@@ -6803,7 +7136,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
6803
7136
  */
6804
7137
  bindToTransaction(tx) {
6805
7138
  const txDriver = new PostgresBackendDriver(tx, this.realtimeService, this.registry, this.user, this.poolManager, this.historyService);
6806
- txDriver.dataService = new DataService(tx, this.registry);
7139
+ txDriver.dataService = new DataService(tx, this.registry, () => txDriver.buildCallContext());
6807
7140
  txDriver.client = this.client;
6808
7141
  txDriver._deferNotifications = this._deferNotifications;
6809
7142
  txDriver._pendingNotifications = this._pendingNotifications;
@@ -6825,7 +7158,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
6825
7158
  */
6826
7159
  callContextWithin(tx, user) {
6827
7160
  const txDriver = new PostgresBackendDriver(tx, this.realtimeService, this.registry, user, this.poolManager, this.historyService);
6828
- txDriver.dataService = new DataService(tx, this.registry);
7161
+ txDriver.dataService = new DataService(tx, this.registry, () => txDriver.buildCallContext());
6829
7162
  txDriver.client = this.client;
6830
7163
  return txDriver.buildCallContext();
6831
7164
  }
@@ -7411,7 +7744,8 @@ var AuthenticatedPostgresBackendDriver = class {
7411
7744
  if (!this.user?.uid) logger.warn("[DataDriver] User ID (uid) is missing for authenticated delegate. Using 'anonymous'. User object", { detail: this.user });
7412
7745
  if (!this.user?.roles) logger.warn("[DataDriver] User roles are missing for authenticated delegate. Using empty array. User object", { detail: this.user });
7413
7746
  await applyAuthContext(tx, authContextOf(this.user), this.delegate.rlsUserRole);
7414
- const txEntityService = new DataService(tx, this.delegate.registry);
7747
+ let hookContext;
7748
+ const txEntityService = new DataService(tx, this.delegate.registry, () => hookContext ??= this.delegate.callContextWithin(tx, this.user));
7415
7749
  const txDelegate = new PostgresBackendDriver(tx, this.delegate.realtimeService, this.delegate.registry, this.user, this.delegate.poolManager, this.delegate.historyService);
7416
7750
  txDelegate.dataService = txEntityService;
7417
7751
  txDelegate._deferNotifications = true;
@@ -9566,7 +9900,8 @@ var RealtimeService = class RealtimeService extends EventEmitter {
9566
9900
  isAnonymous: activeAuth.isAnonymous === true,
9567
9901
  claims: activeAuth.claims
9568
9902
  }, this.rlsUserRole);
9569
- const fetchedEntities = await new DataService(tx, this.registry).fetchCollectionForRest(notifyPath, {
9903
+ let hookContext;
9904
+ const fetchedEntities = await new DataService(tx, this.registry, () => hookContext ??= callbackContextWithin(this.driver, tx, activeAuth)).fetchCollectionForRest(notifyPath, {
9570
9905
  filter: collectionRequest.filter,
9571
9906
  logical: collectionRequest.logical,
9572
9907
  orderBy: collectionRequest.orderBy,
@@ -9665,7 +10000,8 @@ var RealtimeService = class RealtimeService extends EventEmitter {
9665
10000
  isAnonymous: activeAuth.isAnonymous === true,
9666
10001
  claims: activeAuth.claims
9667
10002
  }, this.rlsUserRole);
9668
- return countOnce(new DataService(tx, this.registry));
10003
+ let hookContext;
10004
+ return countOnce(new DataService(tx, this.registry, () => hookContext ??= callbackContextWithin(this.driver, tx, activeAuth)));
9669
10005
  });
9670
10006
  } else total = await countOnce(this.dataService);
9671
10007
  } catch (error) {
@@ -9744,7 +10080,8 @@ var RealtimeService = class RealtimeService extends EventEmitter {
9744
10080
  isAnonymous: activeAuth.isAnonymous === true,
9745
10081
  claims: activeAuth.claims
9746
10082
  }, this.rlsUserRole);
9747
- let processedEntity = await new DataService(tx, this.registry).fetchOneForRest(notifyPath, id, void 0, collection?.databaseId) ?? void 0;
10083
+ let hookContext;
10084
+ let processedEntity = await new DataService(tx, this.registry, () => hookContext ??= callbackContextWithin(this.driver, tx, activeAuth)).fetchOneForRest(notifyPath, id, void 0, collection?.databaseId) ?? void 0;
9748
10085
  if (processedEntity) {
9749
10086
  const registryCollection = this.registry.getCollectionByPath(notifyPath);
9750
10087
  const resolvedCollection = collection ? {
@@ -11592,7 +11929,7 @@ function createBackupCron(config) {
11592
11929
  enabled: config.enabled ?? true,
11593
11930
  timeoutSeconds: 3600,
11594
11931
  async handler({ log }) {
11595
- const { createDump, pruneBackups, uploadBackup, validateDump } = await import("./backup-service-BNLwvxuy.js").then((n) => n.r);
11932
+ const { createDump, pruneBackups, uploadBackup, validateDump } = await import("./backup-service-CaMOS76G.js").then((n) => n.r);
11596
11933
  const { destination } = config;
11597
11934
  if (destination.kind !== "local" && !config.storage) throw new Error(`Backup destination is ${destination.kind} but no storage controller was provided. Pass the backend's configured StorageController to createBackupCron({ storage }).`);
11598
11935
  log(`Starting backup of "${dbName}"…`);
@@ -14745,7 +15082,7 @@ function createPostgresBootstrapper(pgConfig) {
14745
15082
  * handful of `CREATE OR REPLACE FUNCTION`.
14746
15083
  */
14747
15084
  const provisionRlsRuntime = async (queryable) => {
14748
- const { RLS_BOOTSTRAP_STATEMENTS } = await import("./rls-bootstrap-sql-BHA6jLtj.js").then((n) => n.i);
15085
+ const { RLS_BOOTSTRAP_STATEMENTS } = await import("./rls-bootstrap-sql-H3rCFi3F.js").then((n) => n.i);
14749
15086
  await queryable.query("SELECT pg_advisory_lock(hashtext('rebase_auth_functions_init'))");
14750
15087
  try {
14751
15088
  for (const statement of RLS_BOOTSTRAP_STATEMENTS) await queryable.query(statement);
@@ -15139,7 +15476,7 @@ function createPostgresBootstrapper(pgConfig) {
15139
15476
  * proved it can bootstrap with.
15140
15477
  */
15141
15478
  async ensureCollectionSchema(collections, driverResult, log) {
15142
- const { ensureCollectionTables } = await import("./ensure-collection-tables-Cr2ye5JC.js").then((n) => n.n);
15479
+ const { ensureCollectionTables } = await import("./ensure-collection-tables-CpAgy51F.js").then((n) => n.n);
15143
15480
  const plan = await ensureCollectionTables(provisioningQueryable(driverResult), collections, log);
15144
15481
  for (const failure of plan.failures) logger.warn(describeEnsureFailure(failure));
15145
15482
  return { applied: plan.actions.length - plan.failures.length };
@@ -15173,7 +15510,7 @@ function createPostgresBootstrapper(pgConfig) {
15173
15510
  await provisionRlsRuntime(provisioningQueryable(driverResult));
15174
15511
  },
15175
15512
  async ensureCollectionPolicies(collections, driverResult, log) {
15176
- const { ensureCollectionPolicies } = await import("./ensure-collection-policies-B4IDFtQ-.js");
15513
+ const { ensureCollectionPolicies } = await import("./ensure-collection-policies-Dzd-2S81.js");
15177
15514
  const queryable = provisioningQueryable(driverResult);
15178
15515
  const { rows } = await queryable.query("SELECT to_regprocedure('rebase.roles()') IS NOT NULL AS present");
15179
15516
  if (!rows[0]?.present) await provisionRlsRuntime(queryable);
@@ -15184,7 +15521,7 @@ function createPostgresBootstrapper(pgConfig) {
15184
15521
  for (const u of outcome.unsecured.filter((u) => u.grantWithdrawn)) logger.error(`🔐 [rls] Could not enable row-level security on "${u.table}": ${u.error}. Its privileges have been revoked from ${REBASE_USER_ROLE}, so the table is unreachable rather than unprotected. Reads and writes to that collection will fail until RLS can be enabled.`);
15185
15522
  if (unrevoked.length > 0) throw new Error("Refusing to start: row-level security could not be enabled on " + unrevoked.map((u) => `"${u.table}" (${u.error})`).join(", ") + `, and the privileges granted to ${REBASE_USER_ROLE} could not be revoked either. The table would be served with no row filtering. Fix the database permissions (the connection role must own these tables, or be able to ALTER them) and boot again.`);
15186
15523
  try {
15187
- const { dropLegacyAuthSchema } = await import("./rls-bootstrap-sql-BHA6jLtj.js").then((n) => n.i);
15524
+ const { dropLegacyAuthSchema } = await import("./rls-bootstrap-sql-H3rCFi3F.js").then((n) => n.i);
15188
15525
  await dropLegacyAuthSchema(async (text) => (await queryable.query(text)).rows, {
15189
15526
  info: (m) => logger.info(m),
15190
15527
  warn: (m) => logger.warn(m)