@rebasepro/server 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.
Files changed (80) hide show
  1. package/dist/{GCSStorageController-ZXPoNqW3.js → GCSStorageController-CLIJXwGS.js} +2 -2
  2. package/dist/{GCSStorageController-ZXPoNqW3.js.map → GCSStorageController-CLIJXwGS.js.map} +1 -1
  3. package/dist/{S3StorageController-5pAXyv31.js → S3StorageController-Dcuf8lMA.js} +2 -2
  4. package/dist/{S3StorageController-5pAXyv31.js.map → S3StorageController-Dcuf8lMA.js.map} +1 -1
  5. package/dist/api/errors.d.ts +6 -1
  6. package/dist/api/rest/api-generator.d.ts +80 -0
  7. package/dist/api/rest/batch.d.ts +71 -0
  8. package/dist/api/rest/conflict-target.d.ts +20 -0
  9. package/dist/api/rest/etag.d.ts +55 -0
  10. package/dist/api/rest/field-access-query.d.ts +66 -0
  11. package/dist/api/rest/field-ops.d.ts +86 -0
  12. package/dist/api/rest/query-parser.d.ts +16 -2
  13. package/dist/api/rest/soft-delete-params.d.ts +37 -0
  14. package/dist/api/rest/write-validation.d.ts +51 -8
  15. package/dist/api/types.d.ts +28 -3
  16. package/dist/{ast-schema-editor-CNgFJ3NF.js → ast-schema-editor-C6mDz0XN.js} +2 -2
  17. package/dist/{ast-schema-editor-CNgFJ3NF.js.map → ast-schema-editor-C6mDz0XN.js.map} +1 -1
  18. package/dist/auth/jwt.d.ts +17 -0
  19. package/dist/auth/rls-scope.d.ts +1 -0
  20. package/dist/{auth-BRiOuyq8.js → auth-DJsLXsCR.js} +61 -60
  21. package/dist/auth-DJsLXsCR.js.map +1 -0
  22. package/dist/{backup-C8P6Cl3G.js → backup-DGu0v9Ku.js} +2 -2
  23. package/dist/{backup-C8P6Cl3G.js.map → backup-DGu0v9Ku.js.map} +1 -1
  24. package/dist/boot/bundle.d.ts +12 -3
  25. package/dist/boot/driver.d.ts +11 -4
  26. package/dist/boot/env.d.ts +2 -2
  27. package/dist/{contract-routes-CusnEB5h.js → contract-routes-eLxV0le1.js} +2 -2
  28. package/dist/{contract-routes-CusnEB5h.js.map → contract-routes-eLxV0le1.js.map} +1 -1
  29. package/dist/{cron-loader-DnmIePn_.js → cron-loader-CQjvjpEw.js} +2 -2
  30. package/dist/{cron-loader-DnmIePn_.js.map → cron-loader-CQjvjpEw.js.map} +1 -1
  31. package/dist/{cron-routes-D3x2ydMa.js → cron-routes-Bfwni8Zg.js} +3 -3
  32. package/dist/{cron-routes-D3x2ydMa.js.map → cron-routes-Bfwni8Zg.js.map} +1 -1
  33. package/dist/{cron-store-CCQXwgVL.js → cron-store-Bsiw4Q6u.js} +3 -3
  34. package/dist/{cron-store-CCQXwgVL.js.map → cron-store-Bsiw4Q6u.js.map} +1 -1
  35. package/dist/{errors-HjfaPlvY.js → errors-DMImyqyR.js} +8 -3
  36. package/dist/errors-DMImyqyR.js.map +1 -0
  37. package/dist/{function-routes-gQ0EShVG.js → function-routes-C4nB2h0z.js} +2 -2
  38. package/dist/{function-routes-gQ0EShVG.js.map → function-routes-C4nB2h0z.js.map} +1 -1
  39. package/dist/functions/index.js +7 -2
  40. package/dist/functions/index.js.map +1 -1
  41. package/dist/{history-recorder-B1FwXx9J.js → history-recorder-r5_IzSHK.js} +2 -2
  42. package/dist/{history-recorder-B1FwXx9J.js.map → history-recorder-r5_IzSHK.js.map} +1 -1
  43. package/dist/{history-store-LHXaQywp.js → history-store-D4RVK-uZ.js} +2 -2
  44. package/dist/{history-store-LHXaQywp.js.map → history-store-D4RVK-uZ.js.map} +1 -1
  45. package/dist/index.d.ts +5 -0
  46. package/dist/index.es.js +2429 -7303
  47. package/dist/index.es.js.map +1 -1
  48. package/dist/{jobs-DkkD9mPV.js → jobs-CW5lm_Ix.js} +3 -3
  49. package/dist/{jobs-DkkD9mPV.js.map → jobs-CW5lm_Ix.js.map} +1 -1
  50. package/dist/{jwt-DkhXwMzR.js → jwt-DATvkKB_.js} +39 -3
  51. package/dist/{jwt-DkhXwMzR.js.map → jwt-DATvkKB_.js.map} +1 -1
  52. package/dist/{keys-g8lbVC_o.js → keys-Qfc4XieN.js} +2 -2
  53. package/dist/{keys-g8lbVC_o.js.map → keys-Qfc4XieN.js.map} +1 -1
  54. package/dist/{logs-routes-CbsTpozn.js → logs-routes-DnJINsMu.js} +2 -2
  55. package/dist/{logs-routes-CbsTpozn.js.map → logs-routes-DnJINsMu.js.map} +1 -1
  56. package/dist/{openapi-generator-BIBbO1Tq.js → openapi-generator-DGyLbISS.js} +374 -48
  57. package/dist/openapi-generator-DGyLbISS.js.map +1 -0
  58. package/dist/{query-parser-C68Q9EX4.js → query-parser-BQiPZrM-.js} +257 -17
  59. package/dist/query-parser-BQiPZrM-.js.map +1 -0
  60. package/dist/{request-timeout-DESvlfrS.js → request-timeout-BR-OBwES.js} +2 -2
  61. package/dist/{request-timeout-DESvlfrS.js.map → request-timeout-BR-OBwES.js.map} +1 -1
  62. package/dist/{schema-editor-routes-CcZKh50q.js → schema-editor-routes-C3TLZqAC.js} +13 -13
  63. package/dist/{schema-editor-routes-CcZKh50q.js.map → schema-editor-routes-C3TLZqAC.js.map} +1 -1
  64. package/dist/{src-DHK4fHkw.js → src-Br6ARbs6.js} +26 -2
  65. package/dist/src-Br6ARbs6.js.map +1 -0
  66. package/dist/{src-Dq-I3Ybx.js → src-DqZ9YiGA.js} +1387 -94
  67. package/dist/src-DqZ9YiGA.js.map +1 -0
  68. package/dist/storage/index.d.ts +2 -0
  69. package/dist/storage/property-limits.d.ts +77 -0
  70. package/dist/storage/routes.d.ts +13 -0
  71. package/dist/storage/tus-handler.d.ts +22 -1
  72. package/package.json +7 -6
  73. package/dist/auth-BRiOuyq8.js.map +0 -1
  74. package/dist/errors-HjfaPlvY.js.map +0 -1
  75. package/dist/openapi-generator-BIBbO1Tq.js.map +0 -1
  76. package/dist/query-parser-C68Q9EX4.js.map +0 -1
  77. package/dist/schemas-C3234HWE.js +0 -6827
  78. package/dist/schemas-C3234HWE.js.map +0 -1
  79. package/dist/src-DHK4fHkw.js.map +0 -1
  80. package/dist/src-Dq-I3Ybx.js.map +0 -1
@@ -3,7 +3,7 @@ import __rebaseProcess from "process";
3
3
  globalThis.process ??= __rebaseProcess;
4
4
  __rebaseCreateRequire(import.meta.url);
5
5
  import { r as __require, t as __commonJSMin } from "./rolldown-runtime-dW7B1o5h.js";
6
- import { A as REST_TO_CANONICAL, C as isPostgresCollectionConfig, D as ALL_WHERE_FILTER_OPS, E as getDataSourceCapabilities, N as sortKeyToString, O as CANONICAL_TO_REST, P as toCanonicalOp, S as getDeclaredSubcollections, j as isRelationAggregateSort, k as NULL_OPS, p as resolveResourceRefs, w as isRelationalCollectionConfig } from "./src-DHK4fHkw.js";
6
+ import { A as LIST_OPS, C as getDeclaredSubcollections, D as getDataSourceCapabilities, F as sortKeyToString, I as toCanonicalOp, M as REST_TO_CANONICAL, N as isRelationAggregateSort, O as ALL_WHERE_FILTER_OPS, T as isRelationalCollectionConfig, j as NULL_OPS, k as CANONICAL_TO_REST, p as resolveResourceRefs, w as isPostgresCollectionConfig, x as rewriteLegacyRlsFunctions } from "./src-Br6ARbs6.js";
7
7
  //#region ../types/src/errors.ts
8
8
  /**
9
9
  * The single error type thrown across the entire Rebase client surface —
@@ -363,8 +363,124 @@ var policy = {
363
363
  value
364
364
  }),
365
365
  authUid: () => ({ kind: "authUid" }),
366
- authRoles: () => ({ kind: "authRoles" })
366
+ authRoles: () => ({ kind: "authRoles" }),
367
+ authClaim: (name) => ({
368
+ kind: "authClaim",
369
+ name
370
+ })
367
371
  };
372
+ //#endregion
373
+ //#region ../types/src/types/tenancy.ts
374
+ /** Narrow a {@link TenantSource} to its claim form. @group Models */
375
+ function isTenantClaimSource(source) {
376
+ return typeof source.claim === "string";
377
+ }
378
+ /**
379
+ * The roles tenancy does not apply to, when the collection names none.
380
+ *
381
+ * `admin`, mirroring the security baseline every collection already carries
382
+ * (`<table>_default_admin_read` / `_write`): the Studio, `dataAsAdmin` and a
383
+ * support operator all run with it, and a tenancy rule that locked them out
384
+ * would make the admin panel show an empty table on a collection full of rows.
385
+ *
386
+ * @group Models
387
+ */
388
+ var DEFAULT_TENANT_BYPASS_ROLES = ["admin"];
389
+ //#endregion
390
+ //#region ../types/src/controllers/data.ts
391
+ /**
392
+ * SDK collection client — returns flat rows, no Entity wrapper.
393
+ *
394
+ * This is the public API surface for app developers using
395
+ * `createRebaseClient()`. admin internals use `CollectionAccessor` instead.
396
+ *
397
+ * Type parameters:
398
+ * - `M` — the **Row** shape returned by reads (`find`, `findById`, `listen`).
399
+ * - `I` — the **Insert** shape accepted by {@link create}. Defaults to
400
+ * `Partial<M>`; the generated SDK supplies a dedicated `Insert` type where
401
+ * required columns are required and auto-generated / read-only columns are
402
+ * omitted, so `create({})` on a table with required fields is a compile error.
403
+ * - `U` — the **Update** shape accepted by {@link update}. Defaults to
404
+ * `Partial<M>`; the generated SDK supplies a dedicated `Update` type.
405
+ *
406
+ * @example
407
+ * const { data: posts } = await rebase.data.posts.find();
408
+ * console.log(posts[0].title); // flat access
409
+ * console.log(posts[0].id); // id at top level
410
+ *
411
+ * const post = await rebase.data.posts.findById(1);
412
+ * console.log(post?.title); // no .values needed
413
+ *
414
+ * @group Data
415
+ */
416
+ /**
417
+ * A change expressed as an operation on the column's current value, rather than
418
+ * as the value to store.
419
+ *
420
+ * `{ views: 5 }` says what the number becomes; `{ views: { $inc: 1 } }` says
421
+ * what happens to it. The difference is the read the caller no longer has to
422
+ * make — and the race that read opens. Two requests that each read `4`, add one
423
+ * and write `5` lose an increment between them; `SET views = views + 1` cannot,
424
+ * because the arithmetic happens inside the statement holding the row lock.
425
+ *
426
+ * Exactly one operator per field. `{ views: { $inc: 1, $push: "x" } }` is
427
+ * refused rather than applied in an order the caller cannot see.
428
+ *
429
+ * @group Data
430
+ */
431
+ /**
432
+ * The operator names, as a value.
433
+ *
434
+ * A runtime list beside the type because three layers have to *recognise* an
435
+ * operation, not just accept one: the REST validator, the driver that compiles
436
+ * it, and the offline queue that must refuse to apply one locally. Three copies
437
+ * of four strings is three chances for one of them to miss an operator added to
438
+ * the other two, and the failure is silent in the worst direction — an
439
+ * unrecognised marker is written to the column as a JSON document.
440
+ *
441
+ * @group Data
442
+ */
443
+ var FIELD_OPERATORS = [
444
+ "$inc",
445
+ "$push",
446
+ "$pull",
447
+ "$merge"
448
+ ];
449
+ /**
450
+ * The key of a {@link BatchRef}. Declared here, beside the field operators,
451
+ * because the two share one namespace: a `$`-prefixed key in a write payload is
452
+ * a marker, and every reader of that namespace has to know all of it.
453
+ *
454
+ * @group Data
455
+ */
456
+ var BATCH_REF_KEY = "$ref";
457
+ /**
458
+ * Whether a value is *trying* to be a field operation — including a misspelled
459
+ * one, which is the case worth catching.
460
+ *
461
+ * Any `$`-prefixed key counts, because `{ $increment: 1 }` written to a number
462
+ * column as a JSON document is the failure this exists to prevent. No collection
463
+ * can declare a column whose value legitimately has a key beginning with `$`: a
464
+ * `map` property's sub-keys are declared, and `$` is not valid in the
465
+ * identifiers the DDL generators emit.
466
+ *
467
+ * The one exception is `{ $ref: … }`, the batch's backward reference. It stands
468
+ * where a *value* goes and is resolved to one before the row is written, so it
469
+ * is not an operation on a column — reading it as a misspelled operator refused
470
+ * every `$ref` in a batch with "unknown field operator '$ref'".
471
+ *
472
+ * @group Data
473
+ */
474
+ function isFieldOperation(value) {
475
+ if (typeof value !== "object" || value === null || Array.isArray(value) || value instanceof Date) return false;
476
+ const keys = Object.keys(value);
477
+ if (keys.length === 1 && keys[0] === "$ref") return false;
478
+ return keys.some((key) => key.startsWith("$"));
479
+ }
480
+ /** True when any value in a write payload is (or is attempting to be) one. @group Data */
481
+ function hasFieldOperation(values) {
482
+ return !!values && Object.values(values).some(isFieldOperation);
483
+ }
368
484
  /** Largest `limit` a client may ask for on any surface. Above it, the read is refused. */
