@rebasepro/common 0.19.1 → 0.19.2-canary.g08eed46

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,76 @@
1
+ import type { CollectionConfig, FieldAccess, Property } from "@rebasepro/types";
2
+ /**
3
+ * Field-level access control: one mechanism, read by every enforcement point.
4
+ *
5
+ * A collection's `securityRules` decide which *rows* a caller reaches;
6
+ * `property.access` decides which *fields* of a reached row they see and may
7
+ * set. The two are independent — a field rule never widens row access, and a
8
+ * row a caller cannot read has no fields to talk about.
9
+ *
10
+ * `excludeFromApi` is sugar for `access: { read: [], write: [] }` and is
11
+ * normalised into it by {@link effectiveAccess}, which is the only place either
12
+ * spelling is read. It used to be its own code path in five files — the read
13
+ * strip, the write refusal, the SDK generator, the OpenAPI schema builder and
14
+ * the filter-parameter builder — and the second rule would have made ten.
15
+ * There is one predicate now, and the flag is a shorthand for it.
16
+ *
17
+ * @module
18
+ */
19
+ /**
20
+ * The caller a field rule is judged against: whatever the call context carries
21
+ * as the user's application roles.
22
+ *
23
+ * `undefined` is the trusted server plane — an in-process `rebase.data` call
24
+ * with no request behind it, the auth adapter writing a password hash, a
25
+ * migration. Every API boundary has a viewer: an unauthenticated REST request is
26
+ * scoped as `{ uid: ANONYMOUS_USER_ID, roles: ["anon"] }` before it reaches a
27
+ * driver, so "no viewer" cannot be reached from outside.
28
+ */
29
+ export interface FieldViewer {
30
+ roles?: readonly string[];
31
+ }
32
+ /**
33
+ * The role that satisfies any non-empty list.
34
+ *
35
+ * The same arm every baseline policy carries: `security_rules` injects
36
+ * `rolesOverlap(['admin'])` into the default read and write policies, and
37
+ * `rebase.dataAsAdmin` is scoped with `{ uid: "service", roles: ["admin"] }`.
38
+ * Without this an author could declare `access: { read: ["hr"] }` and lock the
39
+ * administrator out of a column of their own database — and lock the Studio out
40
+ * of rendering it.
41
+ */
42
+ export declare const ADMIN_ROLE = "admin";
43
+ /**
44
+ * What a property's access rules actually are, with `excludeFromApi` expanded.
45
+ *
46
+ * Returns `undefined` when the property constrains nothing, so callers can skip
47
+ * the whole check for the overwhelmingly common case.
48
+ */
49
+ export declare function effectiveAccess(property: Property | undefined): FieldAccess | undefined;
50
+ /** May this caller receive this field's value? */
51
+ export declare function canReadField(property: Property | undefined, viewer: FieldViewer | undefined): boolean;
52
+ /** May this caller set this field's value? */
53
+ export declare function canWriteField(property: Property | undefined, viewer: FieldViewer | undefined): boolean;
54
+ /**
55
+ * The names on this collection a caller may not touch, in the two spellings a
56
+ * caller can write them in.
57
+ *
58
+ * `declared` is the property keys, which is what has to leave a *known-fields*
59
+ * set. `refused` is those plus the physical column names behind them: a caller
60
+ * who knows the table can send `password_hash` as readily as `passwordHash`, and
61
+ * a rule that only knew the wire name would be one rename away from useless.
62
+ *
63
+ * `kind` picks which half of the rule is read; nothing else differs.
64
+ */
65
+ export declare function restrictedFieldNames(collection: CollectionConfig, viewer: FieldViewer | undefined, kind: "read" | "write"): {
66
+ declared: string[];
67
+ refused: Set<string>;
68
+ };
69
+ /**
70
+ * True when nothing on this collection restricts a field, for either direction.
71
+ *
72
+ * Every read of every row runs through the strip, so the collection that has no
73
+ * rules — which is almost all of them — has to cost one property walk and no
74
+ * allocation.
75
+ */
76
+ export declare function hasFieldAccessRules(collection: CollectionConfig): boolean;
@@ -1,2 +1,3 @@
1
1
  export * from "./CollectionRegistry.js";
2
2
  export * from "./default-collections.js";
