@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.
@@ -15,8 +15,14 @@ export declare class QueryBuilder<M extends Record<string, unknown> = Record<str
15
15
  where(logicalCondition: LogicalCondition): this;
16
16
  /**
17
17
  * Order the results by a specific column.
18
+ *
19
+ * Called again, this adds a tie-breaker rather than replacing the sort:
20
+ * keys apply in the order they were added.
21
+ *
18
22
  * @example
19
23
  * client.collection('users').orderBy('createdAt', 'desc').find()
24
+ * @example
25
+ * client.collection('users').orderBy('roles').orderBy('createdAt', 'desc').find()
20
26
  */
21
27
  orderBy(column: (keyof M & string) | ComputedSortField, direction?: "asc" | "desc"): this;
22
28
  /**
@@ -1,9 +1,10 @@
1
- import type { OrderByTuple } from "@rebasepro/types";
1
+ import type { OrderBySpec, OrderByTuple } from "@rebasepro/types";
2
2
  /**
3
3
  * Sort-order wire codec.
4
4
  *
5
5
  * This is the ONLY module that knows about the colon-delimited wire format
6
- * (`"field:direction"`) used in HTTP query parameters.
6
+ * (`"field:direction"`) used in HTTP query parameters, and about the JSON-array
7
+ * form that carries a multi-column sort over the same parameter.
7
8
  * Everything else speaks {@link OrderByTuple} exclusively.
8
9
  *
9
10
  * Mirrors the filter architecture in `filter-dialect.ts`.
@@ -11,22 +12,82 @@ import type { OrderByTuple } from "@rebasepro/types";
11
12
  * @module
12
13
  */
13
14
  /**
14
- * Serialize an {@link OrderByTuple} to the wire format `"field:direction"`.
15
+ * Collapse the one-key and many-key spellings of a sort into the list form.
16
+ *
17
+ * `["a", "desc"]` and `[["a", "desc"]]` mean the same thing and normalize to
18
+ * the same value; the two are told apart by whether the first element is
19
+ * itself an array, which no field name ever is.
20
+ *
21
+ * @returns The keys in order of significance, or `undefined` for no sort. An
22
+ * empty list also returns `undefined` — "sort by nothing" is no sort, and
23
+ * letting `[]` through would have every layer below re-deciding what it meant.
24
+ */
25
+ export declare function normalizeOrderBy(orderBy?: OrderBySpec): OrderByTuple[] | undefined;
26
+ /**
27
+ * The most significant sort key, for a caller that can only express one —
28
+ * a column header's arrow, a URL parameter, a driver that has not been taught
29
+ * the list form.
30
+ */
31
+ export declare function primaryOrderBy(orderBy?: OrderBySpec): OrderByTuple | undefined;
32
+ /**
33
+ * Collapse the driver-level `{orderBy, order}` pair into the list form.
34
+ *
35
+ * The driver contract spells a single-column sort as a field name plus a
36
+ * separate direction, and a multi-column one as a list of tuples that leaves
37
+ * `order` meaningless. Every driver reads both through here so neither
38
+ * spelling has to be handled twice.
39
+ *
40
+ * An absent direction means ascending — the same thing a bare `?orderBy=name`
41
+ * has always meant over HTTP. The Postgres driver used to read the same pair as
42
+ * *descending* while Mongo read it as ascending, so one field name and no
43
+ * direction described two different queries depending on which database was
44
+ * underneath. Neither had a caller: every path in the workspace passes a
45
+ * direction, which is why the disagreement went unnoticed rather than being
46
+ * load-bearing.
47
+ */
48
+ export declare function normalizeDriverOrderBy(orderBy?: string | OrderByTuple[], order?: "asc" | "desc"): OrderByTuple[] | undefined;
49
+ /** A sort whose *shape* is unusable, as opposed to one naming a field that does not exist. */
50
+ export declare class OrderBySpecError extends Error {
51
+ readonly code = "INVALID_ORDER_BY";
52
+ constructor(detail: string);
53
+ }
54
+ /**
55
+ * Validate an `orderBy` that arrived from outside this process — a WebSocket
56
+ * subscribe frame, a driver call from untyped JavaScript — and return it in the
57
+ * list form.
58
+ *
59
+ * Strict on purpose, in the same way the REST `parseOrderByParam` is: the
60
+ * failure mode for a shape nobody checks is not a crash but a *silently
61
+ * different query*. A malformed entry read as a field name resolves to no
62
+ * column, and under the lenient unknown-field mode the sort is then dropped and
63
+ * the rows come back in whatever order the database pleased — sorted, as far as
64
+ * the subscriber can tell, by whatever they asked for.
65
+ */
66
+ export declare function parseOrderBySpecStrict(raw: unknown, order?: "asc" | "desc"): OrderByTuple[] | undefined;
67
+ /**
68
+ * Serialize a sort to the wire.
69
+ *
70
+ * A single key keeps the `"field:direction"` shorthand it has always used —
71
+ * short, readable in a URL, and what every existing client and test expects.
72
+ * Several keys are emitted as the canonical JSON array the server already
73
+ * accepts, because the shorthand has no separator to spare: a comma-joined
74
+ * `"a:asc,b:desc"` parses as one field named `a` with the direction
75
+ * `"asc,b:desc"`, which the server refuses.
15
76
  *
16
77
  * **Runtime tolerance:** if the input is already a well-formed wire string
17
78
  * (from an untyped JS caller), it is returned unchanged.
18
79
  * This is undocumented tolerance, not public API — don't rely on it.
19
80
  *
20
- * @param orderBy - A canonical `[field, direction]` tuple, or at runtime
81
+ * @param orderBy - A canonical tuple or list of tuples, or at runtime
21
82
  * possibly a pre-serialized string (undocumented tolerance).
22
83
  * @returns The wire-format string, or `undefined` if the input is falsy.
23
84
  *
24
85
  * @remarks
25
86
  * Field names containing `:` are representable in the tuple form but
26
- * **not** on the wire — this is an inherent limitation of the colon-delimited
27
- * encoding and is not resolved here.
87
+ * **not** in the single-key wire encoding — this is an inherent limitation of
88
+ * the colon-delimited shorthand and is not resolved here.
28
89
  */
