@rebasepro/common 0.7.0 → 0.8.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 (42) hide show
  1. package/dist/collections/CollectionRegistry.d.ts +17 -2
  2. package/dist/collections/default-collections.d.ts +255 -2
  3. package/dist/data/filter-dialect.d.ts +61 -0
  4. package/dist/data/query_builder.d.ts +4 -4
  5. package/dist/data/resolveDataSource.d.ts +7 -7
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.es.js +604 -188
  8. package/dist/index.es.js.map +1 -1
  9. package/dist/index.umd.js +611 -186
  10. package/dist/index.umd.js.map +1 -1
  11. package/dist/util/builders.d.ts +48 -1
  12. package/dist/util/callbacks.d.ts +6 -1
  13. package/dist/util/index.d.ts +1 -0
  14. package/dist/util/permissions.d.ts +26 -2
  15. package/dist/util/policy/evaluatePolicy.d.ts +31 -0
  16. package/dist/util/policy/index.d.ts +3 -0
  17. package/dist/util/policy/policyToPostgres.d.ts +10 -0
  18. package/dist/util/policy/securityRuleToConditions.d.ts +24 -0
  19. package/dist/util/policy/sqlToPolicy.d.ts +20 -0
  20. package/dist/util/storage.d.ts +26 -1
  21. package/package.json +13 -13
  22. package/src/collections/CollectionRegistry.ts +59 -28
  23. package/src/collections/default-collections.ts +4 -4
  24. package/src/data/buildRebaseData.ts +9 -120
  25. package/src/data/filter-dialect.ts +318 -0
  26. package/src/data/query_builder.ts +10 -10
  27. package/src/data/resolveDataSource.ts +9 -9
  28. package/src/index.ts +1 -0
  29. package/src/util/builders.ts +78 -1
  30. package/src/util/callbacks.ts +8 -1
  31. package/src/util/index.ts +1 -0
  32. package/src/util/permissions.test.ts +5 -3
  33. package/src/util/permissions.ts +85 -158
  34. package/src/util/policy/evaluatePolicy.ts +146 -0
  35. package/src/util/policy/index.ts +3 -0
  36. package/src/util/policy/policyToPostgres.ts +85 -0
  37. package/src/util/policy/securityRuleToConditions.ts +67 -0
  38. package/src/util/policy/sqlToPolicy.ts +88 -0
  39. package/src/util/references.ts +1 -1
  40. package/src/util/relations.ts +8 -9
  41. package/src/util/resolutions.ts +6 -6
  42. package/src/util/storage.ts +34 -1
@@ -1,4 +1,4 @@
1
- import { AdditionalFieldDelegate, ArrayProperty, BooleanProperty, DateProperty, EntityCallbacks, EntityCollection, EnumValueConfig, EnumValues, GeopointProperty, MapProperty, NumberProperty, Properties, Property, ReferenceProperty, StringProperty, User } from "@rebasepro/types";
1
+ import { AdditionalFieldDelegate, ArrayProperty, BooleanProperty, DateProperty, EntityCallbacks, EntityCollection, EnumValueConfig, EnumValues, FirebaseCollection, FirebaseProperties, GeopointProperty, InferEntityType, MapProperty, MongoDBCollection, MongoProperties, NumberProperty, PostgresCollection, PostgresProperties, Properties, Property, ReferenceProperty, StringProperty, User } from "@rebasepro/types";
2
2
  /**
3
3
  * Identity function we use to defeat the type system of Typescript and build
4
4
  * collection views with all its properties
@@ -6,6 +6,53 @@ import { AdditionalFieldDelegate, ArrayProperty, BooleanProperty, DateProperty,
6
6
  * @group Builder
7
7
  */
8
8
  export declare function buildCollection<M extends Record<string, unknown> = Record<string, unknown>, USER extends User = User>(collection: EntityCollection<M, USER>): EntityCollection<M, USER>;