3
+ export * from "./field-access.js";
@@ -1,4 +1,13 @@
1
1
  import { DataDriver, RebaseData, RebaseSdkData } from "@rebasepro/types";
2
+ /**
3
+ * Derive the response key an aggregate comes back under.
4
+ *
5
+ * `sum(total)` → `sum_total`, `count()` → `count`. Written once, here, because
6
+ * the REST parser derives the same alias from `?select=sum(total)` and the two
7
+ * have to agree — a caller reading `row.sum_total` off an SDK result and off an
8
+ * HTTP response is reading the same key or the SDK is broken.
9
+ */
10
+ export declare function aggregateAlias(fn: string, field?: string): string;
2
11
  export interface EntityDataOptions {
3
12
  /**
4
13
  * Look up a collection's config by slug, to derive row addresses from its
@@ -10,20 +19,10 @@ export interface EntityDataOptions {
10
19
  */
11
20
  resolveCollection?: (slug: string) => {
12
21
  properties?: Record<string, unknown>;
22
+ relations?: unknown[];
23
+ slug?: string;
13
24
  } | undefined;
14
25
  }
15
- /**
16
- * Build a `RebaseData` object from a `DataDriver` using JavaScript Proxy.
17
- *
18
- * This is the key bridge: any property access like `data.products` returns
19
- * a `CollectionAccessor` backed by the underlying DataDriver, without
20
- * needing per-collection code generation.
21
- *
22
- * @example
23
- * const data = buildRebaseData(driver);
24
- * await data.products.create({ name: "Camera", price: 299 });
25
- * const { data: items } = await data.products.find({ where: { status: ["==", "published"] } });
26
- */
27
26
  export declare function buildRebaseData(driver: DataDriver, options?: EntityDataOptions): RebaseData;
