@rebasepro/common 0.8.0 → 0.9.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 (53) hide show
  1. package/README.md +4 -4
  2. package/dist/collections/CollectionRegistry.d.ts +16 -16
  3. package/dist/collections/default-collections.d.ts +1 -1
  4. package/dist/data/buildRebaseData.d.ts +30 -2
  5. package/dist/data/buildRoutedRebaseData.d.ts +14 -9
  6. package/dist/data/filter-dialect.d.ts +18 -4
  7. package/dist/data/query_builder.d.ts +1 -1
  8. package/dist/data/resolveDataSource.d.ts +1 -1
  9. package/dist/data/sort-dialect.d.ts +41 -0
  10. package/dist/index.d.ts +1 -0
  11. package/dist/index.es.js +569 -159
  12. package/dist/index.es.js.map +1 -1
  13. package/dist/index.umd.js +573 -163
  14. package/dist/index.umd.js.map +1 -1
  15. package/dist/util/builders.d.ts +19 -56
  16. package/dist/util/callbacks.d.ts +3 -3
  17. package/dist/util/collections.d.ts +4 -4
  18. package/dist/util/entities.d.ts +2 -2
  19. package/dist/util/filter-operator-resolution.d.ts +32 -0
  20. package/dist/util/index.d.ts +1 -0
  21. package/dist/util/navigation_from_path.d.ts +4 -4
  22. package/dist/util/navigation_utils.d.ts +3 -3
  23. package/dist/util/parent_references_from_path.d.ts +2 -2
  24. package/dist/util/permissions.d.ts +6 -6
  25. package/dist/util/policy/policyToPostgres.d.ts +14 -2
  26. package/dist/util/references.d.ts +2 -2
  27. package/dist/util/relations.d.ts +5 -5
  28. package/dist/util/resolutions.d.ts +2 -2
  29. package/package.json +3 -3
  30. package/src/collections/CollectionRegistry.ts +36 -36
  31. package/src/data/buildRebaseData.ts +332 -57
  32. package/src/data/buildRoutedRebaseData.ts +22 -16
  33. package/src/data/filter-dialect.ts +145 -60
  34. package/src/data/query_builder.ts +11 -2
  35. package/src/data/resolveDataSource.ts +1 -1
  36. package/src/data/sort-dialect.ts +56 -0
  37. package/src/index.ts +1 -0
  38. package/src/util/builders.ts +25 -99
  39. package/src/util/callbacks.ts +8 -8
  40. package/src/util/collections.ts +4 -4
  41. package/src/util/entities.ts +4 -4
  42. package/src/util/filter-operator-resolution.ts +81 -0
  43. package/src/util/index.ts +1 -0
  44. package/src/util/navigation_from_path.ts +4 -4
  45. package/src/util/navigation_utils.ts +8 -8
  46. package/src/util/parent_references_from_path.ts +3 -3
  47. package/src/util/permissions.test.ts +2 -2
  48. package/src/util/permissions.ts +7 -7
  49. package/src/util/policy/evaluatePolicy.ts +6 -0
  50. package/src/util/policy/policyToPostgres.ts +90 -10
  51. package/src/util/references.ts +2 -2
  52. package/src/util/relations.ts +12 -12
  53. package/src/util/resolutions.ts +5 -5
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  DefaultSelectedViewBuilder,
3
3
  DefaultSelectedViewParams,
4
- EntityCollection,
4
+ CollectionConfig,
5
5
  Properties,
6
6
  Property
7
7
  } from "@rebasepro/types";
@@ -99,7 +99,7 @@ export function resolveDefaultSelectedView(
99
99
  }
100
100
 
101
101
 