9
+ /**
10
+ * Define a PostgreSQL-backed collection with full type inference.
11
+ *
12
+ * The `const P` generic captures literal property types from your
13
+ * `properties` object, which enables autocomplete on `titleProperty`,
14
+ * `sort`, `propertiesOrder`, `fixedFilter`, and entity callbacks.
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * const products = defineCollection({
19
+ * name: "Products",
20
+ * slug: "products",
21
+ * table: "products",
22
+ * properties: {
23
+ * name: { name: "Name", type: "string", validation: { required: true } },
24
+ * price: { name: "Price", type: "number" },
25
+ * },
26
+ * titleProperty: "name", // ✅ autocomplete: "name" | "price"
27
+ * sort: ["price", "asc"], // ✅ autocomplete on first element
28
+ * });
29
+ * ```
30
+ *
31
+ * @group Builder
32
+ */
33
+ export declare function defineCollection<const P extends PostgresProperties, USER extends User = User>(collection: Omit<PostgresCollection<InferEntityType<P>, USER>, "properties"> & {
34
+ properties: P;
35
+ }): PostgresCollection<InferEntityType<P>, USER> & {
36
+ properties: P;
37
+ };
38
+ /**
39
+ * Define a Firestore-backed collection with full type inference.
40
+ * @group Builder
41
+ */
42
+ export declare function defineCollection<const P extends FirebaseProperties, USER extends User = User>(collection: Omit<FirebaseCollection<InferEntityType<P>, USER>, "properties"> & {
43
+ properties: P;
44
+ }): FirebaseCollection<InferEntityType<P>, USER> & {
45
+ properties: P;
46
+ };
47
+ /**
48
+ * Define a MongoDB-backed collection with full type inference.
49
+ * @group Builder
50
+ */
51
+ export declare function defineCollection<const P extends MongoProperties, USER extends User = User>(collection: Omit<MongoDBCollection<InferEntityType<P>, USER>, "properties"> & {
52
+ properties: P;
53
+ }): MongoDBCollection<InferEntityType<P>, USER> & {
54
+ properties: P;
55
+ };
9
56
  /**
10
57
  * Identity function we use to defeat the type system of Typescript and preserve
11
58
  * the property keys.
@@ -1,4 +1,9 @@
1
- import { EntityCallbacks, Properties } from "@rebasepro/types";
1
+ import { EntityCallbacks, Properties, RebaseCallContext } from "@rebasepro/types";
2
+ /**
3
+ * Context passed to entity lifecycle callbacks.
4
+ * @group Models
5
+ */
6
+ export type EntityCallbackContext = RebaseCallContext;
2
7
  /**
3
8
  * Helper function to extract field-level PropertyCallbacks from a properties schema
4
9
  * and wrap them into an EntityCallbacks object recursively.
@@ -4,6 +4,7 @@ export * from "./entities";
4
4
  export * from "./enums";
5
5
  export * from "./paths";
6
6
  export * from "./resolutions";
7
+ export * from "./policy";
7
8
  export * from "./permissions";
8
9
  export * from "./references";
9
10
  export * from "./navigation_from_path";
@@ -1,4 +1,4 @@
1
- import { Entity, EntityCollection, User } from "@rebasepro/types";
1
+ import { Entity, EntityCollection, SecurityOperation, User } from "@rebasepro/types";
2
2
  /**
3
3
  * Minimal auth context for permission checking.
4
4
  * Only requires the user object — avoids forcing callers to construct
@@ -7,7 +7,31 @@ import { Entity, EntityCollection, User } from "@rebasepro/types";
7
7
  export interface AuthContext<USER extends User = User> {
8
8
  user: USER | null;
9
9
  }
10
- export declare function checkOperation<M extends Record<string, unknown>, USER extends User>(collection: EntityCollection<M>, authContext: AuthContext<USER>, entity: Entity<M> | null, targetOperation: "select" | "insert" | "update" | "delete"): boolean;
10
+ /**
11
+ * How to resolve a policy result that cannot be decided client-side (a raw-SQL
12
+ * escape-hatch rule, or a row-column reference with no row in hand).
13
+ *
14
+ * - `"allow"` (default): optimistic — used for admin-UI gating, where Postgres
15
+ * remains the authoritative gate and hiding a working action is worse than
16
+ * showing one the server may reject.
17
+ * - `"deny"`: fail-closed — used by real enforcement callers (e.g. a driver
18
+ * applying policies in-process), so an undecidable rule never silently allows.
19
+ */
20
+ export type UnknownResolution = "allow" | "deny";
21
+ export interface CheckOperationOptions {
22
+ onUnknown?: UnknownResolution;
23
+ }
24
+ /**
25
+ * Decide whether an operation is permitted for a user on a (possibly null) row,
26
+ * by evaluating the collection's security rules with the shared policy model —
27
+ * the same model compiled to Postgres RLS DDL, so the decision matches database
28
+ * enforcement for every non-raw rule.
29
+ *
30
+ * @param options.onUnknown how to treat rules that cannot be decided
31
+ * client-side (raw SQL, or row predicates with no row). Defaults to `"allow"`
32
+ * for optimistic UI gating; enforcement callers should pass `"deny"`.
33
+ */
34
+ export declare function checkOperation<M extends Record<string, unknown>, USER extends User>(collection: EntityCollection<M>, authContext: AuthContext<USER>, entity: Entity<M> | null, targetOperation: SecurityOperation, options?: CheckOperationOptions): boolean;
11
35
  export declare function canReadCollection<M extends Record<string, unknown>, USER extends User>(collection: EntityCollection<M>, authContext: AuthContext<USER>): boolean;