369
485
  var MAX_LIST_LIMIT = 1e3;
370
486
  /**
@@ -1469,6 +1585,16 @@ function removeFunctions(o) {
1469
1585
  return o;
1470
1586
  }
1471
1587
  /**
1588
+ * Get a RegExp out of a serialized string
1589
+ * @param input
1590
+ */
1591
+ function hydrateRegExp(input) {
1592
+ if (!input) return void 0;
1593
+ const fragments = input.match(/\/(.*?)\/([a-z]*)?$/i);
1594
+ if (fragments) return new RegExp(fragments[1], fragments[2] || "");
1595
+ else return new RegExp(input, "");
1596
+ }
1597
+ /**
1472
1598
  * Returns the singular of an English word.
1473
1599
  *
1474
1600
  * @param {string} word
@@ -1823,7 +1949,8 @@ function resolveRelation(relation, sourceCollection, propertyKey) {
1823
1949
  through: {
1824
1950
  table: relation.through?.table ?? [sourceTable, targetTable].sort().join("_"),
1825
1951
  sourceColumn: relation.through?.sourceColumn ?? generateForeignKeyName(sourceName),
1826
- targetColumn: relation.through?.targetColumn ?? generateForeignKeyName(relationName)
1952
+ targetColumn: relation.through?.targetColumn ?? generateForeignKeyName(relationName),
1953
+ properties: relation.through?.properties ?? {}
1827
1954
  }
1828
1955
  };
1829
1956
  }
@@ -2150,6 +2277,145 @@ function getSubcollections(collection) {
2150
2277
  //#endregion
2151
2278
  //#region ../common/src/util/policy/sqlToPolicy.ts
2152
2279
  /**
2280
+ * A tiny, regex-based SQL "parser" for security rules.
2281
+ *
2282
+ * This is NOT a full SQL parser. It is designed to handle the subset of SQL
2283
+ * commonly used in `USING` and `WITH CHECK` clauses, enough to drive the
2284
+ * optimistic client-side UI decision.
2285
+ *
2286
+ * It handles:
2287
+ * - `field = 'literal'`
2288
+ * - `field != 'literal'`
2289
+ * - `field = current_setting('app.uid')` (or the legacy `app.user_id`)
2290
+ * - `A AND B`, `A OR B` — only where the keyword is at the top level
2291
+ * - `true`
2292
+ * - `IN (...)` (as optimistic true)
2293
+ *
2294
+ * For anything it doesn't understand, it returns a `raw` expression, which
2295
+ * the evaluator treats as "unknown" (and usually optimistic true).
2296
+ *
2297
+ * **This output also round-trips back into DDL** via `policyToPostgres` (the
2298
+ * schema/policy generators), so decomposing a clause the parser only partly
2299
+ * understands is not a cosmetic mistake — it emits invalid SQL. When in doubt,
2300
+ * prefer `raw`: it is reproduced verbatim.
2301
+ */
2302
+ /** True when `keyword` starts at `i` as a standalone word. */
2303
+ function isKeywordAt(upper, i, keyword) {
2304
+ if (!upper.startsWith(keyword, i)) return false;
2305
+ const before = i === 0 ? " " : upper[i - 1];
2306
+ const after = upper[i + keyword.length] ?? " ";
2307
+ return /[\s()]/.test(before) && /[\s()]/.test(after);
2308
+ }
2309
+ /**
2310
+ * Split `sql` on a boolean keyword, but only where it sits at paren depth 0 and
2311
+ * outside a string literal. Returns null when it never does, so the caller
2312
+ * leaves the clause alone.
2313
+ *
2314
+ * This used to be `sql.split(/ AND /i)`, which tore subqueries in half: the
2315
+ * `AND` inside
2316
+ * `EXISTS (SELECT 1 FROM organization_members m WHERE m.org = t.org AND m.user_id = rebase.uid())`
2317
+ * split the expression, and re-emitting the halves produced
2318
+ * `(EXISTS (...) AND m.user_id = rebase.uid())`
2319
+ * where `m` is no longer in scope — SQL that Postgres rejects outright with
2320
+ * "missing FROM-clause entry for table". Returning null instead keeps such a
2321
+ * clause as a `raw` expression, which round-trips verbatim.
2322
+ */
2323
+ function splitTopLevel(sql, keyword) {
2324
+ const upper = sql.toUpperCase();
2325
+ const parts = [];
2326
+ let depth = 0;
2327
+ let inString = false;
2328
+ let start = 0;
2329
+ for (let i = 0; i < sql.length; i++) {
2330
+ const ch = sql[i];
2331
+ if (inString) {
2332
+ if (ch === "'") if (sql[i + 1] === "'") i++;
2333
+ else inString = false;
2334
+ continue;
2335
+ }
2336
+ if (ch === "'") {
2337
+ inString = true;
2338
+ continue;
2339
+ }
2340
+ if (ch === "(") {
2341
+ depth++;
2342
+ continue;
2343
+ }
2344
+ if (ch === ")") {
2345
+ depth--;
2346
+ continue;
2347
+ }
2348
+ if (depth === 0 && isKeywordAt(upper, i, keyword)) {
2349
+ parts.push(sql.slice(start, i));
2350
+ i += keyword.length - 1;
2351
+ start = i + 1;
2352
+ }
2353
+ }
2354
+ if (parts.length === 0) return null;
2355
+ parts.push(sql.slice(start));
2356
+ const trimmedParts = parts.map((p) => p.trim()).filter((p) => p.length > 0);
2357
+ return trimmedParts.length > 1 ? trimmedParts : null;
2358
+ }
2359
+ /** Drop redundant wrapping parens (`(a AND b)` → `a AND b`), never `(a) AND (b)`. */
2360
+ function stripOuterParens(sql) {
2361
+ let s = sql.trim();
2362
+ for (;;) {
2363
+ if (!s.startsWith("(") || !s.endsWith(")")) return s;
2364
+ let depth = 0;
2365
+ let inString = false;
2366
+ let wraps = true;
2367
+ for (let i = 0; i < s.length; i++) {
2368
+ const ch = s[i];
2369
+ if (inString) {
2370
+ if (ch === "'") if (s[i + 1] === "'") i++;
2371
+ else inString = false;
2372
+ continue;
2373
+ }
2374
+ if (ch === "'") {
2375
+ inString = true;
2376
+ continue;
2377
+ }
2378
+ if (ch === "(") depth++;
2379
+ else if (ch === ")") {
2380
+ depth--;
2381
+ if (depth === 0 && i < s.length - 1) {
2382
+ wraps = false;
2383
+ break;
2384
+ }
2385
+ }
2386
+ }
2387
+ if (!wraps) return s;
2388
+ s = s.slice(1, -1).trim();
2389
+ }
2390
+ }
2391
+ function sqlToPolicy(sql) {
2392
+ const trimmed = stripOuterParens(rewriteLegacyRlsFunctions(sql).trim());
2393
+ if (trimmed.toLowerCase() === "true") return policy.true();
2394
+ if (trimmed.toLowerCase() === "false") return policy.false();
2395
+ const overlapMatch = trimmed.match(/^string_to_array\s*\(\s*rebase\.roles\(\)\s*,\s*','\s*\)\s*&&\s*ARRAY\s*\[(.+)\]$/i);
2396
+ if (overlapMatch) {
2397
+ const roles = overlapMatch[1].split(",").map((s) => s.trim().replace(/^'|'$/g, ""));
2398
+ return policy.rolesOverlap(roles);
2399
+ }
2400
+ const containMatch = trimmed.match(/^string_to_array\s*\(\s*rebase\.roles\(\)\s*,\s*','\s*\)\s*@>\s*ARRAY\s*\[(.+)\]$/i);
2401
+ if (containMatch) {
2402
+ const roles = containMatch[1].split(",").map((s) => s.trim().replace(/^'|'$/g, ""));
2403
+ return policy.rolesContain(roles);
2404
+ }
2405
+ const orParts = splitTopLevel(trimmed, "OR");
2406
+ if (orParts) return policy.or(...orParts.map(sqlToPolicy));
2407
+ const andParts = splitTopLevel(trimmed, "AND");
2408
+ if (andParts) return policy.and(...andParts.map(sqlToPolicy));
2409
+ const match = trimmed.match(/^(.+?)\s*(!?=)\s*(.+)$/);
2410
+ if (match) {
2411
+ const [, leftStr, op, rightStr] = match;
2412
+ const left = parseOperand(leftStr.trim());
2413
+ const right = parseOperand(rightStr.trim());
2414
+ if (left && right) return policy.compare(left, op === "=" ? "eq" : "neq", right);
2415
+ }
2416
+ return policy.raw(trimmed);
2417
+ }
2418
+ /**
2153
2419
  * Literals from other BaaS platforms that people compare `rebase.uid()` against
2154
2420
  * out of habit. Mirrors the driver's `FOREIGN_CONVENTION_ROLES` guard on
2155
2421
  * `pgRoles`, one surface over: the same muscle memory inside a `using:` string
@@ -2178,6 +2444,87 @@ var FOREIGN_CONVENTION_UIDS = /* @__PURE__ */ new Map([
2178
2444
  ["service_role", "Supabase"]
2179
2445
  ]);
2180
2446
  new RegExp(String.raw`rebase\.uid\(\)\s*=\s*'(${[...FOREIGN_CONVENTION_UIDS.keys()].join("|")})'`, "i");
2447
+ function parseOperand(str) {
2448
+ if (/^current_setting\s*\(\s*'app\.(uid|user_id)'\s*\)$/i.test(str) || /^rebase\.uid\(\)$/i.test(str)) return policy.authUid();
2449
+ const literal = parseSingleQuoted(str);
2450
+ if (literal !== null) return policy.literal(literal);
2451
+ if (/^-?\d+$/.test(str)) return policy.literal(Number(str));
2452
+ if (/^-?\d*\.\d+$/.test(str)) return policy.literal(Number(str));
2453
+ if (/^true$/i.test(str)) return policy.literal(true);
2454
+ if (/^false$/i.test(str)) return policy.literal(false);
2455
+ if (/^null$/i.test(str)) return policy.literal(null);
2456
+ if (/^\w+$/.test(str) && toSnakeCase(str) !== "") return policy.field(str);
2457
+ return null;
2458
+ }
2459
+ /**
2460
+ * Decode a single-quoted SQL literal, or null when `str` is not exactly one.
2461
+ *
2462
+ * Rejecting is as important as decoding: `'a' = 'b'` is two literals and an
2463
+ * operator, not one literal whose body contains a quote, and a regex anchored
2464
+ * on the outer quotes would happily read it as the latter. Every interior quote
2465
+ * must therefore be part of a `''` pair.
2466
+ */
2467
+ function parseSingleQuoted(str) {
2468
+ if (str.length < 2 || !str.startsWith("'") || !str.endsWith("'")) return null;
2469
+ const body = str.slice(1, -1);
2470
+ let out = "";
2471
+ for (let i = 0; i < body.length; i++) {
2472
+ if (body[i] !== "'") {
2473
+ out += body[i];
2474
+ continue;
2475
+ }
2476
+ if (body[i + 1] === "'") {
2477
+ out += "'";
2478
+ i++;
2479
+ continue;
2480
+ }
2481
+ return null;
2482
+ }
2483
+ return out;
2484
+ }
2485
+ //#endregion
2486
+ //#region ../common/src/util/policy/securityRuleToConditions.ts
2487
+ /**
2488
+ * Desugars a {@link SecurityRule} — its `access`/`ownerField`/`roles` shortcuts,
2489
+ * structured `condition`/`check`, and raw `using`/`withCheck` — into a single
2490
+ * normalized {@link PolicyExpression} pair.
2491
+ *
2492
+ * **This is the linchpin against drift:** both the Postgres DDL generators and
2493
+ * the client-side evaluator consume this one function, so there is exactly one
2494
+ * definition of what a rule means. In particular, application `roles` are folded
2495
+ * into the expression here (AND'd with the base condition, matching how Postgres
2496
+ * generates the clause) rather than being handled separately by each consumer.
2497
+ */
2498
+ function securityRuleToConditions(rule) {
2499
+ return {
2500
+ usingExpr: withRoles(baseUsing(rule), rule),
2501
+ withCheckExpr: withRoles(baseWithCheck(rule), rule)
2502
+ };
2503
+ }
2504
+ function baseUsing(rule) {
2505
+ if (rule.condition) return rule.condition;
2506
+ if (rule.using != null) return sqlToPolicy(rule.using);
2507
+ if (rule.access === "public") return policy.true();
2508
+ if (rule.ownerField) return policy.compare(policy.field(rule.ownerField), "eq", policy.authUid());
2509
+ return null;
2510
+ }
2511
+ function baseWithCheck(rule) {
2512
+ if (rule.check) return rule.check;
2513
+ if (rule.withCheck != null) return sqlToPolicy(rule.withCheck);
2514
+ return baseUsing(rule);
2515
+ }
2516
+ /**
2517
+ * AND the base condition with an application-role check, or produce a roles-only
2518
+ * condition when there is no base. Mirrors the Postgres generator so that a
2519
+ * role-scoped restrictive rule denies exactly the same set of users on both
2520
+ * sides.
2521
+ */
2522
+ function withRoles(base, rule) {
2523
+ if (!rule.roles || rule.roles.length === 0) return base;
2524
+ const rolesExpr = policy.rolesOverlap(rule.roles);
2525
+ if (rule.mode === "restrictive") return base ? policy.or(policy.not(rolesExpr), base) : policy.not(rolesExpr);
2526
+ return base ? policy.and(base, rolesExpr) : rolesExpr;
2527
+ }
2181
2528
  //#endregion
