@rebasepro/common 0.13.0 → 0.13.1-canary.g18cfeb7
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/data/buildRebaseData.d.ts +10 -1
- package/dist/data/filter-dialect.d.ts +15 -1
- package/dist/data/paginate.d.ts +20 -0
- package/dist/data/resolveDataSource.d.ts +36 -0
- package/dist/index.es.js +405 -43
- package/dist/index.es.js.map +1 -1
- package/dist/util/builders.d.ts +2 -2
- package/dist/util/conditions.d.ts +7 -3
- package/dist/util/entities.d.ts +8 -1
- package/dist/util/index.d.ts +1 -0
- package/dist/util/internal-tables.d.ts +95 -0
- package/dist/util/policy/sqlToPolicy.d.ts +4 -4
- package/dist/util/relations.d.ts +18 -1
- package/dist/util/resolutions.d.ts +31 -0
- package/package.json +3 -3
- package/src/data/buildRebaseData.ts +100 -16
- package/src/data/filter-dialect.ts +28 -3
- package/src/data/paginate.ts +30 -0
- package/src/data/resolveDataSource.ts +56 -0
- package/src/util/builders.ts +3 -3
- package/src/util/conditions.ts +8 -3
- package/src/util/entities.ts +15 -1
- package/src/util/index.ts +1 -0
- package/src/util/internal-tables.ts +154 -0
- package/src/util/policy/policyToPostgres.ts +23 -10
- package/src/util/policy/sqlToPolicy.ts +40 -20
- package/src/util/relations.ts +31 -0
- package/src/util/resolutions.ts +77 -5
package/dist/util/builders.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { FirebaseCollectionConfig, FirebaseProperties, InferEntityType, MongoDBC
|
|
|
3
3
|
* Define a PostgreSQL-backed collection with full type inference.
|
|
4
4
|
*
|
|
5
5
|
* The `const P` generic captures literal property types from your
|
|
6
|
-
* `properties` object, which enables autocomplete on `
|
|
6
|
+
* `properties` object, which enables autocomplete on `display.title`,
|
|
7
7
|
* `sort`, `propertiesOrder`, `fixedFilter`, and entity callbacks.
|
|
8
8
|
*
|
|
9
9
|
* @example
|
|
@@ -16,7 +16,7 @@ import { FirebaseCollectionConfig, FirebaseProperties, InferEntityType, MongoDBC
|
|
|
16
16
|
* name: { name: "Name", type: "string", validation: { required: true } },
|
|
17
17
|
* price: { name: "Price", type: "number" },
|
|
18
18
|
* },
|
|
19
|
-
*
|
|
19
|
+
* display: { title: "name" }, // ✅ autocomplete: "name" | "price"
|
|
20
20
|
* sort: ["price", "asc"], // ✅ autocomplete on first element
|
|
21
21
|
* });
|
|
22
22
|
* ```
|
|
@@ -1,13 +1,17 @@
|
|
|
1
|
-
import { AuthState, ConditionContext,
|
|
1
|
+
import { AuthState, ConditionContext, ConditionRule } from "@rebasepro/types";
|
|
2
2
|
/**
|
|
3
3
|
* Register custom JSON Logic operations for Rebase.
|
|
4
4
|
* Call this once at app initialization.
|
|
5
5
|
*/
|
|
6
6
|
export declare function registerConditionOperations(): void;
|
|
7
7
|
/**
|
|
8
|
-
* Evaluate a
|
|
8
|
+
* Evaluate a condition against the given context.
|
|
9
|
+
*
|
|
10
|
+
* A condition may be stated as a literal instead of a rule — `hidden: true`
|
|
11
|
+
* rather than `hidden: { "==": [1, 1] }` — and a literal is already its own
|
|
12
|
+
* answer, so it is returned rather than handed to the evaluator.
|
|
9
13
|
*/
|
|
10
|
-
export declare function evaluateCondition(rule:
|
|
14
|
+
export declare function evaluateCondition(rule: ConditionRule, context: ConditionContext): unknown;
|
|
11
15
|
/**
|
|
12
16
|
* Build a ConditionContext from the current property resolution context.
|
|
13
17
|
*/
|
package/dist/util/entities.d.ts
CHANGED
|
@@ -31,9 +31,16 @@ export declare function getRelationFrom<M extends Record<string, unknown>>(entit
|
|
|
31
31
|
* have `id` and `path` fields — these are relation-shaped objects from
|
|
32
32
|
* edge cases in the data pipeline (REST fallback, stale cache, custom data source).
|
|
33
33
|
*
|
|
34
|
+
* When `targetPath` is given, also accepts a bare id. A relation column is a
|
|
35
|
+
* foreign key, and the REST layer returns it as the scalar it is; only some
|
|
36
|
+
* fetch paths hydrate it into an object. Which form a caller sees therefore
|
|
37
|
+
* depends on how the row was loaded, and a caller that only accepted objects
|
|
38
|
+
* reported half of its own data as a type error. The declared target is the
|
|
39
|
+
* missing half: with it, an id is a relation that has not been fetched yet.
|
|
40
|
+
*
|
|
34
41
|
* Returns null if the value cannot be coerced.
|
|
35
42
|
*/
|
|
36
|
-
export declare function normalizeToEntityRelation(value: unknown, propertyType?: string): EntityRelation | null;
|
|
43
|
+
export declare function normalizeToEntityRelation(value: unknown, propertyType?: string, targetPath?: string): EntityRelation | null;
|
|
37
44
|
export declare function traverseValuesProperties<M extends Record<string, unknown>>(inputValues: Partial<EntityValues<M>>, properties: Properties, operation: (value: unknown, property: Property) => unknown): EntityValues<M> | undefined;
|
|
38
45
|
export declare function traverseValueProperty(inputValue: unknown, property: Property, operation: (value: unknown, property: Property) => unknown): unknown;
|
|
39
46
|
/**
|
package/dist/util/index.d.ts
CHANGED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tables Rebase creates for its own bookkeeping, and the SQL that keeps the
|
|
3
|
+
* end-user role away from them.
|
|
4
|
+
*
|
|
5
|
+
* ## Why this exists
|
|
6
|
+
*
|
|
7
|
+
* Authenticated requests run as {@link REBASE_USER_ROLE}, and the boot-time role
|
|
8
|
+
* provisioning grants that role `SELECT, INSERT, UPDATE, DELETE` on every table
|
|
9
|
+
* in the schemas a project uses — including `rebase`, because a project's own
|
|
10
|
+
* collections are allowed to live there (the scaffold puts `users` there). It
|
|
11
|
+
* also sets `ALTER DEFAULT PRIVILEGES`, so a table created *later* by the
|
|
12
|
+
* migrating role inherits the same grant.
|
|
13
|
+
*
|
|
14
|
+
* Every framework-internal table is created later: auth's tables come up during
|
|
15
|
+
* `initializeAuth`, `api_keys` during route mounting, `cron_logs` when the first
|
|
16
|
+
* job registers, `idempotency_keys` on the first request that carries a key. So
|
|
17
|
+
* they all inherited full DML for the end-user role — and none of them enables
|
|
18
|
+
* row-level security, because none of them is a collection with
|
|
19
|
+
* `securityRules`. Measured on a freshly provisioned database, `SET ROLE
|
|
20
|
+
* rebase_user` could read `rebase.refresh_tokens` (session token hashes),
|
|
21
|
+
* `rebase.mfa_factors` (`secret_encrypted`), `rebase.recovery_codes`, and
|
|
22
|
+
* `rebase.api_keys` (including its `admin` flag), and insert into
|
|
23
|
+
* `rebase.app_config`.
|
|
24
|
+
*
|
|
25
|
+
* Nothing routes a user-context query at those tables today, so this was not
|
|
26
|
+
* reachable over the API. That is the wrong thing to depend on: the documented
|
|
27
|
+
* model is that RLS is the authorization boundary, and these tables sat outside
|
|
28
|
+
* it. The boundary is now a privilege boundary instead — the role simply cannot
|
|
29
|
+
* address them.
|
|
30
|
+
*
|
|
31
|
+
* ## Why REVOKE rather than ENABLE ROW LEVEL SECURITY
|
|
32
|
+
*
|
|
33
|
+
* RLS with no policy denies every row, which is the same outcome, but it is the
|
|
34
|
+
* *weaker* statement: it leaves the grant in place, so a later policy — or a
|
|
35
|
+
* `FORCE` flag cleared by some future migration — reopens the table. There is no
|
|
36
|
+
* row of `refresh_tokens` any end user should ever reach, so the honest encoding
|
|
37
|
+
* is "this role has no privilege here at all". It also keeps the owner
|
|
38
|
+
* connection (which auth actually runs on) completely unaffected.
|
|
39
|
+
*
|
|
40
|
+
* ## Keeping it true
|
|
41
|
+
*
|
|
42
|
+
* `packages/rls-check` scans the `rebase` schema — it used to skip it as a
|
|
43
|
+
* "platform" schema — and its `rls-disabled` check fires on exactly the
|
|
44
|
+
* condition this module removes: RLS off *and* a DML grant to a reachable role.
|
|
45
|
+
* So a table added here without a revoke is caught by `pnpm rls:check`, not by
|
|
46
|
+
* someone re-reading this file.
|
|
47
|
+
*/
|
|
48
|
+
/**
|
|
49
|
+
* The Postgres role authenticated requests run as.
|
|
50
|
+
*
|
|
51
|
+
* Defined here rather than in the Postgres driver because both the driver (which
|
|
52
|
+
* provisions the role) and this module (which revokes on its behalf) need it,
|
|
53
|
+
* and a second spelling of a role name is a silent no-op waiting to happen.
|
|
54
|
+
*/
|
|
55
|
+
export declare const REBASE_USER_ROLE = "rebase_user";
|
|
56
|
+
/**
|
|
57
|
+
* Framework-internal table names, unqualified.
|
|
58
|
+
*
|
|
59
|
+
* Deliberately NOT including `users`: the auth user table is also a collection,
|
|
60
|
+
* with `securityRules`, RLS enabled and policies applied. Users read their own
|
|
61
|
+
* row through it — revoking there would break sign-in.
|
|
62
|
+
*
|
|
63
|
+
* `atlas_schema_revisions` is Atlas's migration ledger, which lands in `rebase`
|
|
64
|
+
* because `db migrate apply` passes `--revisions-schema rebase`.
|
|
65
|
+
*/
|
|
66
|
+
export declare const REBASE_INTERNAL_TABLES: readonly string[];
|
|
67
|
+
/**
|
|
68
|
+
* A single statement that takes every privilege on `schema.table` away from the
|
|
69
|
+
* end-user role.
|
|
70
|
+
*
|
|
71
|
+
* Wrapped in a `DO` block guarded on `pg_roles` for two reasons, both of which
|
|
72
|
+
* happen in practice:
|
|
73
|
+
*
|
|
74
|
+
* - the role does not exist when the connection is unprivileged (Rebase then
|
|
75
|
+
* relies on native RLS rather than a role switch), and a bare `REVOKE` on a
|
|
76
|
+
* missing role is an error, not a no-op;
|
|
77
|
+
* - the table may not exist yet — `cron_logs` never appears in a project with
|
|
78
|
+
* no cron jobs — and `to_regclass` returning NULL has to be tolerated too.
|
|
79
|
+
*
|
|
80
|
+
* One command, so it is safe on handles that speak the extended query protocol
|
|
81
|
+
* and reject multi-statement strings.
|
|
82
|
+
*/
|
|
83
|
+
export declare function revokeInternalTableSql(schema: string, table: string): string;
|
|
84
|
+
/**
|
|
85
|
+
* Revoke on every internal table in `schema`, one statement at a time.
|
|
86
|
+
*
|
|
87
|
+
* Best-effort per table: a connection that does not own one of them (a
|
|
88
|
+
* pre-provisioned database, a platform-managed ledger) cannot revoke on it, and
|
|
89
|
+
* that must not take down a boot. The caller decides how loud to be — `onError`
|
|
90
|
+
* exists so the driver can warn without this module importing a logger.
|
|
91
|
+
*/
|
|
92
|
+
export declare function revokeInternalTableAccess(execute: (sql: string) => Promise<unknown>, schema: string, options?: {
|
|
93
|
+
tables?: readonly string[];
|
|
94
|
+
onError?: (table: string, error: unknown) => void;
|
|
95
|
+
}): Promise<void>;
|
|
@@ -12,13 +12,13 @@ export interface AnonymousGrantRisk {
|
|
|
12
12
|
/**
|
|
13
13
|
* Find clauses that read as "signed-in users only" but admit anonymous callers.
|
|
14
14
|
*
|
|
15
|
-
* Both spellings come from the same place — Supabase, where `auth.uid()`
|
|
16
|
-
* is NULL for an anonymous request. Rebase substitutes
|
|
15
|
+
* Both spellings come from the same place — Supabase, where its own `auth.uid()`
|
|
16
|
+
* really is NULL for an anonymous request. Rebase substitutes
|
|
17
17
|
* {@link ANONYMOUS_USER_ID} instead (a blank id would read back as NULL, which
|
|
18
18
|
* is how the trusted *server* context is recognised), so:
|
|
19
19
|
*
|
|
20
|
-
* - `
|
|
21
|
-
* - `
|
|
20
|
+
* - `rebase.uid() IS NOT NULL` is a tautology on the user path, and
|
|
21
|
+
* - `rebase.uid() != 'anon'` excludes one spelling of anonymous and admits the
|
|
22
22
|
* other. This one is not hypothetical and was not only a foreign habit:
|
|
23
23
|
* rebase's own request path reported `'anon'` while everything that compiled
|
|
24
24
|
* or checked a policy used `'anonymous'`, so whichever literal an author
|
package/dist/util/relations.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CollectionConfig, ResolvedRelation } from "@rebasepro/types";
|
|
1
|
+
import { CollectionConfig, ResolvedRelation, RelationProperty } from "@rebasepro/types";
|
|
2
2
|
/**
|
|
3
3
|
* Whether the target rows are shared with other parents — a many-to-many, or a
|
|
4
4
|
* multi-hop `via` chain.
|
|
@@ -27,6 +27,23 @@ export declare function isJunctionBackedRelation(relation: ResolvedRelation): bo
|
|
|
27
27
|
* which is worth hearing about.
|
|
28
28
|
*/
|
|
29
29
|
export declare function resolveCollectionRelations(collection: CollectionConfig): Record<string, ResolvedRelation>;
|
|
30
|
+
/**
|
|
31
|
+
* The path of the collection a relation property points at, derived from the
|
|
32
|
+
* property alone.
|
|
33
|
+
*
|
|
34
|
+
* A preview holds a property and a value and no collection, so it cannot call
|
|
35
|
+
* `resolveRelationProperty`. It does not need to: both forms that carry a
|
|
36
|
+
* target — the stamped `resolvedRelation` and the inline `relation` — name it
|
|
37
|
+
* directly. Only the third form, a relation declared by name in the
|
|
38
|
+
* collection's `relations` array, is out of reach, and that one has no target
|
|
39
|
+
* to read without the collection anyway.
|
|
40
|
+
*
|
|
41
|
+
* This is what lets a preview render a relation column that arrived as a bare
|
|
42
|
+
* foreign key: the id says *which* row, the declared target says *which
|
|
43
|
+
* collection*, and `RelationPreview` fetches the rest. Without it a scalar id
|
|
44
|
+
* is indistinguishable from a value of the wrong type.
|
|
45
|
+
*/
|
|
46
|
+
export declare function getRelationTargetPath(property: RelationProperty): string | undefined;
|
|
30
47
|
export declare function getTableName(collection: CollectionConfig): string;
|
|
31
48
|
export declare function getTableVarName(tableName: string): string;
|
|
32
49
|
export declare function getEnumVarName(tableName: string, propName: string): string;
|
|
@@ -92,6 +92,37 @@ export declare function resolveEnumValues(input: EnumValues): EnumValueConfig[]
|
|
|
92
92
|
* 3. many-relations on an engine that has relations (SQL).
|
|
93
93
|
*/
|
|
94
94
|
export declare function getEntityChildViews<M extends Record<string, unknown> = Record<string, unknown>>(collection: CollectionConfig<M>): EntityChildView[];
|
|
95
|
+
/**
|
|
96
|
+
* Each of `collection`'s tabs paired with the property that declared it, when a
|
|
97
|
+
* property declared it: child view key → property key.
|
|
98
|
+
*
|
|
99
|
+
* A many-relation can only be declared as a property — that is the documented
|
|
100
|
+
* and only mechanism — and {@link getEntityChildViews} promotes it to a tab. So
|
|
101
|
+
* one declaration reaches the panel twice, and neither surface knew about the
|
|
102
|
+
* other. The form rendered a relation picker beside the tab, and the collection
|
|
103
|
+
* table rendered *two* columns under one heading: the relation's own column,
|
|
104
|
+
* showing the child rows, and a jump-to-tab button carrying the same name.
|
|
105
|
+
*
|
|
106
|
+
* The pairing is what lets each surface decide which half is redundant, and it
|
|
107
|
+
* has to be a pairing rather than two sets because the two keys differ whenever
|
|
108
|
+
* a relation is named. The match is on the resolved `relationName` — the
|
|
109
|
+
* identity `getEntityChildViews` itself dedupes on — so a relation declared in
|
|
110
|
+
* `relations` and pointed at by a differently-named property is recognised too.
|
|
111
|
+
*
|
|
112
|
+
* A relation with no property of its own is absent here, which is the point: it
|
|
113
|
+
* has exactly one surface already, and nothing to weigh it against.
|
|
114
|
+
*
|
|
115
|
+
* Only top-level properties: a relation nested inside a `map` gets no tab.
|
|
116
|
+
*/
|
|
117
|
+
export declare function getChildViewDeclaringProperties<M extends Record<string, unknown> = Record<string, unknown>>(collection: CollectionConfig<M>): Map<string, string>;
|
|
118
|
+
/**
|
|
119
|
+
* The property keys of `collection` whose relation is already one of its tabs.
|
|
120
|
+
*
|
|
121
|
+
* What a form asks: the tab is the treatment for a list of child rows, so the
|
|
122
|
+
* picker beside it is the redundant half. See
|
|
123
|
+
* {@link getChildViewDeclaringProperties}.
|
|
124
|
+
*/
|
|
125
|
+
export declare function getChildViewRelationPropertyKeys<M extends Record<string, unknown> = Record<string, unknown>>(collection: CollectionConfig<M>): Set<string>;
|
|
95
126
|
/**
|
|
96
127
|
* The child views of `collection` as bare collections.
|
|
97
128
|
*
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rebasepro/common",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.13.
|
|
4
|
+
"version": "0.13.1-canary.g18cfeb7",
|
|
5
5
|
"description": "Awesome Firebase/Firestore-based headless open-source CMS",
|
|
6
6
|
"funding": {
|
|
7
7
|
"url": "https://github.com/sponsors/rebaseco"
|
|
@@ -40,8 +40,8 @@
|
|
|
40
40
|
"dependencies": {
|
|
41
41
|
"fast-equals": "6.0.2",
|
|
42
42
|
"json-logic-js": "^2.0.5",
|
|
43
|
-
"@rebasepro/types": "0.13.
|
|
44
|
-
"@rebasepro/utils": "0.13.
|
|
43
|
+
"@rebasepro/types": "0.13.1-canary.g18cfeb7",
|
|
44
|
+
"@rebasepro/utils": "0.13.1-canary.g18cfeb7"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
47
|
"@jest/globals": "^30.4.1",
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
} from "@rebasepro/types";
|
|
19
19
|
import { toSnakeCase } from "@rebasepro/utils";
|
|
20
20
|
import { QueryBuilder } from "./query_builder";
|
|
21
|
-
import { collectAllPages, paginateFind } from "./paginate";
|
|
21
|
+
import { collectAllPages, paginateFind, resolveFindWindow } from "./paginate";
|
|
22
22
|
import { deserializeFilter } from "./filter-dialect";
|
|
23
23
|
import { buildCompositeId, resolvePrimaryKeys, PrimaryKeyInfo } from "../util/identity";
|
|
24
24
|
|
|
@@ -153,8 +153,7 @@ function createDriverAccessor<M extends Record<string, unknown> = Record<string,
|
|
|
153
153
|
async find(params?: FindParams<M>): Promise<FindResponse<M>> {
|
|
154
154
|
// Ensure filters are in canonical [op, value] format even if passed as PostgREST strings
|
|
155
155
|
const filter = params?.where ? deserializeFilter(params.where as Record<string, unknown>) : undefined;
|
|
156
|
-
const limit = params
|
|
157
|
-
const offset = params?.offset ?? 0;
|
|
156
|
+
const { limit, offset, driverOffset } = resolveFindWindow(params);
|
|
158
157
|
|
|
159
158
|
// One relation shape, whatever the call looks like.
|
|
160
159
|
//
|
|
@@ -177,8 +176,12 @@ function createDriverAccessor<M extends Record<string, unknown> = Record<string,
|
|
|
177
176
|
slug,
|
|
178
177
|
{
|
|
179
178
|
filter,
|
|
180
|
-
|
|
181
|
-
|
|
179
|
+
// Without this the group was dropped and the read ran
|
|
180
|
+
// unfiltered — every row the caller's policies allow,
|
|
181
|
+
// in place of the ones they asked for.
|
|
182
|
+
logical: params?.logical,
|
|
183
|
+
limit,
|
|
184
|
+
offset: driverOffset,
|
|
182
185
|
orderBy: params?.orderBy?.[0],
|
|
183
186
|
order: params?.orderBy?.[1],
|
|
184
187
|
searchString: params?.searchString
|
|
@@ -187,9 +190,10 @@ function createDriverAccessor<M extends Record<string, unknown> = Record<string,
|
|
|
187
190
|
)
|
|
188
191
|
: await driver.fetchCollection<M>({
|
|
189
192
|
path: slug,
|
|
190
|
-
limit
|
|
191
|
-
offset:
|
|
193
|
+
limit,
|
|
194
|
+
offset: driverOffset,
|
|
192
195
|
filter,
|
|
196
|
+
logical: params?.logical,
|
|
193
197
|
orderBy: params?.orderBy?.[0],
|
|
194
198
|
order: params?.orderBy?.[1],
|
|
195
199
|
searchString: params?.searchString
|
|
@@ -199,7 +203,16 @@ function createDriverAccessor<M extends Record<string, unknown> = Record<string,
|
|
|
199
203
|
let total = rows.length + offset;
|
|
200
204
|
let hasMore = rows.length >= limit;
|
|
201
205
|
if (driver.count) {
|
|
202
|
-
|
|
206
|
+
// The same narrowing the rows were read with. Counting only by
|
|
207
|
+
// `filter` reported the whole collection beside a narrowed
|
|
208
|
+
// page, and `hasMore` is derived from it — so the list offered
|
|
209
|
+
// a next page that did not exist.
|
|
210
|
+
total = await driver.count({
|
|
211
|
+
path: slug,
|
|
212
|
+
filter,
|
|
213
|
+
logical: params?.logical,
|
|
214
|
+
searchString: params?.searchString
|
|
215
|
+
});
|
|
203
216
|
hasMore = offset + rows.length < total;
|
|
204
217
|
}
|
|
205
218
|
|
|
@@ -258,29 +271,56 @@ values: {} as Record<string, unknown> }
|
|
|
258
271
|
});
|
|
259
272
|
},
|
|
260
273
|
|
|
274
|
+
// Present only when the driver is: exposing these unconditionally and
|
|
275
|
+
// looping single writes underneath would give a caller neither the
|
|
276
|
+
// atomicity nor the single round trip they reached for a batch to get,
|
|
277
|
+
// while looking exactly like it had.
|
|
278
|
+
updateMany: driver.updateMany
|
|
279
|
+
? async (updates: { id: string | number; data: Partial<EntityValues<M>> }[]): Promise<Entity<M>[]> => {
|
|
280
|
+
const rows = await driver.updateMany!<M>({
|
|
281
|
+
path: slug,
|
|
282
|
+
updates: updates.map(u => ({ id: u.id,
|
|
283
|
+
values: u.data })),
|
|
284
|
+
});
|
|
285
|
+
return rows.map(row => rowToEntity<M>(row, slug, getPks()));
|
|
286
|
+
}
|
|
287
|
+
: undefined,
|
|
288
|
+
|
|
289
|
+
deleteMany: driver.deleteMany
|
|
290
|
+
? async (ids: (string | number)[]): Promise<void> => {
|
|
291
|
+
await driver.deleteMany!<M>({ path: slug,
|
|
292
|
+
ids });
|
|
293
|
+
}
|
|
294
|
+
: undefined,
|
|
295
|
+
|
|
261
296
|
count: driver.count
|
|
262
297
|
? async (params?: FindParams<M>): Promise<number> => {
|
|
263
298
|
const filter = params?.where ? deserializeFilter(params.where as Record<string, unknown>) : undefined;
|
|
299
|
+
// Every narrowing `find()` applies has to apply here too, or
|
|
300
|
+
// the count describes a different query than the one it is
|
|
301
|
+
// reported against.
|
|
264
302
|
return driver.count!({
|
|
265
303
|
path: slug,
|
|
266
|
-
filter
|
|
304
|
+
filter,
|
|
305
|
+
logical: params?.logical,
|
|
306
|
+
searchString: params?.searchString
|
|
267
307
|
});
|
|
268
308
|
}
|
|
269
309
|
: undefined,
|
|
270
310
|
|
|
271
311
|
listen: driver.listenCollection
|
|
272
312
|
? (params: FindParams<M> | undefined, onUpdate: (response: FindResponse<M>) => void, onError?: (error: Error) => void) => {
|
|
273
|
-
const limit = params
|
|
274
|
-
const offset = params?.offset ?? 0;
|
|
313
|
+
const { limit, offset, driverOffset } = resolveFindWindow(params);
|
|
275
314
|
// Realtime has no REST-pipeline equivalent, so the rows arrive
|
|
276
315
|
// admin-shaped. Flatten them to the one shape the rest of this
|
|
277
316
|
// accessor serves.
|
|
278
317
|
const normalize = driver.restFetchService ? inlineRelationRefs : (row: Record<string, unknown>) => row;
|
|
279
318
|
return driver.listenCollection!<M>({
|
|
280
319
|
path: slug,
|
|
281
|
-
limit
|
|
282
|
-
offset:
|
|
320
|
+
limit,
|
|
321
|
+
offset: driverOffset,
|
|
283
322
|
filter: params?.where,
|
|
323
|
+
logical: params?.logical,
|
|
284
324
|
orderBy: params?.orderBy?.[0],
|
|
285
325
|
order: params?.orderBy?.[1],
|
|
286
326
|
searchString: params?.searchString,
|
|
@@ -288,7 +328,12 @@ values: {} as Record<string, unknown> }
|
|
|
288
328
|
onUpdate({
|
|
289
329
|
data: entities.map((row: Record<string, unknown>) => rowToEntity<M>(normalize(row), slug, getPks())),
|
|
290
330
|
meta: {
|
|
291
|
-
|
|
331
|
+
// No count is issued on this path, so the total
|
|
332
|
+
// is unknown; the lower bound is the rows in
|
|
333
|
+
// hand plus the ones paged past to reach them.
|
|
334
|
+
// Reporting `entities.length` claimed a read at
|
|
335
|
+
// offset 100 had found a collection of two.
|
|
336
|
+
total: offset + entities.length,
|
|
292
337
|
limit,
|
|
293
338
|
offset,
|
|
294
339
|
hasMore: entities.length >= limit
|
|
@@ -506,9 +551,39 @@ function toSdkCollectionClient<M extends Record<string, unknown>>(
|
|
|
506
551
|
async update(id: string | number, data: Partial<M>): Promise<M> {
|
|
507
552
|
return entityToRow(await snap.update(id, data as Partial<EntityValues<M>>));
|
|
508
553
|
},
|
|
554
|
+
async updateMany(updates: { id: string | number; data: Partial<M> }[]): Promise<M[]> {
|
|
555
|
+
if (!Array.isArray(updates)) {
|
|
556
|
+
throw new TypeError("updateMany expects an array of { id, data } entries.");
|
|
557
|
+
}
|
|
558
|
+
if (updates.length === 0) return [];
|
|
559
|
+
if (!snap.updateMany) {
|
|
560
|
+
throw new Error(
|
|
561
|
+
"Bulk updates are not supported by this collection's data source. " +
|
|
562
|
+
"Fall back to update() per record."
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
const rows = await snap.updateMany(
|
|
566
|
+
updates.map(u => ({ id: u.id,
|
|
567
|
+
data: u.data as Partial<EntityValues<M>> }))
|
|
568
|
+
);
|
|
569
|
+
return rows.map(entityToRow);
|
|
570
|
+
},
|
|
509
571
|
delete(id: string | number): Promise<void> {
|
|
510
572
|
return snap.delete(id);
|
|
511
573
|
},
|
|
574
|
+
async deleteMany(ids: (string | number)[]): Promise<void> {
|
|
575
|
+
if (!Array.isArray(ids)) {
|
|
576
|
+
throw new TypeError("deleteMany expects an array of ids.");
|
|
577
|
+
}
|
|
578
|
+
if (ids.length === 0) return;
|
|
579
|
+
if (!snap.deleteMany) {
|
|
580
|
+
throw new Error(
|
|
581
|
+
"Bulk deletes are not supported by this collection's data source. " +
|
|
582
|
+
"Fall back to delete() per record."
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
await snap.deleteMany(ids);
|
|
586
|
+
},
|
|
512
587
|
count: snap.count ? (params?: FindParams<M>) => snap.count!(params) : undefined,
|
|
513
588
|
listen: snap.listen
|
|
514
589
|
? (params: FindParams<M> | undefined, onUpdate: (r: FindResult<M>) => void, onError?: (e: Error) => void) =>
|
|
@@ -537,7 +612,7 @@ function toSdkCollectionClient<M extends Record<string, unknown>>(
|
|
|
537
612
|
/**
|
|
538
613
|
* Wrap a flat {@link SDKCollectionClient} into a Entity-shaped
|
|
539
614
|
* {@link CollectionAccessor}. Every returned row is re-wrapped into the
|
|
540
|
-
* `{ id, path, values }` view-model the admin
|
|
615
|
+
* `{ id, path, values }` view-model the admin panel renders.
|
|
541
616
|
*/
|
|
542
617
|
function toEntityAccessor<M extends Record<string, unknown>>(
|
|
543
618
|
sdk: SDKCollectionClient<M>,
|
|
@@ -598,7 +673,16 @@ function toEntityAccessor<M extends Record<string, unknown>>(
|
|
|
598
673
|
* admin `RebaseDataContext` — without it the admin renders rows with only their
|
|
599
674
|
* `id`.
|
|
600
675
|
*/
|
|
601
|
-
|
|
676
|
+
/**
|
|
677
|
+
* Only the by-slug accessor is asked for, so only that is required.
|
|
678
|
+
*
|
|
679
|
+
* Taking a whole `RebaseSdkData` meant taking `RebaseSdkData<unknown>`, whose
|
|
680
|
+
* dynamic branch is an index signature — and no `RebaseSdkData<DB>` satisfies
|
|
681
|
+
* it, because its own `collection` method is not a `SDKCollectionClient`. So a
|
|
682
|
+
* caller holding a *typed* client could not pass it to a function that reads
|
|
683
|
+
* one method off it, and that method is identical on every instantiation.
|
|
684
|
+
*/
|
|
685
|
+
export function wrapAsEntityData(sdkData: Pick<RebaseSdkData, "collection">, options?: EntityDataOptions): RebaseData {
|
|
602
686
|
const cache = new Map<string, CollectionAccessor>();
|
|
603
687
|
const primaryKeysFor = createPrimaryKeyResolver(options);
|
|
604
688
|
|
|
@@ -354,9 +354,34 @@ export function serializeLogicalCondition(
|
|
|
354
354
|
* deserializeLogicalCondition("or(status.eq.active,age.gte.18)")
|
|
355
355
|
* // → { type: "or", conditions: [...] }
|
|
356
356
|
*/
|
|
357
|
+
/**
|
|
358
|
+
* How deeply `or(...)`/`and(...)` groups may nest.
|
|
359
|
+
*
|
|
360
|
+
* This parser recurses once per level, on a value that arrives in a query
|
|
361
|
+
* string. Unbounded, twenty thousand levels reached `RangeError: Maximum call
|
|
362
|
+
* stack size exceeded`, which a caller sees as a 500 about the call stack
|
|
363
|
+
* rather than a 400 about their filter. Node's 16 KB header cap keeps a GET
|
|
364
|
+
* below that in practice, but "the HTTP layer happens to stop it" is not a
|
|
365
|
+
* bound this parser should rely on.
|
|
366
|
+
*
|
|
367
|
+
* Thirty-two is far past anything a real filter expresses; the deepest in this
|
|
368
|
+
* repository's own tests is three.
|
|
369
|
+
*/
|
|
370
|
+
export const MAX_LOGICAL_NESTING_DEPTH = 32;
|
|
371
|
+
|
|
357
372
|
export function deserializeLogicalCondition(
|
|
358
|
-
str: string
|
|
373
|
+
str: string,
|
|
374
|
+
// Not `depth`: the body already uses that name for paren tracking, inside a
|
|
375
|
+
// block that shadows a parameter of the same name — so the recursion
|
|
376
|
+
// counter silently became the paren counter and never grew.
|
|
377
|
+
nesting = 0
|
|
359
378
|
): LogicalCondition | FilterCondition {
|
|
379
|
+
if (nesting > MAX_LOGICAL_NESTING_DEPTH) {
|
|
380
|
+
throw new Error(
|
|
381
|
+
`Filter groups nest more than ${MAX_LOGICAL_NESTING_DEPTH} levels deep. ` +
|
|
382
|
+
"Flatten the condition — `or(a,or(b,c))` is `or(a,b,c)`."
|
|
383
|
+
);
|
|
384
|
+
}
|
|
360
385
|
// Check for logical group: "and(...)" or "or(...)"
|
|
361
386
|
const logicalMatch = str.match(/^(and|or)\((.+)\)$/);
|
|
362
387
|
if (logicalMatch) {
|
|
@@ -371,11 +396,11 @@ export function deserializeLogicalCondition(
|
|
|
371
396
|
if (innerStr[i] === "(") depth++;
|
|
372
397
|
else if (innerStr[i] === ")") depth--;
|
|
373
398
|
else if (innerStr[i] === "," && depth === 0) {
|
|
374
|
-
conditions.push(deserializeLogicalCondition(innerStr.slice(start, i)));
|
|
399
|
+
conditions.push(deserializeLogicalCondition(innerStr.slice(start, i), nesting + 1));
|
|
375
400
|
start = i + 1;
|
|
376
401
|
}
|
|
377
402
|
}
|
|
378
|
-
conditions.push(deserializeLogicalCondition(innerStr.slice(start)));
|
|
403
|
+
conditions.push(deserializeLogicalCondition(innerStr.slice(start), nesting + 1));
|
|
379
404
|
|
|
380
405
|
return { type, conditions };
|
|
381
406
|
}
|
package/src/data/paginate.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
DEFAULT_LIST_LIMIT,
|
|
2
3
|
FilterValues,
|
|
3
4
|
FieldPath,
|
|
4
5
|
FindAllParams,
|
|
@@ -70,6 +71,35 @@ export class RebasePaginationError extends Error {
|
|
|
70
71
|
export type PageFinder<M extends Record<string, unknown> = Record<string, unknown>> =
|
|
71
72
|
(params: FindParams<M>) => Promise<FindResult<M>>;
|
|
72
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Resolve `limit`/`offset`/`page` into the window a read will actually use.
|
|
76
|
+
*
|
|
77
|
+
* Lives here, next to the walk, for the reason at the top of this file: every
|
|
78
|
+
* transport has to mean the same thing by "page two". Four of them did not —
|
|
79
|
+
* the REST layer strode by {@link DEFAULT_LIST_LIMIT}, the local-first
|
|
80
|
+
* evaluator by {@link DEFAULT_PAGE_SIZE}, the in-process accessor by 20, and
|
|
81
|
+
* the published type documented a fourth number. Pages that overlap or skip
|
|
82
|
+
* rows are the mildest of those outcomes.
|
|
83
|
+
*
|
|
84
|
+
* `page` wins over `offset`, as {@link FindParams} documents. `driverOffset`
|
|
85
|
+
* is the value to hand a driver: it stays `undefined` when the caller named no
|
|
86
|
+
* offset, because keyset pagination seeks with a `where` clause and must not
|
|
87
|
+
* look like it is paging by offset.
|
|
88
|
+
*/
|
|
89
|
+
export function resolveFindWindow(
|
|
90
|
+
params?: Pick<FindParams, "limit" | "offset" | "page">
|
|
91
|
+
): { limit: number; offset: number; driverOffset: number | undefined } {
|
|
92
|
+
const limit = params?.limit ?? DEFAULT_LIST_LIMIT;
|
|
93
|
+
const offset = params?.page != null
|
|
94
|
+
? Math.max(0, (params.page - 1) * limit)
|
|
95
|
+
: (params?.offset ?? 0);
|
|
96
|
+
return {
|
|
97
|
+
limit,
|
|
98
|
+
offset,
|
|
99
|
+
driverOffset: params?.page != null ? offset : params?.offset
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
73
103
|
function normalizePageSize(raw: number | undefined): number {
|
|
74
104
|
if (raw === undefined || !Number.isFinite(raw)) return DEFAULT_PAGE_SIZE;
|
|
75
105
|
return Math.max(1, Math.floor(raw));
|
|
@@ -77,3 +77,59 @@ export function resolveDataSource(
|
|
|
77
77
|
capabilities: getDataSourceCapabilities(engine)
|
|
78
78
|
};
|
|
79
79
|
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Does a SQL toolchain own this collection's storage?
|
|
83
|
+
*
|
|
84
|
+
* "Owns the storage" means: something generates a table for it, pushes that
|
|
85
|
+
* table to a database, plans its RLS policies, and reports it as drifted when
|
|
86
|
+
* the two disagree. That is true of a Postgres collection and false of a
|
|
87
|
+
* Firestore or MongoDB one, whose documents live in a store Rebase never
|
|
88
|
+
* migrates — and the two were never told apart. Every stage of the SQL
|
|
89
|
+
* toolchain took "the collections" to mean *all* of them, so a Firestore
|
|
90
|
+
* collection declared next to the Postgres ones got a `pgTable` in the
|
|
91
|
+
* generated schema, a `CREATE TABLE` at boot, RLS policies, and a place in the
|
|
92
|
+
* `db push` include list — where its name shielding a same-named real table
|
|
93
|
+
* from Atlas's exclude list is the one that can lose data.
|
|
94
|
+
*
|
|
95
|
+
* The answer is the resolved engine's {@link DataSourceCapabilities}, not a
|
|
96
|
+
* name check: an engine registered through `registerDataSourceCapabilities`
|
|
97
|
+
* gets the same treatment as the built-in ones.
|
|
98
|
+
*
|
|
99
|
+
* Deliberately answers **true** for an engine nobody has heard of. Build-time
|
|
100
|
+
* tooling (the CLI, the schema generator) has no data-source registry to
|
|
101
|
+
* resolve a `dataSource` key against, so an unknown key resolves to an unknown
|
|
102
|
+
* engine — and the cost of the two mistakes is not symmetric. Wrongly
|
|
103
|
+
* including a collection generates a table nothing writes to; wrongly excluding
|
|
104
|
+
* one silently stops generating a table the app is serving from. Declare
|
|
105
|
+
* `engine` on a collection that is not SQL-backed and this is exact.
|
|
106
|
+
*/
|
|
107
|
+
export function isRelationalCollection(
|
|
108
|
+
collection: DataSourceResolvable | undefined,
|
|
109
|
+
registry?: DataSourceRegistry
|
|
110
|
+
): boolean {
|
|
111
|
+
// The collection's own `engine` wins over a registered definition's. That
|
|
112
|
+
// is the opposite of {@link resolveDataSource}'s precedence, deliberately:
|
|
113
|
+
// there a definition describes where the data *goes*, so it should override;
|
|
114
|
+
// here the question is what the author said this collection is, and a
|
|
115
|
+
// collection declaring `engine: "firestore"` with no `dataSource` must not
|
|
116
|
+
// come back as the default source's engine and be handed a table.
|
|
117
|
+
const engine = collection?.engine
|
|
118
|
+
?? (collection?.dataSource ? resolveDataSource(collection, registry).engine : undefined);
|
|
119
|
+
return getDataSourceCapabilities(engine).supportsRelations;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The subset of `collections` a SQL toolchain owns — see
|
|
124
|
+
* {@link isRelationalCollection}.
|
|
125
|
+
*
|
|
126
|
+
* Every stage that generates SQL from collections starts by calling this, so
|
|
127
|
+
* the rule lives in one place rather than being re-decided per generator. It
|
|
128
|
+
* keeps the input order.
|
|
129
|
+
*/
|
|
130
|
+
export function relationalCollections<C extends DataSourceResolvable>(
|
|
131
|
+
collections: readonly C[],
|
|
132
|
+
registry?: DataSourceRegistry
|
|
133
|
+
): C[] {
|
|
134
|
+
return collections.filter(collection => isRelationalCollection(collection, registry));
|
|
135
|
+
}
|
package/src/util/builders.ts
CHANGED
|
@@ -14,14 +14,14 @@ import {
|
|
|
14
14
|
// ── defineCollection ─────────────────────────────────────────────────────
|
|
15
15
|
// A smarter builder that uses `const` type-parameter inference (TS 5.0+)
|
|
16
16
|
// to capture literal property types automatically. This gives you
|
|
17
|
-
// autocomplete on `
|
|
17
|
+
// autocomplete on `display.title`, `sort`, `propertiesOrder`, `fixedFilter`,
|
|
18
18
|
// callbacks, etc. — without writing `as const` or passing manual generics.
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* Define a PostgreSQL-backed collection with full type inference.
|
|
22
22
|
*
|
|
23
23
|
* The `const P` generic captures literal property types from your
|
|
24
|
-
* `properties` object, which enables autocomplete on `
|
|
24
|
+
* `properties` object, which enables autocomplete on `display.title`,
|
|
25
25
|
* `sort`, `propertiesOrder`, `fixedFilter`, and entity callbacks.
|
|
26
26
|
*
|
|
27
27
|
* @example
|
|
@@ -34,7 +34,7 @@ import {
|
|
|
34
34
|
* name: { name: "Name", type: "string", validation: { required: true } },
|
|
35
35
|
* price: { name: "Price", type: "number" },
|
|
36
36
|
* },
|
|
37
|
-
*
|
|
37
|
+
* display: { title: "name" }, // ✅ autocomplete: "name" | "price"
|
|
38
38
|
* sort: ["price", "asc"], // ✅ autocomplete on first element
|
|
39
39
|
* });
|
|
40
40
|
* ```
|