@rebasepro/common 0.6.0 → 0.7.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.
@@ -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>>;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/common",
3
3
  "type": "module",
4
- "version": "0.6.0",
4
+ "version": "0.7.0",
5
5
  "description": "Awesome Firebase/Firestore-based headless open-source CMS",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/rebaseco"
@@ -29,6 +29,14 @@
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
+ },
32
40
  "exports": {
33
41
  ".": {
34
42
  "types": "./dist/index.d.ts",
@@ -39,10 +47,10 @@
39
47
  "./package.json": "./package.json"
40
48
  },
41
49
  "dependencies": {
50
+ "@rebasepro/types": "workspace:*",
51
+ "@rebasepro/utils": "workspace:*",
42
52
  "fast-equals": "6.0.0",
43
- "json-logic-js": "^2.0.5",
44
- "@rebasepro/types": "0.6.0",
45
- "@rebasepro/utils": "0.6.0"
53
+ "json-logic-js": "^2.0.5"
46
54
  },
47
55
  "devDependencies": {
48
56
  "@jest/globals": "^30.4.1",
@@ -97,13 +105,5 @@
97
105
  "^@rebasepro/types$": "<rootDir>/../types/src/index.ts",
98
106
  "^@rebasepro/utils$": "<rootDir>/../utils/src/index.ts"
99
107
  }
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
+ }
@@ -15,9 +15,17 @@ import { deepEqual } from "fast-equals";
15
15
 
16
16
  import { enumToObjectEntries, getSubcollections, getTableName, resolveCollectionRelations, findRelation, sanitizeRelation } from "../util";
17
17
  import { removeFunctions, mergeDeep, deepClone } from "@rebasepro/utils";
18
+ import { resolveDataSource, DataSourceRegistry } from "../data/resolveDataSource";
18
19
 