2182
2529
  //#region ../common/src/util/builders.ts
2183
2530
  /**
@@ -2190,6 +2537,103 @@ function defineCollection(collection) {
2190
2537
  return resolveResourceRefs(collection);
2191
2538
  }
2192
2539
  //#endregion
2540
+ //#region ../common/src/util/tenant.ts
2541
+ /**
2542
+ * The one reading of `collection.tenant`.
2543
+ *
2544
+ * Four things have to agree for a tenant-scoped collection to work — the
2545
+ * column, the RLS policy, the value stamped on insert and the index — and
2546
+ * before this they were four hand-written declarations that nothing compared.
2547
+ * The three that are schema become a {@link SecurityRule} and a column effect
2548
+ * derived here and in `planSchema`; the fourth, the write path, is
2549
+ * {@link resolveTenantWrite}.
2550
+ *
2551
+ * Everything in this module is pure. The policy it builds is a `SecurityRule`
2552
+ * like any other, which is what makes `db push`, the doctor, boot-ensure, the
2553
+ * drift detector and the Studio treat the tenancy policy as what it is —
2554
+ * generated, named, and recognisable — rather than as somebody's hand-written
2555
+ * SQL that a push should offer to drop.
2556
+ */
2557
+ /**
2558
+ * The collection's tenancy declaration, or nothing.
2559
+ *
2560
+ * Read through this rather than off the object, so the one shape check —
2561
+ * `tenant` is an object carrying a `field` and a `from` — is in one place. A
2562
+ * config that is *wrong* is refused by `validateCollectionConfig` with a
2563
+ * message; this is only asking whether there is one.
2564
+ */
2565
+ function getTenantConfig(collection) {
2566
+ const tenant = collection?.tenant;
2567
+ if (!tenant || typeof tenant !== "object") return void 0;
2568
+ if (typeof tenant.field !== "string" || !tenant.field) return void 0;
2569
+ if (!tenant.from || typeof tenant.from !== "object") return void 0;
2570
+ return tenant;
2571
+ }
2572
+ /** The roles tenancy does not apply to, defaulted. */
2573
+ function tenantBypassRoles(tenant) {
2574
+ return tenant.bypassRoles ?? DEFAULT_TENANT_BYPASS_ROLES;
2575
+ }
2576
+ /**
2577
+ * The name of the policy a tenant declaration compiles to.
2578
+ *
2579
+ * Explicit — not a `getPolicyNameHash` of the rule — precisely because the
2580
+ * rule's *body* is compiled with more information in some callers than in
2581
+ * others (`planSchema` can resolve a relation's target collection and so knows
2582
+ * the column's type; the Studio, asking only for names, cannot). A hashed name
2583
+ * would then differ between the two, and the same policy would read as drift.
2584
+ * A frozen identifier: see `contracts/derived-names.txt`.
2585
+ */
2586
+ function tenantPolicyName(tableName) {
2587
+ return `${tableName}_tenant_scope`;
2588
+ }
2589
+ /**
2590
+ * The condition a tenant declaration means, as a policy expression.
2591
+ *
2592
+ * `serverContext()` first, for the same reason every injected baseline rule
2593
+ * carries it: the trusted plane runs migrations, the auth flows and the boot,
2594
+ * and a restrictive policy that excluded it would not protect a tenant, it
2595
+ * would stop the server from starting.
2596
+ *
2597
+ * Then the bypass roles, then the tenancy test itself — a claim comparison or a
2598
+ * correlated `EXISTS` over the membership table, which are the two ways a
2599
+ * deployment answers "which tenant is this caller in".
2600
+ */
2601
+ function tenantScopeExpression(tenant) {
2602
+ const match = isTenantClaimSource(tenant.from) ? policy.compare(policy.field(tenant.field), "eq", policy.authClaim(tenant.from.claim)) : policy.existsIn({
2603
+ collection: tenant.from.membership.collection,
2604
+ where: policy.and(policy.compare(policy.field(tenant.from.membership.tenantField), "eq", policy.outerField(tenant.field)), policy.compare(policy.field(tenant.from.membership.userField), "eq", policy.authUid()))
2605
+ });
2606
+ const bypass = tenantBypassRoles(tenant);
2607
+ return bypass.length > 0 ? policy.or(policy.serverContext(), policy.rolesOverlap(bypass), match) : policy.or(policy.serverContext(), match);
2608
+ }
2609
+ /**
2610
+ * The rule a tenant declaration compiles to, or nothing when there is none.
2611
+ *
2612
+ * **Restrictive**, and that is the whole design. A restrictive policy is ANDed
2613
+ * with every other policy on the table, so tenancy narrows what the
2614
+ * collection's own `securityRules` allow and can never widen it. A permissive
2615
+ * one would OR with them, and a single `access: "public"` rule elsewhere in the
2616
+ * file would take the entire tenancy boundary off without contradicting
2617
+ * anything a reader could see.
2618
+ *
2619
+ * One rule with `operation: "all"` rather than four with `operations: [...]`:
2620
+ * `FOR ALL` gives Postgres the USING clause for SELECT/UPDATE/DELETE and the
2621
+ * WITH CHECK clause for INSERT/UPDATE, which is exactly the coverage wanted,
2622
+ * as one policy with one name instead of four.
2623
+ */
2624
+ function buildTenantSecurityRule(collection) {
2625
+ const tenant = getTenantConfig(collection);
2626
+ if (!tenant) return void 0;
2627
+ const expression = tenantScopeExpression(tenant);
2628
+ return {
2629
+ name: tenantPolicyName(getTableName(collection)),
2630
+ mode: "restrictive",
2631
+ operation: "all",
2632
+ condition: expression,
2633
+ check: expression
2634
+ };
2635
+ }
2636
+ //#endregion
2193
2637
  //#region ../common/src/util/auth-default-policies.ts
2194
2638
  /**
2195
2639
  * Default RLS policies injected by the schema generator.
@@ -2234,8 +2678,16 @@ function defineCollection(collection) {
2234
2678
  * FORCE RLS. A *user* request never reaches that state: an anonymous one carries
2235
2679
  * `ANONYMOUS_USER_ID`, precisely so it cannot pass for the server here.
2236
2680
  *
2681
+ * **For a collection declaring `tenant`, additionally**
2682
+ * 5. A **restrictive** tenancy gate for every operation. Same kind of thing as
2683
+ * the admin write gate and injected for the same reason: it is ANDed with
2684
+ * every other policy, so it narrows what the author's permissive rules
2685
+ * grant and can never widen them. See `./tenant.ts`.
2686
+ *
2237
2687
  * Opt out with `disableDefaultPolicies: true` to take full responsibility for
2238
- * the collection's RLS.
2688
+ * the collection's RLS. The *restrictive* rules are not part of that opt-out:
2689
+ * dropping a rule that can only remove access could express nothing but "let
2690
+ * more people in", which is what the flag already does by removing the grants.
2239
2691
  */
2240
2692
  var SERVER_OR_ADMIN_EXPR$1 = policy.or(policy.serverContext(), policy.rolesOverlap(["admin"]));
2241
2693
  /** Write operations that must be admin-gated by default on auth collections. */
@@ -2279,11 +2731,27 @@ function adminWriteGate(tableName) {
2279
2731
  check: SERVER_OR_ADMIN_EXPR$1
2280
2732
  };
2281
2733
  }
2734
+ /**
2735
+ * The restrictive tenancy policy, as a list of zero or one.
2736
+ *
2737
+ * A list so the two call sites can splice it in without a conditional, and a
2738
+ * separate function so it is obvious that it is injected on *both* paths —
2739
+ * including the `disableDefaultPolicies` one, where it is the only permissive-
2740
+ * looking thing that stays. See `./tenant.ts`.
2741
+ */
2742
+ function tenantRule(collection) {
2743
+ const rule = buildTenantSecurityRule(collection);
2744
+ return rule ? [rule] : [];
2745
+ }
2282
2746
  function getEffectiveSecurityRules(collection) {
2283
2747
  const explicit = [...collection.securityRules ?? []];
2284
2748
  const tableName = getTableName(collection);
2285
2749
  const injected = [];
2286
- if (isPostgresCollectionConfig(collection) && collection.disableDefaultPolicies) return isAuthCollection(collection) ? [...explicit, adminWriteGate(tableName)] : explicit;
2750
+ if (isPostgresCollectionConfig(collection) && collection.disableDefaultPolicies) return [
2751
+ ...explicit,
2752
+ ...tenantRule(collection),
2753
+ ...isAuthCollection(collection) ? [adminWriteGate(tableName)] : []
2754
+ ];
2287
2755
  injected.push({
2288
2756
  name: `${tableName}_default_admin_read`,
2289
2757
  operations: ["select"],
@@ -2303,6 +2771,7 @@ function getEffectiveSecurityRules(collection) {
2303
2771
  });
2304
2772
  injected.push(adminWriteGate(tableName));
2305
2773
  }
2774
+ injected.push(...tenantRule(collection));
2306
2775
  return [...explicit, ...injected];
2307
2776
  }
2308
2777
  policy.or(policy.serverContext(), policy.rolesOverlap(["admin"]));
@@ -3047,6 +3516,35 @@ function resolveDataSource(collection, registry) {
3047
3516
  capabilities: getDataSourceCapabilities(engine)
3048
3517
  };
3049
3518
  }
3519
+ /**
3520
+ * Does a SQL toolchain own this collection's storage?
3521
+ *
3522
+ * "Owns the storage" means: something generates a table for it, pushes that
3523
+ * table to a database, plans its RLS policies, and reports it as drifted when
3524
+ * the two disagree. That is true of a Postgres collection and false of a
3525
+ * Firestore or MongoDB one, whose documents live in a store Rebase never
3526
+ * migrates — and the two were never told apart. Every stage of the SQL
3527
+ * toolchain took "the collections" to mean *all* of them, so a Firestore
3528
+ * collection declared next to the Postgres ones got a `pgTable` in the
3529
+ * generated schema, a `CREATE TABLE` at boot, RLS policies, and a place in the
3530
+ * `db push` include list — where its name shielding a same-named real table
3531
+ * from Atlas's exclude list is the one that can lose data.
3532
+ *
3533
+ * The answer is the resolved engine's {@link DataSourceCapabilities}, not a
3534
+ * name check: an engine registered through `registerDataSourceCapabilities`
3535
+ * gets the same treatment as the built-in ones.
3536
+ *
3537
+ * Deliberately answers **true** for an engine nobody has heard of. Build-time
3538
+ * tooling (the CLI, the schema generator) has no data-source registry to
3539
+ * resolve a `dataSource` key against, so an unknown key resolves to an unknown
3540
+ * engine — and the cost of the two mistakes is not symmetric. Wrongly
3541
+ * including a collection generates a table nothing writes to; wrongly excluding
3542
+ * one silently stops generating a table the app is serving from. Declare
3543
+ * `engine` on a collection that is not SQL-backed and this is exact.
3544
+ */
3545
+ function isRelationalCollection(collection, registry) {
3546
+ return getDataSourceCapabilities(collection?.engine ?? (collection?.dataSource ? resolveDataSource(collection, registry).engine : void 0)).supportsRelations;
3547
+ }
3050
3548
  //#endregion
3051
3549
  //#region ../common/src/collections/CollectionRegistry.ts
3052
3550
  var CollectionRegistry = class {
@@ -3404,6 +3902,205 @@ var defaultUsersCollection = defineCollection({
3404
3902
  }
3405
3903
  }
3406
3904
  });
