@rebasepro/common 0.14.0 → 0.14.1-canary.g7e666eb

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/common",
3
3
  "type": "module",
4
- "version": "0.14.0",
4
+ "version": "0.14.1-canary.g7e666eb",
5
5
  "description": "Rebase shared core — collection registry, data driver adapter and fluent query builder. No React dependency.",
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.14.0",
44
- "@rebasepro/utils": "0.14.0"
43
+ "@rebasepro/types": "0.14.1-canary.g7e666eb",
44
+ "@rebasepro/utils": "0.14.1-canary.g7e666eb"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@jest/globals": "^30.4.1",
@@ -9,6 +9,7 @@ import {
9
9
  FindResult,
10
10
  IterateParams,
11
11
  LogicalCondition,
12
+ OrderByTuple,
12
13
  RebaseData,
13
14
  RebaseSdkData,
14
15
  SDKCollectionClient,
@@ -21,6 +22,7 @@ import {
21
22
  import { toSnakeCase } from "@rebasepro/utils";
22
23
  import { QueryBuilder } from "./query_builder";
23
24
  import { collectAllPages, paginateFind, resolveFindWindow } from "./paginate";
25
+ import { normalizeOrderBy } from "./sort-dialect";
24
26
  import { deserializeFilter } from "./filter-dialect";
25
27
  import { buildCompositeId, resolvePrimaryKeys, PrimaryKeyInfo } from "../util/identity";
26
28
 
@@ -191,8 +193,7 @@ function createDriverAccessor<M extends Record<string, unknown> = Record<string,
191
193
  logical: params?.logical,
192
194
  limit,
193
195
  offset: driverOffset,
194
- orderBy: params?.orderBy?.[0],
195
- order: params?.orderBy?.[1],
196
+ orderBy: normalizeOrderBy(params?.orderBy),
196
197
  searchString: params?.searchString
197
198
  },
198
199
  params?.include
@@ -203,8 +204,7 @@ function createDriverAccessor<M extends Record<string, unknown> = Record<string,
203
204
  offset: driverOffset,
204
205
  filter,
205
206
  logical: params?.logical,
206
- orderBy: params?.orderBy?.[0],
207
- order: params?.orderBy?.[1],
207
+ orderBy: normalizeOrderBy(params?.orderBy),
208
208
  searchString: params?.searchString
209
209
  });
210
210
 
@@ -330,8 +330,7 @@ ids });
330
330
  offset: driverOffset,
331
331
  filter: params?.where,
332
332
  logical: params?.logical,
333
- orderBy: params?.orderBy?.[0],
334
- order: params?.orderBy?.[1],
333
+ orderBy: normalizeOrderBy(params?.orderBy),
335
334
  searchString: params?.searchString,
336
335
  searchExplain: params?.searchExplain,
337
336
  onUpdate: (entities) => {
@@ -494,8 +493,10 @@ class SdkQueryBuilder<M extends Record<string, unknown> = Record<string, unknown
494
493
  return this;
495
494
  }
496
495
 
496
+ /** Called again, this adds a tie-breaker rather than replacing the sort. */
497
497
  orderBy(column: (keyof M & string) | ComputedSortField, direction: "asc" | "desc" = "asc"): this {
498
- this.params.orderBy = [column, direction];
498
+ const existing = normalizeOrderBy(this.params.orderBy) ?? [];
499
+ this.params.orderBy = [...existing, [column, direction] as OrderByTuple];
499
500
  return this;
500
501
  }
501
502
 
@@ -8,6 +8,7 @@ import {
8
8
  IterateParams,
9
9
  WhereFilterOp
10
10
  } from "@rebasepro/types";
11
+ import { normalizeOrderBy } from "./sort-dialect";
11
12
 