29
- export declare function serializeOrderBy(orderBy?: OrderByTuple | string): string | undefined;
90
+ export declare function serializeOrderBy(orderBy?: OrderBySpec | string): string | undefined;
30
91
  /**
31
92
  * Deserialize a wire-format `"field:direction"` string into an {@link OrderByTuple}.
32
93
  *
@@ -35,7 +96,23 @@ export declare function serializeOrderBy(orderBy?: OrderByTuple | string): strin
35
96
  * - Unknown direction: `"name:foo"` → `["name", "asc"]`
36
97
  * - Empty / falsy input: → `undefined`
37
98
  *
99
+ * Reads the single-key shorthand only. For a value that may carry several keys,
100
+ * use {@link deserializeOrderByList} — handed a JSON array this returns the
101
+ * whole array as one nonsensical field name.
102
+ *
38
103
  * @param raw - The wire-format string from an HTTP query parameter.
39
104
  * @returns The canonical tuple, or `undefined` if the input is empty/falsy.
40
105
  */
41
106
  export declare function deserializeOrderBy(raw?: string): OrderByTuple | undefined;
107
+ /**
108
+ * Deserialize either wire spelling — the single-key shorthand or the JSON
109
+ * array — into the list form.
110
+ *
111
+ * Lenient in the same way {@link deserializeOrderBy} is: this is the client end
112
+ * of the codec, where the value was produced by {@link serializeOrderBy} a
113
+ * moment earlier. The *server* end parses the same shapes strictly, in
114
+ * `parseOrderByParam`, because there the value came from a stranger and a
115
+ * direction it cannot read has to be refused rather than quietly turned into
116
+ * `"asc"`.
117
+ */
118
+ export declare function deserializeOrderByList(raw?: string): OrderByTuple[] | undefined;
package/dist/index.es.js CHANGED
@@ -1471,8 +1471,147 @@ function schemaOf(collection) {
1471
1471
  }