28
27
  /**
29
28
  * Wrap a flat {@link RebaseSdkData} into a Entity-shaped {@link RebaseData}.
@@ -0,0 +1,102 @@
1
+ import type { OrderByTuple } from "@rebasepro/types";
2
+ /**
3
+ * The keyset-cursor wire codec.
4
+ *
5
+ * ## Why this is one module
6
+ *
7
+ * Keyset pagination was implemented three times and reachable once. The driver
8
+ * has a NULL-correct multi-key comparison (`FetchService.buildKeysetComparison`)
9
+ * that only a WebSocket `startAfter` could reach; REST could not seek at all;
10
+ * and the SDK's `iterate({cursor})` re-implemented a *single*-column keyset as a
11
+ * `where` clause, which threw on any multi-key sort and silently dropped rows
12
+ * whose sort value was NULL. Three implementations, three answers to "what is
13
+ * page two".
14
+ *
15
+ * There is now one. The driver's comparison is the implementation; this module
16
+ * is the only thing that says how a cursor is written down, and every transport
17
+ * — the REST `?after=`, the WebSocket `startAfter`, the SDK's `iterate()` —
18
+ * carries the string this produces and hands it back unread.
19
+ *
20
+ * ## What a cursor holds
21
+ *
22
+ * The sort keys the query was ordered by, the last served row's value for each
23
+ * of them, and that row's id. The keys travel *with* the values because a
24
+ * cursor that carried only values would be silently reinterpretable: paging a
25
+ * `created_at DESC` listing and then asking for `title ASC` would seek on the
26
+ * dates as though they were titles. Carrying the keys makes that a refusal
27
+ * ({@link CursorMismatchError}) rather than a page of arbitrary rows.
28
+ *
29
+ * ## Opacity
30
+ *
31
+ * The encoding is base64url of JSON, and it is **not** API. It is opaque so it
32
+ * can change — adding a key, changing how a value is tagged — without every
33
+ * client that learned to read it breaking. Nothing outside this file parses it.
34
+ *
35
+ * @module
36
+ */
37
+ /** The decoded contents of a cursor. */
38
+ export interface DecodedCursor {
39
+ /** The sort keys the cursor was produced under, in order of significance. */
40
+ orderBy: OrderByTuple[];
41
+ /** The last served row's value for each sort key, by field name. */
42
+ values: Record<string, unknown>;
43
+ /** The last served row's id, which breaks ties on the last key. */
44
+ id: unknown;
45
+ }
46
+ /** A cursor that cannot be read at all — truncated, re-encoded, or invented. */
47
+ export declare class CursorError extends Error {
48
+ readonly code = "INVALID_CURSOR";
49
+ constructor(detail: string);
50
+ }
51
+ /**
52
+ * A cursor that reads fine but describes a different query.
53
+ *
54
+ * Separate from {@link CursorError} because the fix is different: this one is
55
+ * not a corrupt string, it is a correct cursor used against a sort it was not
56
+ * produced under. Seeking anyway would return rows in an order nobody asked
57
+ * for, and — worse — would look like it worked.
58
+ */
59
+ export declare class CursorMismatchError extends Error {
60
+ readonly code = "CURSOR_ORDER_MISMATCH";
61
+ constructor(cursorKeys: string[], queryKeys: string[]);
62
+ }
63
+ /**
64
+ * Encode "everything strictly after this row, in this order".
65
+ *
66
+ * @param orderBy the sort keys the listing ran under, in order of significance
67
+ * @param row the last row served, which the next page picks up after
68
+ * @param id that row's id — the tiebreaker every keyset comparison ends on
69
+ * @returns the opaque cursor, or `undefined` when no cursor can describe the
70
+ * page. That is not a failure: a listing sorted by relevance has no stored
71
+ * value to compare a later page against (scores are computed per query and
72
+ * are not on the same scale between two of them), so it pages by offset and
73
+ * `meta.nextCursor` is simply absent.
74
+ */
75
+ export declare function encodeCursor(orderBy: OrderByTuple[] | undefined, row: Record<string, unknown>, id: unknown): string | undefined;
76
+ /**
77
+ * Read a cursor produced by {@link encodeCursor}.
78
+ *
79
+ * @throws {CursorError} when the string is not a cursor this codec wrote.
80
+ */
81
+ export declare function decodeCursor(raw: string): DecodedCursor;
82
+ /**
83
+ * The `orderBy` a request should run under, given a cursor and whatever sort
84
+ * the request itself named.
85
+ *
86
+ * A request that names no sort **adopts the cursor's** — that is what makes
87
+ * `find({ after })` work without restating the `orderBy` from the previous
88
+ * call, and it cannot be wrong, since the cursor is the only sort in play.
89
+ * A request that names one must name the *same* one, key for key, direction for
90
+ * direction, nulls for nulls; anything else is {@link CursorMismatchError}.
91
+ *
92
+ * @throws {CursorMismatchError}
93
+ */
94
+ export declare function reconcileCursorOrder(cursor: DecodedCursor, requested: OrderByTuple[] | undefined): OrderByTuple[];
95
+ /**
96
+ * The `startAfter` shape the driver contract takes, built from a cursor.
97
+ *
98
+ * The driver has always accepted `{ id, values }`; this is the one place that
99
+ * shape is produced, so the REST route and the WebSocket ingress cannot drift
100
+ * into two spellings of the same seek.
101
+ */
102
+ export declare function cursorToStartAfter(cursor: DecodedCursor): Record<string, unknown>;
@@ -0,0 +1,128 @@
1
+ import type { FilterValues, IncludeSpec, LogicalCondition, OrderByTuple } from "@rebasepro/types";
2
+ /**
3
+ * The `include` codec: one shape, whatever spelling it arrived in.
4
+ *
5
+ * `include` reaches the driver by four routes — the REST `?include=` parameter,
6
+ * a WebSocket subscribe frame, the SDK's `include(...)`, and the admin panel's
7
+ * "all relations" — and each used to hand the driver something slightly
8
+ * different. This normalises all four to one tree, so the fetch pipeline has a
9
+ * single thing to read and `find()`, `findById()` and `listen()` cannot disagree
10
+ * about what "include the author" means.
11
+ *
12
+ * @module
13
+ */
14
+ /**
15
+ * One relation to load, and how.
16
+ *
17
+ * `children` is the nesting: `comments.author` is a `comments` node with an
18
+ * `author` child. Every other field narrows the rows *of this relation* — the
19
+ * same knobs a top-level query has, which is the point.
20
+ */
21
+ export interface IncludeNode {
22
+ /** Rows to load per parent row. */
23
+ limit?: number;
24
+ /** Filter over the related rows. */
25
+ where?: FilterValues<string>;
26
+ /** An `and`/`or`/`not` group over the related rows. */
27
+ logical?: LogicalCondition;
28
+ /** Sort for the related rows. */
29
+ orderBy?: OrderByTuple[];
30
+ /** Columns of the related row to return. */
31
+ fields?: string[];
32
+ /** Relations of the related row, loaded in turn. */
33
+ children: Record<string, IncludeNode>;
34
+ }
35
+ /**
36
+ * A whole `include` request: the tree, plus whether the caller asked for
37
+ * *every* relation.
38
+ *
39
+ * The wildcard is kept as a flag rather than expanded into names here, because
40
+ * expanding it needs the collection — which this package does not have. The
41
+ * driver expands it against the relations it actually resolved.
42
+ */
43
+ export interface NormalizedInclude {
44
+ /** `include=*` — every relation of the collection, one hop deep. */
45
+ wildcard: boolean;
46
+ /** The named relations. Empty when `wildcard` is set alone. */
47
+ tree: Record<string, IncludeNode>;
48
+ }
49
+ /** An `include` that cannot be read, as opposed to one naming a relation that does not exist. */
50
+ export declare class IncludeSpecError extends Error {
51
+ readonly code: string;
52
+ constructor(detail: string, code?: string);
53
+ }
54
+ /**
55
+ * Collapse any {@link IncludeSpec} spelling into one tree.
56
+ *
57
+ * `["author", "comments.author"]` and
58
+ * `{ author: true, comments: { include: { author: true } } }` normalize to the
59
+ * same value — which is the whole point: the REST parameter can only carry the
60
+ * flat spelling, the SDK prefers the tree, and the driver should never learn
61
+ * about either.
62
+ *
63
+ * @throws {IncludeSpecError} for a shape that is not an include at all, or one
64
+ * that nests past {@link MAX_INCLUDE_DEPTH}.
65
+ */
66
+ export declare function normalizeInclude(spec?: IncludeSpec): NormalizedInclude | undefined;
67
+ /**
68
+ * Every relation name a tree names, as dotted paths — `["comments",
69
+ * "comments.author"]`.
70
+ *
71
+ * Used to report which names an `include` asked for when one of them is not a
72
+ * relation, and to serialize a tree that carries no per-relation options back
73
+ * to the flat wire spelling.
74
+ */
75
+ export declare function includePaths(tree: Record<string, IncludeNode>, prefix?: string): string[];
76
+ /**
77
+ * The relation names an `include` asks for at the top level.
78
+ *
79
+ * `["author", "comments.author"]` and `{author: true, comments: {...}}` both
80
+ * answer `["author", "comments"]` — a *hop*, not a path, because the only
81
+ * consumer is `?fields=`, which names keys on the row being returned and a
82
+ * nested relation is not one of those.
83
+ *
84
+ * Derived rather than passed: `include` has four spellings and three of them
85
+ * are not a `string[]`, so every consumer that wants the plain names either
86
+ * calls this or reimplements the flattening.
87
+ */
88
+ export declare function topLevelIncludeNames(spec?: IncludeSpec): string[];
89
+ /**
90
+ * Serialize an {@link IncludeSpec} for the REST `?include=` parameter.
91
+ *
92
+ * Two spellings, and which one is used is decided by the request rather than
93
+ * chosen:
94
+ *
95
+ * - **Comma-separated dotted paths** — `include=author,comments.author`. What a
96
+ * plain include is, what a human types, and what every existing client sends.
97
+ * - **JSON**, when any relation carries options — `include={"comments":{"limit":5,
98
+ * "include":{"author":true}}}`. The flat spelling has nowhere to put a
99
+ * `limit`, and inventing a punctuation for it (`comments(limit:5)`) would be a
100
+ * third grammar to learn beside the two this API already has.
101
+ *
102
+ * The server accepts both on every list and get route, and tells them apart the
103
+ * same way this does: a value starting with `{` is JSON.
104
+ */
105
+ export declare function serializeInclude(spec?: IncludeSpec): string | undefined;
106
+ /**
107
+ * A normalized tree, back in the {@link IncludeSpec} spelling a caller writes.
108
+ *
109
+ * The round trip is what lets a builder accumulate `include` calls: normalize
110
+ * each, merge, and hand the result back as a spec the next layer can normalize
111
+ * again. Idempotent, so doing it twice changes nothing.
112
+ */
113
+ export declare function denormalizeInclude(normalized: NormalizedInclude): IncludeSpec;
114
+ /**
115
+ * Combine several `include` requests into one.
116
+ *
117
+ * Repeated `.include(...)` calls on a query builder are additive: each names
118
+ * more of the graph to load, and a later one must not discard what an earlier
119
+ * one asked for. Assigning instead of merging is why `.include("author")
120
+ * .include("tags")` used to load only tags.
121
+ */
122
+ export declare function mergeIncludeSpecs(existing: IncludeSpec | undefined, additions: (string | IncludeSpec)[]): IncludeSpec | undefined;
123
+ /**
124
+ * Read the REST `?include=` parameter, in either spelling.
125
+ *
126
+ * @throws {IncludeSpecError} for malformed JSON or a tree that nests too deep.
127
+ */
128
+ export declare function deserializeInclude(raw?: string): IncludeSpec | undefined;
@@ -26,12 +26,15 @@ export type PaginationErrorCode =
26
26
  "max-rows"
