@rebasepro/common 0.6.1 → 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 (46) hide show
  1. package/dist/collections/CollectionRegistry.d.ts +30 -2
  2. package/dist/collections/default-collections.d.ts +255 -2
  3. package/dist/data/buildRoutedRebaseData.d.ts +53 -0
  4. package/dist/data/filter-dialect.d.ts +61 -0
  5. package/dist/data/query_builder.d.ts +4 -4
  6. package/dist/data/resolveDataSource.d.ts +43 -0
  7. package/dist/index.d.ts +4 -0
  8. package/dist/index.es.js +777 -178
  9. package/dist/index.es.js.map +1 -1
  10. package/dist/index.umd.js +793 -176
  11. package/dist/index.umd.js.map +1 -1
  12. package/dist/table-classification.d.ts +47 -0
  13. package/dist/util/builders.d.ts +48 -1
  14. package/dist/util/callbacks.d.ts +6 -1
  15. package/dist/util/index.d.ts +1 -0
  16. package/dist/util/permissions.d.ts +26 -2
  17. package/dist/util/policy/evaluatePolicy.d.ts +31 -0
  18. package/dist/util/policy/index.d.ts +3 -0
  19. package/dist/util/policy/policyToPostgres.d.ts +10 -0
  20. package/dist/util/policy/securityRuleToConditions.d.ts +24 -0
  21. package/dist/util/policy/sqlToPolicy.d.ts +20 -0
  22. package/dist/util/storage.d.ts +26 -1
  23. package/package.json +3 -3
  24. package/src/collections/CollectionRegistry.ts +80 -16
  25. package/src/collections/default-collections.ts +4 -4
  26. package/src/data/buildRebaseData.ts +9 -120
  27. package/src/data/buildRoutedRebaseData.ts +97 -0
  28. package/src/data/filter-dialect.ts +318 -0
  29. package/src/data/query_builder.ts +10 -10
  30. package/src/data/resolveDataSource.ts +79 -0
  31. package/src/index.ts +4 -1
  32. package/src/table-classification.ts +109 -0
  33. package/src/util/builders.ts +78 -1
  34. package/src/util/callbacks.ts +8 -1
  35. package/src/util/index.ts +1 -0
  36. package/src/util/permissions.test.ts +5 -3
  37. package/src/util/permissions.ts +85 -158
  38. package/src/util/policy/evaluatePolicy.ts +146 -0
  39. package/src/util/policy/index.ts +3 -0
  40. package/src/util/policy/policyToPostgres.ts +85 -0
  41. package/src/util/policy/securityRuleToConditions.ts +67 -0
  42. package/src/util/policy/sqlToPolicy.ts +88 -0
  43. package/src/util/references.ts +1 -1
  44. package/src/util/relations.ts +8 -9
  45. package/src/util/resolutions.ts +6 -6
  46. package/src/util/storage.ts +34 -1
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Table Classification
3
+ *
4
+ * Shared constants and pure functions for classifying database tables.
5
+ * Used by both the server-side PostgresBackendDriver and the Studio RLS editor.
6
+ */
7
+ /** Possible categories a database table can belong to. */
8
+ export type TableCategory = "rebase-internal" | "junction" | "user";
9
+ /** Schemas that are always considered Rebase-internal. */
10
+ export declare const REBASE_INTERNAL_SCHEMAS: readonly string[];
11
+ /** Table-name prefixes that mark a table as Rebase-internal regardless of schema. */
12
+ export declare const REBASE_INTERNAL_PREFIXES: readonly string[];
13
+ /**
14
+ * Synchronously classify a table based on naming conventions.
15
+ *
16
+ * @param tableName - The unqualified name of the table.
17
+ * @param schemaName - The schema the table belongs to (e.g. `"public"`, `"rebase"`).
18
+ * @returns `"rebase-internal"` when the table belongs to a reserved schema or
19
+ * carries a reserved prefix; `"user"` otherwise.
20
+ *
21
+ * @remarks
22
+ * Junction-table detection requires an async database query and is therefore
23
+ * **not** handled by this function. Use {@link detectJunctionTables} to obtain
24
+ * the set of junction tables, then reclassify as needed.
25
+ */
26
+ export declare function classifyTable(tableName: string, schemaName: string): TableCategory;
27
+ /**
28
+ * Convenience predicate that checks whether a table is Rebase-internal.
29
+ *
30
+ * @param tableName - The unqualified name of the table.
31
+ * @param schemaName - The schema the table belongs to.
32
+ * @returns `true` if the table is classified as `"rebase-internal"`.
33
+ */
34
+ export declare function isRebaseInternalTable(tableName: string, schemaName: string): boolean;
35
+ /** SQL query that detects junction tables in the `public` schema. */
36
+ export declare const JUNCTION_TABLES_SQL = "\n SELECT t.table_name\n FROM information_schema.tables t\n WHERE t.table_schema = 'public'\n AND t.table_type = 'BASE TABLE'\n AND NOT EXISTS (\n SELECT 1\n FROM information_schema.columns c\n WHERE c.table_schema = t.table_schema\n AND c.table_name = t.table_name\n AND c.column_name NOT IN (\n SELECT kcu.column_name\n FROM information_schema.key_column_usage kcu\n JOIN information_schema.table_constraints tc\n ON tc.constraint_name = kcu.constraint_name\n AND tc.table_schema = kcu.table_schema\n WHERE tc.constraint_type = 'FOREIGN KEY'\n AND kcu.table_schema = t.table_schema\n AND kcu.table_name = t.table_name\n )\n )\n";
37
+ /**
38
+ * Asynchronously detect junction (link) tables in the `public` schema.
39
+ *
40
+ * A junction table is defined as a table where **every** column participates in
41
+ * at least one foreign-key constraint.
42
+ *
43
+ * @param executeSql - A callback that executes a raw SQL string and returns the
44
+ * resulting rows.
45
+ * @returns A `Set` containing the names of all detected junction tables.
46
+ */
47
+ export declare function detectJunctionTables(executeSql: (sql: string) => Promise<Record<string, unknown>[]>): Promise<Set<string>>;
@@ -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.6.1",
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"
@@ -41,8 +41,8 @@
41
41
  "dependencies": {
42
42
  "fast-equals": "6.0.0",
43
43
  "json-logic-js": "^2.0.5",
44
- "@rebasepro/types": "0.6.1",
45
- "@rebasepro/utils": "0.6.1"
44
+ "@rebasepro/utils": "0.8.0",
45
+ "@rebasepro/types": "0.8.0"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@jest/globals": "^30.4.1",
@@ -1,23 +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";
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";
18
27
 
19
28
  export class CollectionRegistry {
20
29
 
30
+ /**
31
+ * Declared data sources, used during normalization to resolve each
32
+ * collection's engine (so `dataSource`-only collections get the right
33
+ * capabilities). Empty by default.
34
+ */
35
+ private dataSources: DataSourceRegistry = {};
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
+
21
59
  // Normalized runtime layer (used by Data Grid / UI)
22
60
  private collectionsByTableName = new Map<string, EntityCollection>();
23
61
  private collectionsBySlug = new Map<string, EntityCollection>();
@@ -34,12 +72,24 @@ export class CollectionRegistry {
34
72
  // to avoid the issue where normalization creates new objects that always fail equality.
35
73
  private lastRawInputSnapshot: ReturnType<typeof removeFunctions>[] | null = null;
36
74
 
37
- constructor(collections?: EntityCollection[]) {
75
+ constructor(collections?: EntityCollection[], dataSources?: DataSourceRegistry) {
76
+ if (dataSources) this.dataSources = dataSources;
38
77
  if (collections) {
39
78
  this.registerMultiple(collections);
40
79
  }
41
80
  }
42
81
 
82
+ /**
83
+ * Provide the declared data sources used to resolve each collection's
84
+ * engine during normalization. Set this before registering collections.
85
+ * Returns true if the registry changed (callers may re-register).
86
+ */
87
+ setDataSources(dataSources: DataSourceRegistry): boolean {
88
+ if (deepEqual(this.dataSources, dataSources)) return false;
89
+ this.dataSources = dataSources ?? {};
90
+ return true;
91
+ }
92
+
43
93
  reset() {
44
94
  this.collectionsByTableName.clear();
45
95
  this.collectionsBySlug.clear();
@@ -163,12 +213,24 @@ export class CollectionRegistry {
163
213
  // and for preventing mutation of module-level collection singletons.
164
214
  const result = { ...collection } as EntityCollection;
165
215
 
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;
226
+ }
227
+
166
228
  // 1. Extract relations from properties that have inline config (target set)
167
229
  const extractedRelations = this.extractRelationsFromProperties(result.properties);
168
230
 
169
231
  // 2. Merge with manual relations[] (manual entries win on name conflict)
170
- const relResult = result as CollectionWithRelations;
171
- const manualRelations = getDataSourceCapabilities(result.driver).supportsRelations ? (relResult.relations ?? []) : [];
232
+ const relResult = result;
233
+ const manualRelations = getDataSourceCapabilities(result.engine).supportsRelations ? (relResult.relations ?? []) : [];
172
234
  const mergedRelationsRaw = [...extractedRelations];
173
235
  for (const manual of manualRelations) {
174
236
  const name = manual.relationName;
@@ -194,7 +256,7 @@ export class CollectionRegistry {
194
256
  // foreignKeyOnTarget, etc.) are populated. Without this the
195
257
  // property.relation stamp is missing junction-table metadata and
196
258
  // the backend cannot fetch many-to-many data.
197
- if (getDataSourceCapabilities(result.driver).supportsRelations) {
259
+ if (getDataSourceCapabilities(result.engine).supportsRelations) {
198
260
  mergedRelations = mergedRelationsRaw.map(r => {
199
261
  try {
200
262
  return sanitizeRelation(r, result, (slug) => this.get(slug));
@@ -211,13 +273,15 @@ export class CollectionRegistry {
211
273
 
212
274
  // 4. Normalize properties (which stamps relation on each property)
213
275
  const properties: Properties = this.normalizeProperties(result.properties, mergedRelations);
214
- result.properties = properties;
276
+ result.properties = properties as EngineProperties;
215
277
 
216
278
  // Populate childCollections from driver-specific fields
217
279
  if (!result.childCollections) {
218
- if (getDataSourceCapabilities(result.driver).supportsSubcollections && (result as CollectionWithSubcollections).subcollections) {
219
- result.childCollections = (result as CollectionWithSubcollections).subcollections;
220
- } 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) {
221
285
  const manyRelations = relResult.relations.filter((r: Relation) => r.cardinality === "many");
222
286
  if (manyRelations.length > 0) {
223
287
  result.childCollections = () => manyRelations.map((r: Relation) => {
@@ -378,8 +442,8 @@ export class CollectionRegistry {
378
442
  const relationKey = pathSegments[i];
379
443
 
380
444
  // Get relations for current collection
381
- if (!getDataSourceCapabilities(currentCollection.driver).supportsRelations) {
382
- 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}'`);
383
447
  }
384
448
  const resolvedRelations = resolveCollectionRelations(currentCollection);
385
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
+ });