3905
+ /**
3906
+ * What a property's access rules actually are, with `excludeFromApi` expanded.
3907
+ *
3908
+ * Returns `undefined` when the property constrains nothing, so callers can skip
3909
+ * the whole check for the overwhelmingly common case.
3910
+ */
3911
+ function effectiveAccess(property) {
3912
+ if (!property) return void 0;
3913
+ if (property.excludeFromApi) return EXCLUDED_ACCESS;
3914
+ const access = property.access;
3915
+ if (!access) return void 0;
3916
+ if (access.read === void 0 && access.write === void 0) return void 0;
3917
+ return access;
3918
+ }
3919
+ /** The rule `excludeFromApi: true` expands to. Frozen: it is shared by every caller. */
3920
+ var EXCLUDED_ACCESS = Object.freeze({
3921
+ read: Object.freeze([]),
3922
+ write: Object.freeze([])
3923
+ });
3924
+ /**
3925
+ * Does a caller holding `roles` satisfy `allowed`?
3926
+ *
3927
+ * Three cases, and the middle one is the one worth stating out loud:
3928
+ *
3929
+ * - `allowed` omitted — the field carries no rule of its own, so the row's
3930
+ * policies have already answered. True.
3931
+ * - `allowed` empty — nobody, at any privilege, through any API. Not the admin,
3932
+ * not the service key, not the trusted plane reading on a caller's behalf.
3933
+ * This is what `excludeFromApi` has always meant on the read side, and
3934
+ * collapsing the two spellings means the empty list has to keep meaning it.
3935
+ * - `allowed` non-empty — one of the named roles, or `admin`, or no viewer at
3936
+ * all (the trusted server plane, which is not an API caller).
3937
+ */
3938
+ function satisfies(allowed, viewer) {
3939
+ if (allowed === void 0) return true;
3940
+ if (allowed.length === 0) return false;
3941
+ if (!viewer) return true;
3942
+ const roles = viewer.roles;
3943
+ if (!roles || roles.length === 0) return false;
3944
+ return roles.includes("admin") || allowed.some((role) => roles.includes(role));
3945
+ }
3946
+ /** May this caller receive this field's value? */
3947
+ function canReadField(property, viewer) {
3948
+ const access = effectiveAccess(property);
3949
+ return access ? satisfies(access.read, viewer) : true;
3950
+ }
3951
+ /** May this caller set this field's value? */
3952
+ function canWriteField(property, viewer) {
3953
+ const access = effectiveAccess(property);
3954
+ return access ? satisfies(access.write, viewer) : true;
3955
+ }
3956
+ /**
3957
+ * The names on this collection a caller may not touch, in the two spellings a
3958
+ * caller can write them in.
3959
+ *
3960
+ * `declared` is the property keys, which is what has to leave a *known-fields*
3961
+ * set. `refused` is those plus the physical column names behind them: a caller
3962
+ * who knows the table can send `password_hash` as readily as `passwordHash`, and
3963
+ * a rule that only knew the wire name would be one rename away from useless.
3964
+ *
3965
+ * `kind` picks which half of the rule is read; nothing else differs.
3966
+ */
3967
+ function restrictedFieldNames(collection, viewer, kind) {
3968
+ const declared = [];
3969
+ const refused = /* @__PURE__ */ new Set();
3970
+ const allowed = kind === "read" ? canReadField : canWriteField;
3971
+ for (const [name, property] of Object.entries(collection.properties ?? {})) {
3972
+ if (allowed(property, viewer)) continue;
3973
+ declared.push(name);
3974
+ refused.add(name);
3975
+ const columnName = property.columnName;
3976
+ if (columnName) refused.add(columnName);
3977
+ }
3978
+ return {
3979
+ declared,
3980
+ refused
3981
+ };
3982
+ }
3983
+ //#endregion
3984
+ //#region ../common/src/data/cursor.ts
3985
+ /** A cursor that cannot be read at all — truncated, re-encoded, or invented. */
3986
+ var CursorError = class CursorError extends Error {
3987
+ code = "INVALID_CURSOR";
3988
+ constructor(detail) {
3989
+ super(`Invalid \`after\` cursor: ${detail}. Pass back the \`meta.nextCursor\` from the previous page unchanged — it is opaque and must not be built by hand.`);
3990
+ this.name = "CursorError";
3991
+ Object.setPrototypeOf(this, CursorError.prototype);
3992
+ }
3993
+ };
3994
+ /**
3995
+ * A cursor that reads fine but describes a different query.
3996
+ *
3997
+ * Separate from {@link CursorError} because the fix is different: this one is
3998
+ * not a corrupt string, it is a correct cursor used against a sort it was not
3999
+ * produced under. Seeking anyway would return rows in an order nobody asked
4000
+ * for, and — worse — would look like it worked.
4001
+ */
4002
+ var CursorMismatchError = class CursorMismatchError extends Error {
4003
+ code = "CURSOR_ORDER_MISMATCH";
4004
+ constructor(cursorKeys, queryKeys) {
4005
+ super(`The \`after\` cursor was produced by a query ordered by ${cursorKeys.map((k) => `"${k}"`).join(", ") || "(nothing)"}, but this query orders by ${queryKeys.map((k) => `"${k}"`).join(", ") || "(nothing)"}. A cursor only continues the listing it came from — keep \`orderBy\` identical across pages, or drop \`after\` to start over.`);
4006
+ this.name = "CursorMismatchError";
4007
+ Object.setPrototypeOf(this, CursorMismatchError.prototype);
4008
+ }
4009
+ };
4010
+ /**
4011
+ * Tag for a value whose JSON round-trip would otherwise lose its type.
4012
+ *
4013
+ * A `timestamp` column comes back from the driver as a `Date`; JSON turns it
4014
+ * into a string, and the string would then be compared against the column by
4015
+ * whatever cast Postgres chose. Round-tripping it as a `Date` keeps the
4016
+ * comparison the one the ORDER BY made.
4017
+ */
4018
+ var DATE_TAG = "$date";
4019
+ function decodeValue(value) {
4020
+ if (value && typeof value === "object" && !Array.isArray(value)) {
4021
+ const tagged = value[DATE_TAG];
4022
+ if (typeof tagged === "string") {
4023
+ const date = new Date(tagged);
4024
+ return Number.isNaN(date.getTime()) ? tagged : date;
4025
+ }
4026
+ }
4027
+ return value;
4028
+ }
4029
+ function fromBase64Url(encoded) {
4030
+ const padded = encoded.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - encoded.length % 4) % 4);
4031
+ const binary = atob(padded);
4032
+ const bytes = new Uint8Array(binary.length);
4033
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
4034
+ return new TextDecoder().decode(bytes);
4035
+ }
4036
+ /**
4037
+ * Read a cursor produced by {@link encodeCursor}.
4038
+ *
4039
+ * @throws {CursorError} when the string is not a cursor this codec wrote.
4040
+ */
4041
+ function decodeCursor(raw) {
4042
+ let parsed;
4043
+ try {
4044
+ parsed = JSON.parse(fromBase64Url(raw.trim()));
4045
+ } catch {
4046
+ throw new CursorError("it is not a cursor this API issued");
4047
+ }
4048
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new CursorError("it does not decode to a cursor");
4049
+ const body = parsed;
4050
+ if (!Array.isArray(body.k)) throw new CursorError("it carries no sort keys");
4051
+ if (body.i === void 0) throw new CursorError("it carries no row id");
4052
+ const orderBy = [];
4053
+ for (const entry of body.k) {
4054
+ if (!Array.isArray(entry) || typeof entry[0] !== "string") throw new CursorError("one of its sort keys is malformed");
4055
+ const direction = entry[1] === "desc" ? "desc" : "asc";
4056
+ orderBy.push(entry[2] === "first" || entry[2] === "last" ? [
4057
+ entry[0],
4058
+ direction,
4059
+ entry[2]
4060
+ ] : [entry[0], direction]);
4061
+ }
4062
+ const rawValues = body.v && typeof body.v === "object" && !Array.isArray(body.v) ? body.v : {};
4063
+ const values = {};
4064
+ for (const [field, value] of Object.entries(rawValues)) values[field] = decodeValue(value);
4065
+ return {
4066
+ orderBy,
4067
+ values,
4068
+ id: decodeValue(body.i)
4069
+ };
4070
+ }
4071
+ /**
4072
+ * The `orderBy` a request should run under, given a cursor and whatever sort
4073
+ * the request itself named.
4074
+ *
4075
+ * A request that names no sort **adopts the cursor's** — that is what makes
4076
+ * `find({ after })` work without restating the `orderBy` from the previous
4077
+ * call, and it cannot be wrong, since the cursor is the only sort in play.
4078
+ * A request that names one must name the *same* one, key for key, direction for
4079
+ * direction, nulls for nulls; anything else is {@link CursorMismatchError}.
4080
+ *
4081
+ * @throws {CursorMismatchError}
4082
+ */
4083
+ function reconcileCursorOrder(cursor, requested) {
4084
+ if (!requested || requested.length === 0) return cursor.orderBy;
4085
+ const spell = (keys) => keys.map(([field, direction, nulls]) => `${field}:${direction}${nulls ? `:${nulls}` : ""}`);
4086
+ const cursorKeys = spell(cursor.orderBy);
4087
+ const queryKeys = spell(requested);
4088
+ if (cursorKeys.length !== queryKeys.length || cursorKeys.some((key, i) => key !== queryKeys[i])) throw new CursorMismatchError(cursorKeys, queryKeys);
4089
+ return requested;
4090
+ }
4091
+ /**
4092
+ * The `startAfter` shape the driver contract takes, built from a cursor.
4093
+ *
4094
+ * The driver has always accepted `{ id, values }`; this is the one place that
4095
+ * shape is produced, so the REST route and the WebSocket ingress cannot drift
4096
+ * into two spellings of the same seek.
4097
+ */
4098
+ function cursorToStartAfter(cursor) {
4099
+ return {
4100
+ id: cursor.id,
4101
+ values: cursor.values
4102
+ };
4103
+ }
3407
4104
  //#endregion
3408
4105
  //#region ../common/src/data/sort-dialect.ts
3409
4106
  /**
@@ -3450,13 +4147,24 @@ var OrderBySpecError = class extends Error {
3450
4147
  this.name = "OrderBySpecError";
3451
4148
  }
3452
4149
  };
4150
+ /** `first`/`last`, or a refusal naming the entry — see {@link NullsPlacement}. */
4151
+ function toStrictNulls(raw, index) {
4152
+ if (raw === void 0 || raw === null) return void 0;
4153
+ if (raw !== "first" && raw !== "last") throw new OrderBySpecError(`entry ${index} has nulls '${String(raw)}' — expected "first" or "last"`);
4154
+ return raw;
4155
+ }
3453
4156
  function toStrictTuple(raw, index) {
3454
4157
  if (!Array.isArray(raw)) throw new OrderBySpecError(`entry ${index} has no field name`);
3455
4158
  const key = isRelationAggregateSort(raw[0]) ? sortKeyToString(raw[0]) : raw[0];
3456
4159
  if (typeof key !== "string" || key.trim() === "") throw new OrderBySpecError(`entry ${index} has no field name`);
3457
4160
  const direction = raw[1];
3458
4161
  if (direction !== void 0 && direction !== "asc" && direction !== "desc") throw new OrderBySpecError(`entry ${index} has direction '${String(direction)}'`);
3459
- return [key, direction ?? "asc"];
4162
+ const nulls = toStrictNulls(raw[2], index);
4163
+ return nulls ? [
4164
+ key,
4165
+ direction ?? "asc",
4166
+ nulls
4167
+ ] : [key, direction ?? "asc"];
3460
4168
  }