12
13
  /**
13
14
  * The pagination engine behind `iterate()` / `findAll()`.
@@ -184,16 +185,29 @@ export async function* paginateFind<M extends Record<string, unknown> = Record<s
184
185
 
185
186
  let direction: "asc" | "desc" = "asc";
186
187
  if (cursorField) {
187
- const orderBy = findParams.orderBy;
188
- if (orderBy && orderBy[0] !== cursorField) {
188
+ const orderBy = normalizeOrderBy(findParams.orderBy);
189
+ // A seek is one `>`/`<` on one column, so it can only follow a sort of
190
+ // one column. Over a multi-key sort the same comparison both repeats
191
+ // rows (every later key's ties) and skips them, which is the failure
192
+ // this error exists to prevent — name it rather than seek anyway.
193
+ if (orderBy && orderBy.length > 1) {
189
194
  throw new RebasePaginationError(
190
195
  "cursor-order-mismatch",
191
- `Cannot seek on "${cursorField}" while ordering "${label}" by "${orderBy[0]}": ` +
196
+ `Cannot seek on "${cursorField}" while ordering "${label}" by ` +
197
+ `${orderBy.map(([field]) => `"${field}"`).join(", ")}: ` +
198
+ `keyset pagination advances along a single column. ` +
199
+ `Order by "${cursorField}" alone, or drop the cursor and page by offset.`
200
+ );
201
+ }
202
+ if (orderBy && orderBy[0][0] !== cursorField) {
203
+ throw new RebasePaginationError(
204
+ "cursor-order-mismatch",
205
+ `Cannot seek on "${cursorField}" while ordering "${label}" by "${orderBy[0][0]}": ` +
192
206
  `keyset pagination only advances along the column the query is sorted by. ` +
193
207
  `Order by "${cursorField}", or drop the cursor and page by offset.`
194
208
  );
195
209
  }
196
- direction = requestedDirection ?? orderBy?.[1] ?? "asc";
210
+ direction = requestedDirection ?? orderBy?.[0][1] ?? "asc";
197
211
  findParams.orderBy = [cursorField, direction] as FindParams<M>["orderBy"];
198
212
  }
199
213
  const seekOp: WhereFilterOp = direction === "desc" ? "<" : ">";
@@ -4,11 +4,13 @@ import {
4
4
  FindParams,
5
5
  FindResponse,
6
6
  LogicalCondition,
7
+ OrderByTuple,
7
8
  QueryBuilderInterface,
8
9
  WhereFilterOp,
9
10
  WhereValueFor,
10
11
  type ComputedSortField
11
12
  } from "@rebasepro/types";
13
+ import { normalizeOrderBy } from "./sort-dialect";
12
14
 
13
15
  export function or(...conditions: (FilterCondition | LogicalCondition)[]): LogicalCondition {
14
16
  return { type: "or",
@@ -78,11 +80,18 @@ export class QueryBuilder<M extends Record<string, unknown> = Record<string, unk
78
80
 
79
81
  /**
80
82
  * Order the results by a specific column.
83
+ *
84
+ * Called again, this adds a tie-breaker rather than replacing the sort:
85
+ * keys apply in the order they were added.
86
+ *
81
87
  * @example
82
88
  * client.collection('users').orderBy('createdAt', 'desc').find()
89
+ * @example
90
+ * client.collection('users').orderBy('roles').orderBy('createdAt', 'desc').find()
83
91
  */
84
92
  orderBy(column: (keyof M & string) | ComputedSortField, direction: "asc" | "desc" = "asc"): this {
85
- this.params.orderBy = [column, direction];
93
+ const existing = normalizeOrderBy(this.params.orderBy) ?? [];
94
+ this.params.orderBy = [...existing, [column, direction] as OrderByTuple];
86
95
  return this;
87
96
  }
88
97
 
@@ -1,10 +1,11 @@
1
- import type { OrderByTuple } from "@rebasepro/types";
1
+ import type { OrderBySpec, OrderByTuple } from "@rebasepro/types";
2
2
 
3
3
  /**
4
4
  * Sort-order wire codec.
5
5
  *
6
6
  * This is the ONLY module that knows about the colon-delimited wire format
7
- * (`"field:direction"`) used in HTTP query parameters.
7
+ * (`"field:direction"`) used in HTTP query parameters, and about the JSON-array
8
+ * form that carries a multi-column sort over the same parameter.
8
9
  * Everything else speaks {@link OrderByTuple} exclusively.
9
10
  *
10
11
  * Mirrors the filter architecture in `filter-dialect.ts`.
@@ -13,26 +14,140 @@ import type { OrderByTuple } from "@rebasepro/types";
13
14
  */
14
15
 