12
36
  export declare function canEditEntity<M extends Record<string, unknown>, USER extends User>(collection: EntityCollection<M>, authContext: AuthContext<USER>, path: string, entity: Entity<M> | null): boolean;
13
37
  export declare function canCreateEntity<M extends Record<string, unknown>, USER extends User>(collection: EntityCollection<M>, authContext: AuthContext<USER>, path: string, entity: Entity<M> | null): boolean;
@@ -0,0 +1,31 @@
1
+ import { Entity, PolicyExpression } from "@rebasepro/types";
2
+ /**
3
+ * Result of evaluating a policy client-side. `"unknown"` means the expression
4
+ * could not be decided without more information — either a raw-SQL escape-hatch
5
+ * node (which the client deliberately never guesses) or a row-column reference
6
+ * with no entity in hand (e.g. list-level gating). Callers decide how to resolve
7
+ * `"unknown"`: fail-closed for an enforcement decision, optimistic for pure
8
+ * visibility gating.
9
+ */
10
+ export type TriState = boolean | "unknown";
11
+ /**
12
+ * Context for {@link evaluatePolicy}: the acting user (or none) and the row
13
+ * being evaluated (or none, for collection-level gating).
14
+ */
15
+ export interface PolicyEvalContext {
16
+ /** The current user's id, or null/undefined when unauthenticated. */
17
+ uid?: string | null;
18
+ /** The current user's application roles. */
19
+ roles?: string[];
20
+ /** The row being evaluated, or null when no specific row is available. */
21
+ entity: Entity | null;
22
+ }
23
+ /**
24
+ * Evaluates a {@link PolicyExpression} against a user + row, using three-valued
25
+ * (Kleene) logic so that `"unknown"` sub-results propagate soundly.
26
+ *
27
+ * This is the JavaScript twin of {@link policyToPostgres}: both derive from the
28
+ * same expression, so the admin UI matches database enforcement by construction
29
+ * for every non-raw rule.
30
+ */
31
+ export declare function evaluatePolicy(expr: PolicyExpression, ctx: PolicyEvalContext): TriState;
@@ -0,0 +1,3 @@
1
+ export * from "./securityRuleToConditions";
2
+ export * from "./policyToPostgres";
3
+ export * from "./evaluatePolicy";
@@ -0,0 +1,10 @@
1
+ import { EntityCollection, PolicyExpression } from "@rebasepro/types";
2
+ /**
3
+ * Compiles a {@link PolicyExpression} to a PostgreSQL boolean SQL string,
4
+ * suitable for a `USING (...)` / `WITH CHECK (...)` clause.
5
+ *
6
+ * This is one of the two consumers of the shared policy model (the other being
7
+ * {@link evaluatePolicy}); the Postgres schema generators call it so that DDL
8
+ * and the admin UI derive from the exact same expression.
9
+ */
10
+ export declare function policyToPostgres(expr: PolicyExpression, collection?: EntityCollection): string;
@@ -0,0 +1,24 @@
1
+ import { PolicyExpression, SecurityRule } from "@rebasepro/types";
2
+ /**
3
+ * The normalized `USING` / `WITH CHECK` conditions for a single security rule,
4
+ * expressed in the engine-agnostic {@link PolicyExpression} model.
5
+ *
6
+ * A `null` clause means "this rule contributes no condition for that clause";
7
+ * consumers apply the default (Postgres denies with `false`).
8
+ */
9
+ export interface RuleConditions {
10
+ usingExpr: PolicyExpression | null;
11
+ withCheckExpr: PolicyExpression | null;
12
+ }
13
+ /**
14
+ * Desugars a {@link SecurityRule} — its `access`/`ownerField`/`roles` shortcuts,
15
+ * structured `condition`/`check`, and raw `using`/`withCheck` — into a single
16
+ * normalized {@link PolicyExpression} pair.
17
+ *
18
+ * **This is the linchpin against drift:** both the Postgres DDL generators and
19
+ * the client-side evaluator consume this one function, so there is exactly one
20
+ * definition of what a rule means. In particular, application `roles` are folded
21
+ * into the expression here (AND'd with the base condition, matching how Postgres
22
+ * generates the clause) rather than being handled separately by each consumer.
23
+ */
24
+ export declare function securityRuleToConditions(rule: SecurityRule): RuleConditions;
@@ -0,0 +1,20 @@
1
+ import { PolicyExpression } from "@rebasepro/types";
2
+ /**
3
+ * A tiny, regex-based SQL "parser" for security rules.
4
+ *
5
+ * This is NOT a full SQL parser. It is designed to handle the subset of SQL
6
+ * commonly used in `USING` and `WITH CHECK` clauses, enough to drive the
7
+ * optimistic client-side UI decision.
8
+ *
9
+ * It handles:
10
+ * - `field = 'literal'`
11
+ * - `field != 'literal'`
12
+ * - `field = current_setting('app.user_id')`
13
+ * - `A AND B`
14
+ * - `true`
15
+ * - `IN (...)` (as optimistic true)
16
+ *
17
+ * For anything it doesn't understand, it returns a `raw` expression, which
18
+ * the evaluator treats as "unknown" (and usually optimistic true).
19
+ */
20
+ export declare function sqlToPolicy(sql: string): PolicyExpression;
@@ -1,4 +1,29 @@
1
- import { ArrayProperty, EntityValues, StorageConfig, StringProperty, UploadedFileContext } from "@rebasepro/types";
1
+ import { ArrayProperty, EntityValues, StorageConfig, StorageSource, StorageSourceRegistry, StringProperty, UploadedFileContext } from "@rebasepro/types";
2
+ /**
3
+ * Resolve the {@link StorageSource} to use for a property, given the key
4
+ * referenced by `StorageConfig.storageSource`.
5
+ *
6
+ * Resolution priority:
7
+ * 1. No `sourceKey` → the default source (backward compatible).
8
+ * 2. An explicit {@link StorageSourceRegistry} (e.g. `client.storageRegistry`).
9
+ * 3. A `sources` lookup map (e.g. the `StorageSourcesContext`).
10
+ * 4. Fall back to the default source.
11
+ *
12
+ * Shared by the upload hook, the markdown editor, and the read-only previews
13
+ * so the resolution logic lives in one place.
14
+ *
15
+ * @group Storage
16
+ */
17
+ export declare function resolveStorageSource(params: {
18
+ /** Key from `StorageConfig.storageSource`. */
19
+ sourceKey?: string | null;
20
+ /** Built sources keyed by storage-source key (e.g. from context). */
21
+ sources?: Record<string, StorageSource>;
22
+ /** Optional explicit registry — takes precedence over `sources`. */
23
+ registry?: StorageSourceRegistry;
24
+ /** Default source, used when no key is set or the key cannot be resolved. */
25
+ defaultSource: StorageSource;
26
+ }): StorageSource;
2
27
  interface ResolveFilenameStringParams<M extends Record<string, unknown>> {
3
28
  input: string | ((context: UploadedFileContext) => (Promise<string> | string));
4
29
  storage: StorageConfig;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/common",
3
3
  "type": "module",
4
- "version": "0.7.0",
4
+ "version": "0.8.0",
5
5
  "description": "Awesome Firebase/Firestore-based headless open-source CMS",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/rebaseco"
@@ -29,14 +29,6 @@
29
29
  "headless cms",
30
30
  "content manager"
31
31
  ],