3461
4169
  /**
3462
4170
  * Serialize a sort to the wire.
@@ -3486,11 +4194,360 @@ function serializeOrderBy(orderBy) {
3486
4194
  if (typeof orderBy === "string") return orderBy;
3487
4195
  const list = normalizeOrderBy(orderBy);
3488
4196
  if (!list) return void 0;
3489
- if (list.length === 1) return `${list[0][0]}:${list[0][1]}`;
3490
- return JSON.stringify(list.map(([field, direction]) => ({
4197
+ if (list.length === 1) {
4198
+ const [field, direction, nulls] = list[0];
4199
+ return nulls ? `${field}:${direction}:${nulls}` : `${field}:${direction}`;
4200
+ }
4201
+ return JSON.stringify(list.map(([field, direction, nulls]) => nulls ? {
4202
+ field,
4203
+ direction,
4204
+ nulls
4205
+ } : {
3491
4206
  field,
3492
4207
  direction
3493
- })));
4208
+ }));
4209
+ }
4210
+ /**
4211
+ * Deserialize a wire-format `"field:direction"` string into an {@link OrderByTuple}.
4212
+ *
4213
+ * Lenient parsing:
4214
+ * - Bare field name (no colon): `"name"` → `["name", "asc"]`
4215
+ * - Unknown direction: `"name:foo"` → `["name", "asc"]`
4216
+ * - Empty / falsy input, or a blank field name: → `undefined`
4217
+ *
4218
+ * The leniency is this end's alone; the *server* refuses the same value. This
4219
+ * used to say "matches existing server behaviour", and it stopped being true
4220
+ * when `parseOrderByParam` grew a strict direction check: `?orderBy=name:foo`
4221
+ * now answers `400 INVALID_ORDER_BY` ("entry 0 has direction 'foo'"). The split
4222
+ * is deliberate — see {@link parseOrderBySpecStrict} — because a value this
4223
+ * function is handed was produced by {@link serializeOrderBy} a moment earlier,
4224
+ * and one that reaches the server came from a stranger.
4225
+ *
4226
+ * A blank field is `undefined` rather than `[" ", "asc"]`: whitespace is not a
4227
+ * field name, and the tuple it used to produce could not be re-encoded — the
4228
+ * only value in this codec that survived a decode and failed the next encode.
4229
+ *
4230
+ * Reads the single-key shorthand only. For a value that may carry several keys,
4231
+ * use {@link deserializeOrderByList} — handed a JSON array this returns the
4232
+ * whole array as one nonsensical field name.
4233
+ *
4234
+ * @param raw - The wire-format string from an HTTP query parameter.
4235
+ * @returns The canonical tuple, or `undefined` if the input names no field.
4236
+ */
4237
+ function deserializeOrderBy(raw) {
4238
+ if (!raw) return void 0;
4239
+ const idx = raw.indexOf(":");
4240
+ if (idx === -1) return raw.trim() === "" ? void 0 : [raw, "asc"];
4241
+ const field = raw.slice(0, idx);
4242
+ if (field.trim() === "") return void 0;
4243
+ const rest = raw.slice(idx + 1);
4244
+ const nullsIdx = rest.indexOf(":");
4245
+ const dir = nullsIdx === -1 ? rest : rest.slice(0, nullsIdx);
4246
+ const nulls = nullsIdx === -1 ? void 0 : rest.slice(nullsIdx + 1);
4247
+ const direction = dir === "desc" ? "desc" : "asc";
4248
+ return nulls === "first" || nulls === "last" ? [
4249
+ field,
4250
+ direction,
4251
+ nulls
4252
+ ] : [field, direction];
4253
+ }
4254
+ /**
4255
+ * Deserialize either wire spelling — the single-key shorthand or the JSON
4256
+ * array — into the list form.
4257
+ *
4258
+ * Lenient in the same way {@link deserializeOrderBy} is: this is the client end
4259
+ * of the codec, where the value was produced by {@link serializeOrderBy} a
4260
+ * moment earlier. The *server* end parses the same shapes strictly, in
4261
+ * `parseOrderByParam`, because there the value came from a stranger and a
4262
+ * direction it cannot read has to be refused rather than quietly turned into
4263
+ * `"asc"`.
4264
+ */
4265
+ function deserializeOrderByList(raw) {
4266
+ if (!raw) return void 0;
4267
+ const trimmed = raw.trim();
4268
+ if (trimmed.startsWith("[")) try {
4269
+ const parsed = JSON.parse(trimmed);
4270
+ if (Array.isArray(parsed)) {
4271
+ const list = parsed.map((entry) => {
4272
+ if (typeof entry === "string") return deserializeOrderBy(entry);
4273
+ if (entry && typeof entry === "object" && typeof entry.field === "string") {
4274
+ const direction = entry.direction === "desc" ? "desc" : "asc";
4275
+ return entry.nulls === "first" || entry.nulls === "last" ? [
4276
+ entry.field,
4277
+ direction,
4278
+ entry.nulls
4279
+ ] : [entry.field, direction];
4280
+ }
4281
+ }).filter((entry) => entry !== void 0);
4282
+ return list.length > 0 ? list : void 0;
4283
+ }
4284
+ } catch {}
4285
+ const single = deserializeOrderBy(trimmed);
4286
+ return single ? [single] : void 0;
4287
+ }
4288
+ //#endregion
4289
+ //#region ../common/src/data/include-spec.ts
4290
+ /** An `include` that cannot be read, as opposed to one naming a relation that does not exist. */
4291
+ var IncludeSpecError = class IncludeSpecError extends Error {
4292
+ code;
4293
+ constructor(detail, code = "INVALID_INCLUDE") {
4294
+ super(`Invalid \`include\`: ${detail}`);
4295
+ this.name = "IncludeSpecError";
4296
+ this.code = code;
4297
+ Object.setPrototypeOf(this, IncludeSpecError.prototype);
4298
+ }
4299
+ };
4300
+ var emptyNode = () => ({ children: {} });
4301
+ function ensureNode(tree, key) {
4302
+ return tree[key] ??= emptyNode();
4303
+ }
4304
+ /**
4305
+ * Merge one dotted path (`"comments.author"`) into a tree.
4306
+ *
4307
+ * Merging rather than assigning is what makes `include=comments,comments.author`
4308
+ * mean the same thing as `include=comments.author`: the second path deepens the
4309
+ * node the first created instead of replacing it and losing its options.
4310
+ */
4311
+ function addPath(tree, path) {
4312
+ const segments = path.split(".").map((s) => s.trim()).filter(Boolean);
4313
+ if (segments.length === 0) return;
4314
+ if (segments.length > 3) throw new IncludeSpecError(`"${path}" nests ${segments.length} relations deep; the limit is 3. Each hop is another query, and an unbounded one walks a self-referencing relation forever.`, "INCLUDE_TOO_DEEP");
4315
+ let level = tree;
4316
+ for (const segment of segments) level = ensureNode(level, segment).children;
4317
+ }
4318
+ function normalizeOptions(key, options, depth) {
4319
+ if (depth > 3) throw new IncludeSpecError(`"${key}" nests more than 3 relations deep.`, "INCLUDE_TOO_DEEP");
4320
+ if (options.limit !== void 0 && (!Number.isInteger(options.limit) || options.limit < 1)) throw new IncludeSpecError(`"${key}" has limit ${JSON.stringify(options.limit)} — expected a whole number of 1 or more.`);
4321
+ const node = { children: {} };
4322
+ if (options.limit !== void 0) node.limit = options.limit;
4323
+ if (options.where) node.where = options.where;
4324
+ if (options.logical) node.logical = options.logical;
4325
+ if (options.fields && options.fields.length > 0) node.fields = [...options.fields];
4326
+ const orderBy = typeof options.orderBy === "string" ? deserializeOrderByList(options.orderBy) : normalizeOrderBy(options.orderBy);
4327
+ if (orderBy) node.orderBy = orderBy;
4328
+ if (options.include) {
4329
+ const nested = normalizeIncludeAt(options.include, depth + 1);
4330
+ if (nested.wildcard) throw new IncludeSpecError(`"${key}" asks for \`*\` inside a nested include. Name the relations you need.`);
4331
+ node.children = nested.tree;
4332
+ }
4333
+ return node;
4334
+ }
4335
+ function normalizeIncludeAt(spec, depth) {
4336
+ if (Array.isArray(spec)) {
4337
+ const tree = {};
4338
+ let wildcard = false;
4339
+ for (const raw of spec) {
4340
+ if (typeof raw !== "string") throw new IncludeSpecError(`${typeof raw} is not a relation name`);
4341
+ const name = raw.trim();
4342
+ if (!name) continue;
4343
+ if (name === "*") {
4344
+ wildcard = true;
4345
+ continue;
4346
+ }
4347
+ addPath(tree, name);
4348
+ }
4349
+ return {
4350
+ wildcard,
4351
+ tree
4352
+ };
4353
+ }
4354
+ if (typeof spec !== "object" || spec === null) throw new IncludeSpecError(`${typeof spec} is not a list of relations or an include tree`);
4355
+ const tree = {};
4356
+ let wildcard = false;
4357
+ for (const [key, value] of Object.entries(spec)) {
4358
+ if (key === "*") {
4359
+ if (value) wildcard = true;
4360
+ continue;
4361
+ }
4362
+ if (value === true) {
4363
+ ensureNode(tree, key);
4364
+ continue;
4365
+ }
4366
+ if (value === false || value === void 0 || value === null) continue;
4367
+ if (typeof value !== "object" || Array.isArray(value)) throw new IncludeSpecError(`"${key}" must be \`true\` or an options object`);
4368
+ tree[key] = normalizeOptions(key, value, depth);
4369
+ }
4370
+ return {
4371
+ wildcard,
4372
+ tree
4373
+ };
4374
+ }
4375
+ /**
4376
+ * Collapse any {@link IncludeSpec} spelling into one tree.
4377
+ *
4378
+ * `["author", "comments.author"]` and
4379
+ * `{ author: true, comments: { include: { author: true } } }` normalize to the
4380
+ * same value — which is the whole point: the REST parameter can only carry the
4381
+ * flat spelling, the SDK prefers the tree, and the driver should never learn
4382
+ * about either.
4383
+ *
4384
+ * @throws {IncludeSpecError} for a shape that is not an include at all, or one
4385
+ * that nests past {@link MAX_INCLUDE_DEPTH}.
4386
+ */
4387
+ function normalizeInclude(spec) {
4388
+ if (spec === void 0 || spec === null) return void 0;
4389
+ const normalized = normalizeIncludeAt(spec, 1);
4390
+ if (!normalized.wildcard && Object.keys(normalized.tree).length === 0) return void 0;
4391
+ return normalized;
4392
+ }
4393
+ /**
4394
+ * Every relation name a tree names, as dotted paths — `["comments",
4395
+ * "comments.author"]`.
4396
+ *
4397
+ * Used to report which names an `include` asked for when one of them is not a
4398
+ * relation, and to serialize a tree that carries no per-relation options back
4399
+ * to the flat wire spelling.
4400
+ */
4401
+ function includePaths(tree, prefix = "") {
4402
+ const out = [];
4403
+ for (const [key, node] of Object.entries(tree)) {
4404
+ const path = prefix ? `${prefix}.${key}` : key;
4405
+ out.push(path);
4406
+ out.push(...includePaths(node.children, path));
4407
+ }
4408
+ return out;
4409
+ }
4410
+ /**
4411
+ * The relation names an `include` asks for at the top level.
4412
+ *
4413
+ * `["author", "comments.author"]` and `{author: true, comments: {...}}` both
4414
+ * answer `["author", "comments"]` — a *hop*, not a path, because the only
4415
+ * consumer is `?fields=`, which names keys on the row being returned and a
4416
+ * nested relation is not one of those.
4417
+ *
4418
+ * Derived rather than passed: `include` has four spellings and three of them
4419
+ * are not a `string[]`, so every consumer that wants the plain names either
4420
+ * calls this or reimplements the flattening.
4421
+ */
4422
+ function topLevelIncludeNames(spec) {
4423
+ const normalized = normalizeInclude(spec);
4424
+ if (!normalized) return [];
4425
+ return Object.keys(normalized.tree);
4426
+ }
4427
+ /** Whether any node in the tree carries per-relation options. */
4428
+ function hasOptions(tree) {
4429
+ return Object.values(tree).some((node) => node.limit !== void 0 || node.where !== void 0 || node.logical !== void 0 || node.orderBy !== void 0 || node.fields !== void 0 || hasOptions(node.children));
4430
+ }
4431
+ /**
4432
+ * Serialize an {@link IncludeSpec} for the REST `?include=` parameter.
4433
+ *
4434
+ * Two spellings, and which one is used is decided by the request rather than
4435
+ * chosen:
4436
+ *
4437
+ * - **Comma-separated dotted paths** — `include=author,comments.author`. What a
4438
+ * plain include is, what a human types, and what every existing client sends.
4439
+ * - **JSON**, when any relation carries options — `include={"comments":{"limit":5,
4440
+ * "include":{"author":true}}}`. The flat spelling has nowhere to put a
4441
+ * `limit`, and inventing a punctuation for it (`comments(limit:5)`) would be a
4442
+ * third grammar to learn beside the two this API already has.
4443
+ *
4444
+ * The server accepts both on every list and get route, and tells them apart the
4445
+ * same way this does: a value starting with `{` is JSON.
4446
+ */
4447
+ function serializeInclude(spec) {
4448
+ const normalized = normalizeInclude(spec);
4449
+ if (!normalized) return void 0;
4450
+ if (normalized.wildcard && Object.keys(normalized.tree).length === 0) return "*";
4451
+ if (!hasOptions(normalized.tree)) {
4452
+ const paths = includePaths(normalized.tree);
4453
+ const leaves = paths.filter((path) => !paths.some((other) => other.startsWith(`${path}.`)));
4454
+ const all = normalized.wildcard ? ["*", ...leaves] : leaves;
4455
+ return all.length > 0 ? all.join(",") : void 0;
4456
+ }
4457
+ return JSON.stringify(toWireTree(normalized));
4458
+ }
4459
+ /**
4460
+ * A normalized tree, back in the {@link IncludeSpec} spelling a caller writes.
4461
+ *
4462
+ * The round trip is what lets a builder accumulate `include` calls: normalize
4463
+ * each, merge, and hand the result back as a spec the next layer can normalize
4464
+ * again. Idempotent, so doing it twice changes nothing.
4465
+ */
4466
+ function denormalizeInclude(normalized) {
4467
+ return toWireTree(normalized);
4468
+ }
4469
+ function mergeTrees(into, from) {
4470
+ for (const [key, node] of Object.entries(from)) {
4471
+ const existing = into[key];
4472
+ if (!existing) {
4473
+ into[key] = node;
4474
+ continue;
4475
+ }
4476
+ if (node.limit !== void 0) existing.limit = node.limit;
4477
+ if (node.where !== void 0) existing.where = node.where;
4478
+ if (node.logical !== void 0) existing.logical = node.logical;
4479
+ if (node.orderBy !== void 0) existing.orderBy = node.orderBy;
4480
+ if (node.fields !== void 0) existing.fields = node.fields;
4481
+ existing.children = mergeTrees(existing.children, node.children);
4482
+ }
4483
+ return into;
4484
+ }
4485
+ /**
4486
+ * Combine several `include` requests into one.
4487
+ *
4488
+ * Repeated `.include(...)` calls on a query builder are additive: each names
4489
+ * more of the graph to load, and a later one must not discard what an earlier
4490
+ * one asked for. Assigning instead of merging is why `.include("author")
4491
+ * .include("tags")` used to load only tags.
4492
+ */
4493
+ function mergeIncludeSpecs(existing, additions) {
4494
+ const merged = {
4495
+ wildcard: false,
4496
+ tree: {}
4497
+ };
4498
+ const absorb = (spec) => {
4499
+ const normalized = normalizeInclude(spec);
4500
+ if (!normalized) return;
4501
+ merged.wildcard ||= normalized.wildcard;
4502
+ mergeTrees(merged.tree, normalized.tree);
4503
+ };
4504
+ absorb(existing);
4505
+ const names = additions.filter((a) => typeof a === "string");
4506
+ if (names.length > 0) absorb(names);
4507
+ for (const addition of additions) if (typeof addition !== "string") absorb(addition);
4508
+ if (!merged.wildcard && Object.keys(merged.tree).length === 0) return void 0;
4509
+ return denormalizeInclude(merged);
4510
+ }
4511
+ function toWireTree(normalized) {
4512
+ const emit = (tree) => {
4513
+ const out = {};
4514
+ for (const [key, node] of Object.entries(tree)) {
4515
+ const options = {};
4516
+ if (node.limit !== void 0) options.limit = node.limit;
4517
+ if (node.where) options.where = node.where;
4518
+ if (node.logical) options.logical = node.logical;
4519
+ if (node.orderBy) options.orderBy = node.orderBy;
4520
+ if (node.fields) options.fields = node.fields;
4521
+ const children = emit(node.children);
4522
+ if (Object.keys(children).length > 0) options.include = children;
4523
+ out[key] = Object.keys(options).length > 0 ? options : true;
4524
+ }
4525
+ return out;
4526
+ };
4527
+ const tree = emit(normalized.tree);
4528
+ if (normalized.wildcard) tree["*"] = true;
4529
+ return tree;
4530
+ }
4531
+ /**
4532
+ * Read the REST `?include=` parameter, in either spelling.
4533
+ *
4534
+ * @throws {IncludeSpecError} for malformed JSON or a tree that nests too deep.
4535
+ */
4536
+ function deserializeInclude(raw) {
4537
+ if (raw === void 0 || raw === null) return void 0;
4538
+ const text = raw.trim();
4539
+ if (!text) return void 0;
4540
+ if (text.startsWith("{")) {
4541
+ let parsed;
4542
+ try {
4543
+ parsed = JSON.parse(text);
4544
+ } catch {
4545
+ throw new IncludeSpecError("the parametrised form must be a JSON object, e.g. {\"comments\":{\"limit\":5,\"include\":{\"author\":true}}}");
4546
+ }
4547
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new IncludeSpecError("the parametrised form must be a JSON object");
4548
+ return parsed;
4549
+ }
4550
+ return text.split(",").map((s) => s.trim()).filter(Boolean);
3494
4551
  }