19
20
  export class CollectionRegistry {
20
21
 
22
+ /**
23
+ * Declared data sources, used during normalization to resolve each
24
+ * collection's engine (so `dataSource`-only collections get the right
25
+ * capabilities). Empty by default → behaviour keys off `driver` as before.
26
+ */
27
+ private dataSources: DataSourceRegistry = {};
28
+
21
29
  // Normalized runtime layer (used by Data Grid / UI)
22
30
  private collectionsByTableName = new Map<string, EntityCollection>();
23
31
  private collectionsBySlug = new Map<string, EntityCollection>();
@@ -34,12 +42,24 @@ export class CollectionRegistry {
34
42
  // to avoid the issue where normalization creates new objects that always fail equality.
35
43
  private lastRawInputSnapshot: ReturnType<typeof removeFunctions>[] | null = null;
36
44
 
37
- constructor(collections?: EntityCollection[]) {
45
+ constructor(collections?: EntityCollection[], dataSources?: DataSourceRegistry) {
46
+ if (dataSources) this.dataSources = dataSources;
38
47
  if (collections) {
39
48
  this.registerMultiple(collections);
40
49
  }
41
50
  }
42
51
 
52
+ /**
53
+ * Provide the declared data sources used to resolve each collection's
54
+ * engine during normalization. Set this before registering collections.
55
+ * Returns true if the registry changed (callers may re-register).
56
+ */
57
+ setDataSources(dataSources: DataSourceRegistry): boolean {
58
+ if (deepEqual(this.dataSources, dataSources)) return false;
59
+ this.dataSources = dataSources ?? {};
60
+ return true;
61
+ }
62
+
43
63
  reset() {
44
64
  this.collectionsByTableName.clear();
45
65
  this.collectionsBySlug.clear();
@@ -163,6 +183,19 @@ export class CollectionRegistry {
163
183
  // and for preventing mutation of module-level collection singletons.
164
184
  const result = { ...collection } as EntityCollection;
165
185
 
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;
197
+ }
198
+
166
199
  // 1. Extract relations from properties that have inline config (target set)
167
200
  const extractedRelations = this.extractRelationsFromProperties(result.properties);
168
201
 
@@ -0,0 +1,97 @@
1
+ import { RebaseData, CollectionAccessor } from "@rebasepro/types";
2
+ import { toSnakeCase } from "@rebasepro/utils";
3
+
4
+ /**
5
+ * Parameters for {@link buildRoutedRebaseData}.
6
+ */
7
+ export interface RoutedRebaseDataParams {
8
+ /**
9
+ * The default data source. Handles every collection that does not
10
+ * resolve to an entry in `sources` (i.e. server-transport collections,
11
+ * which ride the Rebase client).
12
+ */
13
+ defaultData: RebaseData;
14
+
15
+ /**
16
+ * Per-data-source {@link RebaseData} instances for direct and custom
17
+ * transports, keyed by data-source key (e.g. `"analytics"`). Server-
18
+ * mediated sources are not listed here — they fall through to
19
+ * `defaultData`.
20
+ */
21
+ sources: Record<string, RebaseData>;
22
+
23
+ /**
24
+ * Resolve the data-source key for a given collection slug or path.
25
+ * Typically backed by the collection registry + `resolveDataSource`
26
+ * (`resolveDataSource(registry.getCollection(path), defs).key`).
27
+ *
28
+ * Return `undefined` (or a key absent from `sources`) to route to the
29
+ * default data source.
30
+ */
31
+ resolveKey: (slugOrPath: string) => string | undefined;
32
+ }
33
+
34
+ /**
35
+ * Build a {@link RebaseData} that routes each collection to the right
36
+ * backend based on its resolved data source.
37
+ *
38
+ * `.collection(path)` (and dynamic `data.products`-style access) resolves the
39
+ * collection's data-source key via `resolveKey` and delegates to the matching
40
+ * entry in `sources`, falling back to `defaultData` when there is no match.
41
+ * Because routing keys off the *path being accessed*, a reference widget
42
+ * inside a Firestore form that points at a Postgres collection is still
43
+ * served by Postgres — routing follows the target, not the ancestor.
44
+ *
45
+ * When `sources` is empty this returns `defaultData` untouched, so the
46
+ * single-driver setup keeps identical behaviour and identity (important for
47
+ * effect dependencies that key off the data instance).
48
+ *
49
+ * @example
50
+ * const data = buildRoutedRebaseData({
51
+ * defaultData: client.data,
52
+ * sources: { analytics: buildRebaseData(firestoreDriver) },
53
+ * resolveKey: (path) => resolveDataSource(registry.getCollection(path), defs).key
54
+ * });
55
+ * await data.products.find(); // → default (server / Postgres)
56
+ * await data.events.find(); // → Firestore, if `events.dataSource === "analytics"`
57
+ */
58
+ export function buildRoutedRebaseData({
59
+ defaultData,
60
+ sources,
61
+ resolveKey
62
+ }: RoutedRebaseDataParams): RebaseData {
63
+
64
+ // Fast path: nothing to route → return the default untouched (preserves
65
+ // referential identity for effect dependencies).
66
+ if (!sources || Object.keys(sources).length === 0) {
67
+ return defaultData;
68
+ }
69
+
70
+ function resolve(slugOrPath: string): RebaseData {
71
+ const key = resolveKey(slugOrPath);
72
+ if (key && sources[key]) return sources[key];
73
+ return defaultData;
74
+ }
75
+
76
+ function getAccessor(slugOrPath: string): CollectionAccessor {
77
+ return resolve(slugOrPath).collection(slugOrPath);
78
+ }
79
+
80
+ const target = {
81
+ collection: getAccessor
82
+ } as RebaseData;
83
+
84
+ return new Proxy(target, {
85
+ get(_target, prop: string | symbol) {
86
+ if (prop === "collection") return getAccessor;
87
+ // Ignore Symbol properties (e.g. Symbol.toPrimitive, Symbol.iterator)
88
+ if (typeof prop === "symbol") return undefined;
89
+ // Ignore internal JS properties
90
+ if (prop === "then" || prop === "toJSON" || prop === "$$typeof") return undefined;
91
+
92
+ // Convert camelCase property names to snake_case slugs, mirroring
93
+ // buildRebaseData so dynamic access routes consistently.
94
+ return getAccessor(toSnakeCase(prop));
95
+ }
96
+ });
97
+ }
@@ -0,0 +1,79 @@
1
+ import {
2
+ DataSourceDefinition,
3
+ ResolvedDataSource,
4
+ DEFAULT_DATA_SOURCE_KEY,
5
+ getDataSourceCapabilities
6
+ } from "@rebasepro/types";
7
+
8
+ /**
9
+ * The subset of a collection needed to resolve its data source. Accepting a
10
+ * structural type (rather than the full `EntityCollection`) keeps this usable
11
+ * from anywhere — frontend router, backend registry, editor — without coupling
12
+ * to the collection union.
13
+ */
14
+ export interface DataSourceResolvable {
15
+ /** Preferred routing key. */
16
+ dataSource?: string;
17
+ /** Legacy engine hint / fallback routing key. */
18
+ driver?: string;
19
+ /** Within-engine instance. */
20
+ databaseId?: string;
21
+ }
22
+
23
+ /** A lookup of data-source definitions by key. */
24
+ export type DataSourceRegistry = Record<string, DataSourceDefinition>;
25
+
26
+ /**
27
+ * Build a keyed registry from a list of {@link DataSourceDefinition}s.
28
+ * Later entries win on key collision.
29
+ */
30
+ export function createDataSourceRegistry(definitions?: DataSourceDefinition[]): DataSourceRegistry {
31
+ const registry: DataSourceRegistry = {};
32
+ for (const def of definitions ?? []) {
33
+ registry[def.key] = def;
34
+ }
35
+ return registry;
36
+ }
37
+
38
+ /**
39
+ * Resolve the effective data source for a collection — the single source of
40
+ * truth shared by the frontend router, the backend driver registry, and the
41
+ * editor's capability lookups.
42
+ *
43
+ * Resolution order:
44
+ * 1. The routing **key** is `collection.dataSource`, else the legacy
45
+ * `collection.driver`, else {@link DEFAULT_DATA_SOURCE_KEY}.
46
+ * 2. If a definition is registered for that key, it provides `engine`,
47
+ * `transport`, and `databaseId`.
48
+ * 3. Otherwise values are synthesized for backward compatibility: `engine`
49
+ * from the legacy `driver` (or the key, or `"postgres"`), `transport`
50
+ * defaults to `"server"`, and `databaseId` from the collection.
51
+ *
52
+ * `capabilities` are always derived from the resolved `engine`, so two
53
+ * data sources sharing an engine share capabilities.
54
+ *
55
+ * @param collection the collection (or any object carrying the routing fields)
56
+ * @param registry optional registry of declared data sources
57
+ */
58
+ export function resolveDataSource(
59
+ collection: DataSourceResolvable | undefined,
60
+ registry?: DataSourceRegistry
61
+ ): ResolvedDataSource {
62
+ const key = collection?.dataSource ?? collection?.driver ?? DEFAULT_DATA_SOURCE_KEY;
63
+ const def = registry?.[key];
64
+
65
+ const engine = def?.engine
66
+ ?? collection?.driver
67
+ ?? (key !== DEFAULT_DATA_SOURCE_KEY ? key : "postgres");
68
+
69
+ const transport = def?.transport ?? "server";
70
+ const databaseId = collection?.databaseId ?? def?.databaseId;
71
+
72
+ return {
73
+ key,
74
+ engine,
75
+ transport,
76
+ databaseId,
77
+ capabilities: getDataSourceCapabilities(engine)
78
+ };
79
+ }
package/src/index.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  export * from "./util";
2
2
  export * from "./collections";
3
3
  export * from "./data/buildRebaseData";
4
+ export * from "./data/buildRoutedRebaseData";
5
+ export * from "./data/resolveDataSource";
4
6
  export * from "./data/query_builder";
5
-
7
+ export * from "./table-classification";
@@ -0,0 +1,109 @@
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
+
8
+ /** Possible categories a database table can belong to. */
9
+ export type TableCategory = "rebase-internal" | "junction" | "user";
10
+
11
+ /** Schemas that are always considered Rebase-internal. */
12
+ export const REBASE_INTERNAL_SCHEMAS: readonly string[] = ["rebase", "auth"];
13
+
14
+ /** Table-name prefixes that mark a table as Rebase-internal regardless of schema. */
15
+ export const REBASE_INTERNAL_PREFIXES: readonly string[] = [
16
+ "_rebase_",
17
+ "_auth_",
18
+ "drizzle_",
19
+ ];
20
+
21
+ /**
22
+ * Synchronously classify a table based on naming conventions.
23
+ *
24
+ * @param tableName - The unqualified name of the table.
25
+ * @param schemaName - The schema the table belongs to (e.g. `"public"`, `"rebase"`).
26
+ * @returns `"rebase-internal"` when the table belongs to a reserved schema or
27
+ * carries a reserved prefix; `"user"` otherwise.
28
+ *
29
+ * @remarks
30
+ * Junction-table detection requires an async database query and is therefore
31
+ * **not** handled by this function. Use {@link detectJunctionTables} to obtain
32
+ * the set of junction tables, then reclassify as needed.
33
+ */
34
+ export function classifyTable(
35
+ tableName: string,
36
+ schemaName: string,
37
+ ): TableCategory {
38
+ if (
39
+ REBASE_INTERNAL_SCHEMAS.includes(schemaName) ||
40
+ REBASE_INTERNAL_PREFIXES.some((prefix) => tableName.startsWith(prefix))
41
+ ) {
42
+ return "rebase-internal";
43
+ }
44
+
45
+ return "user";
46
+ }
47
+
48
+ /**
49
+ * Convenience predicate that checks whether a table is Rebase-internal.
50
+ *
51
+ * @param tableName - The unqualified name of the table.
52
+ * @param schemaName - The schema the table belongs to.
53
+ * @returns `true` if the table is classified as `"rebase-internal"`.
54
+ */
55
+ export function isRebaseInternalTable(
56
+ tableName: string,
57
+ schemaName: string,
58
+ ): boolean {
59
+ return classifyTable(tableName, schemaName) === "rebase-internal";
60
+ }
61
+
62
+ /** SQL query that detects junction tables in the `public` schema. */
63
+ export const JUNCTION_TABLES_SQL = `
64
+ SELECT t.table_name
65
+ FROM information_schema.tables t
66
+ WHERE t.table_schema = 'public'
67
+ AND t.table_type = 'BASE TABLE'
68
+ AND NOT EXISTS (
69
+ SELECT 1
70
+ FROM information_schema.columns c
71
+ WHERE c.table_schema = t.table_schema
72
+ AND c.table_name = t.table_name
73
+ AND c.column_name NOT IN (
74
+ SELECT kcu.column_name
75
+ FROM information_schema.key_column_usage kcu
76
+ JOIN information_schema.table_constraints tc
77
+ ON tc.constraint_name = kcu.constraint_name
78
+ AND tc.table_schema = kcu.table_schema
79
+ WHERE tc.constraint_type = 'FOREIGN KEY'
80
+ AND kcu.table_schema = t.table_schema
81
+ AND kcu.table_name = t.table_name
82
+ )
83
+ )
84
+ `;
85
+
86
+ /**
87
+ * Asynchronously detect junction (link) tables in the `public` schema.
88
+ *
89
+ * A junction table is defined as a table where **every** column participates in
90
+ * at least one foreign-key constraint.
91
+ *
92
+ * @param executeSql - A callback that executes a raw SQL string and returns the
93
+ * resulting rows.
94
+ * @returns A `Set` containing the names of all detected junction tables.
95
+ */
96
+ export async function detectJunctionTables(
97
+ executeSql: (sql: string) => Promise<Record<string, unknown>[]>,
98
+ ): Promise<Set<string>> {
99
+ const rows = await executeSql(JUNCTION_TABLES_SQL);
100
+ const junctionTables = new Set<string>();
101
+
102
+ for (const row of rows) {
103
+ if (typeof row.table_name === "string") {
104
+ junctionTables.add(row.table_name);
105
+ }
106
+ }
107
+
108
+ return junctionTables;
109
+ }