15
16
  /**
16
- * Serialize an {@link OrderByTuple} to the wire format `"field:direction"`.
17
+ * Collapse the one-key and many-key spellings of a sort into the list form.
18
+ *
19
+ * `["a", "desc"]` and `[["a", "desc"]]` mean the same thing and normalize to
20
+ * the same value; the two are told apart by whether the first element is
21
+ * itself an array, which no field name ever is.
22
+ *
23
+ * @returns The keys in order of significance, or `undefined` for no sort. An
24
+ * empty list also returns `undefined` — "sort by nothing" is no sort, and
25
+ * letting `[]` through would have every layer below re-deciding what it meant.
26
+ */
27
+ export function normalizeOrderBy(orderBy?: OrderBySpec): OrderByTuple[] | undefined {
28
+ if (!orderBy || orderBy.length === 0) return undefined;
29
+ const list = Array.isArray(orderBy[0])
30
+ ? orderBy as OrderByTuple[]
31
+ : [orderBy as OrderByTuple];
32
+ return list.length > 0 ? list : undefined;
33
+ }
34
+
35
+ /**
36
+ * The most significant sort key, for a caller that can only express one —
37
+ * a column header's arrow, a URL parameter, a driver that has not been taught
38
+ * the list form.
39
+ */
40
+ export function primaryOrderBy(orderBy?: OrderBySpec): OrderByTuple | undefined {
41
+ return normalizeOrderBy(orderBy)?.[0];
42
+ }
43
+
44
+ /**
45
+ * Collapse the driver-level `{orderBy, order}` pair into the list form.
46
+ *
47
+ * The driver contract spells a single-column sort as a field name plus a
48
+ * separate direction, and a multi-column one as a list of tuples that leaves
49
+ * `order` meaningless. Every driver reads both through here so neither
50
+ * spelling has to be handled twice.
51
+ *
52
+ * An absent direction means ascending — the same thing a bare `?orderBy=name`
53
+ * has always meant over HTTP. The Postgres driver used to read the same pair as
54
+ * *descending* while Mongo read it as ascending, so one field name and no
55
+ * direction described two different queries depending on which database was
56
+ * underneath. Neither had a caller: every path in the workspace passes a
57
+ * direction, which is why the disagreement went unnoticed rather than being
58
+ * load-bearing.
59
+ */
60
+ export function normalizeDriverOrderBy(
61
+ orderBy?: string | OrderByTuple[],
62
+ order?: "asc" | "desc"
63
+ ): OrderByTuple[] | undefined {
64
+ if (!orderBy) return undefined;
65
+ if (typeof orderBy === "string") return [[orderBy, order === "desc" ? "desc" : "asc"]];
66
+ return orderBy.length > 0 ? orderBy : undefined;
67
+ }
68
+
69
+ /** A sort whose *shape* is unusable, as opposed to one naming a field that does not exist. */
70
+ export class OrderBySpecError extends Error {
71
+ readonly code = "INVALID_ORDER_BY";
72
+ constructor(detail: string) {
73
+ super(
74
+ `Invalid \`orderBy\`: ${detail}. Expected a field name, or a list of ` +
75
+ "[field, direction] pairs like [[\"roles\",\"asc\"],[\"created_at\",\"desc\"]]"
76
+ );
77
+ this.name = "OrderBySpecError";
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Validate an `orderBy` that arrived from outside this process — a WebSocket
83
+ * subscribe frame, a driver call from untyped JavaScript — and return it in the
84
+ * list form.
85
+ *
86
+ * Strict on purpose, in the same way the REST `parseOrderByParam` is: the
87
+ * failure mode for a shape nobody checks is not a crash but a *silently
88
+ * different query*. A malformed entry read as a field name resolves to no
89
+ * column, and under the lenient unknown-field mode the sort is then dropped and
90
+ * the rows come back in whatever order the database pleased — sorted, as far as
91
+ * the subscriber can tell, by whatever they asked for.
92
+ */
93
+ export function parseOrderBySpecStrict(raw: unknown, order?: "asc" | "desc"): OrderByTuple[] | undefined {
94
+ if (raw === undefined || raw === null || raw === "") return undefined;
95
+ // The string spelling is the driver contract's, so it takes its direction
96
+ // from the same companion `order` — and defaults the same way it does.
97
+ if (typeof raw === "string") return normalizeDriverOrderBy(raw, order);
98
+ if (!Array.isArray(raw) || raw.length === 0) {
99
+ throw new OrderBySpecError(`${typeof raw} is not a field name or a list of sort keys`);
100
+ }
101
+
102
+ // The single-tuple spelling, `["created_at", "desc"]`.
103
+ if (typeof raw[0] === "string") return [toStrictTuple(raw, 0)];
104
+
105
+ return raw.map(toStrictTuple);
106
+ }
107
+
108
+ function toStrictTuple(raw: unknown, index: number): OrderByTuple {
109
+ if (!Array.isArray(raw) || typeof raw[0] !== "string" || raw[0].trim() === "") {
110
+ throw new OrderBySpecError(`entry ${index} has no field name`);
111
+ }
112
+ const direction = raw[1];
113
+ if (direction !== undefined && direction !== "asc" && direction !== "desc") {
114
+ throw new OrderBySpecError(`entry ${index} has direction '${String(direction)}'`);
115
+ }
116
+ return [raw[0], direction ?? "asc"];
117
+ }
118
+
119
+ /**
120
+ * Serialize a sort to the wire.
121
+ *
122
+ * A single key keeps the `"field:direction"` shorthand it has always used —
123
+ * short, readable in a URL, and what every existing client and test expects.
124
+ * Several keys are emitted as the canonical JSON array the server already
125
+ * accepts, because the shorthand has no separator to spare: a comma-joined
126
+ * `"a:asc,b:desc"` parses as one field named `a` with the direction
127
+ * `"asc,b:desc"`, which the server refuses.
17
128
  *
18
129
  * **Runtime tolerance:** if the input is already a well-formed wire string
19
130
  * (from an untyped JS caller), it is returned unchanged.
20
131
  * This is undocumented tolerance, not public API — don't rely on it.
21
132
  *
22
- * @param orderBy - A canonical `[field, direction]` tuple, or at runtime
133
+ * @param orderBy - A canonical tuple or list of tuples, or at runtime
23
134
  * possibly a pre-serialized string (undocumented tolerance).
24
135
  * @returns The wire-format string, or `undefined` if the input is falsy.
25
136
  *
26
137
  * @remarks
27
138
  * Field names containing `:` are representable in the tuple form but
28
- * **not** on the wire — this is an inherent limitation of the colon-delimited
29
- * encoding and is not resolved here.
139
+ * **not** in the single-key wire encoding — this is an inherent limitation of
140
+ * the colon-delimited shorthand and is not resolved here.
30
141
  */
31
- export function serializeOrderBy(orderBy?: OrderByTuple | string): string | undefined {
142
+ export function serializeOrderBy(orderBy?: OrderBySpec | string): string | undefined {
32
143
  if (!orderBy) return undefined;
33
144
  // Runtime tolerance: pass through a pre-serialized wire string unchanged.
34
145
  if (typeof orderBy === "string") return orderBy;
35
- return `${orderBy[0]}:${orderBy[1]}`;
146
+ const list = normalizeOrderBy(orderBy);
147
+ if (!list) return undefined;
148
+ if (list.length === 1) return `${list[0][0]}:${list[0][1]}`;
149
+ return JSON.stringify(list.map(([field, direction]) => ({ field,
150
+ direction })));
36
151
  }
37
152
 
38
153
  /**
@@ -43,6 +158,10 @@ export function serializeOrderBy(orderBy?: OrderByTuple | string): string | unde
43
158
  * - Unknown direction: `"name:foo"` → `["name", "asc"]`
44
159
  * - Empty / falsy input: → `undefined`
45
160
  *
161
+ * Reads the single-key shorthand only. For a value that may carry several keys,
162
+ * use {@link deserializeOrderByList} — handed a JSON array this returns the
163
+ * whole array as one nonsensical field name.
164
+ *
46
165
  * @param raw - The wire-format string from an HTTP query parameter.
47
166
  * @returns The canonical tuple, or `undefined` if the input is empty/falsy.
48
167
  */
@@ -54,3 +173,41 @@ export function deserializeOrderBy(raw?: string): OrderByTuple | undefined {
54
173
  const dir = raw.slice(idx + 1);
55
174
  return [field, dir === "desc" ? "desc" : "asc"];
56
175
  }
176
+
177
+ /**
178
+ * Deserialize either wire spelling — the single-key shorthand or the JSON
179
+ * array — into the list form.
180
+ *
181
+ * Lenient in the same way {@link deserializeOrderBy} is: this is the client end
182
+ * of the codec, where the value was produced by {@link serializeOrderBy} a
183
+ * moment earlier. The *server* end parses the same shapes strictly, in
184
+ * `parseOrderByParam`, because there the value came from a stranger and a
185
+ * direction it cannot read has to be refused rather than quietly turned into
186
+ * `"asc"`.
187
+ */
188
+ export function deserializeOrderByList(raw?: string): OrderByTuple[] | undefined {
189
+ if (!raw) return undefined;
190
+ const trimmed = raw.trim();
191
+ if (trimmed.startsWith("[")) {
192
+ try {
193
+ const parsed = JSON.parse(trimmed);
194
+ if (Array.isArray(parsed)) {
195
+ const list = parsed
196
+ .map((entry): OrderByTuple | undefined => {
197
+ if (typeof entry === "string") return deserializeOrderBy(entry);
198
+ if (entry && typeof entry === "object" && typeof entry.field === "string") {
199
+ return [entry.field, entry.direction === "desc" ? "desc" : "asc"];
200
+ }
201
+ return undefined;
202
+ })
203
+ .filter((entry): entry is OrderByTuple => entry !== undefined);
204
+ return list.length > 0 ? list : undefined;
205
+ }
206
+ } catch {
207
+ // Not JSON after all — fall through to the shorthand, which is what
208
+ // a field name that merely begins with "[" would be.
209
+ }
210
+ }
211
+ const single = deserializeOrderBy(trimmed);
212
+ return single ? [single] : undefined;
213
+ }
@@ -192,9 +192,62 @@ function schemaOf(collection?: CollectionConfig): string | undefined {
192
192
  function resolveColumnName(propName: string, collection?: CollectionConfig): string {
193
193
  const prop = collection?.properties?.[propName] as Property | undefined;
194
194
  if (prop && "columnName" in prop && typeof (prop as { columnName?: unknown }).columnName === "string") {
195
- return (prop as { columnName: string }).columnName;
195
+ return quoteColumnIdentifier((prop as { columnName: string }).columnName);
196
196
  }
197
- return toSnakeCase(propName);
197
+ return quoteColumnIdentifier(toSnakeCase(propName));
198
+ }
199
+
200
+ /**
201
+ * Every PostgreSQL keyword that cannot stand as a bare column reference.
202
+ * Appendix C's two reserved categories — plain "reserved", and "reserved (can
203
+ * be function or type name)" — since neither may name a column unquoted.
204
+ */
205
+ const RESERVED_SQL_WORDS = new Set([
206
+ "all", "analyse", "analyze", "and", "any", "array", "as", "asc", "asymmetric", "authorization",
207
+ "binary", "both", "case", "cast", "check", "collate", "collation", "column", "concurrently",
208
+ "constraint", "create", "cross", "current_catalog", "current_date", "current_role",
209
+ "current_schema", "current_time", "current_timestamp", "current_user", "default", "deferrable",
210
+ "desc", "distinct", "do", "else", "end", "except", "false", "fetch", "for", "foreign", "freeze",
211
+ "from", "full", "grant", "group", "having", "ilike", "in", "initially", "inner", "intersect",
212
+ "into", "is", "isnull", "join", "lateral", "leading", "left", "like", "limit", "localtime",
213
+ "localtimestamp", "natural", "not", "notnull", "null", "offset", "on", "only", "or", "order",
214
+ "outer", "overlaps", "placing", "primary", "references", "returning", "right", "select",
215
+ "session_user", "similar", "some", "symmetric", "system_user", "table", "tablesample", "then",
216
+ "to", "trailing", "true", "union", "unique", "user", "using", "variadic", "verbose", "when",
217
+ "where", "window", "with"
218
+ ]);
219
+
220
+ /** An identifier Postgres reads back unchanged without quotes. */
221
+ const BARE_IDENTIFIER = /^[a-z_][a-z0-9_$]*$/;
222
+
223
+ /**
224
+ * Quote a column reference when Postgres would not read the bare name as that
225
+ * column — and only then.
226
+ *
227
+ * Three ways a bare name goes wrong, in ascending order of how long it takes to
228
+ * notice:
229
+ *
230
+ * - **Case.** `columnName` is used verbatim, and `rebase schema introspect`
231
+ * populates it from a live database, so a legacy `"createdAt"` column arrives
232
+ * spelled exactly that way. Unquoted, Postgres folds it to `createdat` and
233
+ * `CREATE POLICY` fails with "column does not exist" — the collection keeps
234
+ * RLS enabled with no policy, which denies every row.
235
+ * - **Syntax.** A column named `order` or `default` is a syntax error mid-clause.
236
+ * - **Silent rebinding.** `user`, `current_user`, `session_user`, `current_date`
237
+ * and friends are *valid bare expressions*, so the policy compiles, applies,
238
+ * and is reported as a success — while comparing against the connected role
239
+ * or the wall clock instead of the column. Under RLS every request runs as the
240
+ * same `rebase_user` role, so `USING (user = rebase.uid())` is a constant: it
241
+ * denies everything, and its negation admits everything.
242
+ *
243
+ * Only the names that need it are quoted, so an ordinary snake_case policy body
244
+ * is emitted byte-for-byte as before. That keeps generated artifacts and the
245
+ * policies already stored in shipped databases stable — this fix reaches the
246
+ * clauses that were broken and no others.
247
+ */
248
+ function quoteColumnIdentifier(name: string): string {
249
+ if (BARE_IDENTIFIER.test(name) && !RESERVED_SQL_WORDS.has(name)) return name;
250
+ return `"${name.replace(/"/g, "\"\"")}"`;
198
251
  }
199
252
 
200
253
  function quoteLiteral(value: string | number | boolean | null): string {