3495
4552
  //#endregion
3496
4553
  //#region ../common/src/data/query_builder.ts
@@ -3671,26 +4728,6 @@ function normalizeMaxRows(raw) {
3671
4728
  return Math.max(0, Math.floor(raw));
3672
4729
  }
3673
4730
  /**
3674
- * Add one condition to a `where` map without disturbing what is already there.
3675
- *
3676
- * The caller's own filter on the cursor column has to survive — dropping it
3677
- * would widen the query, which is the silent-filter-loss failure mode — so a
3678
- * second condition on the same column becomes the array-of-tuples form that
3679
- * `FindParams.where` already accepts, and both are AND-ed.
3680
- */
3681
- function appendCondition(where, column, condition) {
3682
- const next = { ...where ?? {} };
3683
- const existing = next[column];
3684
- if (existing === void 0) next[column] = condition;
3685
- else if (Array.isArray(existing) && existing.length > 0 && Array.isArray(existing[0])) next[column] = [...existing, condition];
3686
- else next[column] = [existing, condition];
3687
- return next;
3688
- }
3689
- function cursorEquals(a, b) {
3690
- if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
3691
- return Object.is(a, b);
3692
- }
3693
- /**
3694
4731
  * Walk every row a query matches, yielding one row at a time and fetching the
3695
4732
  * next page only when the consumer asks for it.
3696
4733
  *
@@ -3706,30 +4743,23 @@ async function* paginateFind(find, params, label = "collection") {
3706
4743
  const findParams = { ...rest };
3707
4744
  const size = normalizePageSize(pageSize);
3708
4745
  const pageCap = normalizeMaxPages(maxPages);
3709
- const cursorField = typeof cursor === "string" ? cursor : cursor?.field;
3710
- const requestedDirection = typeof cursor === "object" && cursor !== null ? cursor.direction : void 0;
3711
- let direction = "asc";
3712
- if (cursorField) {
3713
- const orderBy = normalizeOrderBy(findParams.orderBy);
3714
- 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.`);
3715
- 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.`);
3716
- direction = requestedDirection ?? orderBy?.[0][1] ?? "asc";
3717
- findParams.orderBy = [cursorField, direction];
4746
+ const seekRequested = cursor !== void 0 && cursor !== null;
4747
+ if (seekRequested) {
4748
+ const field = typeof cursor === "string" ? cursor : cursor.field;
4749
+ const requested = typeof cursor === "object" && cursor !== null ? cursor.direction : void 0;
4750
+ if (!normalizeOrderBy(findParams.orderBy)) findParams.orderBy = [field, requested ?? "asc"];
3718
4751
  }
3719
- const seekOp = direction === "desc" ? "<" : ">";
3720
- const baseWhere = findParams.where;
3721
4752
  let offset = 0;
3722
4753
  let pages = 0;
3723
- let cursorValue;
3724
- let seeking = false;
4754
+ let after;
3725
4755
  for (;;) {
3726
4756
  if (pages >= pageCap) throw new RebasePaginationError("max-pages", `Iterating "${label}" made ${pages} requests without the server reporting the end of the collection. Stopping rather than looping forever — raise \`maxPages\` if the walk is genuinely this long, or check that the backend sets \`meta.hasMore\`.`);
3727
4757
  const pageParams = {
3728
4758
  ...findParams,
3729
4759
  limit: size
3730
4760
  };
3731
- if (cursorField) {
3732
- if (seeking) pageParams.where = appendCondition(baseWhere, cursorField, [seekOp, cursorValue]);
4761
+ if (seekRequested) {
4762
+ if (after) pageParams.after = after;
3733
4763
  } else pageParams.offset = offset;
3734
4764
  const page = await find(pageParams);
3735
4765
  pages += 1;
@@ -3737,12 +4767,11 @@ async function* paginateFind(find, params, label = "collection") {
3737
4767
  if (rows.length === 0) return;
3738
4768
  for (const row of rows) yield row;
3739
4769
  if (page?.meta?.hasMore !== true) return;
3740
- if (cursorField) {
3741
- const nextValue = rows[rows.length - 1]?.[cursorField];
3742
- if (nextValue === void 0 || nextValue === null) throw new RebasePaginationError("cursor-missing", `Cannot seek past the last row of "${label}": it has no value for the cursor column "${cursorField}". Pick a column that is present and non-null on every row.`);
3743
- if (seeking && cursorEquals(nextValue, cursorValue)) throw new RebasePaginationError("cursor-stalled", `Iterating "${label}" is stuck: two pages in a row ended at ${cursorField}=${String(nextValue)}. The cursor column has to be unique — a repeated value cannot be seeked past, and continuing would either loop forever or skip the duplicates. Use the primary key, or page by offset.`);
3744
- cursorValue = nextValue;
3745
- seeking = true;
4770
+ if (seekRequested) {
4771
+ const next = page.meta.nextCursor;
4772
+ if (!next) throw new RebasePaginationError("cursor-missing", `Cannot seek past the last row of "${label}": the server reported another page but issued no cursor for it. An ordering with no stored value to compare against — relevance (\`_score\`) — cannot key a cursor. Drop \`cursor\` to page by offset.`);
4773
+ if (next === after) throw new RebasePaginationError("cursor-stalled", `Iterating "${label}" is stuck: two pages in a row ended on the same cursor, so the walk cannot advance. Continuing would loop forever. Page by offset instead, or report this — a cursor that does not move is a server-side bug.`);
4774
+ after = next;
3746
4775
  } else offset += rows.length;
3747
4776
  }
3748
4777
  }
@@ -4172,15 +5201,66 @@ function serializeFilter(filter) {
4172
5201
  return result;
4173
5202
  }
4174
5203
  /**
5204
+ * The spellings a null-testing operator's operand may take.
5205
+ *
5206
+ * The serializer writes `isnull.null`; a hand-written `isnull.true` means the
5207
+ * same thing and has always been accepted. Anything else after the operator is
5208
+ * not an operand it has — `notnull.reason` is a *value* — see
5209
+ * {@link deserializeSingle}.
5210
+ */
5211
+ var NULL_OPERANDS = /* @__PURE__ */ new Set([
5212
+ "null",
5213
+ "true",
5214
+ "false",
5215
+ ""
5216
+ ]);
5217
+ /**
4175
5218
  * Parse a single PostgREST dot-string into a `[WhereFilterOp, unknown]` tuple.
4176
5219
  *
4177
5220
  * All values are returned as strings — the wire format carries no type
4178
5221
  * metadata, so coercion is the data driver's responsibility.
4179
5222
  *
4180
- * If the string doesn't match a known operator prefix, it falls back to
4181
- * `["==", originalString]` (treating the whole string as an equality value).
4182
- * This intentional defense handles values like `"user@host.com"` or
4183
- * `"1.2.3"` that happen to contain dots.
5223
+ * ## When a leading segment is an operator, and when it is part of the value
5224
+ *
5225
+ * `?status=in.progress` and `?status=in.(a,b)` differ by one character and mean
5226
+ * entirely different things, and the reading here decides which. The rule, in
5227
+ * full:
5228
+ *
5229
+ * > A dot-string is read as `operator.operand` **only** when its first segment
5230
+ * > names a known REST operator **and** what follows is a well-formed operand
5231
+ * > *for that operator's arity*. Otherwise the whole string is the value.
5232
+ *
5233
+ * Arity, per operator family:
5234
+ *
5235
+ * - **List** operators (`in`, `nin`, `csa` — `LIST_OPS`) take a parenthesised
5236
+ * list and nothing else. `in.(draft,review)` is the operator; `in.progress`
5237
+ * is the *value* `"in.progress"`, because there is no list there and so no
5238
+ * `in` filter that could have been written. That case used to compile to
5239
+ * `status IN ('progress')` — a filter the caller never wrote, quietly
5240
+ * matching the wrong rows and, on a status field, hiding every row they were
5241
+ * looking for.
5242
+ * - **Null** operators (`isnull`, `notnull` — {@link NULL_OPS}) take no
5243
+ * operand: only {@link NULL_OPERANDS}. `notnull.reason` is the value
5244
+ * `"notnull.reason"`, not "reason is not null".
5245
+ * - **Everything else** takes one scalar, and any remainder is one — including
5246
+ * the empty string, so `eq.` really is "equals the empty string".
5247
+ *
5248
+ * ### The one ambiguity that remains, and how to write past it
5249
+ *
5250
+ * A scalar operator's operand is unconstrained, so `?status=like.that` is a
5251
+ * `LIKE 'that'` and no rule at this layer can tell it from the literal value
5252
+ * `"like.that"` — both are well-formed encodings, and picking either by guess
5253
+ * would break the other. Two spellings say "value" unambiguously, and both
5254
+ * round-trip:
5255
+ *
5256
+ * - `?status=eq.like.that` — name the operator. The *first* segment is consumed
5257
+ * as the operator and everything after it is the value, dots and all. This is
5258
+ * what `serializeFilter` emits, which is why the SDK never meets the
5259
+ * ambiguity at all.
5260
+ * - `?where={"status":["==","like.that"]}` — the JSON dialect's tuple form.
5261
+ *
5262
+ * Values that merely *contain* dots (`user@host.com`, `1.2.3`) were never
5263
+ * ambiguous: their first segment names no operator to begin with.
4184
5264
  */
