@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.
- package/dist/collections/CollectionRegistry.d.ts +17 -2
- package/dist/collections/default-collections.d.ts +255 -2
- package/dist/data/filter-dialect.d.ts +61 -0
- package/dist/data/query_builder.d.ts +4 -4
- package/dist/data/resolveDataSource.d.ts +7 -7
- package/dist/index.d.ts +1 -0
- package/dist/index.es.js +604 -188
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +611 -186
- package/dist/index.umd.js.map +1 -1
- package/dist/util/builders.d.ts +48 -1
- package/dist/util/callbacks.d.ts +6 -1
- package/dist/util/index.d.ts +1 -0
- package/dist/util/permissions.d.ts +26 -2
- package/dist/util/policy/evaluatePolicy.d.ts +31 -0
- package/dist/util/policy/index.d.ts +3 -0
- package/dist/util/policy/policyToPostgres.d.ts +10 -0
- package/dist/util/policy/securityRuleToConditions.d.ts +24 -0
- package/dist/util/policy/sqlToPolicy.d.ts +20 -0
- package/dist/util/storage.d.ts +26 -1
- package/package.json +13 -13
- package/src/collections/CollectionRegistry.ts +59 -28
- package/src/collections/default-collections.ts +4 -4
- package/src/data/buildRebaseData.ts +9 -120
- package/src/data/filter-dialect.ts +318 -0
- package/src/data/query_builder.ts +10 -10
- package/src/data/resolveDataSource.ts +9 -9
- package/src/index.ts +1 -0
- package/src/util/builders.ts +78 -1
- package/src/util/callbacks.ts +8 -1
- package/src/util/index.ts +1 -0
- package/src/util/permissions.test.ts +5 -3
- package/src/util/permissions.ts +85 -158
- package/src/util/policy/evaluatePolicy.ts +146 -0
- package/src/util/policy/index.ts +3 -0
- package/src/util/policy/policyToPostgres.ts +85 -0
- package/src/util/policy/securityRuleToConditions.ts +67 -0
- package/src/util/policy/sqlToPolicy.ts +88 -0
- package/src/util/references.ts +1 -1
- package/src/util/relations.ts +8 -9
- package/src/util/resolutions.ts +6 -6
- package/src/util/storage.ts +34 -1
package/dist/util/builders.d.ts
CHANGED
|
@@ -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.
|
package/dist/util/callbacks.d.ts
CHANGED
|
@@ -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.
|
package/dist/util/index.d.ts
CHANGED
|
@@ -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
|
-
|
|
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,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;
|
package/dist/util/storage.d.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
4
|
-
|
|
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 {
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
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.
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
|
204
|
-
const manualRelations = getDataSourceCapabilities(result.
|
|
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.
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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.
|
|
415
|
-
throw new Error(`Relation path navigation requires a collection that supports relations, but '${currentCollection.slug}' uses
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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?:
|
|
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);
|