27
27
  /** The walk made its maximum number of requests without the server finishing. */
28
28
  | "max-pages"
29
- /** A cursor row carried no value for the cursor column. */
29
+ /**
30
+ * The server said there was another page but issued no cursor to reach it.
31
+ *
32
+ * A query whose ordering has no stored value to seek on — relevance — is the
33
+ * case that produces this. Page it by offset instead.
34
+ */
30
35
  | "cursor-missing"
31
- /** Two consecutive pages ended on the same cursor value, so the walk cannot advance. */
32
- | "cursor-stalled"
33
- /** A `cursor` was asked for on one column while `orderBy` sorted by another. */
34
- | "cursor-order-mismatch";
36
+ /** Two consecutive pages returned the same cursor, so the walk cannot advance. */
37
+ | "cursor-stalled";
35
38
  /**
36
39
  * Thrown when a walk stops for a reason the caller needs to know about.
37
40
  *
@@ -1,6 +1,21 @@
1
1
  import { CollectionAccessor, FilterCondition, FindResponse, LogicalCondition, QueryBuilderInterface, WhereFilterOp, WhereValueFor, type ComputedSortField } from "@rebasepro/types";
2
2
  export declare function or(...conditions: (FilterCondition | LogicalCondition)[]): LogicalCondition;
3
3
  export declare function and(...conditions: (FilterCondition | LogicalCondition)[]): LogicalCondition;
4
+ /**
5
+ * Negate a group: `not(a)` is `NOT a`, and `not(a, b)` is `NOT (a AND b)`.
6
+ *
7
+ * The conjunction, not the disjunction — one rule, stated on
8
+ * {@link LogicalCondition} and applied identically by the wire codec, the REST
9
+ * `?not=` parameter and every driver compiler. Groups nest, so De Morgan's
10
+ * other half is `not(or(a, b))`.
11
+ *
12
+ * It compiles to a real SQL `NOT (...)` rather than to inverted operators,
13
+ * which matters more than it looks: SQL is three-valued, so `NOT (a AND b)` and
14
+ * `(NOT a) OR (NOT b)` stop agreeing the moment a NULL is involved, and only
15
+ * one of them is the query the caller wrote. It also means a negation includes
16
+ * rows whose column is NULL — which is what `NOT` means.
17
+ */
18
+ export declare function not(...conditions: (FilterCondition | LogicalCondition)[]): LogicalCondition;
4
19
  export declare function cond(column: string, operator: WhereFilterOp, value: unknown): FilterCondition;
5
20
  export declare class QueryBuilder<M extends Record<string, unknown> = Record<string, unknown>> implements QueryBuilderInterface<M> {
6
21
  private collection;
package/dist/index.d.ts CHANGED
@@ -5,6 +5,8 @@ export * from "./data/buildRoutedRebaseData.js";
5
5
  export * from "./data/resolveDataSource.js";
6
6
  export * from "./data/query_builder.js";
7
7
  export * from "./data/paginate.js";
8
+ export * from "./data/cursor.js";
9
+ export * from "./data/include-spec.js";
8
10
  export * from "./data/filter-conditions.js";
9
11
  export * from "./data/filter-dialect.js";
10
12
  export * from "./data/sort-dialect.js";