4185
5265
  function deserializeSingle(raw) {
4186
5266
  const dotIndex = raw.indexOf(".");
@@ -4189,11 +5269,15 @@ function deserializeSingle(raw) {
4189
5269
  const rest = raw.substring(dotIndex + 1);
4190
5270
  const canonicalOp = REST_OP_LOOKUP.get(prefix);
4191
5271
  if (!canonicalOp) return ["==", raw];
4192
- if (NULL_OPS.has(canonicalOp)) return [canonicalOp, null];
5272
+ if (NULL_OPS.has(canonicalOp)) {
5273
+ if (!NULL_OPERANDS.has(rest)) return ["==", raw];
5274
+ return [canonicalOp, null];
5275
+ }
4193
5276
  if (rest.startsWith("(") && rest.endsWith(")")) {
4194
5277
  const inner = rest.slice(1, -1);
4195
5278
  return [canonicalOp, inner === EMPTY_LIST_TOKEN ? [] : splitListItems(inner)];
4196
5279
  }
5280
+ if (LIST_OPS.has(canonicalOp)) return ["==", raw];
4197
5281
  return [canonicalOp, rest];
4198
5282
  }
4199
5283
  /**
@@ -4321,7 +5405,7 @@ function splitLeafCondition(str) {
4321
5405
  }
4322
5406
  function deserializeLogicalCondition(str, nesting = 0) {
4323
5407
  if (nesting > 32) throw new Error(`Filter groups nest more than 32 levels deep. Flatten the condition — \`or(a,or(b,c))\` is \`or(a,b,c)\`.`);
4324
- const logicalMatch = str.match(/^(and|or)\((.+)\)$/);
5408
+ const logicalMatch = str.match(/^(and|or|not)\((.+)\)$/);
4325
5409
  if (logicalMatch) {
4326
5410
  const type = logicalMatch[1];
4327
5411
  const innerStr = logicalMatch[2];
@@ -4375,6 +5459,33 @@ function deserializeLogicalCondition(str, nesting = 0) {
4375
5459
  var noRealtime = (slug) => `Realtime is not available for "${slug}": its data source does not support subscriptions.`;
4376
5460
  /** What a client says when its data source cannot count. */
4377
5461
  var noCount = (slug) => `Counting is not available for "${slug}": its data source does not support it.`;
5462
+ /**
5463
+ * Derive the response key an aggregate comes back under.
5464
+ *
5465
+ * `sum(total)` → `sum_total`, `count()` → `count`. Written once, here, because
5466
+ * the REST parser derives the same alias from `?select=sum(total)` and the two
5467
+ * have to agree — a caller reading `row.sum_total` off an SDK result and off an
5468
+ * HTTP response is reading the same key or the SDK is broken.
5469
+ */
5470
+ function aggregateAlias(fn, field) {
5471
+ return field ? `${fn}_${field}` : fn;
5472
+ }
5473
+ function toDriverAggregate(select) {
5474
+ const field = select.field;
5475
+ return {
5476
+ fn: select.fn,
5477
+ field,
5478
+ alias: aggregateAlias(select.fn, field)
5479
+ };
5480
+ }
5481
+ /**
5482
+ * What a client says when its data source cannot aggregate.
5483
+ *
5484
+ * A stub rather than a fallback that fetches and reduces in JavaScript: that
5485
+ * would be wrong under a `limit` and unaffordable without one, and it would look
5486
+ * like it had worked.
5487
+ */
5488
+ var noAggregate = (slug) => `Aggregates are not available for "${slug}": its data source does not implement them.`;
4378
5489
  function createPrimaryKeyResolver(options) {
4379
5490
  const cache = /* @__PURE__ */ new Map();
4380
5491
  const warned = /* @__PURE__ */ new Set();
@@ -4396,6 +5507,89 @@ function createPrimaryKeyResolver(options) {
4396
5507
  };
4397
5508
  }
4398
5509
  /**
5510
+ * Build the admin's view model out of the row the wire serves.
5511
+ *
5512
+ * The wire has ONE shape, for every consumer: flat columns, typed the way the
5513
+ * database typed them, and a relation rendered as the target's own columns (or
5514
+ * only its foreign key, when nothing asked for it). That is the REST contract,
5515
+ * what `find()` returns, what `listen()` pushes, and what the generated types
5516
+ * describe.
5517
+ *
5518
+ * The admin renders neither of those directly. Its date field requires a real
5519
+ * `Date` and rejects a string outright; its relation cells read `.data.values`
5520
+ * off a relation ref. Those requirements are the *admin's*, so they are met
5521
+ * here — in the browser, from the collection config the panel already has —
5522
+ * rather than by asking the server for a second wire shape.
5523
+ *
5524
+ * That second shape is what this replaces. Until 2026-09-09 the realtime wire
5525
+ * carried the view model and every other read carried flat rows, so `find()`
5526
+ * and `listen()` answered one query two ways; unifying the wire without doing
5527
+ * this conversion is what left every date cell reading "Invalid date value"
5528
+ * and every relation cell "Unexpected value".
5529
+ *
5530
+ * Values already in view-model form pass through untouched: a driver that
5531
+ * still sends `{ __type: "date" }` or a relation ref (the client revives both)
5532
+ * is served by the same walk.
5533
+ */
5534
+ function toViewModelValues(values, properties, collection, resolveCollection) {
5535
+ if (!properties) return values;
5536
+ const relations = collection ? resolveCollectionRelations(collection) : {};
5537
+ let out;
5538
+ const write = (key, value) => {
5539
+ out = out ?? { ...values };
5540
+ out[key] = value;
5541
+ };
5542
+ for (const [key, rawProperty] of Object.entries(properties)) {
5543
+ const property = rawProperty;
5544
+ if (!property) continue;
5545
+ if (!(key in values)) {
5546
+ const fkRelation = relations[key];
5547
+ const column = fkRelation && "localKey" in fkRelation ? fkRelation.localKey : void 0;
5548
+ const fk = column !== void 0 ? values[column] ?? values[toWireKey(column)] : void 0;
5549
+ const fkTarget = fkRelation?.targetSlug;
5550
+ if (fkTarget && (typeof fk === "string" || typeof fk === "number")) write(key, new EntityRelation(fk, fkTarget));
5551
+ continue;
5552
+ }
5553
+ const value = values[key];
5554
+ if (value === null || value === void 0) continue;
5555
+ const relation = relations[key];
5556
+ if (relation && (property.type === "relation" || property.of?.type === "relation" || property.type === "array")) {
5557
+ const target = relation.targetSlug;
5558
+ if (!target) continue;
5559
+ const targetProperties = resolveCollection?.(target)?.properties;
5560
+ const targetCollection = resolveCollection?.(target);
5561
+ const toRef = (item) => {
5562
+ if (item instanceof EntityRelation) return item;
5563
+ if (typeof item === "object" && item !== null && "__type" in item) return item;
5564
+ if (typeof item === "object" && item !== null) {
5565
+ const row = item;
5566
+ const keys = targetCollection ? resolvePrimaryKeys(targetCollection) : [];
5567
+ const id = keys.length > 0 ? buildCompositeId(row, keys) : row.id;
5568
+ if (id === void 0 || id === null || id === "") return item;
5569
+ return new EntityRelation(id, target, {
5570
+ id,
5571
+ path: target,
5572
+ values: toViewModelValues(row, targetProperties, targetCollection, resolveCollection)
5573
+ });
5574
+ }
5575
+ if (typeof item === "string" || typeof item === "number") return new EntityRelation(item, target);
5576
+ return item;
5577
+ };
5578
+ write(key, Array.isArray(value) ? value.map(toRef) : toRef(value));
5579
+ continue;
5580
+ }
5581
+ if (property.type === "date" && !(value instanceof Date)) {
5582
+ if (typeof value === "string" || typeof value === "number") {
5583
+ const date = new Date(value);
5584
+ write(key, isNaN(date.getTime()) ? null : date);
5585
+ }
5586
+ continue;
5587
+ }
5588
+ if (property.type === "map" && property.properties && typeof value === "object" && !Array.isArray(value)) write(key, toViewModelValues(value, property.properties, void 0, resolveCollection));
5589
+ }
5590
+ return out ?? values;
5591
+ }
5592
+ /**
4399
5593
  * Give a flat row the Entity view-model the admin renders.
4400
5594
  *
4401
5595
  * The address is *derived here* — it is not a column, and the row it came from
@@ -4406,12 +5600,12 @@ function createPrimaryKeyResolver(options) {
4406
5600
  * `primaryKeys` empty falls back to a literal `id` on the row: drivers other
4407
5601
  * than postgres still serve rows with one, and this keeps them working.
4408
5602
  */
4409
- function rowToEntity(row, slug, primaryKeys = []) {
5603
+ function rowToEntity(row, slug, primaryKeys = [], toViewModel) {
4410
5604
  const { _matches, ...values } = row;
4411
5605
  return {
4412
5606
  id: primaryKeys.length > 0 ? buildCompositeId(row, primaryKeys) : row.id,
4413
5607
  path: slug,
4414
- values,
5608
+ values: toViewModel ? toViewModel(values) : values,
4415
5609
  ..._matches ? { searchMatches: _matches } : {}
4416
5610
  };
4417
5611
  }
@@ -4431,14 +5625,16 @@ function inlineEnvelope(envelope) {
4431
5625
  * Replace every relation envelope on a row with the target's flat columns.
4432
5626
  *
4433
5627
  * The SDK serves one relation shape — the inlined one (see
4434
- * {@link RestFetchService}) — and reads that come back through a *driver*
4435
- * method rather than the REST pipeline still carry envelopes. Realtime is the
4436
- * one such read left: there is no `listenForRest`, so the rows arrive shaped
4437
- * for the admin and are flattened here instead.
5628
+ * {@link RestFetchService}) — and Postgres now serves it on every read, so
5629
+ * against that driver this walk finds nothing to do. It stays for the drivers
5630
+ * whose own `fetchCollection` still answers with refs: a developer reading
5631
+ * through this accessor gets one shape whichever driver is underneath.
4438
5632
  *
4439
5633
  * Only applied where the REST pipeline is the contract (see `find`); a driver
4440
- * without a `restFetchService` keeps whatever it returns, so the admin's own
4441
- * path through {@link buildRebaseData} is untouched.
5634
+ * without a `restFetchService` keeps whatever it returns.
5635
+ *
5636
+ * Note this is NOT how the admin gets its view model — that is built in the
5637
+ * browser by {@link toViewModelValues}, from the same flat row.
4442
5638
  */
4443
5639
  function inlineRelationRefs(row) {
4444
5640
  let out;
@@ -4451,30 +5647,43 @@ function inlineRelationRefs(row) {
4451
5647
  }
4452
5648
  return out ?? row;
4453
5649
  }
4454
- function createDriverAccessor(driver, slug, getPks = () => []) {
5650
+ function createDriverAccessor(driver, slug, getPks = () => [], toViewModel) {
4455
5651
  const accessor = {
4456
5652
  async find(params) {
4457
5653
  const filter = params?.where ? deserializeFilter(params.where) : void 0;
4458
5654
  const { limit, offset, driverOffset } = resolveFindWindow(params);
5655
+ const cursor = params?.after ? decodeCursor(params.after) : void 0;
5656
+ const orderBy = cursor ? reconcileCursorOrder(cursor, normalizeOrderBy(params?.orderBy)) : normalizeOrderBy(params?.orderBy);
5657
+ const startAfter = cursor ? cursorToStartAfter(cursor) : void 0;
5658
+ const probeLimit = startAfter ? limit + 1 : limit;
4459
5659
  const fetchService = driver.restFetchService;
4460
- const rows = fetchService ? await fetchService.fetchCollectionForRest(slug, {
5660
+ const fetched = fetchService ? await fetchService.fetchCollectionForRest(slug, {
4461
5661
  filter,
4462
5662
  logical: params?.logical,
4463
- limit,
4464
- offset: driverOffset,
4465
- orderBy: normalizeOrderBy(params?.orderBy),
4466
- searchString: params?.searchString
5663
+ limit: probeLimit,
5664
+ offset: startAfter ? void 0 : driverOffset,
5665
+ startAfter,
5666
+ orderBy,
5667
+ searchString: params?.searchString,
5668
+ fields: params?.fields,
5669
+ distinct: params?.distinct
4467
5670
  }, params?.include) : await driver.fetchCollection({
4468
5671
  path: slug,
4469
- limit,
4470
- offset: driverOffset,
5672
+ limit: probeLimit,
5673
+ offset: startAfter ? void 0 : driverOffset,
5674
+ startAfter,
4471
5675
  filter,
4472
5676
  logical: params?.logical,
4473
- orderBy: normalizeOrderBy(params?.orderBy),
4474
- searchString: params?.searchString
5677
+ orderBy,
5678
+ searchString: params?.searchString,
5679
+ include: params?.include,
5680
+ fields: params?.fields,
5681
+ distinct: params?.distinct
4475
5682
  });
5683
+ const seeking = startAfter !== void 0;
5684
+ const rows = seeking ? fetched.slice(0, limit) : fetched;
4476
5685
  let total = rows.length + offset;
4477
- let hasMore = rows.length >= limit;
5686
+ let hasMore = seeking ? fetched.length > limit : rows.length >= limit;
4478
5687
  if (driver.count) {
4479
5688
  total = await driver.count({
4480
5689
  path: slug,
@@ -4482,15 +5691,18 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
4482
5691
  logical: params?.logical,
4483
5692
  searchString: params?.searchString
4484
5693
  });
4485
- hasMore = offset + rows.length < total;
5694
+ if (!seeking) hasMore = offset + rows.length < total;
4486
5695
  }
5696
+ const last = rows[rows.length - 1];
5697
+ const nextCursor = hasMore && last && driver.restFetchService?.cursorFor ? driver.restFetchService.cursorFor(slug, last, orderBy) : void 0;
4487
5698
  return {
4488
- data: rows.map((row) => rowToEntity(row, slug, getPks())),
5699
+ data: rows.map((row) => rowToEntity(row, slug, getPks(), toViewModel)),
4489
5700
  meta: {
4490
5701
  total,
4491
5702
  limit,
4492
5703
  offset,
4493
- hasMore
5704
+ hasMore,
5705
+ ...nextCursor && { nextCursor }
4494
5706
  }
4495
5707
  };
4496
5708
  },
@@ -4500,22 +5712,31 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
4500
5712
  path: slug,
4501
5713
  id
4502
5714
  });
4503
- return row ? rowToEntity(row, slug, getPks()) : void 0;
5715
+ return row ? rowToEntity(row, slug, getPks(), toViewModel) : void 0;
4504
5716
  },
5717
+ aggregate: driver.restFetchService?.aggregate ? async (params) => driver.restFetchService.aggregate(slug, {
5718
+ aggregates: params.select.map(toDriverAggregate),
5719
+ groupBy: params.groupBy,
5720
+ filter: params.where ? deserializeFilter(params.where) : void 0,
5721
+ logical: params.logical,
5722
+ searchString: params.searchString,
5723
+ limit: params.limit
5724
+ }) : void 0,
4505
5725
  async create(data, id) {
4506
5726
  return rowToEntity(await driver.save({
4507
5727
  path: slug,
4508
5728
  values: data,
4509
5729
  id,
4510
5730
  status: "new"
4511
- }), slug, getPks());
5731
+ }), slug, getPks(), toViewModel);
4512
5732
  },
4513
5733
  createMany: driver.saveMany ? async (data, options) => {
4514
5734
  return (await driver.saveMany({
4515
5735
  path: slug,
4516
5736
  rows: data,
4517
- upsert: options?.upsert
4518
- })).map((row) => rowToEntity(row, slug, getPks()));
5737
+ upsert: options?.upsert,
5738
+ onConflict: options?.onConflict
5739
+ })).map((row) => rowToEntity(row, slug, getPks(), toViewModel));
4519
5740
  } : void 0,