32
- "scripts": {
33
- "watch": "vite build --watch",
34
- "build": "vite build && tsc --emitDeclarationOnly -p tsconfig.prod.json",
35
- "test:lint": "eslint \"src/**\" --quiet",
36
- "test": "jest --passWithNoTests",
37
- "clean": "rm -rf dist && find ./src -name '*.js' -type f | xargs rm -f",
38
- "generateIcons": "ts-node --esm src/icons/generateIcons.ts"
39
- },
40
32
  "exports": {
41
33
  ".": {
42
34
  "types": "./dist/index.d.ts",
@@ -47,10 +39,10 @@
47
39
  "./package.json": "./package.json"
48
40
  },
49
41
  "dependencies": {
50
- "@rebasepro/types": "workspace:*",
51
- "@rebasepro/utils": "workspace:*",
52
42
  "fast-equals": "6.0.0",
53
- "json-logic-js": "^2.0.5"
43
+ "json-logic-js": "^2.0.5",
44
+ "@rebasepro/utils": "0.8.0",
45
+ "@rebasepro/types": "0.8.0"
54
46
  },
55
47
  "devDependencies": {
56
48
  "@jest/globals": "^30.4.1",
@@ -105,5 +97,13 @@
105
97
  "^@rebasepro/types$": "<rootDir>/../types/src/index.ts",
106
98
  "^@rebasepro/utils$": "<rootDir>/../utils/src/index.ts"
107
99
  }
100
+ },
101
+ "scripts": {
102
+ "watch": "vite build --watch",
103
+ "build": "vite build && tsc --emitDeclarationOnly -p tsconfig.prod.json",
104
+ "test:lint": "eslint \"src/**\" --quiet",
105
+ "test": "jest --passWithNoTests",
106
+ "clean": "rm -rf dist && find ./src -name '*.js' -type f | xargs rm -f",
107
+ "generateIcons": "ts-node --esm src/icons/generateIcons.ts"
108
108
  }