102
- export function getLocalChangesBackup(collection: EntityCollection) {
102
+ export function getLocalChangesBackup(collection: CollectionConfig) {
103
103
  if (!collection.localChangesBackup) {
104
104
  return "manual_apply";
105
105
  }
@@ -108,12 +108,12 @@ export function getLocalChangesBackup(collection: EntityCollection) {
108
108
  }
109
109
 
110
110
  /**
111
- * Returns the primary keys for an entity collection by inspecting the properties
111
+ * Returns the primary keys for a entity collection by inspecting the properties
112
112
  * and finding any properties with `isId`.
113
113
  * Fallbacks to `["id"]` if no properties are marked as `isId: true`.
114
114
  * @param collection
115
115
  */
116
- export function getPrimaryKeys<M extends Record<string, unknown>>(collection: EntityCollection<M>): Extract<keyof M, string>[] {
116
+ export function getPrimaryKeys<M extends Record<string, unknown>>(collection: CollectionConfig<M>): Extract<keyof M, string>[] {
117
117
  const properties = collection.properties;
118
118
  if (!properties) {
119
119
  return ["id"] as Extract<keyof M, string>[];
@@ -81,7 +81,7 @@ export function getDefaultValueFortype(type: DataType): unknown {
81
81
  }
82
82
 
83
83
  /**
84
- * Update the automatic values in an entity before save
84
+ * Update the automatic values in a entity before save
85
85
  * @group Driver
86
86
  */
87
87
  export function updateDateAutoValues<M extends Record<string, unknown>>({
@@ -117,7 +117,7 @@ export function updateDateAutoValues<M extends Record<string, unknown>>({
117
117
  }
118
118
 
119
119
  /**
120
- * Add missing required fields, expected in the collection, to the values of an entity
120
+ * Add missing required fields, expected in the collection, to the values of a entity
121
121
  * @param values
122
122
  * @param properties
123
123
  * @group Driver
@@ -148,7 +148,7 @@ export function getReferenceFrom<M extends Record<string, unknown>>(entity: Enti
148
148
  }
149
149
 
150
150
  export function getRelationFrom<M extends Record<string, unknown>>(entity: Entity<M>): EntityRelation {
151
- return new EntityRelation(entity.id, entity.path, entity);
151
+ return new EntityRelation(entity.id, entity.path, entity as unknown as Record<string, unknown>);
152
152
  }
153
153
 
154
154
  /**
@@ -179,7 +179,7 @@ export function normalizeToEntityRelation(value: unknown, propertyType?: string)
179
179
  return new EntityRelation(
180
180
  obj.id as string | number,
181
181
  obj.path as string,
182
- obj.data as Entity | undefined
182
+ obj.data as Record<string, unknown> | undefined
183
183
  );
184
184
  }
185
185
 
@@ -0,0 +1,81 @@
1
+ import {
2
+ ALL_WHERE_FILTER_OPS,
3
+ DataType,
4
+ getDataSourceCapabilities,
5
+ Property,
6
+ WhereFilterOp
7
+ } from "@rebasepro/types";
8
+
9
+ /**
10
+ * Default operators offered per property type, before engine capabilities and
11
+ * per-property narrowing are applied. These mirror what the built-in filter
12
+ * fields can render.
13
+ */
14
+ const COMPARISON_OPS: readonly WhereFilterOp[] = ["==", "!=", ">", ">=", "<", "<="];
15
+ const NULL_CHECK_OPS: readonly WhereFilterOp[] = ["is-null", "is-not-null"];
16
+ const MEMBERSHIP_OPS: readonly WhereFilterOp[] = ["in", "not-in"];
17
+ const PATTERN_OPS: readonly WhereFilterOp[] = ["like", "ilike", "not-like", "not-ilike"];
18
+
19
+ const DEFAULT_OPS_BY_TYPE: Partial<Record<DataType, readonly WhereFilterOp[]>> = {
20
+ string: [...COMPARISON_OPS, ...MEMBERSHIP_OPS, ...PATTERN_OPS, ...NULL_CHECK_OPS],
21
+ number: [...COMPARISON_OPS, ...MEMBERSHIP_OPS, ...NULL_CHECK_OPS],
22
+ date: [...COMPARISON_OPS, ...NULL_CHECK_OPS],
23
+ boolean: ["==", "!=", ...NULL_CHECK_OPS],
24
+ reference: ["==", "!=", ...MEMBERSHIP_OPS, ...NULL_CHECK_OPS],
25
+ relation: ["==", "!=", ...MEMBERSHIP_OPS, ...NULL_CHECK_OPS]
26
+ // geopoint, map, vector, binary, array (as a container): not filterable
27
+ // through the generic filter UI.
28
+ };
29
+
30
+ /** Operators offered when the property is an *array of* a filterable type. */
31
+ const ARRAY_OPS: readonly WhereFilterOp[] = ["array-contains", "array-contains-any"];
32
+
33
+ export interface ResolveFilterOperatorsParams {
34
+ /**
35
+ * The property to filter on. For array properties, pass the **item**
36
+ * property (`property.of`) together with `isArray: true` — the same
37
+ * convention the filter field dispatchers use.
38
+ */
39
+ property: Property;
40
+ /** True when filtering an array of `property`. */
41
+ isArray?: boolean;
42
+ /**
43
+ * The engine backing the collection (`collection.engine`, e.g.
44
+ * `"postgres"`, `"firestore"`). Falls back to the default engine's
45
+ * capabilities when omitted.
46
+ */
47
+ engine?: string;
48
+ }
49
+
50
+ /**
51
+ * Resolve which filter operators the UI should offer for a property.
52
+ *
53
+ * The result is the **intersection** of three sets:
54
+ * 1. what the engine can execute — {@link DataSourceCapabilities.filterOperators}
55
+ * (e.g. Firestore cannot run the LIKE family);
56
+ * 2. what makes sense for the property type (e.g. no `>` on booleans);
57
+ * 3. the developer's optional narrowing — `property.ui.filterOperators`.
58
+ *
59
+ * Returns an empty array when the property is not filterable (either by
60
+ * type, or because the developer disabled it with `filterOperators: []`).
61
+ *
62
+ * @group Models
63
+ */
64
+ export function resolveFilterOperators({
65
+ property,
66
+ isArray,
67
+ engine
68
+ }: ResolveFilterOperatorsParams): WhereFilterOp[] {
69
+ const typeDefaults: readonly WhereFilterOp[] = isArray
70
+ ? ARRAY_OPS
71
+ : DEFAULT_OPS_BY_TYPE[property.type] ?? [];
72
+ if (typeDefaults.length === 0) return [];
73
+
74
+ const engineOps = new Set(getDataSourceCapabilities(engine).filterOperators ?? ALL_WHERE_FILTER_OPS);
75
+
76
+ const narrowing = property.ui?.filterOperators;
77
+ const narrowingSet = narrowing !== undefined ? new Set(narrowing) : undefined;
78
+
79
+ return typeDefaults.filter(op =>
80
+ engineOps.has(op) && (narrowingSet === undefined || narrowingSet.has(op)));
81
+ }
package/src/util/index.ts CHANGED
@@ -15,3 +15,4 @@ export * from "./callbacks";
15
15
  export * from "./relations";
16
16
  export * from "./conditions";
17
17
  export * from "./navigation_utils";
18
+ export * from "./filter-operator-resolution";
@@ -1,4 +1,4 @@
1
- import { EntityCollection } from "@rebasepro/types";
1
+ import { CollectionConfig } from "@rebasepro/types";
2
2
  type EntityCustomView<M extends Record<string, unknown> = Record<string, unknown>> = { key: string; [key: string]: unknown };
3
3
  import { getCollectionPathsCombinations, removeInitialAndTrailingSlashes } from "./navigation_utils";
4
4
  import { getSubcollections } from "./resolutions";
@@ -13,7 +13,7 @@ export interface NavigationViewEntityInternal<M extends Record<string, unknown>>
13
13
  entityId: string | number;
14
14
  slug: string;
15
15
  path: string;
16
- parentCollection: EntityCollection<M>;
16
+ parentCollection: CollectionConfig<M>;
17
17
  }
18
18
 
19
19
  export interface NavigationViewCollectionInternal<M extends Record<string, unknown>> {
@@ -21,7 +21,7 @@ export interface NavigationViewCollectionInternal<M extends Record<string, unkno
21
21
  id: string;
22
22
  slug: string;
23
23
  path: string;
24
- collection: EntityCollection<M>;
24
+ collection: CollectionConfig<M>;
25
25
  }
26
26
 
27
27
  export interface NavigationViewEntityCustomInternal<M extends Record<string, unknown>> {
@@ -34,7 +34,7 @@ export interface NavigationViewEntityCustomInternal<M extends Record<string, unk
34
34
 
35
35
  export function getNavigationEntriesFromPath(props: {
36
36
  path: string,
37
- collections: EntityCollection[] | undefined,
37
+ collections: CollectionConfig[] | undefined,
38
38
  currentFullPath?: string,
39
39
  contextEntityViews?: EntityCustomView[]
40
40
  }): NavigationViewInternal[] {
@@ -1,4 +1,4 @@
1
- import { EntityCollection } from "@rebasepro/types";
1
+ import { CollectionConfig } from "@rebasepro/types";
2
2
 
3
3
  import { getSubcollections } from "./resolutions";
4
4
 
@@ -33,13 +33,13 @@ export function getLastSegment(path: string) {
33
33
  return cleanPath;
34
34
  }
35
35
 
36
- export function resolveCollectionPathIds(path: string, allCollections: EntityCollection[]): string {
36
+ export function resolveCollectionPathIds(path: string, allCollections: CollectionConfig[]): string {
37
37
  let remainingPath = removeInitialAndTrailingSlashes(path);
38
38
  if (!remainingPath) {
39
39
  return "";
40
40
  }
41
41
 
42
- let currentCollections: EntityCollection[] | undefined = allCollections;
42
+ let currentCollections: CollectionConfig[] | undefined = allCollections;
43
43
  const resolvedPathParts: string[] = [];
44
44
 
45
45
  while (remainingPath.length > 0) {
@@ -53,7 +53,7 @@ export function resolveCollectionPathIds(path: string, allCollections: EntityCol
53
53
 
54
54
  let foundMatch = false;
55
55
  // Sort potential matches by length descending to prioritize longer matches (e.g., "a/b" over "a")
56
- const potentialMatches: { col: EntityCollection; match: string; }[] = currentCollections
56
+ const potentialMatches: { col: CollectionConfig; match: string; }[] = currentCollections
57
57
  .flatMap(col => [{
58
58
  col,
59
59
  match: col.slug
@@ -76,7 +76,7 @@ export function resolveCollectionPathIds(path: string, allCollections: EntityCol
76
76
  break; // Path ends with a collection segment
77
77
  }
78
78
 
79
- // The next segment must be an entity ID
79
+ // The next segment must be a entity ID
80
80
  const idSeparatorIndex = remainingPath.indexOf("/");
81
81
  let entityId: string | number;
82
82
  if (idSeparatorIndex > -1) {
@@ -87,7 +87,7 @@ export function resolveCollectionPathIds(path: string, allCollections: EntityCol
87
87
  // but handle it defensively: assume the rest is the ID
88
88
  entityId = remainingPath;
89
89
  remainingPath = "";
90
- console.warn(`resolveCollectionPathIds: Path seems to end with an entity ID "${entityId}" instead of a collection segment in original path "${path}". This might indicate an invalid input path.`);
90
+ console.warn(`resolveCollectionPathIds: Path seems to end with a entity ID "${entityId}" instead of a collection segment in original path "${path}". This might indicate an invalid input path.`);
91
91
  // Even if it ends here, we still need to push the ID
92
92
  }
93
93
 
@@ -123,7 +123,7 @@ export function resolveCollectionPathIds(path: string, allCollections: EntityCol
123
123
  * @param slugOrPath
124
124
  * @param collections
125
125
  */
126
- export function getCollectionBySlugWithin(slugOrPath: string, collections: EntityCollection[]): EntityCollection | undefined {
126
+ export function getCollectionBySlugWithin(slugOrPath: string, collections: CollectionConfig[]): CollectionConfig | undefined {
127
127
 
128
128
  const subpaths = removeInitialAndTrailingSlashes(slugOrPath).split("/");
129
129
  if (subpaths.length % 2 === 0) {
@@ -131,7 +131,7 @@ export function getCollectionBySlugWithin(slugOrPath: string, collections: Entit
131
131
  }
132
132
 
133
133
  const subpathCombinations = getCollectionPathsCombinations(subpaths);
134
- let result: EntityCollection | undefined;
134
+ let result: CollectionConfig | undefined;
135
135
  for (let i = 0; i < subpathCombinations.length; i++) {
136
136
  const subpathCombination = subpathCombinations[i];
137
137
  const navigationEntry = collections && collections
@@ -1,10 +1,10 @@
1
- import { EntityCollection, EntityReference } from "@rebasepro/types";
1
+ import { CollectionConfig, EntityReference } from "@rebasepro/types";
2
2
  import { getCollectionPathsCombinations, removeInitialAndTrailingSlashes } from "./navigation_utils";
3
3
  import { getSubcollections } from "./resolutions";
4
4
 
5
5
  export function getParentReferencesFromPath(props: {
6
6
  path: string,
7
- collections: EntityCollection[] | undefined,
7
+ collections: CollectionConfig[] | undefined,
8
8
  currentFullPath?: string,
9
9
  }): EntityReference[] {
10
10
 
@@ -21,7 +21,7 @@ export function getParentReferencesFromPath(props: {
21
21
  for (let i = 0; i < subpathCombinations.length; i++) {
22
22
  const subpathCombination = subpathCombinations[i];
23
23
 
24
- const collection: EntityCollection | undefined = collections && collections.find((entry) => entry.slug === subpathCombination);
24
+ const collection: CollectionConfig | undefined = collections && collections.find((entry) => entry.slug === subpathCombination);
25
25
 
26
26
  // If we find a collection, we add the reference and continue
27
27
  if (collection) {
@@ -1,5 +1,5 @@
1
1
  import { canCreateEntity, canEditEntity, canDeleteEntity, canReadCollection } from "./permissions";
2
- import { EntityCollection, AuthController, Entity, User, SecurityRule } from "@rebasepro/types";
2
+ import { CollectionConfig, AuthController, Entity, User, SecurityRule } from "@rebasepro/types";
3
3
 
4
4
  describe("Permissions Evaluator", () => {
5
5
 
@@ -46,7 +46,7 @@ describe("Permissions Evaluator", () => {
46
46
  user: null
47
47
  };
48
48
 
49
- const createMockCollection = (rules?: SecurityRule[]): EntityCollection => ({
49
+ const createMockCollection = (rules?: SecurityRule[]): CollectionConfig => ({
50
50
  slug: "test",
51
51
  name: "Test",
52
52
  table: "test",
@@ -1,4 +1,4 @@
1
- import { Entity, EntityCollection, getDataSourceCapabilities, SecurityOperation, SecurityRule, User } from "@rebasepro/types";
1
+ import { Entity, CollectionConfig, getDataSourceCapabilities, SecurityOperation, SecurityRule, User } from "@rebasepro/types";
2
2
  import { securityRuleToConditions } from "./policy/securityRuleToConditions";
3
3
  import { evaluatePolicy, PolicyEvalContext, TriState } from "./policy/evaluatePolicy";
4
4
 
@@ -35,7 +35,7 @@ function kleeneAnd(values: TriState[]): TriState {
35
35
  }
36
36
 
37
37
  /** The operations a rule covers, mirroring the Postgres generator's resolution. */
38
- function ruleOperations(rule: SecurityRule): SecurityOperation[] {
38
+ function ruleOperations(rule: SecurityRule): readonly SecurityOperation[] {
39
39
  return rule.operations && rule.operations.length > 0
40
40
  ? rule.operations
41
41
  : [rule.operation ?? "all"];
@@ -83,7 +83,7 @@ function resolveTriState(value: TriState, onUnknown: UnknownResolution): boolean
83
83
  * for optimistic UI gating; enforcement callers should pass `"deny"`.
84
84
  */
85
85
  export function checkOperation<M extends Record<string, unknown>, USER extends User>(
86
- collection: EntityCollection<M>,
86
+ collection: CollectionConfig<M>,
87
87
  authContext: AuthContext<USER>,
88
88
  entity: Entity<M> | null,
89
89
  targetOperation: SecurityOperation,
@@ -129,7 +129,7 @@ export function checkOperation<M extends Record<string, unknown>, USER extends U
129
129
 
130
130
  export function canReadCollection<M extends Record<string, unknown>, USER extends User>
131
131
  (
132
- collection: EntityCollection<M>,
132
+ collection: CollectionConfig<M>,
133
133
  authContext: AuthContext<USER>
134
134
  ): boolean {
135
135
  return checkOperation(collection, authContext, null, "select");
@@ -137,7 +137,7 @@ export function canReadCollection<M extends Record<string, unknown>, USER extend
137
137
 
138
138
  export function canEditEntity<M extends Record<string, unknown>, USER extends User>
139
139
  (
140
- collection: EntityCollection<M>,
140
+ collection: CollectionConfig<M>,
141
141
  authContext: AuthContext<USER>,
142
142
  path: string,
143
143
  entity: Entity<M> | null
@@ -147,7 +147,7 @@ export function canEditEntity<M extends Record<string, unknown>, USER extends Us
147
147
 
148
148
  export function canCreateEntity<M extends Record<string, unknown>, USER extends User>
149
149
  (
150
- collection: EntityCollection<M>,
150
+ collection: CollectionConfig<M>,
151
151
  authContext: AuthContext<USER>,
152
152
  path: string,
153
153
  entity: Entity<M> | null
@@ -157,7 +157,7 @@ export function canCreateEntity<M extends Record<string, unknown>, USER extends
157
157
 
158
158
  export function canDeleteEntity<M extends Record<string, unknown>, USER extends User>
159
159
  (
160
- collection: EntityCollection<M>,
160
+ collection: CollectionConfig<M>,
161
161
  authContext: AuthContext<USER>,
162
162
  path: string,
163
163
  entity: Entity<M> | null
@@ -55,6 +55,9 @@ export function evaluatePolicy(expr: PolicyExpression, ctx: PolicyEvalContext):
55
55
  }
56
56
  case "authenticated":
57
57
  return ctx.uid != null;
58
+ case "existsIn":
59
+ // A membership subquery cannot be run client-side — server-authoritative.
60
+ return "unknown";
58
61
  case "raw":
59
62
  // Arbitrary SQL cannot be evaluated client-side — never guess.
60
63
  return "unknown";
@@ -96,6 +99,9 @@ function resolveOperand(operand: PolicyOperand, ctx: PolicyEvalContext): Resolve
96
99
  // Can't resolve a row column without the row.
97
100
  if (!ctx.entity) return { known: false };
98
101
  return { known: true, value: ctx.entity.values[operand.name] };
102
+ case "outerField":
103
+ // Only meaningful inside an `existsIn` subquery (server-authoritative).
104
+ return { known: false };
99
105
  }
100
106
  }
101
107
 
@@ -1,5 +1,38 @@
1
- import { EntityCollection, PolicyExpression, PolicyOperand, PolicyCompareOperator, Property } from "@rebasepro/types";
1
+ import { CollectionConfig, PolicyExpression, PolicyOperand, PolicyCompareOperator, Property, ExistsInPolicyExpression } from "@rebasepro/types";
2
2
  import { toSnakeCase } from "@rebasepro/utils";
3
+ import { getTableName } from "../relations";
4
+
5
+ /**
6
+ * Options for {@link policyToPostgres}.
7
+ */
8
+ export interface PolicyCompileOptions {
9
+ /**
10
+ * Resolve a collection by slug. Required to compile
11
+ * {@link ExistsInPolicyExpression} (`policy.existsIn`) — the compiler needs
12
+ * the joined collection to derive its table name / schema. When omitted, the
13
+ * join table falls back to a snake_cased slug.
14
+ */
15
+ resolveCollection?: (slug: string) => CollectionConfig | undefined;
16
+ }
17
+
18
+ /**
19
+ * The lexical scope threaded through compilation. It changes when we descend
20
+ * into an `existsIn` subquery: inside it, `field` refers to the joined table
21
+ * (aliased) while `outerField` refers to the outer RLS row (table-qualified).
22
+ */
23
+ interface CompileScope {
24
+ /** Collection whose columns a bare `field` operand resolves against. */
25
+ fieldCollection?: CollectionConfig;
26
+ /** SQL prefix for `field` operands (`""` at top level, `"alias".` in a subquery). */
27
+ fieldPrefix: string;
28
+ /** The outer RLS collection, for `outerField` operands. */
29
+ outerCollection?: CollectionConfig;
30
+ /** SQL prefix for `outerField` operands (`""` at top level, `"schema"."table".` in a subquery). */
31
+ outerPrefix: string;
32
+ resolveCollection?: (slug: string) => CollectionConfig | undefined;
33
+ /** Monotonic counter for generating unique subquery aliases. */
34
+ alias: { n: number };
35
+ }
3
36
 
4
37
  /**
5
38
  * Compiles a {@link PolicyExpression} to a PostgreSQL boolean SQL string,
@@ -9,7 +42,18 @@ import { toSnakeCase } from "@rebasepro/utils";
9
42
  * {@link evaluatePolicy}); the Postgres schema generators call it so that DDL
10
43
  * and the admin UI derive from the exact same expression.
11
44
  */
12
- export function policyToPostgres(expr: PolicyExpression, collection?: EntityCollection): string {
45
+ export function policyToPostgres(expr: PolicyExpression, collection?: CollectionConfig, options?: PolicyCompileOptions): string {
46
+ return compile(expr, {
47
+ fieldCollection: collection,
48
+ fieldPrefix: "",
49
+ outerCollection: collection,
50
+ outerPrefix: "",
51
+ resolveCollection: options?.resolveCollection,
52
+ alias: { n: 0 }
53
+ });
54
+ }
55
+
56
+ function compile(expr: PolicyExpression, scope: CompileScope): string {
13
57
  switch (expr.kind) {
14
58
  case "true":
15
59
  return "true";
@@ -18,23 +62,25 @@ export function policyToPostgres(expr: PolicyExpression, collection?: EntityColl
18
62
  case "and":
19
63
  return expr.operands.length === 0
20
64
  ? "true"
21
- : expr.operands.map(o => `(${policyToPostgres(o, collection)})`).join(" AND ");
65
+ : expr.operands.map(o => `(${compile(o, scope)})`).join(" AND ");
22
66
  case "or":
23
67
  return expr.operands.length === 0
24
68
  ? "false"
25
- : expr.operands.map(o => `(${policyToPostgres(o, collection)})`).join(" OR ");
69
+ : expr.operands.map(o => `(${compile(o, scope)})`).join(" OR ");
26
70
  case "not":
27
71
  // Render the common `auth.uid() IS NULL` (unauthenticated) form directly.
28
72
  if (expr.operand.kind === "authenticated") return "auth.uid() IS NULL";
29
- return `NOT (${policyToPostgres(expr.operand, collection)})`;
73
+ return `NOT (${compile(expr.operand, scope)})`;
30
74
  case "compare":
31
- return `${operandToSql(expr.left, collection)} ${COMPARE_SQL[expr.op]} ${operandToSql(expr.right, collection)}`;
75
+ return `${operandToSql(expr.left, scope)} ${COMPARE_SQL[expr.op]} ${operandToSql(expr.right, scope)}`;
32
76
  case "rolesOverlap":
33
77
  return `string_to_array(auth.roles(), ',') && ${rolesArraySql(expr.roles)}`;
34
78
  case "rolesContain":
35
79
  return `string_to_array(auth.roles(), ',') @> ${rolesArraySql(expr.roles)}`;
36
80
  case "authenticated":
37
81
  return "auth.uid() IS NOT NULL";
82
+ case "existsIn":
83
+ return compileExistsIn(expr, scope);
38
84
  case "raw":
39
85
  // Full-power escape hatch: `{column}` references resolve to the bare
40
86
  // column name (matching the previous raw-SQL behavior).
@@ -42,6 +88,34 @@ export function policyToPostgres(expr: PolicyExpression, collection?: EntityColl
42
88
  }
43
89
  }
44
90
 
91
+ /**
92
+ * Compiles `existsIn` to a correlated `EXISTS (SELECT 1 FROM <join> WHERE ...)`.
93
+ * Inside the subquery, `field` operands bind to the aliased join table and
94
+ * `outerField` operands bind to the (table-qualified) outer RLS row.
95
+ */
96
+ function compileExistsIn(expr: ExistsInPolicyExpression, scope: CompileScope): string {
97
+ const join = scope.resolveCollection?.(expr.collection);
98
+ const joinTable = join ? getTableName(join) : toSnakeCase(expr.collection);
99
+ const joinSchema = schemaOf(join) ?? schemaOf(scope.outerCollection) ?? "public";
100
+ const alias = `_ex${scope.alias.n++}`;
101
+
102
+ // `outerField` inside the subquery must be qualified with the outer table,
103
+ // otherwise a bare column name would bind to the joined table instead.
104
+ const outerTable = scope.outerCollection ? getTableName(scope.outerCollection) : undefined;
105
+ const outerSchema = schemaOf(scope.outerCollection) ?? "public";
106
+ const outerPrefix = outerTable ? `"${outerSchema}"."${outerTable}".` : "";
107
+
108
+ const innerScope: CompileScope = {
109
+ fieldCollection: join,
110
+ fieldPrefix: `"${alias}".`,
111
+ outerCollection: scope.outerCollection,
112
+ outerPrefix,
113
+ resolveCollection: scope.resolveCollection,
114
+ alias: scope.alias
115
+ };
116
+ return `EXISTS (SELECT 1 FROM "${joinSchema}"."${joinTable}" "${alias}" WHERE ${compile(expr.where, innerScope)})`;
117
+ }
118
+
45
119
  const COMPARE_SQL: Record<PolicyCompareOperator, string> = {
46
120
  eq: "=",
47
121
  neq: "!=",
@@ -51,10 +125,12 @@ const COMPARE_SQL: Record<PolicyCompareOperator, string> = {
51
125
  gte: ">="
52
126
  };
53
127
 
54
- function operandToSql(operand: PolicyOperand, collection?: EntityCollection): string {
128
+ function operandToSql(operand: PolicyOperand, scope: CompileScope): string {
55
129
  switch (operand.kind) {
56
130
  case "field":
57
- return resolveColumnName(operand.name, collection);
131
+ return `${scope.fieldPrefix}${resolveColumnName(operand.name, scope.fieldCollection)}`;
132
+ case "outerField":
133
+ return `${scope.outerPrefix}${resolveColumnName(operand.name, scope.outerCollection)}`;
58
134
  case "literal":
59
135
  return quoteLiteral(operand.value);
60
136
  case "authUid":
@@ -64,7 +140,11 @@ function operandToSql(operand: PolicyOperand, collection?: EntityCollection): st
64
140
  }
65
141
  }
66
142
 
67
- function resolveColumnName(propName: string, collection?: EntityCollection): string {
143
+ function schemaOf(collection?: CollectionConfig): string | undefined {
144
+ return (collection as { schema?: string } | undefined)?.schema || undefined;
145
+ }
146
+
147
+ function resolveColumnName(propName: string, collection?: CollectionConfig): string {
68
148
  const prop = collection?.properties?.[propName] as Property | undefined;
69
149
  if (prop && "columnName" in prop && typeof (prop as { columnName?: unknown }).columnName === "string") {
70
150
  return (prop as { columnName: string }).columnName;
@@ -80,6 +160,6 @@ function quoteLiteral(value: string | number | boolean | null): string {
80
160
  }
81
161
 
82
162
  /** Sorted, single-quoted `ARRAY['a','b']` — matches the generators' output. */
83
- function rolesArraySql(roles: string[]): string {
163
+ function rolesArraySql(roles: readonly string[]): string {
84
164
  return `ARRAY[${[...roles].sort().map(r => `'${r}'`).join(",")}]`;
85
165
  }
@@ -1,6 +1,6 @@
1
- import { EntityCollection } from "@rebasepro/types";
1
+ import { CollectionConfig } from "@rebasepro/types";
2
2
 
3
- export function getEntityImagePreviewPropertyKey<M extends Record<string, unknown>>(collection: EntityCollection<M>): string | undefined {
3
+ export function getEntityImagePreviewPropertyKey<M extends Record<string, unknown>>(collection: CollectionConfig<M>): string | undefined {
4
4
 
5
5
  // find first storage property of type image
6
6
  for (const key in collection.properties) {
@@ -1,18 +1,18 @@
1
- import { EntityCollection, getDataSourceCapabilities, Property, Relation, RelationProperty } from "@rebasepro/types";
1
+ import { CollectionConfig, getDataSourceCapabilities, Property, Relation, RelationProperty } from "@rebasepro/types";
2
2
  import { toSnakeCase } from "@rebasepro/utils";
3
3
  import { generateForeignKeyName } from "@rebasepro/utils";
4
4
 
5
5
  export function sanitizeRelation(
6
6
  relation: Partial<Relation>,
7
- sourceCollection: EntityCollection,
8
- resolveCollection?: (slugOrTable: string) => EntityCollection | undefined
7
+ sourceCollection: CollectionConfig,
8
+ resolveCollection?: (slugOrTable: string) => CollectionConfig | undefined
9
9
  ): Relation {
10
10
  if (!relation.target) {
11
11
  throw new Error("Relation is missing a `target` collection.");
12
12
  }
13
13
 
14
14
  const rawTarget = relation.target;
15
- let targetCollection: EntityCollection | undefined;
15
+ let targetCollection: CollectionConfig | undefined;
16
16
 
17
17
  if (typeof rawTarget === "string") {
18
18
  if (resolveCollection) {
@@ -20,7 +20,7 @@ export function sanitizeRelation(
20
20
  }
21
21
  if (!targetCollection) {
22
22
  targetCollection = { slug: rawTarget,
23
- name: rawTarget } as EntityCollection;
23
+ name: rawTarget } as CollectionConfig;
24
24
  }
25
25
  } else if (typeof rawTarget === "function") {
26
26
  const evaluated = rawTarget();
@@ -30,13 +30,13 @@ name: rawTarget } as EntityCollection;
30
30
  }
31
31
  if (!targetCollection) {
32
32
  targetCollection = { slug: evaluated,
33
- name: evaluated } as EntityCollection;
33
+ name: evaluated } as CollectionConfig;
34
34
  }
35
35
  } else {
36
36
  targetCollection = evaluated;
37
37
  }
38
38
  } else if (rawTarget && typeof rawTarget === "object") {
39
- targetCollection = rawTarget as EntityCollection;
39
+ targetCollection = rawTarget as CollectionConfig;
40
40
  }
41
41
 
42
42
  if (!targetCollection) {
@@ -198,10 +198,10 @@ name: evaluated } as EntityCollection;
198
198
  }
199
199
 
200
200
  /** WeakMap cache — same collection instance always yields the same relation map. */
201
- const _resolvedRelationsCache = new WeakMap<EntityCollection, Record<string, Relation>>();
201
+ const _resolvedRelationsCache = new WeakMap<CollectionConfig, Record<string, Relation>>();
202
202
 
203
203
  export function resolveCollectionRelations(
204
- collection: EntityCollection
204
+ collection: CollectionConfig
205
205
  ): Record<string, Relation> {
206
206
  const cached = _resolvedRelationsCache.get(collection);
207
207
  if (cached) return cached;
@@ -251,7 +251,7 @@ export function resolveCollectionRelations(
251
251
 
252
252
  // We previously skipped if the underlying relation was already registered under
253
253
  // its canonical relationName in section 1. But we need to keep the property mapping
254
- // for EntityFetchService to hydrate the relation back to the correct property key.
254
+ // for FetchService to hydrate the relation back to the correct property key.
255
255
  // Deduplication for Drizzle schema generation is handled in generate-drizzle-schema-logic.ts.
256
256
 
257
257
  if (!relation.relationName) {
@@ -275,7 +275,7 @@ export function resolvePropertyRelation({
275
275
  }: {
276
276
  propertyKey: string;
277
277
  property: Property;
278
- sourceCollection: EntityCollection;
278
+ sourceCollection: CollectionConfig;
279
279
  }): Relation | undefined {
280
280
  if (property.type !== "relation") return undefined;
281
281
 
@@ -304,7 +304,7 @@ export function resolvePropertyRelation({
304
304
  return undefined;
305
305
  }
306
306
 
307
- export function getTableName(collection: EntityCollection): string {
307
+ export function getTableName(collection: CollectionConfig): string {
308
308
  if (getDataSourceCapabilities(collection.engine).supportsRelations) {
309
309
  return collection.table ?? toSnakeCase(collection.slug) ?? toSnakeCase(collection.name);
310
310
  }