4520
5741
  async update(id, data) {
4521
5742
  return rowToEntity(await driver.save({
@@ -4523,7 +5744,7 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
4523
5744
  values: data,
4524
5745
  id,
4525
5746
  status: "existing"
4526
- }), slug, getPks());
5747
+ }), slug, getPks(), toViewModel);
4527
5748
  },
4528
5749
  async delete(id) {
4529
5750
  return driver.delete({ row: {
@@ -4539,7 +5760,7 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
4539
5760
  id: u.id,
4540
5761
  values: u.data
4541
5762
  }))
4542
- })).map((row) => rowToEntity(row, slug, getPks()));
5763
+ })).map((row) => rowToEntity(row, slug, getPks(), toViewModel));
4543
5764
  } : void 0,
4544
5765
  deleteMany: driver.deleteMany ? async (ids) => {
4545
5766
  await driver.deleteMany({
@@ -4571,7 +5792,7 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
4571
5792
  vectorSearch: params?.vectorSearch,
4572
5793
  onUpdate: (entities) => {
4573
5794
  onUpdate({
4574
- data: entities.map((row) => rowToEntity(normalize(row), slug, getPks())),
5795
+ data: entities.map((row) => rowToEntity(normalize(row), slug, getPks(), toViewModel)),
4575
5796
  meta: {
4576
5797
  total: offset + entities.length,
4577
5798
  limit,
@@ -4588,7 +5809,7 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
4588
5809
  return driver.listenOne({
4589
5810
  path: slug,
4590
5811
  id,
4591
- onUpdate: (entity) => onUpdate(entity ? rowToEntity(normalize(entity), slug, getPks()) : void 0),
5812
+ onUpdate: (entity) => onUpdate(entity ? rowToEntity(normalize(entity), slug, getPks(), toViewModel) : void 0),
4592
5813
  onError
4593
5814
  });
4594
5815
  } : void 0,
@@ -4630,13 +5851,32 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
4630
5851
  * await data.products.create({ name: "Camera", price: 299 });
4631
5852
  * const { data: items } = await data.products.find({ where: { status: ["==", "published"] } });
4632
5853
  */
5854
+ /**
5855
+ * The view-model converter for one collection, or `undefined` when there is no
5856
+ * collection config to build it from.
5857
+ *
5858
+ * Absent is the honest answer for every consumer that is not the admin: the
5859
+ * flat SDK derives itself from this same layer (`buildSdkData`) and must keep
5860
+ * the wire's own types, and it registers no collection resolver.
5861
+ */
5862
+ function createViewModelConverter(options) {
5863
+ if (!options?.resolveCollection) return () => void 0;
5864
+ return function converterFor(slug) {
5865
+ return (values) => {
5866
+ const collection = options.resolveCollection?.(slug);
5867
+ if (!collection) return values;
5868
+ return toViewModelValues(values, collection.properties, collection, options.resolveCollection);
5869
+ };
5870
+ };
5871
+ }
4633
5872
  function buildRebaseData(driver, options) {
4634
5873
  const cache = /* @__PURE__ */ new Map();
4635
5874
  const primaryKeysFor = createPrimaryKeyResolver(options);
5875
+ const viewModelFor = createViewModelConverter(options);
4636
5876
  function getAccessor(slug) {
4637
5877
  let accessor = cache.get(slug);
4638
5878
  if (!accessor) {
4639
- accessor = createDriverAccessor(driver, slug, () => primaryKeysFor(slug));
5879
+ accessor = createDriverAccessor(driver, slug, () => primaryKeysFor(slug), viewModelFor(slug));
4640
5880
  cache.set(slug, accessor);
4641
5881
  }
4642
5882
  return accessor;
@@ -4691,9 +5931,14 @@ var SdkQueryBuilder = class {
4691
5931
  return this;
4692
5932
  }
4693
5933
  /** Called again, this adds a tie-breaker rather than replacing the sort. */
4694
- orderBy(column, direction = "asc") {
5934
+ orderBy(column, direction = "asc", nulls) {
4695
5935
  const existing = normalizeOrderBy(this.params.orderBy) ?? [];
4696
- this.params.orderBy = [...existing, [sortKeyToString(column), direction]];
5936
+ const key = sortKeyToString(column);
5937
+ this.params.orderBy = [...existing, nulls ? [
5938
+ key,
5939
+ direction,
5940
+ nulls
5941
+ ] : [key, direction]];
4697
5942
  return this;
4698
5943
  }
4699
5944
  limit(count) {
@@ -4718,13 +5963,39 @@ var SdkQueryBuilder = class {
4718
5963
  };
4719
5964
  return this;
4720
5965
  }
5966
+ /**
5967
+ * Load relations. Merges rather than replaces, so `.include("author")` then
5968
+ * `.include({ comments: { limit: 5 } })` asks for both — a builder call that
5969
+ * silently discarded an earlier one is the same defect `where` had.
5970
+ */
4721
5971
  include(...relations) {
4722
- this.params.include = relations;
5972
+ this.params.include = mergeIncludeSpecs(this.params.include, relations);
5973
+ return this;
5974
+ }
5975
+ fields(...columns) {
5976
+ this.params.fields = [...this.params.fields ?? [], ...columns];
5977
+ return this;
5978
+ }
5979
+ distinct(enabled = true) {
5980
+ this.params.distinct = enabled;
5981
+ return this;
5982
+ }
5983
+ after(cursor) {
5984
+ this.params.after = cursor;
4723
5985
  return this;
4724
5986
  }
4725
5987
  async find() {
4726
5988
  return this.client.find(this.params);
4727
5989
  }
5990
+ /** Aggregate the matching rows. See {@link SDKCollectionClient.aggregate}. */
5991
+ async aggregate(params) {
5992
+ return this.client.aggregate({
5993
+ ...params,
5994
+ where: this.params.where,
5995
+ logical: this.params.logical,
5996
+ searchString: this.params.searchString
5997
+ });
5998
+ }
4728
5999
  /**
4729
6000
  * Page through everything this query matches, one row at a time.
4730
6001
  *
@@ -4802,6 +6073,24 @@ function toSdkCollectionClient(snap, slug = "collection") {
4802
6073
  if (!snap.createMany) throw new Error("Bulk writes are not supported by this collection's data source. Fall back to create() per record.");
4803
6074
  return (await snap.createMany(data, options)).map(entityToRow);
4804
6075
  },
6076
+ /**
6077
+ * One row through the bulk path, because the bulk path is where the
6078
+ * conflict target lives.
6079
+ *
6080
+ * `CollectionAccessor` has no single-row upsert and adding one would
6081
+ * mean a second way to say the same thing to the same driver method —
6082
+ * `saveMany` already takes `upsert` and `onConflict`, and a batch of
6083
+ * one is exactly an upsert of one.
6084
+ */
6085
+ async upsert(data, options) {
6086
+ if (!snap.createMany) throw new Error("Upsert is not supported by this collection's data source: it needs a bulk write, which this driver does not implement. Fall back to create() or update().");
6087
+ const row = (await snap.createMany([data], {
6088
+ upsert: true,
6089
+ onConflict: options?.onConflict
6090
+ }))[0];
6091
+ if (!row) throw new Error(`Upsert into "${slug}" returned no row.`);
6092
+ return entityToRow(row);
6093
+ },
4805
6094
  async update(id, data) {
4806
6095
  return entityToRow(await snap.update(id, data));
4807
6096
  },
@@ -4834,12 +6123,16 @@ function toSdkCollectionClient(snap, slug = "collection") {
4834
6123
  if (typeof columnOrCondition === "object") return builder.where(columnOrCondition);
4835
6124
  return builder.where(columnOrCondition, operator, value);
4836
6125
  },
4837
- orderBy: (column, direction) => new SdkQueryBuilder(client).orderBy(column, direction),
6126
+ orderBy: (column, direction, nulls) => new SdkQueryBuilder(client).orderBy(column, direction, nulls),
4838
6127
  limit: (count) => new SdkQueryBuilder(client).limit(count),
4839
6128
  offset: (count) => new SdkQueryBuilder(client).offset(count),
4840
6129
  search: (searchString) => new SdkQueryBuilder(client).search(searchString),
4841
6130
  vectorSearch: (property, vector, options) => new SdkQueryBuilder(client).vectorSearch(property, vector, options),
4842
- include: (...relations) => new SdkQueryBuilder(client).include(...relations)
6131
+ include: (...relations) => new SdkQueryBuilder(client).include(...relations),
6132
+ fields: (...columns) => new SdkQueryBuilder(client).fields(...columns),
6133
+ distinct: (enabled) => new SdkQueryBuilder(client).distinct(enabled),
6134
+ after: (cursor) => new SdkQueryBuilder(client).after(cursor),
6135
+ aggregate: snap.aggregate ? (params) => snap.aggregate(params) : unsupportedMethod(noAggregate(slug))
4843
6136
  };
4844
6137
  return client;
4845
6138
  }
@@ -4927,6 +6220,6 @@ function buildRoutedRebaseData({ defaultData, sources, resolveKey }) {
4927
6220
  } });
4928
6221
  }
4929
6222
  //#endregion
4930
- export { ANONYMOUS_USER_ID as A, buildCompositeId as C, ListLimitError as D, toSnakeCase as E, Vector as F, RebaseApiError as I, RebaseClientError as L, EntityReference as M, EntityRelation as N, MAX_LIST_LIMIT as O, GeoPoint as P, isUnsupported as R, resolveCollectionRelations as S, suggestNearMiss as T, getEffectiveSecurityRules as _, deserializeLogicalCondition as a, getTableName as b, collectAllPages as c, normalizeOrderBy as d, serializeOrderBy as f, resolveDataSource as g, createDataSourceRegistry as h, deserializeFilter as i, isAnonymousUid as j, resolveClientListLimit as k, paginateFind as l, CollectionRegistry as m, buildSdkData as n, serializeFilter as o, defaultUsersCollection as p, UnknownFilterOperatorError as r, serializeLogicalCondition as s, buildRoutedRebaseData as t, resolveFindWindow as u, fieldKeyForColumn as v, resolvePrimaryKeys as w, isRelationRequired as x, findRelation as y, unsupportedMethod as z };
6223
+ export { isFieldOperation as $, createDataSourceRegistry as A, resolveCollectionRelations as B, decodeCursor as C, restrictedFieldNames as D, effectiveAccess as E, securityRuleToConditions as F, suggestNearMiss as G, buildCompositeId as H, fieldKeyForColumn as I, MAX_LIST_LIMIT as J, toSnakeCase as K, findRelation as L, resolveDataSource as M, getEffectiveSecurityRules as N, defaultUsersCollection as O, getTenantConfig as P, hasFieldOperation as Q, getTableName as R, cursorToStartAfter as S, canWriteField as T, resolvePrimaryKeys as U, enumToObjectEntries as V, hydrateRegExp as W, BATCH_REF_KEY as X, resolveClientListLimit as Y, FIELD_OPERATORS as Z, OrderBySpecError as _, deserializeLogicalCondition as a, Vector as at, CursorError as b, collectAllPages as c, isUnsupported as ct, IncludeSpecError as d, ANONYMOUS_USER_ID as et, deserializeInclude as f, topLevelIncludeNames as g, serializeInclude as h, deserializeFilter as i, GeoPoint as it, isRelationalCollection as j, CollectionRegistry as k, paginateFind as l, unsupportedMethod as lt, normalizeInclude as m, buildSdkData as n, EntityReference as nt, serializeFilter as o, RebaseApiError as ot, mergeIncludeSpecs as p, ListLimitError as q, UnknownFilterOperatorError as r, EntityRelation as rt, serializeLogicalCondition as s, RebaseClientError as st, buildRoutedRebaseData as t, isAnonymousUid as tt, resolveFindWindow as u, normalizeOrderBy as v, reconcileCursorOrder as w, CursorMismatchError as x, serializeOrderBy as y, isRelationRequired as z };
4931
6224
 
4932
- //# sourceMappingURL=src-Dq-I3Ybx.js.map
6225
+ //# sourceMappingURL=src-DqZ9YiGA.js.map