109
- }
109
+ }
@@ -1,31 +1,61 @@
1
1
  import {
2
2
  ArrayProperty,
3
- CollectionWithRelations,
4
- CollectionWithSubcollections,
3
+ EntityCallbacks,
4
+ EngineProperties,
5
5
  EntityCollection,
6
+ getDataSourceCapabilities,
7
+ getDeclaredSubcollections,
6
8
  NumberProperty,
7
9
  Properties,
8
10
  Property,
9
11
  Relation,
10
12
  RelationProperty,
11
- StringProperty,
12
- getDataSourceCapabilities
13
+ StringProperty
13
14
  } from "@rebasepro/types";
14
15
  import { deepEqual } from "fast-equals";
15
16
 
16
- import { enumToObjectEntries, getSubcollections, getTableName, resolveCollectionRelations, findRelation, sanitizeRelation } from "../util";
17
- import { removeFunctions, mergeDeep, deepClone } from "@rebasepro/utils";
18
- import { resolveDataSource, DataSourceRegistry } from "../data/resolveDataSource";
17
+ import {
18
+ enumToObjectEntries,
19
+ findRelation,
20
+ getSubcollections,
21
+ getTableName,
22
+ resolveCollectionRelations,
23
+ sanitizeRelation
24
+ } from "../util";
25
+ import { deepClone, mergeDeep, removeFunctions } from "@rebasepro/utils";
26
+ import { DataSourceRegistry, resolveDataSource } from "../data/resolveDataSource";
19
27
 