1472
1472
  function resolveColumnName(propName, collection) {
1473
1473
  const prop = collection?.properties?.[propName];
1474
- if (prop && "columnName" in prop && typeof prop.columnName === "string") return prop.columnName;
1475
- return toSnakeCase(propName);
1474
+ if (prop && "columnName" in prop && typeof prop.columnName === "string") return quoteColumnIdentifier(prop.columnName);
1475
+ return quoteColumnIdentifier(toSnakeCase(propName));
1476
+ }
1477
+ /**
1478
+ * Every PostgreSQL keyword that cannot stand as a bare column reference.
1479
+ * Appendix C's two reserved categories — plain "reserved", and "reserved (can
1480
+ * be function or type name)" — since neither may name a column unquoted.
1481
+ */
1482
+ var RESERVED_SQL_WORDS = /* @__PURE__ */ new Set([
1483
+ "all",
1484
+ "analyse",
1485
+ "analyze",
1486
+ "and",
1487
+ "any",
1488
+ "array",
1489
+ "as",
1490
+ "asc",
1491
+ "asymmetric",
1492
+ "authorization",
1493
+ "binary",
1494
+ "both",
1495
+ "case",
1496
+ "cast",
1497
+ "check",
1498
+ "collate",
1499
+ "collation",
1500
+ "column",
1501
+ "concurrently",
1502
+ "constraint",
1503
+ "create",
1504
+ "cross",
1505
+ "current_catalog",
1506
+ "current_date",
1507
+ "current_role",
1508
+ "current_schema",
1509
+ "current_time",
1510
+ "current_timestamp",
1511
+ "current_user",
1512
+ "default",
1513
+ "deferrable",
1514
+ "desc",
1515
+ "distinct",
1516
+ "do",
1517
+ "else",
1518
+ "end",
1519
+ "except",
1520
+ "false",
1521
+ "fetch",
1522
+ "for",
1523
+ "foreign",
1524
+ "freeze",
1525
+ "from",
1526
+ "full",
1527
+ "grant",
1528
+ "group",
1529
+ "having",
1530
+ "ilike",
1531
+ "in",
1532
+ "initially",
1533
+ "inner",
1534
+ "intersect",
1535
+ "into",
1536
+ "is",
1537
+ "isnull",
1538
+ "join",
1539
+ "lateral",
1540
+ "leading",
1541
+ "left",
1542
+ "like",
1543
+ "limit",
1544
+ "localtime",
1545
+ "localtimestamp",
1546
+ "natural",
1547
+ "not",
1548
+ "notnull",
1549
+ "null",
1550
+ "offset",
1551
+ "on",
1552
+ "only",
1553
+ "or",
1554
+ "order",
1555
+ "outer",
1556
+ "overlaps",
1557
+ "placing",
1558
+ "primary",
1559
+ "references",
1560
+ "returning",
1561
+ "right",
1562
+ "select",
1563
+ "session_user",
1564
+ "similar",
1565
+ "some",
1566
+ "symmetric",
1567
+ "system_user",
1568
+ "table",
1569
+ "tablesample",
1570
+ "then",
1571
+ "to",
1572
+ "trailing",
1573
+ "true",
1574
+ "union",
1575
+ "unique",
1576
+ "user",
1577
+ "using",
1578
+ "variadic",
1579
+ "verbose",
1580
+ "when",
1581
+ "where",
1582
+ "window",
1583
+ "with"
1584
+ ]);
1585
+ /** An identifier Postgres reads back unchanged without quotes. */
1586
+ var BARE_IDENTIFIER = /^[a-z_][a-z0-9_$]*$/;
1587
+ /**
1588
+ * Quote a column reference when Postgres would not read the bare name as that
1589
+ * column — and only then.
1590
+ *
1591
+ * Three ways a bare name goes wrong, in ascending order of how long it takes to
1592
+ * notice:
1593
+ *
1594
+ * - **Case.** `columnName` is used verbatim, and `rebase schema introspect`
1595
+ * populates it from a live database, so a legacy `"createdAt"` column arrives
1596
+ * spelled exactly that way. Unquoted, Postgres folds it to `createdat` and
1597
+ * `CREATE POLICY` fails with "column does not exist" — the collection keeps
1598
+ * RLS enabled with no policy, which denies every row.
1599
+ * - **Syntax.** A column named `order` or `default` is a syntax error mid-clause.
1600
+ * - **Silent rebinding.** `user`, `current_user`, `session_user`, `current_date`
1601
+ * and friends are *valid bare expressions*, so the policy compiles, applies,
1602
+ * and is reported as a success — while comparing against the connected role
1603
+ * or the wall clock instead of the column. Under RLS every request runs as the
1604
+ * same `rebase_user` role, so `USING (user = rebase.uid())` is a constant: it
1605
+ * denies everything, and its negation admits everything.
1606
+ *
1607
+ * Only the names that need it are quoted, so an ordinary snake_case policy body
1608
+ * is emitted byte-for-byte as before. That keeps generated artifacts and the
1609
+ * policies already stored in shipped databases stable — this fix reaches the
1610
+ * clauses that were broken and no others.
1611
+ */
1612
+ function quoteColumnIdentifier(name) {
1613
+ if (BARE_IDENTIFIER.test(name) && !RESERVED_SQL_WORDS.has(name)) return name;
1614
+ return `"${name.replace(/"/g, "\"\"")}"`;
1476
1615
  }
1477
1616
  function quoteLiteral(value) {
1478
1617
  if (value === null) return "NULL";
@@ -3144,6 +3283,180 @@ var defaultUsersCollection = defineCollection({
3144
3283
  }
3145
3284
  });
3146
3285
  //#endregion
3286
+ //#region src/data/sort-dialect.ts
3287
+ /**
3288
+ * Sort-order wire codec.
3289
+ *
3290
+ * This is the ONLY module that knows about the colon-delimited wire format
3291
+ * (`"field:direction"`) used in HTTP query parameters, and about the JSON-array
3292
+ * form that carries a multi-column sort over the same parameter.
3293
+ * Everything else speaks {@link OrderByTuple} exclusively.
3294
+ *
3295
+ * Mirrors the filter architecture in `filter-dialect.ts`.
3296
+ *
3297
+ * @module
3298
+ */
3299
+ /**
3300
+ * Collapse the one-key and many-key spellings of a sort into the list form.
3301
+ *
3302
+ * `["a", "desc"]` and `[["a", "desc"]]` mean the same thing and normalize to
3303
+ * the same value; the two are told apart by whether the first element is
3304
+ * itself an array, which no field name ever is.
3305
+ *
3306
+ * @returns The keys in order of significance, or `undefined` for no sort. An
3307
+ * empty list also returns `undefined` — "sort by nothing" is no sort, and
3308
+ * letting `[]` through would have every layer below re-deciding what it meant.
3309
+ */
3310
+ function normalizeOrderBy(orderBy) {
3311
+ if (!orderBy || orderBy.length === 0) return void 0;
3312
+ const list = Array.isArray(orderBy[0]) ? orderBy : [orderBy];
3313
+ return list.length > 0 ? list : void 0;
3314
+ }
3315
+ /**
3316
+ * The most significant sort key, for a caller that can only express one —
3317
+ * a column header's arrow, a URL parameter, a driver that has not been taught
3318
+ * the list form.
3319
+ */
3320
+ function primaryOrderBy(orderBy) {
3321
+ return normalizeOrderBy(orderBy)?.[0];
3322
+ }
3323
+ /**
3324
+ * Collapse the driver-level `{orderBy, order}` pair into the list form.
3325
+ *
3326
+ * The driver contract spells a single-column sort as a field name plus a
3327
+ * separate direction, and a multi-column one as a list of tuples that leaves
3328
+ * `order` meaningless. Every driver reads both through here so neither
3329
+ * spelling has to be handled twice.
3330
+ *
3331
+ * An absent direction means ascending — the same thing a bare `?orderBy=name`
3332
+ * has always meant over HTTP. The Postgres driver used to read the same pair as
3333
+ * *descending* while Mongo read it as ascending, so one field name and no
3334
+ * direction described two different queries depending on which database was
3335
+ * underneath. Neither had a caller: every path in the workspace passes a
3336
+ * direction, which is why the disagreement went unnoticed rather than being
3337
+ * load-bearing.
3338
+ */
3339
+ function normalizeDriverOrderBy(orderBy, order) {
3340
+ if (!orderBy) return void 0;
3341
+ if (typeof orderBy === "string") return [[orderBy, order === "desc" ? "desc" : "asc"]];
3342
+ return orderBy.length > 0 ? orderBy : void 0;
3343
+ }
3344
+ /** A sort whose *shape* is unusable, as opposed to one naming a field that does not exist. */
3345
+ var OrderBySpecError = class extends Error {
3346
+ code = "INVALID_ORDER_BY";
3347
+ constructor(detail) {
3348
+ super(`Invalid \`orderBy\`: ${detail}. Expected a field name, or a list of [field, direction] pairs like [["roles","asc"],["created_at","desc"]]`);
3349
+ this.name = "OrderBySpecError";
3350
+ }
3351
+ };
3352
+ /**
3353
+ * Validate an `orderBy` that arrived from outside this process — a WebSocket
3354
+ * subscribe frame, a driver call from untyped JavaScript — and return it in the
3355
+ * list form.
3356
+ *
3357
+ * Strict on purpose, in the same way the REST `parseOrderByParam` is: the
3358
+ * failure mode for a shape nobody checks is not a crash but a *silently
3359
+ * different query*. A malformed entry read as a field name resolves to no
3360
+ * column, and under the lenient unknown-field mode the sort is then dropped and
3361
+ * the rows come back in whatever order the database pleased — sorted, as far as
3362
+ * the subscriber can tell, by whatever they asked for.
3363
+ */
3364
+ function parseOrderBySpecStrict(raw, order) {
3365
+ if (raw === void 0 || raw === null || raw === "") return void 0;
3366
+ if (typeof raw === "string") return normalizeDriverOrderBy(raw, order);
3367
+ if (!Array.isArray(raw) || raw.length === 0) throw new OrderBySpecError(`${typeof raw} is not a field name or a list of sort keys`);
3368
+ if (typeof raw[0] === "string") return [toStrictTuple(raw, 0)];
3369
+ return raw.map(toStrictTuple);
3370
+ }
3371
+ function toStrictTuple(raw, index) {
3372
+ if (!Array.isArray(raw) || typeof raw[0] !== "string" || raw[0].trim() === "") throw new OrderBySpecError(`entry ${index} has no field name`);
3373
+ const direction = raw[1];
3374
+ if (direction !== void 0 && direction !== "asc" && direction !== "desc") throw new OrderBySpecError(`entry ${index} has direction '${String(direction)}'`);
3375
+ return [raw[0], direction ?? "asc"];
3376
+ }
3377
+ /**
3378
+ * Serialize a sort to the wire.
3379
+ *
3380
+ * A single key keeps the `"field:direction"` shorthand it has always used —
3381
+ * short, readable in a URL, and what every existing client and test expects.
3382
+ * Several keys are emitted as the canonical JSON array the server already
3383
+ * accepts, because the shorthand has no separator to spare: a comma-joined
3384
+ * `"a:asc,b:desc"` parses as one field named `a` with the direction
3385
+ * `"asc,b:desc"`, which the server refuses.
3386
+ *
3387
+ * **Runtime tolerance:** if the input is already a well-formed wire string
3388
+ * (from an untyped JS caller), it is returned unchanged.
3389
+ * This is undocumented tolerance, not public API — don't rely on it.
3390
+ *
3391
+ * @param orderBy - A canonical tuple or list of tuples, or at runtime
3392
+ * possibly a pre-serialized string (undocumented tolerance).
3393
+ * @returns The wire-format string, or `undefined` if the input is falsy.
3394
+ *
3395
+ * @remarks
3396
+ * Field names containing `:` are representable in the tuple form but
3397
+ * **not** in the single-key wire encoding — this is an inherent limitation of
3398
+ * the colon-delimited shorthand and is not resolved here.
3399
+ */
3400
+ function serializeOrderBy(orderBy) {
3401
+ if (!orderBy) return void 0;
3402
+ if (typeof orderBy === "string") return orderBy;
3403
+ const list = normalizeOrderBy(orderBy);
3404
+ if (!list) return void 0;
3405
+ if (list.length === 1) return `${list[0][0]}:${list[0][1]}`;
3406
+ return JSON.stringify(list.map(([field, direction]) => ({
3407
+ field,
3408
+ direction
3409
+ })));
3410
+ }
3411
+ /**
3412
+ * Deserialize a wire-format `"field:direction"` string into an {@link OrderByTuple}.
3413
+ *
3414
+ * Lenient parsing (matches existing server behaviour):
3415
+ * - Bare field name (no colon): `"name"` → `["name", "asc"]`
3416
+ * - Unknown direction: `"name:foo"` → `["name", "asc"]`
3417
+ * - Empty / falsy input: → `undefined`
3418
+ *
3419
+ * Reads the single-key shorthand only. For a value that may carry several keys,
3420
+ * use {@link deserializeOrderByList} — handed a JSON array this returns the
3421
+ * whole array as one nonsensical field name.
3422
+ *
3423
+ * @param raw - The wire-format string from an HTTP query parameter.
3424
+ * @returns The canonical tuple, or `undefined` if the input is empty/falsy.
3425
+ */
3426
+ function deserializeOrderBy(raw) {
3427
+ if (!raw) return void 0;
3428
+ const idx = raw.indexOf(":");
3429
+ if (idx === -1) return [raw, "asc"];
3430
+ return [raw.slice(0, idx), raw.slice(idx + 1) === "desc" ? "desc" : "asc"];
3431
+ }
3432
+ /**
3433
+ * Deserialize either wire spelling — the single-key shorthand or the JSON
3434
+ * array — into the list form.
3435
+ *
3436
+ * Lenient in the same way {@link deserializeOrderBy} is: this is the client end
3437
+ * of the codec, where the value was produced by {@link serializeOrderBy} a
3438
+ * moment earlier. The *server* end parses the same shapes strictly, in
3439
+ * `parseOrderByParam`, because there the value came from a stranger and a
3440
+ * direction it cannot read has to be refused rather than quietly turned into
3441
+ * `"asc"`.
3442
+ */
3443
+ function deserializeOrderByList(raw) {
3444
+ if (!raw) return void 0;
3445
+ const trimmed = raw.trim();
3446
+ if (trimmed.startsWith("[")) try {
3447
+ const parsed = JSON.parse(trimmed);
3448
+ if (Array.isArray(parsed)) {
3449
+ const list = parsed.map((entry) => {
3450
+ if (typeof entry === "string") return deserializeOrderBy(entry);
3451
+ if (entry && typeof entry === "object" && typeof entry.field === "string") return [entry.field, entry.direction === "desc" ? "desc" : "asc"];
3452
+ }).filter((entry) => entry !== void 0);
3453
+ return list.length > 0 ? list : void 0;
3454
+ }
3455
+ } catch {}
3456
+ const single = deserializeOrderBy(trimmed);
3457
+ return single ? [single] : void 0;
3458
+ }
3459
+ //#endregion
3147
3460
  //#region src/data/query_builder.ts