20
28
  export class CollectionRegistry {
21
29
 
22
30
  /**
23
31
  * Declared data sources, used during normalization to resolve each
24
32
  * collection's engine (so `dataSource`-only collections get the right
25
- * capabilities). Empty by default → behaviour keys off `driver` as before.
33
+ * capabilities). Empty by default.
26
34
  */
27
35
  private dataSources: DataSourceRegistry = {};
28
36
 
37
+ /**
38
+ * Global lifecycle callbacks applied to every collection.
39
+ * Runs on all data paths (REST, WebSocket, `rebase.data`).
40
+ * Execution order: global → collection → property callbacks.
41
+ */
42
+ private _globalCallbacks?: EntityCallbacks;
43
+
44
+ /**
45
+ * Set global lifecycle callbacks that apply to every collection.
46
+ * Typically called once during backend initialization.
47
+ */
48
+ setGlobalCallbacks(callbacks: EntityCallbacks): void {
49
+ this._globalCallbacks = callbacks;
50
+ }
51
+
52
+ /**
53
+ * Get the currently registered global callbacks, if any.
54
+ */
55
+ getGlobalCallbacks(): EntityCallbacks | undefined {
56
+ return this._globalCallbacks;
57
+ }
58
+
29
59
  // Normalized runtime layer (used by Data Grid / UI)
30
60
  private collectionsByTableName = new Map<string, EntityCollection>();
31
61
  private collectionsBySlug = new Map<string, EntityCollection>();
@@ -183,25 +213,24 @@ export class CollectionRegistry {
183
213
  // and for preventing mutation of module-level collection singletons.
184
214
  const result = { ...collection } as EntityCollection;
185
215
 
186
- // 0. For `dataSource`-only collections (no explicit `driver`), resolve
187
- // the engine from the data-source registry and stamp it as `driver`
188
- // on the normalized copy, so downstream capability lookups (which
189
- // read `driver`) are correct. Surgical on purpose: collections that
190
- // already set `driver`, and plain default collections (no driver,
191
- // no dataSource), are left untouched. Only the normalized layer is
192
- // affected — the raw layer used by the collection editor keeps the
193
- // author's original fields.
194
- if (result.dataSource && !result.driver) {
195
- const engine = resolveDataSource(result, this.dataSources).engine;
196
- if (engine) (result as { driver?: string }).driver = engine;
216
+ // 0. Resolve and stamp `dataSource` and `engine` on the normalized copy.
217
+ // After this block every normalized collection has both fields set,
218
+ // so downstream code can read them directly without calling
219
+ // `resolveDataSource()`. Only the normalized layer is affected
220
+ // the raw layer used by the collection editor keeps the author's
221
+ // original fields.
222
+ {
223
+ const resolved = resolveDataSource(result, this.dataSources);
224
+ if (!result.dataSource) (result as { dataSource?: string }).dataSource = resolved.key;
225
+ if (!result.engine) (result as { engine?: string }).engine = resolved.engine;
197
226
  }
198
227
 
199
228
  // 1. Extract relations from properties that have inline config (target set)
200
229
  const extractedRelations = this.extractRelationsFromProperties(result.properties);
201
230
 
202
231
  // 2. Merge with manual relations[] (manual entries win on name conflict)
203
- const relResult = result as CollectionWithRelations;
204
- const manualRelations = getDataSourceCapabilities(result.driver).supportsRelations ? (relResult.relations ?? []) : [];
232
+ const relResult = result;
233
+ const manualRelations = getDataSourceCapabilities(result.engine).supportsRelations ? (relResult.relations ?? []) : [];
205
234
  const mergedRelationsRaw = [...extractedRelations];
206
235
  for (const manual of manualRelations) {
207
236
  const name = manual.relationName;
@@ -227,7 +256,7 @@ export class CollectionRegistry {
227
256
  // foreignKeyOnTarget, etc.) are populated. Without this the
228
257
  // property.relation stamp is missing junction-table metadata and
229
258
  // the backend cannot fetch many-to-many data.
230
- if (getDataSourceCapabilities(result.driver).supportsRelations) {
259
+ if (getDataSourceCapabilities(result.engine).supportsRelations) {
231
260
  mergedRelations = mergedRelationsRaw.map(r => {
232
261
  try {
233
262
  return sanitizeRelation(r, result, (slug) => this.get(slug));
@@ -244,13 +273,15 @@ export class CollectionRegistry {
244
273
 
245
274
  // 4. Normalize properties (which stamps relation on each property)
246
275
  const properties: Properties = this.normalizeProperties(result.properties, mergedRelations);
247
- result.properties = properties;
276
+ result.properties = properties as EngineProperties;
248
277
 
249
278
  // Populate childCollections from driver-specific fields
250
279
  if (!result.childCollections) {
251
- if (getDataSourceCapabilities(result.driver).supportsSubcollections && (result as CollectionWithSubcollections).subcollections) {
252
- result.childCollections = (result as CollectionWithSubcollections).subcollections;
253
- } else if (getDataSourceCapabilities(result.driver).supportsRelations && relResult.relations) {
280
+ const capabilities = getDataSourceCapabilities(result.engine);
281
+ const declaredSubcollections = getDeclaredSubcollections(result);
282
+ if (capabilities.supportsSubcollections && declaredSubcollections) {
283
+ result.childCollections = declaredSubcollections;
284
+ } else if (capabilities.supportsRelations && relResult.relations) {
254
285
  const manyRelations = relResult.relations.filter((r: Relation) => r.cardinality === "many");
255
286
  if (manyRelations.length > 0) {
256
287
  result.childCollections = () => manyRelations.map((r: Relation) => {
@@ -411,8 +442,8 @@ export class CollectionRegistry {
411
442
  const relationKey = pathSegments[i];
412
443
 
413
444
  // Get relations for current collection
414
- if (!getDataSourceCapabilities(currentCollection.driver).supportsRelations) {
415
- throw new Error(`Relation path navigation requires a collection that supports relations, but '${currentCollection.slug}' uses driver '${currentCollection.driver}'`);
445
+ if (!getDataSourceCapabilities(currentCollection.engine).supportsRelations) {
446
+ throw new Error(`Relation path navigation requires a collection that supports relations, but '${currentCollection.slug}' uses engine '${currentCollection.engine}'`);
416
447
  }
417
448
  const resolvedRelations = resolveCollectionRelations(currentCollection);
418
449
  const relation = findRelation(resolvedRelations, relationKey);
@@ -1,4 +1,4 @@
1
- import type { PostgresCollection } from "@rebasepro/types";
1
+ import { defineCollection } from "../util/builders";
2
2
 
3
3
  /**
4
4
  * Default users collection.
@@ -7,7 +7,7 @@ import type { PostgresCollection } from "@rebasepro/types";
7
7
  * Slug-based dedup (Map keyed by slug, last-write-wins) lets developers
8
8
  * override by defining their own collection with `slug: "users"`.
9
9
  */
10
- export const defaultUsersCollection: PostgresCollection = {
10
+ export const defaultUsersCollection = defineCollection({
11
11
  name: "Users",
12
12
  singularName: "User",
13
13
  slug: "users",
@@ -48,7 +48,7 @@ unique: true }
48
48
  name: "Photo URL",
49
49
  type: "string",
50
50
  columnName: "photo_url",
51
- url: "image"
51
+ ui: { url: "image" }
52
52
  },
53
53
  roles: {
54
54
  name: "Roles",
@@ -120,4 +120,4 @@ disabled: { hidden: true } }
120
120
  },
121
121
  listProperties: ["displayName", "email", "roles", "createdAt"],
122
122
  propertiesOrder: ["id", "email", "displayName", "roles", "createdAt"]
123
- };
123
+ });
@@ -6,128 +6,13 @@ import {
6
6
  FindResponse,
7
7
  Entity,
8
8
  EntityValues,
9
- FilterValues,
10
9
  WhereFilterOp,
11
- WhereFieldValue,
12
- WhereFilterOpShort,
13
10
  LogicalCondition,
14
11
  WhereValue
15
12
  } from "@rebasepro/types";
16
13
  import { toSnakeCase } from "@rebasepro/utils";
17
14
  import { QueryBuilder } from "./query_builder";
18
-
19
- /**
20
- * Convert where-clause filter object to the internal DataDriver FilterValues format.
21
- *
22
- * Supports multiple value formats:
23
- * - PostgREST string: { status: "eq.published", age: "gte.18" }
24
- * - Equality shorthand: { company_profile_id: null, status: "active", age: 18 }
25
- * - Tuple syntax: { age: [">=", 18], role: ["in", ["admin", "editor"]] }
26
- *
27
- * Internal: { status: ["==", "published"], age: [">=", 18] }
28
- */
29
- function convertWhereToFilter(where?: Record<string, WhereFieldValue>): FilterValues<string> | undefined {
30
- if (!where) return undefined;
31
-
32
- const operatorMap: Record<string, WhereFilterOp> = {
33
- "eq": "==",
34
- "neq": "!=",
35
- "gt": ">",
36
- "gte": ">=",
37
- "lt": "<",
38
- "lte": "<=",
39
- "in": "in",
40
- "nin": "not-in",
41
- "not-in": "not-in",
42
- "cs": "array-contains",
43
- "csa": "array-contains-any",
44
- "==": "==",
45
- "!=": "!=",
46
- ">": ">",
47
- ">=": ">=",
48
- "<": "<",
49
- "<=": "<=",
50
- "array-contains": "array-contains",
51
- "array-contains-any": "array-contains-any"
52
- };
53
-
54
- const filter: FilterValues<string> = {};
55
-
56
- for (const [field, rawValue] of Object.entries(where)) {
57
- // Handle null → equality
58
- if (rawValue === null) {
59
- filter[field] = ["==", null];
60
- continue;
61
- }
62
-
63
- // Handle boolean → equality
64
- if (typeof rawValue === "boolean") {
65
- filter[field] = ["==", rawValue];
66
- continue;
67
- }
68
-
69
- // Handle number → equality
70
- if (typeof rawValue === "number") {
71
- filter[field] = ["==", rawValue];
72
- continue;
73
- }
74
-
75
- // Handle tuple or array of tuples
76
- if (Array.isArray(rawValue)) {
77
- const conditions: [WhereFilterOpShort, unknown][] = Array.isArray(rawValue[0])
78
- ? (rawValue as [WhereFilterOpShort, unknown][])
79
- : [rawValue as [WhereFilterOpShort, unknown]];
80
-
81
- const mappedConditions: [WhereFilterOp, unknown][] = conditions.map(([rawOp, val]) => {
82
- const mappedOp = operatorMap[rawOp] ?? "==";
83
- return [mappedOp, val];
84
- });
85
-
86
- filter[field] = Array.isArray(rawValue[0]) ? mappedConditions : mappedConditions[0];
87
- continue;
88
- }
89
-
90
- // Handle PostgREST string format: "op.value"
91
- if (typeof rawValue === "string") {
92
- const dotIndex = rawValue.indexOf(".");
93
- if (dotIndex === -1) {
94
- // Plain string equality
95
- filter[field] = ["==", rawValue];
96
- continue;
97
- }
98
-
99
- const op = rawValue.substring(0, dotIndex);
100
- let value: unknown = rawValue.substring(dotIndex + 1);
101
-
102
- // Parse list values like "(admin,editor)"
103
- if (typeof value === "string" && value.startsWith("(") && value.endsWith(")")) {
104
- value = value.slice(1, -1).split(",").map((v: string) => v.trim());
105
- }
106
-
107
- // Parse null string
108
- if (value === "null") {
109
- value = null;
110
- }
111
- // Parse boolean strings
112
- else if (value === "true") {
113
- value = true;
114
- } else if (value === "false") {
115
- value = false;
116
- }
117
- // Try to parse numbers
118
- else if (typeof value === "string" && !isNaN(Number(value)) && value.trim() !== "") {
119
- value = Number(value);
120
- }
121
-
122
- const mappedOp = operatorMap[op];
123
- if (mappedOp) {
124
- filter[field] = [mappedOp, value];
125
- }
126
- }
127
- }
128
-
129
- return Object.keys(filter).length > 0 ? filter : undefined;
130
- }
15
+ import { deserializeFilter } from "./filter-dialect";
131
16
 
132
17
  /**
133
18
  * Parse an orderBy string like "created_at:desc" into [field, direction].
@@ -147,11 +32,14 @@ function createDriverAccessor<M extends Record<string, unknown> = Record<string,
147
32
  const accessor: CollectionAccessor<M> = {
148
33
  async find(params?: FindParams): Promise<FindResponse<M>> {
149
34
  const orderParsed = parseOrderBy(params?.orderBy);
35
+ // Ensure filters are in canonical [op, value] format even if passed as PostgREST strings
36
+ const filter = params?.where ? deserializeFilter(params.where as any) : undefined;
37
+
150
38
  const entities = await driver.fetchCollection<M>({
151
39
  path: slug,
152
40
  limit: params?.limit,
153
41
  offset: params?.offset,
154
- filter: convertWhereToFilter(params?.where),
42
+ filter,
155
43
  orderBy: orderParsed?.[0],
156
44
  order: orderParsed?.[1],
157
45
  searchString: params?.searchString
@@ -208,9 +96,10 @@ values: {} as Record<string, unknown> }
208
96
 
209
97
  count: driver.countEntities
210
98
  ? async (params?: FindParams): Promise<number> => {
99
+ const filter = params?.where ? deserializeFilter(params.where as any) : undefined;
211
100
  return driver.countEntities!({
212
101
  path: slug,
213
- filter: convertWhereToFilter(params?.where)
102
+ filter
214
103
  });
215
104
  }
216
105
  : undefined,
@@ -224,7 +113,7 @@ values: {} as Record<string, unknown> }
224
113
  path: slug,
225
114
  limit: params?.limit,
226
115
  offset: params?.offset,
227
- filter: convertWhereToFilter(params?.where),
116
+ filter: params?.where,
228
117
  orderBy: orderParsed?.[0],
229
118
  order: orderParsed?.[1],
230
119
  searchString: params?.searchString,
@@ -254,7 +143,7 @@ values: {} as Record<string, unknown> }
254
143
  } : undefined,
255
144
 
256
145
  // Fluent Query Builder
257
- where(columnOrCondition: string | LogicalCondition, operator?: WhereFilterOpShort, value?: unknown) {
146
+ where(columnOrCondition: string | LogicalCondition, operator?: WhereFilterOp, value?: unknown) {
258
147
  const builder = new QueryBuilder<M>(accessor);
259
148
  if (typeof columnOrCondition === "object") {
260
149
  return builder.where(columnOrCondition);