3148
3461
  function or(...conditions) {
3149
3462
  return {
@@ -3191,11 +3504,18 @@ var QueryBuilder = class {
3191
3504
  }
3192
3505
  /**
3193
3506
  * Order the results by a specific column.
3507
+ *
3508
+ * Called again, this adds a tie-breaker rather than replacing the sort:
3509
+ * keys apply in the order they were added.
3510
+ *
3194
3511
  * @example
3195
3512
  * client.collection('users').orderBy('createdAt', 'desc').find()
3513
+ * @example
3514
+ * client.collection('users').orderBy('roles').orderBy('createdAt', 'desc').find()
3196
3515
  */
3197
3516
  orderBy(column, direction = "asc") {
3198
- this.params.orderBy = [column, direction];
3517
+ const existing = normalizeOrderBy(this.params.orderBy) ?? [];
3518
+ this.params.orderBy = [...existing, [column, direction]];
3199
3519
  return this;
3200
3520
  }
3201
3521
  /**
@@ -3384,9 +3704,10 @@ async function* paginateFind(find, params, label = "collection") {
3384
3704
  const requestedDirection = typeof cursor === "object" && cursor !== null ? cursor.direction : void 0;
3385
3705
  let direction = "asc";
3386
3706
  if (cursorField) {
3387
- const orderBy = findParams.orderBy;
3388
- if (orderBy && orderBy[0] !== cursorField) throw new RebasePaginationError("cursor-order-mismatch", `Cannot seek on "${cursorField}" while ordering "${label}" by "${orderBy[0]}": keyset pagination only advances along the column the query is sorted by. Order by "${cursorField}", or drop the cursor and page by offset.`);
3389
- direction = requestedDirection ?? orderBy?.[1] ?? "asc";
3707
+ const orderBy = normalizeOrderBy(findParams.orderBy);
3708
+ if (orderBy && orderBy.length > 1) throw new RebasePaginationError("cursor-order-mismatch", `Cannot seek on "${cursorField}" while ordering "${label}" by ${orderBy.map(([field]) => `"${field}"`).join(", ")}: keyset pagination advances along a single column. Order by "${cursorField}" alone, or drop the cursor and page by offset.`);
3709
+ if (orderBy && orderBy[0][0] !== cursorField) throw new RebasePaginationError("cursor-order-mismatch", `Cannot seek on "${cursorField}" while ordering "${label}" by "${orderBy[0][0]}": keyset pagination only advances along the column the query is sorted by. Order by "${cursorField}", or drop the cursor and page by offset.`);
3710
+ direction = requestedDirection ?? orderBy?.[0][1] ?? "asc";
3390
3711
  findParams.orderBy = [cursorField, direction];
3391
3712
  }
3392
3713
  const seekOp = direction === "desc" ? "<" : ">";
@@ -4073,8 +4394,7 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
4073
4394
  logical: params?.logical,
4074
4395
  limit,
4075
4396
  offset: driverOffset,
4076
- orderBy: params?.orderBy?.[0],
4077
- order: params?.orderBy?.[1],
4397
+ orderBy: normalizeOrderBy(params?.orderBy),
4078
4398
  searchString: params?.searchString
4079
4399
  }, params?.include) : await driver.fetchCollection({
4080
4400
  path: slug,
@@ -4082,8 +4402,7 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
4082
4402
  offset: driverOffset,
4083
4403
  filter,
4084
4404
  logical: params?.logical,
4085
- orderBy: params?.orderBy?.[0],
4086
- order: params?.orderBy?.[1],
4405
+ orderBy: normalizeOrderBy(params?.orderBy),
4087
4406
  searchString: params?.searchString
4088
4407
  });
4089
4408
  let total = rows.length + offset;
@@ -4178,8 +4497,7 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
4178
4497
  offset: driverOffset,
4179
4498
  filter: params?.where,
4180
4499
  logical: params?.logical,
4181
- orderBy: params?.orderBy?.[0],
4182
- order: params?.orderBy?.[1],
4500
+ orderBy: normalizeOrderBy(params?.orderBy),
4183
4501
  searchString: params?.searchString,
4184
4502
  searchExplain: params?.searchExplain,
4185
4503
  onUpdate: (entities) => {
@@ -4299,8 +4617,10 @@ var SdkQueryBuilder = class {
4299
4617
  }
4300
4618
  return this;
4301
4619
  }
4620
+ /** Called again, this adds a tie-breaker rather than replacing the sort. */
4302
4621
  orderBy(column, direction = "asc") {
4303
- this.params.orderBy = [column, direction];
4622
+ const existing = normalizeOrderBy(this.params.orderBy) ?? [];
4623
+ this.params.orderBy = [...existing, [column, direction]];
4304
4624
  return this;
4305
4625
  }
4306
4626
  limit(count) {
@@ -4607,57 +4927,6 @@ function toFilterTuples(filterParam) {
4607
4927
  return [filterParam];
4608
4928
  }
4609
4929
  //#endregion
4610
- //#region src/data/sort-dialect.ts
4611
- /**
4612
- * Sort-order wire codec.
4613
- *
4614
- * This is the ONLY module that knows about the colon-delimited wire format
4615
- * (`"field:direction"`) used in HTTP query parameters.
4616
- * Everything else speaks {@link OrderByTuple} exclusively.
4617
- *
4618
- * Mirrors the filter architecture in `filter-dialect.ts`.
4619
- *
4620
- * @module
4621
- */
4622
- /**
4623
- * Serialize an {@link OrderByTuple} to the wire format `"field:direction"`.
4624
- *
4625
- * **Runtime tolerance:** if the input is already a well-formed wire string
4626
- * (from an untyped JS caller), it is returned unchanged.
4627
- * This is undocumented tolerance, not public API — don't rely on it.
4628
- *
4629
- * @param orderBy - A canonical `[field, direction]` tuple, or at runtime
4630
- * possibly a pre-serialized string (undocumented tolerance).
4631
- * @returns The wire-format string, or `undefined` if the input is falsy.
4632
- *
4633
- * @remarks
4634
- * Field names containing `:` are representable in the tuple form but
4635
- * **not** on the wire — this is an inherent limitation of the colon-delimited
4636
- * encoding and is not resolved here.
4637
- */
4638
- function serializeOrderBy(orderBy) {
4639
- if (!orderBy) return void 0;
4640
- if (typeof orderBy === "string") return orderBy;
4641
- return `${orderBy[0]}:${orderBy[1]}`;
4642
- }
4643
- /**
4644
- * Deserialize a wire-format `"field:direction"` string into an {@link OrderByTuple}.
4645
- *
4646
- * Lenient parsing (matches existing server behaviour):
4647
- * - Bare field name (no colon): `"name"` → `["name", "asc"]`
4648
- * - Unknown direction: `"name:foo"` → `["name", "asc"]`
4649
- * - Empty / falsy input: → `undefined`
4650
- *
4651
- * @param raw - The wire-format string from an HTTP query parameter.
4652
- * @returns The canonical tuple, or `undefined` if the input is empty/falsy.
4653
- */
4654
- function deserializeOrderBy(raw) {
4655
- if (!raw) return void 0;
4656
- const idx = raw.indexOf(":");
4657
- if (idx === -1) return [raw, "asc"];
4658
- return [raw.slice(0, idx), raw.slice(idx + 1) === "desc" ? "desc" : "asc"];
4659
- }
4660
- //#endregion
4661
4930
  //#region src/table-classification.ts
4662
4931
  /** Schemas that are always considered Rebase-internal. */
4663
4932
  var REBASE_INTERNAL_SCHEMAS = ["rebase", "auth"];
@@ -4734,6 +5003,6 @@ async function detectJunctionTables(executeSql) {
4734
5003
  return junctionTables;
4735
5004
  }
4736
5005
  //#endregion
4737
- export { COLLECTION_PATH_SEPARATOR, COMPOSITE_ID_SEPARATOR, CollectionRegistry, DEFAULT_FIND_ALL_MAX_ROWS, DEFAULT_MAX_PAGES, DEFAULT_ONE_OF_TYPE, DEFAULT_ONE_OF_VALUE, DEFAULT_PAGE_SIZE, DEFAULT_STRING_COLUMN_LENGTH, JUNCTION_TABLES_SQL, MAX_LOGICAL_NESTING_DEPTH, QueryBuilder, REBASE_INTERNAL_PREFIXES, REBASE_INTERNAL_SCHEMAS, REBASE_INTERNAL_TABLES, REBASE_USER_ROLE, RebasePaginationError, UnknownFilterOperatorError, and, buildCollectionFromTableMetadata, buildCompositeId, buildConditionContext, buildPropertyCallbacks, buildRebaseData, buildRoutedRebaseData, buildSdkData, canCreateEntity, canDeleteEntity, canEditEntity, canReadCollection, checkOperation, classifyTable, collectAllPages, cond, createDataSourceRegistry, createPaginationHelpers, createRelationRef, createRelationRefWithData, defaultUsersCollection, defineCollection, deserializeFilter, deserializeLogicalCondition, deserializeOrderBy, detectJunctionTables, embedParentExpression, enumToObjectEntries, evaluateCondition, evaluatePolicy, fieldKeyForColumn, findAnonymousGrants, findRelation, fullPathToCollectionSegments, getArrayResolvedProperties, getChildViewDeclaringProperties, getChildViewRelationPropertyKeys, getColumnName, getDeclaredPrimaryKeys, getDefaultValueFor, getDefaultValueFortype, getDefaultValuesFor, getEffectiveSecurityRules, getEntityChildViews, getEnumVarName, getGeneratedPolicyNames, getInjectedSecurityRules, getJunctionCollectionConfig, getJunctionSecurityRules, getLabelOrConfigFrom, getPrimaryKeys, getReferenceFrom, getRelationFrom, getRelationTargetPath, getSubcollections, getTableName, getTableVarName, isAddressableId, isJunctionBackedRelation, isPropertyBuilder, isRebaseInternalTable, isRelationalCollection, normalizeEmail, normalizeToEntityRelation, or, paginateFind, parseIdValues, policyToPostgres, registerConditionOperations, relationalCollections, resolveArrayProperties, resolveCollectionRelations, resolveDataSource, resolveEnumValues, resolveFindWindow, resolveJunctionSpecs, resolvePrimaryKeys, resolveProperties, resolveProperty, resolvePropertyEnum, resolveRelation, resolveRelationProperty, resolveStorageFilenameString, resolveStoragePathString, resolveStorageSource, resolveStringColumnLength, revokeInternalTableAccess, revokeInternalTableSql, sanitizeData, securityRuleToConditions, segmentsToStrippedPath, serializeFilter, serializeLogicalCondition, serializeOrderBy, sortCollectionsBySlug, sortProperties, sqlToPolicy, stripCollectionPath, toFilterTuples, traverseValueProperty, traverseValuesProperties, updateDateAutoValues, wrapAsEntityData, wrapAsSdkData };
5006
+ export { COLLECTION_PATH_SEPARATOR, COMPOSITE_ID_SEPARATOR, CollectionRegistry, DEFAULT_FIND_ALL_MAX_ROWS, DEFAULT_MAX_PAGES, DEFAULT_ONE_OF_TYPE, DEFAULT_ONE_OF_VALUE, DEFAULT_PAGE_SIZE, DEFAULT_STRING_COLUMN_LENGTH, JUNCTION_TABLES_SQL, MAX_LOGICAL_NESTING_DEPTH, OrderBySpecError, QueryBuilder, REBASE_INTERNAL_PREFIXES, REBASE_INTERNAL_SCHEMAS, REBASE_INTERNAL_TABLES, REBASE_USER_ROLE, RebasePaginationError, UnknownFilterOperatorError, and, buildCollectionFromTableMetadata, buildCompositeId, buildConditionContext, buildPropertyCallbacks, buildRebaseData, buildRoutedRebaseData, buildSdkData, canCreateEntity, canDeleteEntity, canEditEntity, canReadCollection, checkOperation, classifyTable, collectAllPages, cond, createDataSourceRegistry, createPaginationHelpers, createRelationRef, createRelationRefWithData, defaultUsersCollection, defineCollection, deserializeFilter, deserializeLogicalCondition, deserializeOrderBy, deserializeOrderByList, detectJunctionTables, embedParentExpression, enumToObjectEntries, evaluateCondition, evaluatePolicy, fieldKeyForColumn, findAnonymousGrants, findRelation, fullPathToCollectionSegments, getArrayResolvedProperties, getChildViewDeclaringProperties, getChildViewRelationPropertyKeys, getColumnName, getDeclaredPrimaryKeys, getDefaultValueFor, getDefaultValueFortype, getDefaultValuesFor, getEffectiveSecurityRules, getEntityChildViews, getEnumVarName, getGeneratedPolicyNames, getInjectedSecurityRules, getJunctionCollectionConfig, getJunctionSecurityRules, getLabelOrConfigFrom, getPrimaryKeys, getReferenceFrom, getRelationFrom, getRelationTargetPath, getSubcollections, getTableName, getTableVarName, isAddressableId, isJunctionBackedRelation, isPropertyBuilder, isRebaseInternalTable, isRelationalCollection, normalizeDriverOrderBy, normalizeEmail, normalizeOrderBy, normalizeToEntityRelation, or, paginateFind, parseIdValues, parseOrderBySpecStrict, policyToPostgres, primaryOrderBy, registerConditionOperations, relationalCollections, resolveArrayProperties, resolveCollectionRelations, resolveDataSource, resolveEnumValues, resolveFindWindow, resolveJunctionSpecs, resolvePrimaryKeys, resolveProperties, resolveProperty, resolvePropertyEnum, resolveRelation, resolveRelationProperty, resolveStorageFilenameString, resolveStoragePathString, resolveStorageSource, resolveStringColumnLength, revokeInternalTableAccess, revokeInternalTableSql, sanitizeData, securityRuleToConditions, segmentsToStrippedPath, serializeFilter, serializeLogicalCondition, serializeOrderBy, sortCollectionsBySlug, sortProperties, sqlToPolicy, stripCollectionPath, toFilterTuples, traverseValueProperty, traverseValuesProperties, updateDateAutoValues, wrapAsEntityData, wrapAsSdkData };
4738
5007
 
4739
5008
  //# sourceMappingURL=index.es.js.map