@rebasepro/common 0.19.1 → 0.19.2-canary.g09316f6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.es.js CHANGED
@@ -1,4 +1,4 @@
1
- import { ALL_WHERE_FILTER_OPS, ANONYMOUS_USER_ID, ANONYMOUS_USER_IDS, CANONICAL_TO_REST, DEFAULT_DATA_SOURCE_KEY, DEFAULT_LIST_LIMIT, EntityReference, EntityRelation, NULL_OPS, REST_TO_CANONICAL, RLS_IS_ANONYMOUS_SQL, RLS_ROLES_SQL, RLS_UID_SQL, RebaseApiError, getDataSourceCapabilities, getDeclaredSubcollections, isAnonymousUid, isManyToMany, isPostgresCollectionConfig, isRelationAggregateSort, isRelationalCollectionConfig, isUnsupported, policy, resolveResourceRefs, rewriteLegacyRlsFunctions, sortKeyToString, toCanonicalOp, unsupportedMethod } from "@rebasepro/types";
1
+ import { ALL_WHERE_FILTER_OPS, ANONYMOUS_USER_ID, ANONYMOUS_USER_IDS, CANONICAL_TO_REST, DEFAULT_DATA_SOURCE_KEY, DEFAULT_LIST_LIMIT, DEFAULT_TENANT_BYPASS_ROLES, EntityReference, EntityRelation, JUNCTION_PIVOT_KEY, LIST_OPS, MAX_INCLUDE_DEPTH, NULL_OPS, REST_TO_CANONICAL, RLS_IS_ANONYMOUS_SQL, RLS_JWT_SQL, RLS_ROLES_SQL, RLS_UID_SQL, RebaseApiError, getDataSourceCapabilities, getDeclaredSubcollections, isAnonymousUid, isManyToMany, isPostgresCollectionConfig, isRelationAggregateSort, isRelationalCollectionConfig, isTenantClaimSource, isUnsupported, policy, resolveResourceRefs, rewriteLegacyRlsFunctions, sortKeyToString, toCanonicalOp, unsupportedMethod } from "@rebasepro/types";
2
2
  import { deepClone, firstFreeKey, generateForeignKeyName, getIn, getPolicyNamesForRules, getPolicyOperations, isDefaultFieldConfigId, mergeDeep, prettifyIdentifier, randomString, removeFunctions, toSnakeCase, toWireKey } from "@rebasepro/utils";
3
3
  import jsonLogic from "json-logic-js";
4
4
  import { deepEqual } from "fast-equals";
@@ -10,10 +10,28 @@ var DEFAULT_ONE_OF_VALUE = "value";
10
10
  function isPropertyBuilder(property) {
11
11
  return typeof property?.dynamicProps === "function";
12
12
  }
13
+ /**
14
+ * What a form opens with: a value for every property it can write.
15
+ *
16
+ * `excludeFromApi` columns are left out, and that is the whole of the rule —
17
+ * they are not part of the API surface in either direction, so there is nothing
18
+ * for a form to open showing and nothing it may send back. Including them was
19
+ * not cosmetic: the baseline is what gets submitted, so a new record carried
20
+ * `passwordHash: null` and `emailVerificationToken: null` into the create, and
21
+ * the server refused the whole write with "these columns are the server's to
22
+ * set" — the users collection could not be added to from the panel at all. The
23
+ * fields were invisible on screen (`admin.disabled.hidden`), which is what made
24
+ * the error read as being about the roles the operator *had* just edited.
25
+ *
26
+ * Server-side defaulting does not come through here: `applyDefaultValuesOnCreate`
27
+ * asks each property for its own default, so an excluded column with a declared
28
+ * `defaultValue` is still filled in on an in-process write.
29
+ */
13
30
  function getDefaultValuesFor(properties) {
14
31
  if (!properties) return {};
15
32
  return Object.entries(properties).map(([key, property]) => {
16
33
  if (!property) return {};
34
+ if (property.excludeFromApi) return {};
17
35
  const value = getDefaultValueFor(property);
18
36
  return value === void 0 ? {} : { [key]: value };
19
37
  }).reduce((a, b) => ({
@@ -24,7 +42,7 @@ function getDefaultValuesFor(properties) {
24
42
  function getDefaultValueFor(property) {
25
43
  if (!property) return void 0;
26
44
  if (isPropertyBuilder(property)) return void 0;
27
- if (property.defaultValue || property.defaultValue === null) return property.defaultValue;
45
+ if (property.defaultValue !== void 0) return property.defaultValue;
28
46
  else if (property.type === "map" && property.properties) {
29
47
  const defaultValuesFor = getDefaultValuesFor(property.properties);
30
48
  if (Object.keys(defaultValuesFor).length === 0) return void 0;
@@ -55,6 +73,101 @@ function updateDateAutoValues({ inputValues, properties, status, timestampNowVal
55
73
  }) ?? {};
56
74
  }
57
75
  /**
76
+ * Stamp the acting user's uid into the `user_on_create` / `user_on_update`
77
+ * columns a collection declares.
78
+ *
79
+ * A deliberate sibling of {@link updateDateAutoValues} rather than another
80
+ * branch inside it. The two share a shape and nothing else: one takes an
81
+ * instant the server generates and the other takes an identity the request
82
+ * carries, so overloading the timestamp function would have meant threading a
83
+ * second, unrelated argument through every one of its callers and letting a
84
+ * `date` property and a `string` property compete for the same `autoValue`
85
+ * union. Called side by side in the driver.
86
+ *
87
+ * The stamped value overwrites whatever arrived in the body. A caller who can
88
+ * set `createdBy` is a caller who can attribute their write to somebody else,
89
+ * which is the one thing an audit column must not allow.
90
+ *
91
+ * `uid` is `undefined` for an anonymous request, a service token or an
92
+ * in-process write; the column is set to an explicit `null` there. Explicit
93
+ * matters on an update: leaving the key absent would keep whatever uid the
94
+ * column already held, so an anonymous edit would be recorded as the previous
95
+ * editor's. Refusing that write outright is `required`'s job, not this
96
+ * function's — see `assertWriteValuesValid`.
97
+ *
98
+ * Top-level properties only, deliberately, unlike {@link updateDateAutoValues}.
99
+ * `traverseValuesProperties` cannot express "set this key to null" — a `null`
100
+ * from its operation means "leave the key out" — and an audit column nested
101
+ * inside a `map` is not a column at all, so there is nothing down there to
102
+ * stamp.
103
+ *
104
+ * @group Driver
105
+ */
106
+ function updateUserAutoValues({ inputValues, properties, status, uid }) {
107
+ const result = { ...inputValues ?? {} };
108
+ for (const [key, property] of Object.entries(properties ?? {})) {
109
+ const prop = property;
110
+ if (!prop || prop.type !== "string") continue;
111
+ const autoValue = prop.autoValue;
112
+ if (autoValue !== "user_on_create" && autoValue !== "user_on_update") continue;
113
+ if (status === "existing" && autoValue === "user_on_create") continue;
114
+ result[key] = uid ?? null;
115
+ }
116
+ return result;
117
+ }
118
+ /**
119
+ * Fill in the `defaultValue`s a create left unset.
120
+ *
121
+ * `defaultValue` was read by exactly one thing: the Studio's form, which uses it
122
+ * to prefill inputs. Every other way into the same collection — the REST create,
123
+ * the SDK, the socket, an import — stored whatever arrived and nothing where the
124
+ * key was absent. So `active: { type: "boolean", defaultValue: true }` produced
125
+ * rows with `active` unset through the API and `true` through the panel, from
126
+ * one declaration that reads like a promise about the data.
127
+ *
128
+ * Only genuinely absent keys are filled. An explicit `null` is a caller saying
129
+ * "no value", which is a different statement from not mentioning the field, and
130
+ * overwriting it would make the default impossible to opt out of.
131
+ *
132
+ * `getDefaultValuesFor` also invents a per-type default for properties with no
133
+ * `defaultValue` at all (`false` for a boolean, `[]` for an array, `null` for
134
+ * the rest) — right for a form, which must render *something* in every input,
135
+ * and wrong here, where an absent key must stay absent so the column's own
136
+ * DEFAULT applies. Only declared defaults are taken.
137
+ *
138
+ * @param values the caller's payload
139
+ * @param properties the collection's declared properties
140
+ * @group Driver
141
+ */
142
+ function applyDefaultValuesOnCreate(values, properties) {
143
+ if (!properties) return values ?? {};
144
+ const result = { ...values ?? {} };
145
+ for (const [key, property] of Object.entries(properties)) {
146
+ if (!property) continue;
147
+ if (!declaresDefault(property)) continue;
148
+ const defaultValue = getDefaultValueFor(property);
149
+ if (result[key] !== void 0) {
150
+ if (property.type === "map" && property.defaultValue === void 0 && isPlainObject(result[key])) result[key] = {
151
+ ...defaultValue ?? {},
152
+ ...result[key]
153
+ };
154
+ continue;
155
+ }
156
+ if (defaultValue !== void 0) result[key] = defaultValue;
157
+ }
158
+ return result;
159
+ }
160
+ /** Does this property, or something nested under it, state a `defaultValue`? */
161
+ function declaresDefault(property) {
162
+ if (isPropertyBuilder(property)) return false;
163
+ if (property.defaultValue !== void 0) return true;
164
+ if (property.type === "map" && property.properties) return Object.values(property.properties).some((child) => child && declaresDefault(child));
165
+ return false;
166
+ }
167
+ function isPlainObject(value) {
168
+ return typeof value === "object" && value !== null && !Array.isArray(value);
169
+ }
170
+ /**
58
171
  * Add missing required fields, expected in the collection, to the values of a entity
59
172
  * @param values
60
173
  * @param properties
@@ -532,7 +645,8 @@ function resolveRelation(relation, sourceCollection, propertyKey) {
532
645
  through: {
533
646
  table: relation.through?.table ?? [sourceTable, targetTable].sort().join("_"),
534
647
  sourceColumn: relation.through?.sourceColumn ?? generateForeignKeyName(sourceName),
535
- targetColumn: relation.through?.targetColumn ?? generateForeignKeyName(relationName)
648
+ targetColumn: relation.through?.targetColumn ?? generateForeignKeyName(relationName),
649
+ properties: relation.through?.properties ?? {}
536
650
  }
537
651
  };
538
652
  }
@@ -1526,8 +1640,9 @@ function compile(expr, scope) {
1526
1640
  case "not": return `NOT (${compile(expr.operand, scope)})`;
1527
1641
  case "compare": {
1528
1642
  const castForAuthUid = (operand, sqlText, other) => other.kind === "authUid" && (operand.kind === "field" || operand.kind === "outerField") ? `(${sqlText})::text` : sqlText;
1529
- const leftSql = castForAuthUid(expr.left, operandToSql(expr.left, scope), expr.right);
1530
- const rightSql = castForAuthUid(expr.right, operandToSql(expr.right, scope), expr.left);
1643
+ const claimSql = (operand, other) => operand.kind === "authClaim" ? authClaimSql(operand.name, claimCastType(other, scope)) : void 0;
1644
+ const leftSql = claimSql(expr.left, expr.right) ?? castForAuthUid(expr.left, operandToSql(expr.left, scope), expr.right);
1645
+ const rightSql = claimSql(expr.right, expr.left) ?? castForAuthUid(expr.right, operandToSql(expr.right, scope), expr.left);
1531
1646
  return `${leftSql} ${COMPARE_SQL[expr.op]} ${rightSql}`;
1532
1647
  }
1533
1648
  case "rolesOverlap": return `string_to_array(${RLS_ROLES_SQL}, ',') && ${rolesArraySql(expr.roles)}`;
@@ -1575,9 +1690,124 @@ function operandToSql(operand, scope) {
1575
1690
  case "literal": return quoteLiteral(operand.value);
1576
1691
  case "authUid": return RLS_UID_SQL;
1577
1692
  case "authRoles": return `string_to_array(${RLS_ROLES_SQL}, ',')`;
1693
+ case "authClaim": return authClaimSql(operand.name, "text");
1578
1694
  }
1579
1695
  }
1580
1696
  /**
1697
+ * The Postgres type a claim has to be cast to, to be compared with `operand`.
1698
+ *
1699
+ * `"text"` means "no cast": a claim already is text, and a `text` / `varchar`
1700
+ * column compares with it directly and keeps using its index.
1701
+ *
1702
+ * Resolved from the *property*, and through a relation's target when the column
1703
+ * is a foreign key — a `belongsTo` tenant field is the ordinary shape, and its
1704
+ * column's type is the target collection's primary key type rather than
1705
+ * anything visible on the property itself. Unknown resolves to `"text"`, which
1706
+ * is the safe direction: a redundant `text` comparison costs nothing, while a
1707
+ * missing `uuid` cast is a `CREATE POLICY` that fails and leaves a table with
1708
+ * RLS enabled and no policy — which denies every row.
1709
+ */
1710
+ function claimCastType(operand, scope) {
1711
+ if (operand.kind !== "field" && operand.kind !== "outerField") return "text";
1712
+ const collection = operand.kind === "field" ? scope.fieldCollection : scope.outerCollection;
1713
+ return propertyClaimCastType(operand.name, collection, scope.resolveCollection);
1714
+ }
1715
+ /**
1716
+ * What `name` on `collection` compares against a text claim as.
1717
+ *
1718
+ * `depth` stops a `reference` cycle — two collections whose keys point at each
1719
+ * other — from recursing forever. Two hops is more than any real declaration
1720
+ * needs.
1721
+ */
1722
+ function propertyClaimCastType(name, collection, resolveCollection, depth = 0) {
1723
+ const prop = collection?.properties?.[name];
1724
+ if (!prop || depth > 2) return "text";
1725
+ switch (prop.type) {
1726
+ case "string": {
1727
+ const sp = prop;
1728
+ if (sp.enum) return "text";
1729
+ return sp.isId === "uuid" || sp.columnType === "uuid" ? "uuid" : "text";
1730
+ }
1731
+ case "number": {
1732
+ const np = prop;
1733
+ if (np.columnType === "numeric") return "numeric";
1734
+ if (np.columnType || np.validation?.integer || np.isId) return "bigint";
1735
+ return "numeric";
1736
+ }
1737
+ case "reference": return primaryKeyClaimCastType(resolveTargetCollection(prop.path, resolveCollection), resolveCollection, depth);
1738
+ case "relation": return primaryKeyClaimCastType(resolveTargetCollection(relationTargetSlug(prop.relation), resolveCollection), resolveCollection, depth);
1739
+ default: return "text";
1740
+ }
1741
+ }
1742
+ /** The cast a column pointing at `target`'s primary key needs. */
1743
+ function primaryKeyClaimCastType(target, resolveCollection, depth) {
1744
+ if (!target) return "text";
1745
+ for (const [key, property] of Object.entries(target.properties ?? {})) {
1746
+ if (!property?.isId) continue;
1747
+ return propertyClaimCastType(key, target, resolveCollection, depth + 1);
1748
+ }
1749
+ return "text";
1750
+ }
1751
+ /**
1752
+ * A relation's target slug, whatever form the declaration took.
1753
+ *
1754
+ * `target` is a slug on a plain object and a thunk on a builder — the two
1755
+ * shapes `resolveRelation` normalises — and this runs on the raw property,
1756
+ * before that resolution.
1757
+ */
1758
+ function relationTargetSlug(relation) {
1759
+ const target = relation?.target;
1760
+ if (typeof target === "string") return target;
1761
+ if (typeof target !== "function") return void 0;
1762
+ try {
1763
+ const slug = target()?.slug;
1764
+ return typeof slug === "string" ? slug : void 0;
1765
+ } catch {
1766
+ return;
1767
+ }
1768
+ }
1769
+ function resolveTargetCollection(slug, resolveCollection) {
1770
+ if (!slug || !resolveCollection) return void 0;
1771
+ return resolveCollection(slug) ?? resolveCollection(slug.split("/").pop());
1772
+ }
1773
+ /**
1774
+ * `NULLIF(rebase.jwt() ->> 'name', '')`, cast to the column's type.
1775
+ *
1776
+ * Two things here are load-bearing beyond the cast:
1777
+ *
1778
+ * - **`NULLIF(…, '')`.** An absent claim already reads as NULL, but one set to
1779
+ * the empty string does not, and `''::uuid` raises rather than denying. Both
1780
+ * spellings of "this caller has no tenant" have to reach the comparison as
1781
+ * NULL, which is never true and therefore never a grant.
1782
+ * - **The guard.** The cast sits inside a `CASE` that first checks the text is
1783
+ * well-formed, because `'nonsense'::uuid` raises `invalid input syntax` — and
1784
+ * a policy that raises does not deny a row, it fails the whole statement. A
1785
+ * caller holding a malformed claim would get a 500 on every read of the table
1786
+ * instead of an empty list. `CASE` rather than an `AND` guard because only
1787
+ * `CASE` is guaranteed not to evaluate its arms out of order.
1788
+ *
1789
+ * The whole expression is STABLE (`rebase.jwt()` is), so Postgres evaluates it
1790
+ * once per query and can still use a btree index on the column it is compared
1791
+ * against — which is the entire reason the cast is on this side.
1792
+ */
1793
+ function authClaimSql(name, cast) {
1794
+ const claim = `NULLIF(${RLS_JWT_SQL} ->> ${quoteLiteral(name)}, '')`;
1795
+ if (cast === "text") return claim;
1796
+ return `CASE WHEN ${claim} ~ '${CLAIM_CAST_GUARDS[cast]}' THEN (${claim})::${cast} END`;
1797
+ }
1798
+ /**
1799
+ * The text a claim must match before it is cast, per target type.
1800
+ *
1801
+ * The `bigint` guard caps the digit run at 18 rather than matching any run of
1802
+ * digits: `'99999999999999999999'::bigint` is a range error, which fails the
1803
+ * statement exactly as the syntax error would have.
1804
+ */
1805
+ var CLAIM_CAST_GUARDS = {
1806
+ uuid: "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$",
1807
+ bigint: "^-?[0-9]{1,18}$",
1808
+ numeric: "^-?[0-9]+(\\.[0-9]+)?$"
1809
+ };
1810
+ /**
1581
1811
  * SQL prefix that qualifies a column of the outer RLS row (`"schema"."table".`),
1582
1812
  * or `""` when the collection is unknown.
1583
1813
  */
@@ -1811,6 +2041,7 @@ function resolveOperand(operand, ctx) {
1811
2041
  value: ctx.entity.values[operand.name]
1812
2042
  };
1813
2043
  case "outerField": return { known: false };
2044
+ case "authClaim": return { known: false };
1814
2045
  }
1815
2046
  }
1816
2047
  function evaluateCompare(op, left, right, ctx) {
@@ -2210,6 +2441,202 @@ function callbackRefusal(stage, path) {
2210
2441
  });
2211
2442
  }
2212
2443
  //#endregion
2444
+ //#region src/util/tenant.ts
2445
+ /**
2446
+ * The one reading of `collection.tenant`.
2447
+ *
2448
+ * Four things have to agree for a tenant-scoped collection to work — the
2449
+ * column, the RLS policy, the value stamped on insert and the index — and
2450
+ * before this they were four hand-written declarations that nothing compared.
2451
+ * The three that are schema become a {@link SecurityRule} and a column effect
2452
+ * derived here and in `planSchema`; the fourth, the write path, is
2453
+ * {@link resolveTenantWrite}.
2454
+ *
2455
+ * Everything in this module is pure. The policy it builds is a `SecurityRule`
2456
+ * like any other, which is what makes `db push`, the doctor, boot-ensure, the
2457
+ * drift detector and the Studio treat the tenancy policy as what it is —
2458
+ * generated, named, and recognisable — rather than as somebody's hand-written
2459
+ * SQL that a push should offer to drop.
2460
+ */
2461
+ /**
2462
+ * The collection's tenancy declaration, or nothing.
2463
+ *
2464
+ * Read through this rather than off the object, so the one shape check —
2465
+ * `tenant` is an object carrying a `field` and a `from` — is in one place. A
2466
+ * config that is *wrong* is refused by `validateCollectionConfig` with a
2467
+ * message; this is only asking whether there is one.
2468
+ */
2469
+ function getTenantConfig(collection) {
2470
+ const tenant = collection?.tenant;
2471
+ if (!tenant || typeof tenant !== "object") return void 0;
2472
+ if (typeof tenant.field !== "string" || !tenant.field) return void 0;
2473
+ if (!tenant.from || typeof tenant.from !== "object") return void 0;
2474
+ return tenant;
2475
+ }
2476
+ /** The roles tenancy does not apply to, defaulted. */
2477
+ function tenantBypassRoles(tenant) {
2478
+ return tenant.bypassRoles ?? DEFAULT_TENANT_BYPASS_ROLES;
2479
+ }
2480
+ /**
2481
+ * The name of the policy a tenant declaration compiles to.
2482
+ *
2483
+ * Explicit — not a `getPolicyNameHash` of the rule — precisely because the
2484
+ * rule's *body* is compiled with more information in some callers than in
2485
+ * others (`planSchema` can resolve a relation's target collection and so knows
2486
+ * the column's type; the Studio, asking only for names, cannot). A hashed name
2487
+ * would then differ between the two, and the same policy would read as drift.
2488
+ * A frozen identifier: see `contracts/derived-names.txt`.
2489
+ */
2490
+ function tenantPolicyName(tableName) {
2491
+ return `${tableName}_tenant_scope`;
2492
+ }
2493
+ /** The `reason` on the index a tenant column gets. Rendered into `schema.sql`. */
2494
+ var TENANT_INDEX_REASON = "tenant scope";
2495
+ /**
2496
+ * The condition a tenant declaration means, as a policy expression.
2497
+ *
2498
+ * `serverContext()` first, for the same reason every injected baseline rule
2499
+ * carries it: the trusted plane runs migrations, the auth flows and the boot,
2500
+ * and a restrictive policy that excluded it would not protect a tenant, it
2501
+ * would stop the server from starting.
2502
+ *
2503
+ * Then the bypass roles, then the tenancy test itself — a claim comparison or a
2504
+ * correlated `EXISTS` over the membership table, which are the two ways a
2505
+ * deployment answers "which tenant is this caller in".
2506
+ */
2507
+ function tenantScopeExpression(tenant) {
2508
+ const match = isTenantClaimSource(tenant.from) ? policy.compare(policy.field(tenant.field), "eq", policy.authClaim(tenant.from.claim)) : policy.existsIn({
2509
+ collection: tenant.from.membership.collection,
2510
+ 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()))
2511
+ });
2512
+ const bypass = tenantBypassRoles(tenant);
2513
+ return bypass.length > 0 ? policy.or(policy.serverContext(), policy.rolesOverlap(bypass), match) : policy.or(policy.serverContext(), match);
2514
+ }
2515
+ /**
2516
+ * The rule a tenant declaration compiles to, or nothing when there is none.
2517
+ *
2518
+ * **Restrictive**, and that is the whole design. A restrictive policy is ANDed
2519
+ * with every other policy on the table, so tenancy narrows what the
2520
+ * collection's own `securityRules` allow and can never widen it. A permissive
2521
+ * one would OR with them, and a single `access: "public"` rule elsewhere in the
2522
+ * file would take the entire tenancy boundary off without contradicting
2523
+ * anything a reader could see.
2524
+ *
2525
+ * One rule with `operation: "all"` rather than four with `operations: [...]`:
2526
+ * `FOR ALL` gives Postgres the USING clause for SELECT/UPDATE/DELETE and the
2527
+ * WITH CHECK clause for INSERT/UPDATE, which is exactly the coverage wanted,
2528
+ * as one policy with one name instead of four.
2529
+ */
2530
+ function buildTenantSecurityRule(collection) {
2531
+ const tenant = getTenantConfig(collection);
2532
+ if (!tenant) return void 0;
2533
+ const expression = tenantScopeExpression(tenant);
2534
+ return {
2535
+ name: tenantPolicyName(getTableName(collection)),
2536
+ mode: "restrictive",
2537
+ operation: "all",
2538
+ condition: expression,
2539
+ check: expression
2540
+ };
2541
+ }
2542
+ /**
2543
+ * An id, however it arrived.
2544
+ *
2545
+ * A tenant field may be a `belongsTo` relation or a `reference`, and those
2546
+ * arrive over the wire as `{ id }` envelopes as often as bare ids. Comparing
2547
+ * the envelope to a bare id would refuse every correct write with
2548
+ * `TENANT_MISMATCH`, which is the most confusing possible failure — the caller
2549
+ * sent exactly the tenant they belong to.
2550
+ */
2551
+ function tenantIdOf(value) {
2552
+ if (value === null || value === void 0) return value;
2553
+ if (typeof value === "object") {
2554
+ const id = value.id;
2555
+ return id === void 0 ? value : id;
2556
+ }
2557
+ return value;
2558
+ }
2559
+ /**
2560
+ * Compare two tenant ids as the database will.
2561
+ *
2562
+ * Stringified, because JSON has one number type and Postgres has several: a
2563
+ * caller sending `"42"` for a `bigint` tenant column is writing the same row as
2564
+ * one sending `42`, and Postgres agrees after the cast. Refusing one of them
2565
+ * would be an API rule the database does not have.
2566
+ */
2567
+ function sameTenant(a, b) {
2568
+ if (a === null || a === void 0 || b === null || b === void 0) return false;
2569
+ return String(tenantIdOf(a)) === String(tenantIdOf(b));
2570
+ }
2571
+ /**
2572
+ * Stamp, or refuse, the tenant on a write.
2573
+ *
2574
+ * Three refusals, and each exists because the alternative lands somewhere
2575
+ * worse:
2576
+ *
2577
+ * - **`TENANT_REQUIRED`** — the caller has no tenant, or belongs to several and
2578
+ * named none. Stamping a guess would put the row in the wrong tenant; letting
2579
+ * it through would write a NULL into a `NOT NULL` column and surface as a
2580
+ * 23502 naming a column the caller never wrote.
2581
+ * - **`TENANT_MISMATCH`** — the caller named a tenant that is not theirs. The
2582
+ * database refuses this too, through the policy's `WITH CHECK`, but as a
2583
+ * 42501 "new row violates row-level security policy" with no mention of which
2584
+ * field or why. Refused here so the answer names the field.
2585
+ * - **`TENANT_IMMUTABLE`** — an update that moves a row to another tenant. RLS
2586
+ * would allow it whenever the caller belongs to both, and it is almost never
2587
+ * what anybody meant: it takes the row out of one tenant's history and drops
2588
+ * it into another's, with no trace on either side. A deliberate move is a
2589
+ * `bypassRoles` operation.
2590
+ *
2591
+ * A bypass caller is exempt from all three: they are trusted across tenants by
2592
+ * declaration, and stamping their write would silently confine a support
2593
+ * operator's row to whichever tenant they happen to carry.
2594
+ */
2595
+ function resolveTenantWrite(input) {
2596
+ const { tenant, values, status, callerTenants, bypass, previousValues, slug } = input;
2597
+ const field = tenant.field;
2598
+ const complete = input.callerTenantsComplete !== false;
2599
+ /** Is `value` one the caller may write? Unknown counts as yes — see `callerTenantsComplete`. */
2600
+ const callerHas = (value) => callerTenants.some((t) => sameTenant(t, value)) || !complete;
2601
+ if (bypass) return { values };
2602
+ const provided = values[field];
2603
+ if (!(status !== "existing")) {
2604
+ if (provided === void 0) return { values };
2605
+ const previous = previousValues?.[field];
2606
+ if (previous !== void 0 && !sameTenant(provided, previous)) return { refusal: {
2607
+ code: "TENANT_IMMUTABLE",
2608
+ field,
2609
+ message: `'${field}' is the tenant '${slug}' rows belong to, and a row cannot change tenant. This update would move it from '${String(tenantIdOf(previous))}' to '${String(tenantIdOf(provided))}'. Create the row in the other tenant and delete this one, or perform the move with a role listed in \`tenant.bypassRoles\`.`
2610
+ } };
2611
+ if (previous === void 0 && !callerHas(provided)) return { refusal: mismatch(field, slug, provided, callerTenants) };
2612
+ return { values };
2613
+ }
2614
+ if (provided === void 0 || provided === null || provided === "") {
2615
+ if (callerTenants.length === 1) return { values: {
2616
+ ...values,
2617
+ [field]: tenantIdOf(callerTenants[0])
2618
+ } };
2619
+ return { refusal: {
2620
+ code: "TENANT_REQUIRED",
2621
+ field,
2622
+ message: callerTenants.length === 0 ? `'${slug}' is scoped to a tenant and this request carries none, so there is nothing to write into '${field}'. ` + sourceHint(tenant) : `'${slug}' is scoped to a tenant and this caller belongs to ${callerTenants.length} of them, so '${field}' cannot be inferred. Send it on the write — it must be one the caller belongs to.`
2623
+ } };
2624
+ }
2625
+ if (!callerHas(provided)) return { refusal: mismatch(field, slug, provided, callerTenants) };
2626
+ return { values };
2627
+ }
2628
+ function mismatch(field, slug, provided, callerTenants) {
2629
+ return {
2630
+ code: "TENANT_MISMATCH",
2631
+ field,
2632
+ message: `'${field}' names tenant '${String(tenantIdOf(provided))}', which this caller does not belong to, so the write to '${slug}' would be refused by the database as well. ` + (callerTenants.length === 0 ? "This request carries no tenant at all." : `The caller's ${callerTenants.length === 1 ? "tenant is" : "tenants are"} ` + callerTenants.map((t) => `'${String(tenantIdOf(t))}'`).join(", ") + ".")
2633
+ };
2634
+ }
2635
+ /** Where a caller's tenant was supposed to come from, for the 400. */
2636
+ function sourceHint(tenant) {
2637
+ return isTenantClaimSource(tenant.from) ? `The tenant comes from the '${tenant.from.claim}' claim on the caller's token; this one has no such claim. Sign in, or add the claim in the custom-claims hook.` : `The tenant comes from rows of '${tenant.from.membership.collection}' whose '${tenant.from.membership.userField}' is the caller; this caller has none.`;
2638
+ }
2639
+ //#endregion
2213
2640
  //#region src/util/auth-default-policies.ts
2214
2641
  /**
2215
2642
  * Default RLS policies injected by the schema generator.
@@ -2254,8 +2681,16 @@ function callbackRefusal(stage, path) {
2254
2681
  * FORCE RLS. A *user* request never reaches that state: an anonymous one carries
2255
2682
  * `ANONYMOUS_USER_ID`, precisely so it cannot pass for the server here.
2256
2683
  *
2684
+ * **For a collection declaring `tenant`, additionally**
2685
+ * 5. A **restrictive** tenancy gate for every operation. Same kind of thing as
2686
+ * the admin write gate and injected for the same reason: it is ANDed with
2687
+ * every other policy, so it narrows what the author's permissive rules
2688
+ * grant and can never widen them. See `./tenant.ts`.
2689
+ *
2257
2690
  * Opt out with `disableDefaultPolicies: true` to take full responsibility for
2258
- * the collection's RLS.
2691
+ * the collection's RLS. The *restrictive* rules are not part of that opt-out:
2692
+ * dropping a rule that can only remove access could express nothing but "let
2693
+ * more people in", which is what the flag already does by removing the grants.
2259
2694
  */
2260
2695
  var SERVER_OR_ADMIN_EXPR$1 = policy.or(policy.serverContext(), policy.rolesOverlap(["admin"]));
2261
2696
  /** Write operations that must be admin-gated by default on auth collections. */
@@ -2299,11 +2734,27 @@ function adminWriteGate(tableName) {
2299
2734
  check: SERVER_OR_ADMIN_EXPR$1
2300
2735
  };
2301
2736
  }
2737
+ /**
2738
+ * The restrictive tenancy policy, as a list of zero or one.
2739
+ *
2740
+ * A list so the two call sites can splice it in without a conditional, and a
2741
+ * separate function so it is obvious that it is injected on *both* paths —
2742
+ * including the `disableDefaultPolicies` one, where it is the only permissive-
2743
+ * looking thing that stays. See `./tenant.ts`.
2744
+ */
2745
+ function tenantRule(collection) {
2746
+ const rule = buildTenantSecurityRule(collection);
2747
+ return rule ? [rule] : [];
2748
+ }
2302
2749
  function getEffectiveSecurityRules(collection) {
2303
2750
  const explicit = [...collection.securityRules ?? []];
2304
2751
  const tableName = getTableName(collection);
2305
2752
  const injected = [];
2306
- if (isPostgresCollectionConfig(collection) && collection.disableDefaultPolicies) return isAuthCollection(collection) ? [...explicit, adminWriteGate(tableName)] : explicit;
2753
+ if (isPostgresCollectionConfig(collection) && collection.disableDefaultPolicies) return [
2754
+ ...explicit,
2755
+ ...tenantRule(collection),
2756
+ ...isAuthCollection(collection) ? [adminWriteGate(tableName)] : []
2757
+ ];
2307
2758
  injected.push({
2308
2759
  name: `${tableName}_default_admin_read`,
2309
2760
  operations: ["select"],
@@ -2323,6 +2774,7 @@ function getEffectiveSecurityRules(collection) {
2323
2774
  });
2324
2775
  injected.push(adminWriteGate(tableName));
2325
2776
  }
2777
+ injected.push(...tenantRule(collection));
2326
2778
  return [...explicit, ...injected];
2327
2779
  }
2328
2780
  /**
@@ -2337,7 +2789,7 @@ function getEffectiveSecurityRules(collection) {
2337
2789
  * DDL, which policies are injected and how to take them off.
2338
2790
  */
2339
2791
  function getInjectedSecurityRules(collection) {
2340
- if (isPostgresCollectionConfig(collection) && collection.disableDefaultPolicies) return isAuthCollection(collection) ? [adminWriteGate(getTableName(collection))] : [];
2792
+ if (isPostgresCollectionConfig(collection) && collection.disableDefaultPolicies) return [...tenantRule(collection), ...isAuthCollection(collection) ? [adminWriteGate(getTableName(collection))] : []];
2341
2793
  const explicitCount = (collection.securityRules ?? []).length;
2342
2794
  return getEffectiveSecurityRules(collection).slice(explicitCount);
2343
2795
  }
@@ -2395,18 +2847,51 @@ function resolveJunctionSpecs(collections) {
2395
2847
  table,
2396
2848
  schema,
2397
2849
  endpoints: [source, target],
2398
- declaringSides: [source]
2850
+ declaringSides: [source],
2851
+ properties: mergeJunctionPayload({}, relation.through.properties, table, collection)
2399
2852
  });
2400
- else if (!existing.declaringSides.some((s) => s.collection === collection)) existing.declaringSides.push(source);
2853
+ else {
2854
+ existing.properties = mergeJunctionPayload(existing.properties, relation.through.properties, table, collection);
2855
+ if (!existing.declaringSides.some((s) => s.collection === collection)) existing.declaringSides.push(source);
2856
+ }
2401
2857
  }
2402
2858
  }
2403
2859
  return specs;
2404
2860
  }
2405
2861
  /**
2862
+ * Fold one side's `through.properties` into the junction's, refusing a
2863
+ * disagreement rather than picking a winner.
2864
+ *
2865
+ * Both ends of a link may declare it — `posts.tags` and `tags.posts` are one
2866
+ * junction — and each end may name the payload. Only one table gets created, so
2867
+ * two descriptions of `role` that are not the same description are a question
2868
+ * with no correct answer: whichever won, one of the two collections would be
2869
+ * writing through a column it does not think it has. Compared structurally, so
2870
+ * two sides that spell the same property twice (the normal case, and the one
2871
+ * the docs recommend) are fine.
2872
+ */
2873
+ function mergeJunctionPayload(into, incoming, table, collection) {
2874
+ if (!incoming || Object.keys(incoming).length === 0) return into;
2875
+ const merged = { ...into };
2876
+ for (const [key, property] of Object.entries(incoming)) {
2877
+ const already = merged[key];
2878
+ if (already && JSON.stringify(already) !== JSON.stringify(property)) throw new Error(`The junction table "${table}" is declared from more than one side, and they disagree about the payload column "${key}": "${collection.slug ?? collection.name}" describes it differently than another declaring collection does. One table is created, so both \`through.properties\` blocks have to describe the same column — or only one side should declare it.`);
2879
+ merged[key] = property;
2880
+ }
2881
+ return merged;
2882
+ }
2883
+ /**
2406
2884
  * A synthetic CollectionConfig standing in for the junction during policy
2407
2885
  * compilation and naming. Its two FK columns carry explicit `columnName`s so
2408
2886
  * `outerField` operands resolve to the exact columns the CREATE TABLE emitted,
2409
2887
  * whatever their casing.
2888
+ *
2889
+ * The payload columns are here too, exactly as authored. That is what lets one
2890
+ * reading of a `Property` serve the junction as well as a collection: the
2891
+ * schema planner plans these columns with the same function it plans a
2892
+ * collection's with, and the write path validates a `_pivot` against them with
2893
+ * the same validator a row's values go through. A second description of a
2894
+ * payload column anywhere is a second description that can disagree.
2410
2895
  */
2411
2896
  function getJunctionCollectionConfig(spec) {
2412
2897
  const properties = {};
@@ -2414,6 +2899,10 @@ function getJunctionCollectionConfig(spec) {
2414
2899
  type: "string",
2415
2900
  columnName: endpoint.junctionColumn
2416
2901
  };
2902
+ for (const [key, property] of Object.entries(spec.properties)) {
2903
+ if (key === JUNCTION_PIVOT_KEY || key in properties) continue;
2904
+ properties[key] = property;
2905
+ }
2417
2906
  return {
2418
2907
  slug: spec.table,
2419
2908
  name: spec.table,
@@ -3550,6 +4039,266 @@ var defaultUsersCollection = defineCollection({
3550
4039
  }
3551
4040
  });
3552
4041
  //#endregion
4042
+ //#region src/collections/field-access.ts
4043
+ /**
4044
+ * The role that satisfies any non-empty list.
4045
+ *
4046
+ * The same arm every baseline policy carries: `security_rules` injects
4047
+ * `rolesOverlap(['admin'])` into the default read and write policies, and
4048
+ * `rebase.dataAsAdmin` is scoped with `{ uid: "service", roles: ["admin"] }`.
4049
+ * Without this an author could declare `access: { read: ["hr"] }` and lock the
4050
+ * administrator out of a column of their own database — and lock the Studio out
4051
+ * of rendering it.
4052
+ */
4053
+ var ADMIN_ROLE = "admin";
4054
+ /**
4055
+ * What a property's access rules actually are, with `excludeFromApi` expanded.
4056
+ *
4057
+ * Returns `undefined` when the property constrains nothing, so callers can skip
4058
+ * the whole check for the overwhelmingly common case.
4059
+ */
4060
+ function effectiveAccess(property) {
4061
+ if (!property) return void 0;
4062
+ if (property.excludeFromApi) return EXCLUDED_ACCESS;
4063
+ const access = property.access;
4064
+ if (!access) return void 0;
4065
+ if (access.read === void 0 && access.write === void 0) return void 0;
4066
+ return access;
4067
+ }
4068
+ /** The rule `excludeFromApi: true` expands to. Frozen: it is shared by every caller. */
4069
+ var EXCLUDED_ACCESS = Object.freeze({
4070
+ read: Object.freeze([]),
4071
+ write: Object.freeze([])
4072
+ });
4073
+ /**
4074
+ * Does a caller holding `roles` satisfy `allowed`?
4075
+ *
4076
+ * Three cases, and the middle one is the one worth stating out loud:
4077
+ *
4078
+ * - `allowed` omitted — the field carries no rule of its own, so the row's
4079
+ * policies have already answered. True.
4080
+ * - `allowed` empty — nobody, at any privilege, through any API. Not the admin,
4081
+ * not the service key, not the trusted plane reading on a caller's behalf.
4082
+ * This is what `excludeFromApi` has always meant on the read side, and
4083
+ * collapsing the two spellings means the empty list has to keep meaning it.
4084
+ * - `allowed` non-empty — one of the named roles, or `admin`, or no viewer at
4085
+ * all (the trusted server plane, which is not an API caller).
4086
+ */
4087
+ function satisfies(allowed, viewer) {
4088
+ if (allowed === void 0) return true;
4089
+ if (allowed.length === 0) return false;
4090
+ if (!viewer) return true;
4091
+ const roles = viewer.roles;
4092
+ if (!roles || roles.length === 0) return false;
4093
+ return roles.includes("admin") || allowed.some((role) => roles.includes(role));
4094
+ }
4095
+ /** May this caller receive this field's value? */
4096
+ function canReadField(property, viewer) {
4097
+ const access = effectiveAccess(property);
4098
+ return access ? satisfies(access.read, viewer) : true;
4099
+ }
4100
+ /** May this caller set this field's value? */
4101
+ function canWriteField(property, viewer) {
4102
+ const access = effectiveAccess(property);
4103
+ return access ? satisfies(access.write, viewer) : true;
4104
+ }
4105
+ /**
4106
+ * The names on this collection a caller may not touch, in the two spellings a
4107
+ * caller can write them in.
4108
+ *
4109
+ * `declared` is the property keys, which is what has to leave a *known-fields*
4110
+ * set. `refused` is those plus the physical column names behind them: a caller
4111
+ * who knows the table can send `password_hash` as readily as `passwordHash`, and
4112
+ * a rule that only knew the wire name would be one rename away from useless.
4113
+ *
4114
+ * `kind` picks which half of the rule is read; nothing else differs.
4115
+ */
4116
+ function restrictedFieldNames(collection, viewer, kind) {
4117
+ const declared = [];
4118
+ const refused = /* @__PURE__ */ new Set();
4119
+ const allowed = kind === "read" ? canReadField : canWriteField;
4120
+ for (const [name, property] of Object.entries(collection.properties ?? {})) {
4121
+ if (allowed(property, viewer)) continue;
4122
+ declared.push(name);
4123
+ refused.add(name);
4124
+ const columnName = property.columnName;
4125
+ if (columnName) refused.add(columnName);
4126
+ }
4127
+ return {
4128
+ declared,
4129
+ refused
4130
+ };
4131
+ }
4132
+ /**
4133
+ * True when nothing on this collection restricts a field, for either direction.
4134
+ *
4135
+ * Every read of every row runs through the strip, so the collection that has no
4136
+ * rules — which is almost all of them — has to cost one property walk and no
4137
+ * allocation.
4138
+ */
4139
+ function hasFieldAccessRules(collection) {
4140
+ for (const property of Object.values(collection.properties ?? {})) if (effectiveAccess(property)) return true;
4141
+ return false;
4142
+ }
4143
+ //#endregion
4144
+ //#region src/data/cursor.ts
4145
+ /** A cursor that cannot be read at all — truncated, re-encoded, or invented. */
4146
+ var CursorError = class CursorError extends Error {
4147
+ code = "INVALID_CURSOR";
4148
+ constructor(detail) {
4149
+ 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.`);
4150
+ this.name = "CursorError";
4151
+ Object.setPrototypeOf(this, CursorError.prototype);
4152
+ }
4153
+ };
4154
+ /**
4155
+ * A cursor that reads fine but describes a different query.
4156
+ *
4157
+ * Separate from {@link CursorError} because the fix is different: this one is
4158
+ * not a corrupt string, it is a correct cursor used against a sort it was not
4159
+ * produced under. Seeking anyway would return rows in an order nobody asked
4160
+ * for, and — worse — would look like it worked.
4161
+ */
4162
+ var CursorMismatchError = class CursorMismatchError extends Error {
4163
+ code = "CURSOR_ORDER_MISMATCH";
4164
+ constructor(cursorKeys, queryKeys) {
4165
+ 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.`);
4166
+ this.name = "CursorMismatchError";
4167
+ Object.setPrototypeOf(this, CursorMismatchError.prototype);
4168
+ }
4169
+ };
4170
+ /**
4171
+ * Tag for a value whose JSON round-trip would otherwise lose its type.
4172
+ *
4173
+ * A `timestamp` column comes back from the driver as a `Date`; JSON turns it
4174
+ * into a string, and the string would then be compared against the column by
4175
+ * whatever cast Postgres chose. Round-tripping it as a `Date` keeps the
4176
+ * comparison the one the ORDER BY made.
4177
+ */
4178
+ var DATE_TAG = "$date";
4179
+ function encodeValue(value) {
4180
+ if (value instanceof Date) return { [DATE_TAG]: value.toISOString() };
4181
+ return value;
4182
+ }
4183
+ function decodeValue(value) {
4184
+ if (value && typeof value === "object" && !Array.isArray(value)) {
4185
+ const tagged = value[DATE_TAG];
4186
+ if (typeof tagged === "string") {
4187
+ const date = new Date(tagged);
4188
+ return Number.isNaN(date.getTime()) ? tagged : date;
4189
+ }
4190
+ }
4191
+ return value;
4192
+ }
4193
+ /** base64url, without depending on Node's Buffer (this package runs in browsers). */
4194
+ function toBase64Url(text) {
4195
+ const bytes = new TextEncoder().encode(text);
4196
+ let binary = "";
4197
+ for (const byte of bytes) binary += String.fromCharCode(byte);
4198
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
4199
+ }
4200
+ function fromBase64Url(encoded) {
4201
+ const padded = encoded.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - encoded.length % 4) % 4);
4202
+ const binary = atob(padded);
4203
+ const bytes = new Uint8Array(binary.length);
4204
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
4205
+ return new TextDecoder().decode(bytes);
4206
+ }
4207
+ /**
4208
+ * Encode "everything strictly after this row, in this order".
4209
+ *
4210
+ * @param orderBy the sort keys the listing ran under, in order of significance
4211
+ * @param row the last row served, which the next page picks up after
4212
+ * @param id that row's id — the tiebreaker every keyset comparison ends on
4213
+ * @returns the opaque cursor, or `undefined` when no cursor can describe the
4214
+ * page. That is not a failure: a listing sorted by relevance has no stored
4215
+ * value to compare a later page against (scores are computed per query and
4216
+ * are not on the same scale between two of them), so it pages by offset and
4217
+ * `meta.nextCursor` is simply absent.
4218
+ */
4219
+ function encodeCursor(orderBy, row, id) {
4220
+ if (id === void 0 || id === null) return void 0;
4221
+ const keys = orderBy ?? [];
4222
+ const values = {};
4223
+ for (const [field] of keys) {
4224
+ if (!(field in row)) return void 0;
4225
+ values[field] = encodeValue(row[field]);
4226
+ }
4227
+ return toBase64Url(JSON.stringify({
4228
+ k: keys,
4229
+ v: values,
4230
+ i: encodeValue(id)
4231
+ }));
4232
+ }
4233
+ /**
4234
+ * Read a cursor produced by {@link encodeCursor}.
4235
+ *
4236
+ * @throws {CursorError} when the string is not a cursor this codec wrote.
4237
+ */
4238
+ function decodeCursor(raw) {
4239
+ let parsed;
4240
+ try {
4241
+ parsed = JSON.parse(fromBase64Url(raw.trim()));
4242
+ } catch {
4243
+ throw new CursorError("it is not a cursor this API issued");
4244
+ }
4245
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new CursorError("it does not decode to a cursor");
4246
+ const body = parsed;
4247
+ if (!Array.isArray(body.k)) throw new CursorError("it carries no sort keys");
4248
+ if (body.i === void 0) throw new CursorError("it carries no row id");
4249
+ const orderBy = [];
4250
+ for (const entry of body.k) {
4251
+ if (!Array.isArray(entry) || typeof entry[0] !== "string") throw new CursorError("one of its sort keys is malformed");
4252
+ const direction = entry[1] === "desc" ? "desc" : "asc";
4253
+ orderBy.push(entry[2] === "first" || entry[2] === "last" ? [
4254
+ entry[0],
4255
+ direction,
4256
+ entry[2]
4257
+ ] : [entry[0], direction]);
4258
+ }
4259
+ const rawValues = body.v && typeof body.v === "object" && !Array.isArray(body.v) ? body.v : {};
4260
+ const values = {};
4261
+ for (const [field, value] of Object.entries(rawValues)) values[field] = decodeValue(value);
4262
+ return {
4263
+ orderBy,
4264
+ values,
4265
+ id: decodeValue(body.i)
4266
+ };
4267
+ }
4268
+ /**
4269
+ * The `orderBy` a request should run under, given a cursor and whatever sort
4270
+ * the request itself named.
4271
+ *
4272
+ * A request that names no sort **adopts the cursor's** — that is what makes
4273
+ * `find({ after })` work without restating the `orderBy` from the previous
4274
+ * call, and it cannot be wrong, since the cursor is the only sort in play.
4275
+ * A request that names one must name the *same* one, key for key, direction for
4276
+ * direction, nulls for nulls; anything else is {@link CursorMismatchError}.
4277
+ *
4278
+ * @throws {CursorMismatchError}
4279
+ */
4280
+ function reconcileCursorOrder(cursor, requested) {
4281
+ if (!requested || requested.length === 0) return cursor.orderBy;
4282
+ const spell = (keys) => keys.map(([field, direction, nulls]) => `${field}:${direction}${nulls ? `:${nulls}` : ""}`);
4283
+ const cursorKeys = spell(cursor.orderBy);
4284
+ const queryKeys = spell(requested);
4285
+ if (cursorKeys.length !== queryKeys.length || cursorKeys.some((key, i) => key !== queryKeys[i])) throw new CursorMismatchError(cursorKeys, queryKeys);
4286
+ return requested;
4287
+ }
4288
+ /**
4289
+ * The `startAfter` shape the driver contract takes, built from a cursor.
4290
+ *
4291
+ * The driver has always accepted `{ id, values }`; this is the one place that
4292
+ * shape is produced, so the REST route and the WebSocket ingress cannot drift
4293
+ * into two spellings of the same seek.
4294
+ */
4295
+ function cursorToStartAfter(cursor) {
4296
+ return {
4297
+ id: cursor.id,
4298
+ values: cursor.values
4299
+ };
4300
+ }
4301
+ //#endregion
3553
4302
  //#region src/data/sort-dialect.ts
3554
4303
  /**
3555
4304
  * Sort-order wire codec.
@@ -3643,13 +4392,24 @@ function parseOrderBySpecStrict(raw, order) {
3643
4392
  if (typeof raw[0] === "string" || isRelationAggregateSort(raw[0])) return [toStrictTuple(raw, 0)];
3644
4393
  return raw.map(toStrictTuple);
3645
4394
  }
4395
+ /** `first`/`last`, or a refusal naming the entry — see {@link NullsPlacement}. */
4396
+ function toStrictNulls(raw, index) {
4397
+ if (raw === void 0 || raw === null) return void 0;
4398
+ if (raw !== "first" && raw !== "last") throw new OrderBySpecError(`entry ${index} has nulls '${String(raw)}' — expected "first" or "last"`);
4399
+ return raw;
4400
+ }
3646
4401
  function toStrictTuple(raw, index) {
3647
4402
  if (!Array.isArray(raw)) throw new OrderBySpecError(`entry ${index} has no field name`);
3648
4403
  const key = isRelationAggregateSort(raw[0]) ? sortKeyToString(raw[0]) : raw[0];
3649
4404
  if (typeof key !== "string" || key.trim() === "") throw new OrderBySpecError(`entry ${index} has no field name`);
3650
4405
  const direction = raw[1];
3651
4406
  if (direction !== void 0 && direction !== "asc" && direction !== "desc") throw new OrderBySpecError(`entry ${index} has direction '${String(direction)}'`);
3652
- return [key, direction ?? "asc"];
4407
+ const nulls = toStrictNulls(raw[2], index);
4408
+ return nulls ? [
4409
+ key,
4410
+ direction ?? "asc",
4411
+ nulls
4412
+ ] : [key, direction ?? "asc"];
3653
4413
  }
3654
4414
  /**
3655
4415
  * Serialize a sort to the wire.
@@ -3679,11 +4439,18 @@ function serializeOrderBy(orderBy) {
3679
4439
  if (typeof orderBy === "string") return orderBy;
3680
4440
  const list = normalizeOrderBy(orderBy);
3681
4441
  if (!list) return void 0;
3682
- if (list.length === 1) return `${list[0][0]}:${list[0][1]}`;
3683
- return JSON.stringify(list.map(([field, direction]) => ({
4442
+ if (list.length === 1) {
4443
+ const [field, direction, nulls] = list[0];
4444
+ return nulls ? `${field}:${direction}:${nulls}` : `${field}:${direction}`;
4445
+ }
4446
+ return JSON.stringify(list.map(([field, direction, nulls]) => nulls ? {
4447
+ field,
4448
+ direction,
4449
+ nulls
4450
+ } : {
3684
4451
  field,
3685
4452
  direction
3686
- })));
4453
+ }));
3687
4454
  }
3688
4455
  /**
3689
4456
  * Deserialize a wire-format `"field:direction"` string into an {@link OrderByTuple}.
@@ -3718,7 +4485,16 @@ function deserializeOrderBy(raw) {
3718
4485
  if (idx === -1) return raw.trim() === "" ? void 0 : [raw, "asc"];
3719
4486
  const field = raw.slice(0, idx);
3720
4487
  if (field.trim() === "") return void 0;
3721
- return [field, raw.slice(idx + 1) === "desc" ? "desc" : "asc"];
4488
+ const rest = raw.slice(idx + 1);
4489
+ const nullsIdx = rest.indexOf(":");
4490
+ const dir = nullsIdx === -1 ? rest : rest.slice(0, nullsIdx);
4491
+ const nulls = nullsIdx === -1 ? void 0 : rest.slice(nullsIdx + 1);
4492
+ const direction = dir === "desc" ? "desc" : "asc";
4493
+ return nulls === "first" || nulls === "last" ? [
4494
+ field,
4495
+ direction,
4496
+ nulls
4497
+ ] : [field, direction];
3722
4498
  }
3723
4499
  /**
3724
4500
  * Deserialize either wire spelling — the single-key shorthand or the JSON
@@ -3739,7 +4515,14 @@ function deserializeOrderByList(raw) {
3739
4515
  if (Array.isArray(parsed)) {
3740
4516
  const list = parsed.map((entry) => {
3741
4517
  if (typeof entry === "string") return deserializeOrderBy(entry);
3742
- if (entry && typeof entry === "object" && typeof entry.field === "string") return [entry.field, entry.direction === "desc" ? "desc" : "asc"];
4518
+ if (entry && typeof entry === "object" && typeof entry.field === "string") {
4519
+ const direction = entry.direction === "desc" ? "desc" : "asc";
4520
+ return entry.nulls === "first" || entry.nulls === "last" ? [
4521
+ entry.field,
4522
+ direction,
4523
+ entry.nulls
4524
+ ] : [entry.field, direction];
4525
+ }
3743
4526
  }).filter((entry) => entry !== void 0);
3744
4527
  return list.length > 0 ? list : void 0;
3745
4528
  }
@@ -3748,6 +4531,270 @@ function deserializeOrderByList(raw) {
3748
4531
  return single ? [single] : void 0;
3749
4532
  }
3750
4533
  //#endregion
4534
+ //#region src/data/include-spec.ts
4535
+ /** An `include` that cannot be read, as opposed to one naming a relation that does not exist. */
4536
+ var IncludeSpecError = class IncludeSpecError extends Error {
4537
+ code;
4538
+ constructor(detail, code = "INVALID_INCLUDE") {
4539
+ super(`Invalid \`include\`: ${detail}`);
4540
+ this.name = "IncludeSpecError";
4541
+ this.code = code;
4542
+ Object.setPrototypeOf(this, IncludeSpecError.prototype);
4543
+ }
4544
+ };
4545
+ var emptyNode = () => ({ children: {} });
4546
+ function ensureNode(tree, key) {
4547
+ return tree[key] ??= emptyNode();
4548
+ }
4549
+ /**
4550
+ * Merge one dotted path (`"comments.author"`) into a tree.
4551
+ *
4552
+ * Merging rather than assigning is what makes `include=comments,comments.author`
4553
+ * mean the same thing as `include=comments.author`: the second path deepens the
4554
+ * node the first created instead of replacing it and losing its options.
4555
+ */
4556
+ function addPath(tree, path) {
4557
+ const segments = path.split(".").map((s) => s.trim()).filter(Boolean);
4558
+ if (segments.length === 0) return;
4559
+ if (segments.length > MAX_INCLUDE_DEPTH) throw new IncludeSpecError(`"${path}" nests ${segments.length} relations deep; the limit is ${MAX_INCLUDE_DEPTH}. Each hop is another query, and an unbounded one walks a self-referencing relation forever.`, "INCLUDE_TOO_DEEP");
4560
+ let level = tree;
4561
+ for (const segment of segments) level = ensureNode(level, segment).children;
4562
+ }
4563
+ function normalizeOptions(key, options, depth) {
4564
+ if (depth > MAX_INCLUDE_DEPTH) throw new IncludeSpecError(`"${key}" nests more than ${MAX_INCLUDE_DEPTH} relations deep.`, "INCLUDE_TOO_DEEP");
4565
+ 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.`);
4566
+ const node = { children: {} };
4567
+ if (options.limit !== void 0) node.limit = options.limit;
4568
+ if (options.where) node.where = options.where;
4569
+ if (options.logical) node.logical = options.logical;
4570
+ if (options.fields && options.fields.length > 0) node.fields = [...options.fields];
4571
+ const orderBy = typeof options.orderBy === "string" ? deserializeOrderByList(options.orderBy) : normalizeOrderBy(options.orderBy);
4572
+ if (orderBy) node.orderBy = orderBy;
4573
+ if (options.include) {
4574
+ const nested = normalizeIncludeAt(options.include, depth + 1);
4575
+ if (nested.wildcard) throw new IncludeSpecError(`"${key}" asks for \`*\` inside a nested include. Name the relations you need.`);
4576
+ node.children = nested.tree;
4577
+ }
4578
+ return node;
4579
+ }
4580
+ function normalizeIncludeAt(spec, depth) {
4581
+ if (Array.isArray(spec)) {
4582
+ const tree = {};
4583
+ let wildcard = false;
4584
+ for (const raw of spec) {
4585
+ if (typeof raw !== "string") throw new IncludeSpecError(`${typeof raw} is not a relation name`);
4586
+ const name = raw.trim();
4587
+ if (!name) continue;
4588
+ if (name === "*") {
4589
+ wildcard = true;
4590
+ continue;
4591
+ }
4592
+ addPath(tree, name);
4593
+ }
4594
+ return {
4595
+ wildcard,
4596
+ tree
4597
+ };
4598
+ }
4599
+ if (typeof spec !== "object" || spec === null) throw new IncludeSpecError(`${typeof spec} is not a list of relations or an include tree`);
4600
+ const tree = {};
4601
+ let wildcard = false;
4602
+ for (const [key, value] of Object.entries(spec)) {
4603
+ if (key === "*") {
4604
+ if (value) wildcard = true;
4605
+ continue;
4606
+ }
4607
+ if (value === true) {
4608
+ ensureNode(tree, key);
4609
+ continue;
4610
+ }
4611
+ if (value === false || value === void 0 || value === null) continue;
4612
+ if (typeof value !== "object" || Array.isArray(value)) throw new IncludeSpecError(`"${key}" must be \`true\` or an options object`);
4613
+ tree[key] = normalizeOptions(key, value, depth);
4614
+ }
4615
+ return {
4616
+ wildcard,
4617
+ tree
4618
+ };
4619
+ }
4620
+ /**
4621
+ * Collapse any {@link IncludeSpec} spelling into one tree.
4622
+ *
4623
+ * `["author", "comments.author"]` and
4624
+ * `{ author: true, comments: { include: { author: true } } }` normalize to the
4625
+ * same value — which is the whole point: the REST parameter can only carry the
4626
+ * flat spelling, the SDK prefers the tree, and the driver should never learn
4627
+ * about either.
4628
+ *
4629
+ * @throws {IncludeSpecError} for a shape that is not an include at all, or one
4630
+ * that nests past {@link MAX_INCLUDE_DEPTH}.
4631
+ */
4632
+ function normalizeInclude(spec) {
4633
+ if (spec === void 0 || spec === null) return void 0;
4634
+ const normalized = normalizeIncludeAt(spec, 1);
4635
+ if (!normalized.wildcard && Object.keys(normalized.tree).length === 0) return void 0;
4636
+ return normalized;
4637
+ }
4638
+ /**
4639
+ * Every relation name a tree names, as dotted paths — `["comments",
4640
+ * "comments.author"]`.
4641
+ *
4642
+ * Used to report which names an `include` asked for when one of them is not a
4643
+ * relation, and to serialize a tree that carries no per-relation options back
4644
+ * to the flat wire spelling.
4645
+ */
4646
+ function includePaths(tree, prefix = "") {
4647
+ const out = [];
4648
+ for (const [key, node] of Object.entries(tree)) {
4649
+ const path = prefix ? `${prefix}.${key}` : key;
4650
+ out.push(path);
4651
+ out.push(...includePaths(node.children, path));
4652
+ }
4653
+ return out;
4654
+ }
4655
+ /**
4656
+ * The relation names an `include` asks for at the top level.
4657
+ *
4658
+ * `["author", "comments.author"]` and `{author: true, comments: {...}}` both
4659
+ * answer `["author", "comments"]` — a *hop*, not a path, because the only
4660
+ * consumer is `?fields=`, which names keys on the row being returned and a
4661
+ * nested relation is not one of those.
4662
+ *
4663
+ * Derived rather than passed: `include` has four spellings and three of them
4664
+ * are not a `string[]`, so every consumer that wants the plain names either
4665
+ * calls this or reimplements the flattening.
4666
+ */
4667
+ function topLevelIncludeNames(spec) {
4668
+ const normalized = normalizeInclude(spec);
4669
+ if (!normalized) return [];
4670
+ return Object.keys(normalized.tree);
4671
+ }
4672
+ /** Whether any node in the tree carries per-relation options. */
4673
+ function hasOptions(tree) {
4674
+ 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));
4675
+ }
4676
+ /**
4677
+ * Serialize an {@link IncludeSpec} for the REST `?include=` parameter.
4678
+ *
4679
+ * Two spellings, and which one is used is decided by the request rather than
4680
+ * chosen:
4681
+ *
4682
+ * - **Comma-separated dotted paths** — `include=author,comments.author`. What a
4683
+ * plain include is, what a human types, and what every existing client sends.
4684
+ * - **JSON**, when any relation carries options — `include={"comments":{"limit":5,
4685
+ * "include":{"author":true}}}`. The flat spelling has nowhere to put a
4686
+ * `limit`, and inventing a punctuation for it (`comments(limit:5)`) would be a
4687
+ * third grammar to learn beside the two this API already has.
4688
+ *
4689
+ * The server accepts both on every list and get route, and tells them apart the
4690
+ * same way this does: a value starting with `{` is JSON.
4691
+ */
4692
+ function serializeInclude(spec) {
4693
+ const normalized = normalizeInclude(spec);
4694
+ if (!normalized) return void 0;
4695
+ if (normalized.wildcard && Object.keys(normalized.tree).length === 0) return "*";
4696
+ if (!hasOptions(normalized.tree)) {
4697
+ const paths = includePaths(normalized.tree);
4698
+ const leaves = paths.filter((path) => !paths.some((other) => other.startsWith(`${path}.`)));
4699
+ const all = normalized.wildcard ? ["*", ...leaves] : leaves;
4700
+ return all.length > 0 ? all.join(",") : void 0;
4701
+ }
4702
+ return JSON.stringify(toWireTree(normalized));
4703
+ }
4704
+ /**
4705
+ * A normalized tree, back in the {@link IncludeSpec} spelling a caller writes.
4706
+ *
4707
+ * The round trip is what lets a builder accumulate `include` calls: normalize
4708
+ * each, merge, and hand the result back as a spec the next layer can normalize
4709
+ * again. Idempotent, so doing it twice changes nothing.
4710
+ */
4711
+ function denormalizeInclude(normalized) {
4712
+ return toWireTree(normalized);
4713
+ }
4714
+ function mergeTrees(into, from) {
4715
+ for (const [key, node] of Object.entries(from)) {
4716
+ const existing = into[key];
4717
+ if (!existing) {
4718
+ into[key] = node;
4719
+ continue;
4720
+ }
4721
+ if (node.limit !== void 0) existing.limit = node.limit;
4722
+ if (node.where !== void 0) existing.where = node.where;
4723
+ if (node.logical !== void 0) existing.logical = node.logical;
4724
+ if (node.orderBy !== void 0) existing.orderBy = node.orderBy;
4725
+ if (node.fields !== void 0) existing.fields = node.fields;
4726
+ existing.children = mergeTrees(existing.children, node.children);
4727
+ }
4728
+ return into;
4729
+ }
4730
+ /**
4731
+ * Combine several `include` requests into one.
4732
+ *
4733
+ * Repeated `.include(...)` calls on a query builder are additive: each names
4734
+ * more of the graph to load, and a later one must not discard what an earlier
4735
+ * one asked for. Assigning instead of merging is why `.include("author")
4736
+ * .include("tags")` used to load only tags.
4737
+ */
4738
+ function mergeIncludeSpecs(existing, additions) {
4739
+ const merged = {
4740
+ wildcard: false,
4741
+ tree: {}
4742
+ };
4743
+ const absorb = (spec) => {
4744
+ const normalized = normalizeInclude(spec);
4745
+ if (!normalized) return;
4746
+ merged.wildcard ||= normalized.wildcard;
4747
+ mergeTrees(merged.tree, normalized.tree);
4748
+ };
4749
+ absorb(existing);
4750
+ const names = additions.filter((a) => typeof a === "string");
4751
+ if (names.length > 0) absorb(names);
4752
+ for (const addition of additions) if (typeof addition !== "string") absorb(addition);
4753
+ if (!merged.wildcard && Object.keys(merged.tree).length === 0) return void 0;
4754
+ return denormalizeInclude(merged);
4755
+ }
4756
+ function toWireTree(normalized) {
4757
+ const emit = (tree) => {
4758
+ const out = {};
4759
+ for (const [key, node] of Object.entries(tree)) {
4760
+ const options = {};
4761
+ if (node.limit !== void 0) options.limit = node.limit;
4762
+ if (node.where) options.where = node.where;
4763
+ if (node.logical) options.logical = node.logical;
4764
+ if (node.orderBy) options.orderBy = node.orderBy;
4765
+ if (node.fields) options.fields = node.fields;
4766
+ const children = emit(node.children);
4767
+ if (Object.keys(children).length > 0) options.include = children;
4768
+ out[key] = Object.keys(options).length > 0 ? options : true;
4769
+ }
4770
+ return out;
4771
+ };
4772
+ const tree = emit(normalized.tree);
4773
+ if (normalized.wildcard) tree["*"] = true;
4774
+ return tree;
4775
+ }
4776
+ /**
4777
+ * Read the REST `?include=` parameter, in either spelling.
4778
+ *
4779
+ * @throws {IncludeSpecError} for malformed JSON or a tree that nests too deep.
4780
+ */
4781
+ function deserializeInclude(raw) {
4782
+ if (raw === void 0 || raw === null) return void 0;
4783
+ const text = raw.trim();
4784
+ if (!text) return void 0;
4785
+ if (text.startsWith("{")) {
4786
+ let parsed;
4787
+ try {
4788
+ parsed = JSON.parse(text);
4789
+ } catch {
4790
+ throw new IncludeSpecError("the parametrised form must be a JSON object, e.g. {\"comments\":{\"limit\":5,\"include\":{\"author\":true}}}");
4791
+ }
4792
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new IncludeSpecError("the parametrised form must be a JSON object");
4793
+ return parsed;
4794
+ }
4795
+ return text.split(",").map((s) => s.trim()).filter(Boolean);
4796
+ }
4797
+ //#endregion
3751
4798
  //#region src/data/query_builder.ts
3752
4799
  function or(...conditions) {
3753
4800
  return {
@@ -3761,6 +4808,26 @@ function and(...conditions) {
3761
4808
  conditions
3762
4809
  };
3763
4810
  }
4811
+ /**
4812
+ * Negate a group: `not(a)` is `NOT a`, and `not(a, b)` is `NOT (a AND b)`.
4813
+ *
4814
+ * The conjunction, not the disjunction — one rule, stated on
4815
+ * {@link LogicalCondition} and applied identically by the wire codec, the REST
4816
+ * `?not=` parameter and every driver compiler. Groups nest, so De Morgan's
4817
+ * other half is `not(or(a, b))`.
4818
+ *
4819
+ * It compiles to a real SQL `NOT (...)` rather than to inverted operators,
4820
+ * which matters more than it looks: SQL is three-valued, so `NOT (a AND b)` and
4821
+ * `(NOT a) OR (NOT b)` stop agreeing the moment a NULL is involved, and only
4822
+ * one of them is the query the caller wrote. It also means a negation includes
4823
+ * rows whose column is NULL — which is what `NOT` means.
4824
+ */
4825
+ function not(...conditions) {
4826
+ return {
4827
+ type: "not",
4828
+ conditions
4829
+ };
4830
+ }
3764
4831
  function cond(column, operator, value) {
3765
4832
  return {
3766
4833
  column,
@@ -3960,26 +5027,6 @@ function normalizeMaxRows(raw) {
3960
5027
  return Math.max(0, Math.floor(raw));
3961
5028
  }
3962
5029
  /**
3963
- * Add one condition to a `where` map without disturbing what is already there.
3964
- *
3965
- * The caller's own filter on the cursor column has to survive — dropping it
3966
- * would widen the query, which is the silent-filter-loss failure mode — so a
3967
- * second condition on the same column becomes the array-of-tuples form that
3968
- * `FindParams.where` already accepts, and both are AND-ed.
3969
- */
3970
- function appendCondition(where, column, condition) {
3971
- const next = { ...where ?? {} };
3972
- const existing = next[column];
3973
- if (existing === void 0) next[column] = condition;
3974
- else if (Array.isArray(existing) && existing.length > 0 && Array.isArray(existing[0])) next[column] = [...existing, condition];
3975
- else next[column] = [existing, condition];
3976
- return next;
3977
- }
3978
- function cursorEquals(a, b) {
3979
- if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
3980
- return Object.is(a, b);
3981
- }
3982
- /**
3983
5030
  * Walk every row a query matches, yielding one row at a time and fetching the
3984
5031
  * next page only when the consumer asks for it.
3985
5032
  *
@@ -3995,30 +5042,23 @@ async function* paginateFind(find, params, label = "collection") {
3995
5042
  const findParams = { ...rest };
3996
5043
  const size = normalizePageSize(pageSize);
3997
5044
  const pageCap = normalizeMaxPages(maxPages);
3998
- const cursorField = typeof cursor === "string" ? cursor : cursor?.field;
3999
- const requestedDirection = typeof cursor === "object" && cursor !== null ? cursor.direction : void 0;
4000
- let direction = "asc";
4001
- if (cursorField) {
4002
- const orderBy = normalizeOrderBy(findParams.orderBy);
4003
- 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.`);
4004
- 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.`);
4005
- direction = requestedDirection ?? orderBy?.[0][1] ?? "asc";
4006
- findParams.orderBy = [cursorField, direction];
4007
- }
4008
- const seekOp = direction === "desc" ? "<" : ">";
4009
- const baseWhere = findParams.where;
5045
+ const seekRequested = cursor !== void 0 && cursor !== null;
5046
+ if (seekRequested) {
5047
+ const field = typeof cursor === "string" ? cursor : cursor.field;
5048
+ const requested = typeof cursor === "object" && cursor !== null ? cursor.direction : void 0;
5049
+ if (!normalizeOrderBy(findParams.orderBy)) findParams.orderBy = [field, requested ?? "asc"];
5050
+ }
4010
5051
  let offset = 0;
4011
5052
  let pages = 0;
4012
- let cursorValue;
4013
- let seeking = false;
5053
+ let after;
4014
5054
  for (;;) {
4015
5055
  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\`.`);
4016
5056
  const pageParams = {
4017
5057
  ...findParams,
4018
5058
  limit: size
4019
5059
  };
4020
- if (cursorField) {
4021
- if (seeking) pageParams.where = appendCondition(baseWhere, cursorField, [seekOp, cursorValue]);
5060
+ if (seekRequested) {
5061
+ if (after) pageParams.after = after;
4022
5062
  } else pageParams.offset = offset;
4023
5063
  const page = await find(pageParams);
4024
5064
  pages += 1;
@@ -4026,12 +5066,11 @@ async function* paginateFind(find, params, label = "collection") {
4026
5066
  if (rows.length === 0) return;
4027
5067
  for (const row of rows) yield row;
4028
5068
  if (page?.meta?.hasMore !== true) return;
4029
- if (cursorField) {
4030
- const nextValue = rows[rows.length - 1]?.[cursorField];
4031
- 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.`);
4032
- 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.`);
4033
- cursorValue = nextValue;
4034
- seeking = true;
5069
+ if (seekRequested) {
5070
+ const next = page.meta.nextCursor;
5071
+ 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.`);
5072
+ 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.`);
5073
+ after = next;
4035
5074
  } else offset += rows.length;
4036
5075
  }
4037
5076
  }
@@ -4473,15 +5512,66 @@ function serializeFilter(filter) {
4473
5512
  return result;
4474
5513
  }
4475
5514
  /**
5515
+ * The spellings a null-testing operator's operand may take.
5516
+ *
5517
+ * The serializer writes `isnull.null`; a hand-written `isnull.true` means the
5518
+ * same thing and has always been accepted. Anything else after the operator is
5519
+ * not an operand it has — `notnull.reason` is a *value* — see
5520
+ * {@link deserializeSingle}.
5521
+ */
5522
+ var NULL_OPERANDS = /* @__PURE__ */ new Set([
5523
+ "null",
5524
+ "true",
5525
+ "false",
5526
+ ""
5527
+ ]);
5528
+ /**
4476
5529
  * Parse a single PostgREST dot-string into a `[WhereFilterOp, unknown]` tuple.
4477
5530
  *
4478
5531
  * All values are returned as strings — the wire format carries no type
4479
5532
  * metadata, so coercion is the data driver's responsibility.
4480
5533
  *
4481
- * If the string doesn't match a known operator prefix, it falls back to
4482
- * `["==", originalString]` (treating the whole string as an equality value).
4483
- * This intentional defense handles values like `"user@host.com"` or
4484
- * `"1.2.3"` that happen to contain dots.
5534
+ * ## When a leading segment is an operator, and when it is part of the value
5535
+ *
5536
+ * `?status=in.progress` and `?status=in.(a,b)` differ by one character and mean
5537
+ * entirely different things, and the reading here decides which. The rule, in
5538
+ * full:
5539
+ *
5540
+ * > A dot-string is read as `operator.operand` **only** when its first segment
5541
+ * > names a known REST operator **and** what follows is a well-formed operand
5542
+ * > *for that operator's arity*. Otherwise the whole string is the value.
5543
+ *
5544
+ * Arity, per operator family:
5545
+ *
5546
+ * - **List** operators (`in`, `nin`, `csa` — `LIST_OPS`) take a parenthesised
5547
+ * list and nothing else. `in.(draft,review)` is the operator; `in.progress`
5548
+ * is the *value* `"in.progress"`, because there is no list there and so no
5549
+ * `in` filter that could have been written. That case used to compile to
5550
+ * `status IN ('progress')` — a filter the caller never wrote, quietly
5551
+ * matching the wrong rows and, on a status field, hiding every row they were
5552
+ * looking for.
5553
+ * - **Null** operators (`isnull`, `notnull` — {@link NULL_OPS}) take no
5554
+ * operand: only {@link NULL_OPERANDS}. `notnull.reason` is the value
5555
+ * `"notnull.reason"`, not "reason is not null".
5556
+ * - **Everything else** takes one scalar, and any remainder is one — including
5557
+ * the empty string, so `eq.` really is "equals the empty string".
5558
+ *
5559
+ * ### The one ambiguity that remains, and how to write past it
5560
+ *
5561
+ * A scalar operator's operand is unconstrained, so `?status=like.that` is a
5562
+ * `LIKE 'that'` and no rule at this layer can tell it from the literal value
5563
+ * `"like.that"` — both are well-formed encodings, and picking either by guess
5564
+ * would break the other. Two spellings say "value" unambiguously, and both
5565
+ * round-trip:
5566
+ *
5567
+ * - `?status=eq.like.that` — name the operator. The *first* segment is consumed
5568
+ * as the operator and everything after it is the value, dots and all. This is
5569
+ * what `serializeFilter` emits, which is why the SDK never meets the
5570
+ * ambiguity at all.
5571
+ * - `?where={"status":["==","like.that"]}` — the JSON dialect's tuple form.
5572
+ *
5573
+ * Values that merely *contain* dots (`user@host.com`, `1.2.3`) were never
5574
+ * ambiguous: their first segment names no operator to begin with.
4485
5575
  */
4486
5576
  function deserializeSingle(raw) {
4487
5577
  const dotIndex = raw.indexOf(".");
@@ -4490,11 +5580,15 @@ function deserializeSingle(raw) {
4490
5580
  const rest = raw.substring(dotIndex + 1);
4491
5581
  const canonicalOp = REST_OP_LOOKUP.get(prefix);
4492
5582
  if (!canonicalOp) return ["==", raw];
4493
- if (NULL_OPS.has(canonicalOp)) return [canonicalOp, null];
5583
+ if (NULL_OPS.has(canonicalOp)) {
5584
+ if (!NULL_OPERANDS.has(rest)) return ["==", raw];
5585
+ return [canonicalOp, null];
5586
+ }
4494
5587
  if (rest.startsWith("(") && rest.endsWith(")")) {
4495
5588
  const inner = rest.slice(1, -1);
4496
5589
  return [canonicalOp, inner === EMPTY_LIST_TOKEN ? [] : splitListItems(inner)];
4497
5590
  }
5591
+ if (LIST_OPS.has(canonicalOp)) return ["==", raw];
4498
5592
  return [canonicalOp, rest];
4499
5593
  }
4500
5594
  /**
@@ -4647,7 +5741,7 @@ function splitLeafCondition(str) {
4647
5741
  var MAX_LOGICAL_NESTING_DEPTH = 32;
4648
5742
  function deserializeLogicalCondition(str, nesting = 0) {
4649
5743
  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)\`.`);
4650
- const logicalMatch = str.match(/^(and|or)\((.+)\)$/);
5744
+ const logicalMatch = str.match(/^(and|or|not)\((.+)\)$/);
4651
5745
  if (logicalMatch) {
4652
5746
  const type = logicalMatch[1];
4653
5747
  const innerStr = logicalMatch[2];
@@ -4701,6 +5795,33 @@ function deserializeLogicalCondition(str, nesting = 0) {
4701
5795
  var noRealtime = (slug) => `Realtime is not available for "${slug}": its data source does not support subscriptions.`;
4702
5796
  /** What a client says when its data source cannot count. */
4703
5797
  var noCount = (slug) => `Counting is not available for "${slug}": its data source does not support it.`;
5798
+ /**
5799
+ * Derive the response key an aggregate comes back under.
5800
+ *
5801
+ * `sum(total)` → `sum_total`, `count()` → `count`. Written once, here, because
5802
+ * the REST parser derives the same alias from `?select=sum(total)` and the two
5803
+ * have to agree — a caller reading `row.sum_total` off an SDK result and off an
5804
+ * HTTP response is reading the same key or the SDK is broken.
5805
+ */
5806
+ function aggregateAlias(fn, field) {
5807
+ return field ? `${fn}_${field}` : fn;
5808
+ }
5809
+ function toDriverAggregate(select) {
5810
+ const field = select.field;
5811
+ return {
5812
+ fn: select.fn,
5813
+ field,
5814
+ alias: aggregateAlias(select.fn, field)
5815
+ };
5816
+ }
5817
+ /**
5818
+ * What a client says when its data source cannot aggregate.
5819
+ *
5820
+ * A stub rather than a fallback that fetches and reduces in JavaScript: that
5821
+ * would be wrong under a `limit` and unaffordable without one, and it would look
5822
+ * like it had worked.
5823
+ */
5824
+ var noAggregate = (slug) => `Aggregates are not available for "${slug}": its data source does not implement them.`;
4704
5825
  function createPrimaryKeyResolver(options) {
4705
5826
  const cache = /* @__PURE__ */ new Map();
4706
5827
  const warned = /* @__PURE__ */ new Set();
@@ -4722,6 +5843,89 @@ function createPrimaryKeyResolver(options) {
4722
5843
  };
4723
5844
  }
4724
5845
  /**
5846
+ * Build the admin's view model out of the row the wire serves.
5847
+ *
5848
+ * The wire has ONE shape, for every consumer: flat columns, typed the way the
5849
+ * database typed them, and a relation rendered as the target's own columns (or
5850
+ * only its foreign key, when nothing asked for it). That is the REST contract,
5851
+ * what `find()` returns, what `listen()` pushes, and what the generated types
5852
+ * describe.
5853
+ *
5854
+ * The admin renders neither of those directly. Its date field requires a real
5855
+ * `Date` and rejects a string outright; its relation cells read `.data.values`
5856
+ * off a relation ref. Those requirements are the *admin's*, so they are met
5857
+ * here — in the browser, from the collection config the panel already has —
5858
+ * rather than by asking the server for a second wire shape.
5859
+ *
5860
+ * That second shape is what this replaces. Until 2026-09-09 the realtime wire
5861
+ * carried the view model and every other read carried flat rows, so `find()`
5862
+ * and `listen()` answered one query two ways; unifying the wire without doing
5863
+ * this conversion is what left every date cell reading "Invalid date value"
5864
+ * and every relation cell "Unexpected value".
5865
+ *
5866
+ * Values already in view-model form pass through untouched: a driver that
5867
+ * still sends `{ __type: "date" }` or a relation ref (the client revives both)
5868
+ * is served by the same walk.
5869
+ */
5870
+ function toViewModelValues(values, properties, collection, resolveCollection) {
5871
+ if (!properties) return values;
5872
+ const relations = collection ? resolveCollectionRelations(collection) : {};
5873
+ let out;
5874
+ const write = (key, value) => {
5875
+ out = out ?? { ...values };
5876
+ out[key] = value;
5877
+ };
5878
+ for (const [key, rawProperty] of Object.entries(properties)) {
5879
+ const property = rawProperty;
5880
+ if (!property) continue;
5881
+ if (!(key in values)) {
5882
+ const fkRelation = relations[key];
5883
+ const column = fkRelation && "localKey" in fkRelation ? fkRelation.localKey : void 0;
5884
+ const fk = column !== void 0 ? values[column] ?? values[toWireKey(column)] : void 0;
5885
+ const fkTarget = fkRelation?.targetSlug;
5886
+ if (fkTarget && (typeof fk === "string" || typeof fk === "number")) write(key, new EntityRelation(fk, fkTarget));
5887
+ continue;
5888
+ }
5889
+ const value = values[key];
5890
+ if (value === null || value === void 0) continue;
5891
+ const relation = relations[key];
5892
+ if (relation && (property.type === "relation" || property.of?.type === "relation" || property.type === "array")) {
5893
+ const target = relation.targetSlug;
5894
+ if (!target) continue;
5895
+ const targetProperties = resolveCollection?.(target)?.properties;
5896
+ const targetCollection = resolveCollection?.(target);
5897
+ const toRef = (item) => {
5898
+ if (item instanceof EntityRelation) return item;
5899
+ if (typeof item === "object" && item !== null && "__type" in item) return item;
5900
+ if (typeof item === "object" && item !== null) {
5901
+ const row = item;
5902
+ const keys = targetCollection ? resolvePrimaryKeys(targetCollection) : [];
5903
+ const id = keys.length > 0 ? buildCompositeId(row, keys) : row.id;
5904
+ if (id === void 0 || id === null || id === "") return item;
5905
+ return new EntityRelation(id, target, {
5906
+ id,
5907
+ path: target,
5908
+ values: toViewModelValues(row, targetProperties, targetCollection, resolveCollection)
5909
+ });
5910
+ }
5911
+ if (typeof item === "string" || typeof item === "number") return new EntityRelation(item, target);
5912
+ return item;
5913
+ };
5914
+ write(key, Array.isArray(value) ? value.map(toRef) : toRef(value));
5915
+ continue;
5916
+ }
5917
+ if (property.type === "date" && !(value instanceof Date)) {
5918
+ if (typeof value === "string" || typeof value === "number") {
5919
+ const date = new Date(value);
5920
+ write(key, isNaN(date.getTime()) ? null : date);
5921
+ }
5922
+ continue;
5923
+ }
5924
+ if (property.type === "map" && property.properties && typeof value === "object" && !Array.isArray(value)) write(key, toViewModelValues(value, property.properties, void 0, resolveCollection));
5925
+ }
5926
+ return out ?? values;
5927
+ }
5928
+ /**
4725
5929
  * Give a flat row the Entity view-model the admin renders.
4726
5930
  *
4727
5931
  * The address is *derived here* — it is not a column, and the row it came from
@@ -4732,12 +5936,12 @@ function createPrimaryKeyResolver(options) {
4732
5936
  * `primaryKeys` empty falls back to a literal `id` on the row: drivers other
4733
5937
  * than postgres still serve rows with one, and this keeps them working.
4734
5938
  */
4735
- function rowToEntity(row, slug, primaryKeys = []) {
5939
+ function rowToEntity(row, slug, primaryKeys = [], toViewModel) {
4736
5940
  const { _matches, ...values } = row;
4737
5941
  return {
4738
5942
  id: primaryKeys.length > 0 ? buildCompositeId(row, primaryKeys) : row.id,
4739
5943
  path: slug,
4740
- values,
5944
+ values: toViewModel ? toViewModel(values) : values,
4741
5945
  ..._matches ? { searchMatches: _matches } : {}
4742
5946
  };
4743
5947
  }
@@ -4757,14 +5961,16 @@ function inlineEnvelope(envelope) {
4757
5961
  * Replace every relation envelope on a row with the target's flat columns.
4758
5962
  *
4759
5963
  * The SDK serves one relation shape — the inlined one (see
4760
- * {@link RestFetchService}) — and reads that come back through a *driver*
4761
- * method rather than the REST pipeline still carry envelopes. Realtime is the
4762
- * one such read left: there is no `listenForRest`, so the rows arrive shaped
4763
- * for the admin and are flattened here instead.
5964
+ * {@link RestFetchService}) — and Postgres now serves it on every read, so
5965
+ * against that driver this walk finds nothing to do. It stays for the drivers
5966
+ * whose own `fetchCollection` still answers with refs: a developer reading
5967
+ * through this accessor gets one shape whichever driver is underneath.
4764
5968
  *
4765
5969
  * Only applied where the REST pipeline is the contract (see `find`); a driver
4766
- * without a `restFetchService` keeps whatever it returns, so the admin's own
4767
- * path through {@link buildRebaseData} is untouched.
5970
+ * without a `restFetchService` keeps whatever it returns.
5971
+ *
5972
+ * Note this is NOT how the admin gets its view model — that is built in the
5973
+ * browser by {@link toViewModelValues}, from the same flat row.
4768
5974
  */
4769
5975
  function inlineRelationRefs(row) {
4770
5976
  let out;
@@ -4777,30 +5983,43 @@ function inlineRelationRefs(row) {
4777
5983
  }
4778
5984
  return out ?? row;
4779
5985
  }
4780
- function createDriverAccessor(driver, slug, getPks = () => []) {
5986
+ function createDriverAccessor(driver, slug, getPks = () => [], toViewModel) {
4781
5987
  const accessor = {
4782
5988
  async find(params) {
4783
5989
  const filter = params?.where ? deserializeFilter(params.where) : void 0;
4784
5990
  const { limit, offset, driverOffset } = resolveFindWindow(params);
5991
+ const cursor = params?.after ? decodeCursor(params.after) : void 0;
5992
+ const orderBy = cursor ? reconcileCursorOrder(cursor, normalizeOrderBy(params?.orderBy)) : normalizeOrderBy(params?.orderBy);
5993
+ const startAfter = cursor ? cursorToStartAfter(cursor) : void 0;
5994
+ const probeLimit = startAfter ? limit + 1 : limit;
4785
5995
  const fetchService = driver.restFetchService;
4786
- const rows = fetchService ? await fetchService.fetchCollectionForRest(slug, {
5996
+ const fetched = fetchService ? await fetchService.fetchCollectionForRest(slug, {
4787
5997
  filter,
4788
5998
  logical: params?.logical,
4789
- limit,
4790
- offset: driverOffset,
4791
- orderBy: normalizeOrderBy(params?.orderBy),
4792
- searchString: params?.searchString
5999
+ limit: probeLimit,
6000
+ offset: startAfter ? void 0 : driverOffset,
6001
+ startAfter,
6002
+ orderBy,
6003
+ searchString: params?.searchString,
6004
+ fields: params?.fields,
6005
+ distinct: params?.distinct
4793
6006
  }, params?.include) : await driver.fetchCollection({
4794
6007
  path: slug,
4795
- limit,
4796
- offset: driverOffset,
6008
+ limit: probeLimit,
6009
+ offset: startAfter ? void 0 : driverOffset,
6010
+ startAfter,
4797
6011
  filter,
4798
6012
  logical: params?.logical,
4799
- orderBy: normalizeOrderBy(params?.orderBy),
4800
- searchString: params?.searchString
6013
+ orderBy,
6014
+ searchString: params?.searchString,
6015
+ include: params?.include,
6016
+ fields: params?.fields,
6017
+ distinct: params?.distinct
4801
6018
  });
6019
+ const seeking = startAfter !== void 0;
6020
+ const rows = seeking ? fetched.slice(0, limit) : fetched;
4802
6021
  let total = rows.length + offset;
4803
- let hasMore = rows.length >= limit;
6022
+ let hasMore = seeking ? fetched.length > limit : rows.length >= limit;
4804
6023
  if (driver.count) {
4805
6024
  total = await driver.count({
4806
6025
  path: slug,
@@ -4808,15 +6027,18 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
4808
6027
  logical: params?.logical,
4809
6028
  searchString: params?.searchString
4810
6029
  });
4811
- hasMore = offset + rows.length < total;
6030
+ if (!seeking) hasMore = offset + rows.length < total;
4812
6031
  }
6032
+ const last = rows[rows.length - 1];
6033
+ const nextCursor = hasMore && last && driver.restFetchService?.cursorFor ? driver.restFetchService.cursorFor(slug, last, orderBy) : void 0;
4813
6034
  return {
4814
- data: rows.map((row) => rowToEntity(row, slug, getPks())),
6035
+ data: rows.map((row) => rowToEntity(row, slug, getPks(), toViewModel)),
4815
6036
  meta: {
4816
6037
  total,
4817
6038
  limit,
4818
6039
  offset,
4819
- hasMore
6040
+ hasMore,
6041
+ ...nextCursor && { nextCursor }
4820
6042
  }
4821
6043
  };
4822
6044
  },
@@ -4826,22 +6048,31 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
4826
6048
  path: slug,
4827
6049
  id
4828
6050
  });
4829
- return row ? rowToEntity(row, slug, getPks()) : void 0;
6051
+ return row ? rowToEntity(row, slug, getPks(), toViewModel) : void 0;
4830
6052
  },
6053
+ aggregate: driver.restFetchService?.aggregate ? async (params) => driver.restFetchService.aggregate(slug, {
6054
+ aggregates: params.select.map(toDriverAggregate),
6055
+ groupBy: params.groupBy,
6056
+ filter: params.where ? deserializeFilter(params.where) : void 0,
6057
+ logical: params.logical,
6058
+ searchString: params.searchString,
6059
+ limit: params.limit
6060
+ }) : void 0,
4831
6061
  async create(data, id) {
4832
6062
  return rowToEntity(await driver.save({
4833
6063
  path: slug,
4834
6064
  values: data,
4835
6065
  id,
4836
6066
  status: "new"
4837
- }), slug, getPks());
6067
+ }), slug, getPks(), toViewModel);
4838
6068
  },
4839
6069
  createMany: driver.saveMany ? async (data, options) => {
4840
6070
  return (await driver.saveMany({
4841
6071
  path: slug,
4842
6072
  rows: data,
4843
- upsert: options?.upsert
4844
- })).map((row) => rowToEntity(row, slug, getPks()));
6073
+ upsert: options?.upsert,
6074
+ onConflict: options?.onConflict
6075
+ })).map((row) => rowToEntity(row, slug, getPks(), toViewModel));
4845
6076
  } : void 0,
4846
6077
  async update(id, data) {
4847
6078
  return rowToEntity(await driver.save({
@@ -4849,7 +6080,7 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
4849
6080
  values: data,
4850
6081
  id,
4851
6082
  status: "existing"
4852
- }), slug, getPks());
6083
+ }), slug, getPks(), toViewModel);
4853
6084
  },
4854
6085
  async delete(id) {
4855
6086
  return driver.delete({ row: {
@@ -4865,7 +6096,7 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
4865
6096
  id: u.id,
4866
6097
  values: u.data
4867
6098
  }))
4868
- })).map((row) => rowToEntity(row, slug, getPks()));
6099
+ })).map((row) => rowToEntity(row, slug, getPks(), toViewModel));
4869
6100
  } : void 0,
4870
6101
  deleteMany: driver.deleteMany ? async (ids) => {
4871
6102
  await driver.deleteMany({
@@ -4897,7 +6128,7 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
4897
6128
  vectorSearch: params?.vectorSearch,
4898
6129
  onUpdate: (entities) => {
4899
6130
  onUpdate({
4900
- data: entities.map((row) => rowToEntity(normalize(row), slug, getPks())),
6131
+ data: entities.map((row) => rowToEntity(normalize(row), slug, getPks(), toViewModel)),
4901
6132
  meta: {
4902
6133
  total: offset + entities.length,
4903
6134
  limit,
@@ -4914,7 +6145,7 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
4914
6145
  return driver.listenOne({
4915
6146
  path: slug,
4916
6147
  id,
4917
- onUpdate: (entity) => onUpdate(entity ? rowToEntity(normalize(entity), slug, getPks()) : void 0),
6148
+ onUpdate: (entity) => onUpdate(entity ? rowToEntity(normalize(entity), slug, getPks(), toViewModel) : void 0),
4918
6149
  onError
4919
6150
  });
4920
6151
  } : void 0,
@@ -4956,13 +6187,32 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
4956
6187
  * await data.products.create({ name: "Camera", price: 299 });
4957
6188
  * const { data: items } = await data.products.find({ where: { status: ["==", "published"] } });
4958
6189
  */
6190
+ /**
6191
+ * The view-model converter for one collection, or `undefined` when there is no
6192
+ * collection config to build it from.
6193
+ *
6194
+ * Absent is the honest answer for every consumer that is not the admin: the
6195
+ * flat SDK derives itself from this same layer (`buildSdkData`) and must keep
6196
+ * the wire's own types, and it registers no collection resolver.
6197
+ */
6198
+ function createViewModelConverter(options) {
6199
+ if (!options?.resolveCollection) return () => void 0;
6200
+ return function converterFor(slug) {
6201
+ return (values) => {
6202
+ const collection = options.resolveCollection?.(slug);
6203
+ if (!collection) return values;
6204
+ return toViewModelValues(values, collection.properties, collection, options.resolveCollection);
6205
+ };
6206
+ };
6207
+ }
4959
6208
  function buildRebaseData(driver, options) {
4960
6209
  const cache = /* @__PURE__ */ new Map();
4961
6210
  const primaryKeysFor = createPrimaryKeyResolver(options);
6211
+ const viewModelFor = createViewModelConverter(options);
4962
6212
  function getAccessor(slug) {
4963
6213
  let accessor = cache.get(slug);
4964
6214
  if (!accessor) {
4965
- accessor = createDriverAccessor(driver, slug, () => primaryKeysFor(slug));
6215
+ accessor = createDriverAccessor(driver, slug, () => primaryKeysFor(slug), viewModelFor(slug));
4966
6216
  cache.set(slug, accessor);
4967
6217
  }
4968
6218
  return accessor;
@@ -5017,9 +6267,14 @@ var SdkQueryBuilder = class {
5017
6267
  return this;
5018
6268
  }
5019
6269
  /** Called again, this adds a tie-breaker rather than replacing the sort. */
5020
- orderBy(column, direction = "asc") {
6270
+ orderBy(column, direction = "asc", nulls) {
5021
6271
  const existing = normalizeOrderBy(this.params.orderBy) ?? [];
5022
- this.params.orderBy = [...existing, [sortKeyToString(column), direction]];
6272
+ const key = sortKeyToString(column);
6273
+ this.params.orderBy = [...existing, nulls ? [
6274
+ key,
6275
+ direction,
6276
+ nulls
6277
+ ] : [key, direction]];
5023
6278
  return this;
5024
6279
  }
5025
6280
  limit(count) {
@@ -5044,13 +6299,39 @@ var SdkQueryBuilder = class {
5044
6299
  };
5045
6300
  return this;
5046
6301
  }
6302
+ /**
6303
+ * Load relations. Merges rather than replaces, so `.include("author")` then
6304
+ * `.include({ comments: { limit: 5 } })` asks for both — a builder call that
6305
+ * silently discarded an earlier one is the same defect `where` had.
6306
+ */
5047
6307
  include(...relations) {
5048
- this.params.include = relations;
6308
+ this.params.include = mergeIncludeSpecs(this.params.include, relations);
6309
+ return this;
6310
+ }
6311
+ fields(...columns) {
6312
+ this.params.fields = [...this.params.fields ?? [], ...columns];
6313
+ return this;
6314
+ }
6315
+ distinct(enabled = true) {
6316
+ this.params.distinct = enabled;
6317
+ return this;
6318
+ }
6319
+ after(cursor) {
6320
+ this.params.after = cursor;
5049
6321
  return this;
5050
6322
  }
5051
6323
  async find() {
5052
6324
  return this.client.find(this.params);
5053
6325
  }
6326
+ /** Aggregate the matching rows. See {@link SDKCollectionClient.aggregate}. */
6327
+ async aggregate(params) {
6328
+ return this.client.aggregate({
6329
+ ...params,
6330
+ where: this.params.where,
6331
+ logical: this.params.logical,
6332
+ searchString: this.params.searchString
6333
+ });
6334
+ }
5054
6335
  /**
5055
6336
  * Page through everything this query matches, one row at a time.
5056
6337
  *
@@ -5128,6 +6409,24 @@ function toSdkCollectionClient(snap, slug = "collection") {
5128
6409
  if (!snap.createMany) throw new Error("Bulk writes are not supported by this collection's data source. Fall back to create() per record.");
5129
6410
  return (await snap.createMany(data, options)).map(entityToRow);
5130
6411
  },
6412
+ /**
6413
+ * One row through the bulk path, because the bulk path is where the
6414
+ * conflict target lives.
6415
+ *
6416
+ * `CollectionAccessor` has no single-row upsert and adding one would
6417
+ * mean a second way to say the same thing to the same driver method —
6418
+ * `saveMany` already takes `upsert` and `onConflict`, and a batch of
6419
+ * one is exactly an upsert of one.
6420
+ */
6421
+ async upsert(data, options) {
6422
+ 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().");
6423
+ const row = (await snap.createMany([data], {
6424
+ upsert: true,
6425
+ onConflict: options?.onConflict
6426
+ }))[0];
6427
+ if (!row) throw new Error(`Upsert into "${slug}" returned no row.`);
6428
+ return entityToRow(row);
6429
+ },
5131
6430
  async update(id, data) {
5132
6431
  return entityToRow(await snap.update(id, data));
5133
6432
  },
@@ -5160,12 +6459,16 @@ function toSdkCollectionClient(snap, slug = "collection") {
5160
6459
  if (typeof columnOrCondition === "object") return builder.where(columnOrCondition);
5161
6460
  return builder.where(columnOrCondition, operator, value);
5162
6461
  },
5163
- orderBy: (column, direction) => new SdkQueryBuilder(client).orderBy(column, direction),
6462
+ orderBy: (column, direction, nulls) => new SdkQueryBuilder(client).orderBy(column, direction, nulls),
5164
6463
  limit: (count) => new SdkQueryBuilder(client).limit(count),
5165
6464
  offset: (count) => new SdkQueryBuilder(client).offset(count),
5166
6465
  search: (searchString) => new SdkQueryBuilder(client).search(searchString),
5167
6466
  vectorSearch: (property, vector, options) => new SdkQueryBuilder(client).vectorSearch(property, vector, options),
5168
- include: (...relations) => new SdkQueryBuilder(client).include(...relations)
6467
+ include: (...relations) => new SdkQueryBuilder(client).include(...relations),
6468
+ fields: (...columns) => new SdkQueryBuilder(client).fields(...columns),
6469
+ distinct: (enabled) => new SdkQueryBuilder(client).distinct(enabled),
6470
+ after: (cursor) => new SdkQueryBuilder(client).after(cursor),
6471
+ aggregate: snap.aggregate ? (params) => snap.aggregate(params) : unsupportedMethod(noAggregate(slug))
5169
6472
  };
5170
6473
  return client;
5171
6474
  }
@@ -5174,39 +6477,40 @@ function toSdkCollectionClient(snap, slug = "collection") {
5174
6477
  * {@link CollectionAccessor}. Every returned row is re-wrapped into the
5175
6478
  * `{ id, path, values }` view-model the admin panel renders.
5176
6479
  */
5177
- function toEntityAccessor(sdk, slug, getPks = () => []) {
6480
+ function toEntityAccessor(sdk, slug, getPks = () => [], toViewModel) {
5178
6481
  const accessor = {
5179
6482
  async find(params) {
5180
6483
  const res = await sdk.find(params);
5181
6484
  return {
5182
- data: res.data.map((row) => rowToEntity(row, slug, getPks())),
6485
+ data: res.data.map((row) => rowToEntity(row, slug, getPks(), toViewModel)),
5183
6486
  meta: res.meta
5184
6487
  };
5185
6488
  },
5186
6489
  async findById(id) {
5187
6490
  const row = await sdk.findById(id);
5188
- return row ? rowToEntity(row, slug, getPks()) : void 0;
6491
+ return row ? rowToEntity(row, slug, getPks(), toViewModel) : void 0;
5189
6492
  },
5190
6493
  async create(data, id) {
5191
- return rowToEntity(await sdk.create(data, id), slug, getPks());
6494
+ return rowToEntity(await sdk.create(data, id), slug, getPks(), toViewModel);
5192
6495
  },
5193
6496
  createMany: sdk.createMany ? async (data, options) => {
5194
- return (await sdk.createMany(data, options)).map((row) => rowToEntity(row, slug, getPks()));
6497
+ return (await sdk.createMany(data, options)).map((row) => rowToEntity(row, slug, getPks(), toViewModel));
5195
6498
  } : void 0,
5196
6499
  async update(id, data) {
5197
6500
  const row = await sdk.update(id, data);
5198
6501
  if (!row) throw new Error(`Update returned no data for id ${id}`);
5199
- return rowToEntity(row, slug, getPks());
6502
+ return rowToEntity(row, slug, getPks(), toViewModel);
5200
6503
  },
5201
6504
  delete(id) {
5202
6505
  return sdk.delete(id);
5203
6506
  },
5204
6507
  count: isUnsupported(sdk.count) ? void 0 : (params) => sdk.count(params),
6508
+ aggregate: isUnsupported(sdk.aggregate) ? void 0 : (params) => sdk.aggregate(params),
5205
6509
  listen: isUnsupported(sdk.listen) ? void 0 : (params, onUpdate, onError) => sdk.listen(params, (res) => onUpdate({
5206
- data: res.data.map((row) => rowToEntity(row, slug, getPks())),
6510
+ data: res.data.map((row) => rowToEntity(row, slug, getPks(), toViewModel)),
5207
6511
  meta: res.meta
5208
6512
  }), onError),
5209
- listenById: isUnsupported(sdk.listenById) ? void 0 : (id, onUpdate, onError) => sdk.listenById(id, (row) => onUpdate(row ? rowToEntity(row, slug, getPks()) : void 0), onError),
6513
+ listenById: isUnsupported(sdk.listenById) ? void 0 : (id, onUpdate, onError) => sdk.listenById(id, (row) => onUpdate(row ? rowToEntity(row, slug, getPks(), toViewModel) : void 0), onError),
5210
6514
  where(columnOrCondition, operator, value) {
5211
6515
  const builder = new QueryBuilder(accessor);
5212
6516
  if (typeof columnOrCondition === "object") return builder.where(columnOrCondition);
@@ -5242,10 +6546,11 @@ function toEntityAccessor(sdk, slug, getPks = () => []) {
5242
6546
  function wrapAsEntityData(sdkData, options) {
5243
6547
  const cache = /* @__PURE__ */ new Map();
5244
6548
  const primaryKeysFor = createPrimaryKeyResolver(options);
6549
+ const viewModelFor = createViewModelConverter(options);
5245
6550
  function getAccessor(slug) {
5246
6551
  let accessor = cache.get(slug);
5247
6552
  if (!accessor) {
5248
- accessor = toEntityAccessor(sdkData.collection(slug), slug, () => primaryKeysFor(slug));
6553
+ accessor = toEntityAccessor(sdkData.collection(slug), slug, () => primaryKeysFor(slug), viewModelFor(slug));
5249
6554
  cache.set(slug, accessor);
5250
6555
  }
5251
6556
  return accessor;
@@ -5438,6 +6743,6 @@ async function detectJunctionTables(executeSql) {
5438
6743
  return junctionTables;
5439
6744
  }
5440
6745
  //#endregion
5441
- export { CALLBACK_REJECTED, COLLECTION_PATH_SEPARATOR, COMPOSITE_ID_SEPARATOR, CollectionRegistry, DEFAULT_FIND_ALL_MAX_ROWS, DEFAULT_MAX_PAGES, DEFAULT_ONE_OF_TYPE, DEFAULT_ONE_OF_VALUE, DEFAULT_PAGE_SIZE, DEFAULT_STRING_COLUMN_LENGTH, JUNCTION_TABLES_SQL, MAX_LOGICAL_NESTING_DEPTH, OrderBySpecError, QueryBuilder, REBASE_INTERNAL_PREFIXES, REBASE_INTERNAL_SCHEMAS, REBASE_INTERNAL_TABLES, REBASE_USER_ROLE, RebasePaginationError, UnknownFilterOperatorError, and, buildCollectionFromTableMetadata, buildCompositeId, buildConditionContext, buildPropertyCallbacks, buildRebaseData, buildRoutedRebaseData, buildSdkData, callbackRefusal, canCreateEntity, canDeleteEntity, canEditEntity, canReadCollection, checkOperation, classifyTable, collectAllPages, cond, createDataSourceRegistry, createPaginationHelpers, createRelationRef, createRelationRefWithData, defaultUsersCollection, defineCollection, deserializeFilter, deserializeLogicalCondition, deserializeOrderBy, deserializeOrderByList, detectJunctionTables, embedParentExpression, enumToObjectEntries, evaluateCondition, evaluatePolicy, fieldKeyForColumn, findAnonymousGrants, findRelation, fullPathToCollectionSegments, getArrayResolvedProperties, getChildViewDeclaringProperties, getChildViewRelationPropertyKeys, getColumnName, getDeclaredPrimaryKeys, getDefaultValueFor, getDefaultValueFortype, getDefaultValuesFor, getEffectiveSecurityRules, getEntityChildViews, getEnumVarName, getGeneratedPolicyNames, getInjectedSecurityRules, getJunctionCollectionConfig, getJunctionSecurityRules, getLabelOrConfigFrom, getPrimaryKeys, getReferenceFrom, getRelationFrom, getRelationTargetPath, getSubcollections, getTableName, getTableVarName, isAddressableId, isJunctionBackedRelation, isPropertyBuilder, isRebaseInternalTable, isRelationRequired, isRelationalCollection, normalizeDriverOrderBy, normalizeEmail, normalizeOrderBy, normalizeToEntityRelation, or, paginateFind, parseIdValues, parseOrderBySpecStrict, policyToPostgres, primaryOrderBy, registerConditionOperations, relationDeclaringProperty, relationalCollections, resolveArrayProperties, resolveCollectionRelations, resolveDataSource, resolveEnumValues, resolveFindWindow, resolveJunctionSpecs, resolvePrimaryKeys, resolveProperties, resolveProperty, resolvePropertyEnum, resolveRelation, resolveRelationProperty, resolveStorageFilenameString, resolveStoragePathString, resolveStorageSource, resolveStringColumnLength, revokeInternalTableAccess, revokeInternalTableSql, sanitizeData, securityRuleToConditions, segmentsToStrippedPath, serializeFilter, serializeLogicalCondition, serializeOrderBy, sortCollectionsBySlug, sortProperties, sqlToPolicy, stripCollectionPath, toCallbackError, toFilterTuples, traverseValueProperty, traverseValuesProperties, updateDateAutoValues, wrapAsEntityData, wrapAsSdkData };
6746
+ export { ADMIN_ROLE, CALLBACK_REJECTED, COLLECTION_PATH_SEPARATOR, COMPOSITE_ID_SEPARATOR, CollectionRegistry, CursorError, CursorMismatchError, DEFAULT_FIND_ALL_MAX_ROWS, DEFAULT_MAX_PAGES, DEFAULT_ONE_OF_TYPE, DEFAULT_ONE_OF_VALUE, DEFAULT_PAGE_SIZE, DEFAULT_STRING_COLUMN_LENGTH, IncludeSpecError, JUNCTION_TABLES_SQL, MAX_LOGICAL_NESTING_DEPTH, OrderBySpecError, QueryBuilder, REBASE_INTERNAL_PREFIXES, REBASE_INTERNAL_SCHEMAS, REBASE_INTERNAL_TABLES, REBASE_USER_ROLE, RebasePaginationError, TENANT_INDEX_REASON, UnknownFilterOperatorError, aggregateAlias, and, applyDefaultValuesOnCreate, buildCollectionFromTableMetadata, buildCompositeId, buildConditionContext, buildPropertyCallbacks, buildRebaseData, buildRoutedRebaseData, buildSdkData, buildTenantSecurityRule, callbackRefusal, canCreateEntity, canDeleteEntity, canEditEntity, canReadCollection, canReadField, canWriteField, checkOperation, classifyTable, collectAllPages, cond, createDataSourceRegistry, createPaginationHelpers, createRelationRef, createRelationRefWithData, cursorToStartAfter, decodeCursor, defaultUsersCollection, defineCollection, denormalizeInclude, deserializeFilter, deserializeInclude, deserializeLogicalCondition, deserializeOrderBy, deserializeOrderByList, detectJunctionTables, effectiveAccess, embedParentExpression, encodeCursor, enumToObjectEntries, evaluateCondition, evaluatePolicy, fieldKeyForColumn, findAnonymousGrants, findRelation, fullPathToCollectionSegments, getArrayResolvedProperties, getChildViewDeclaringProperties, getChildViewRelationPropertyKeys, getColumnName, getDeclaredPrimaryKeys, getDefaultValueFor, getDefaultValueFortype, getDefaultValuesFor, getEffectiveSecurityRules, getEntityChildViews, getEnumVarName, getGeneratedPolicyNames, getInjectedSecurityRules, getJunctionCollectionConfig, getJunctionSecurityRules, getLabelOrConfigFrom, getPrimaryKeys, getReferenceFrom, getRelationFrom, getRelationTargetPath, getSubcollections, getTableName, getTableVarName, getTenantConfig, hasFieldAccessRules, includePaths, isAddressableId, isJunctionBackedRelation, isPropertyBuilder, isRebaseInternalTable, isRelationRequired, isRelationalCollection, mergeIncludeSpecs, normalizeDriverOrderBy, normalizeEmail, normalizeInclude, normalizeOrderBy, normalizeToEntityRelation, not, or, paginateFind, parseIdValues, parseOrderBySpecStrict, policyToPostgres, primaryOrderBy, reconcileCursorOrder, registerConditionOperations, relationDeclaringProperty, relationalCollections, resolveArrayProperties, resolveCollectionRelations, resolveDataSource, resolveEnumValues, resolveFindWindow, resolveJunctionSpecs, resolvePrimaryKeys, resolveProperties, resolveProperty, resolvePropertyEnum, resolveRelation, resolveRelationProperty, resolveStorageFilenameString, resolveStoragePathString, resolveStorageSource, resolveStringColumnLength, resolveTenantWrite, restrictedFieldNames, revokeInternalTableAccess, revokeInternalTableSql, sanitizeData, securityRuleToConditions, segmentsToStrippedPath, serializeFilter, serializeInclude, serializeLogicalCondition, serializeOrderBy, sortCollectionsBySlug, sortProperties, sqlToPolicy, stripCollectionPath, tenantBypassRoles, tenantPolicyName, tenantScopeExpression, toCallbackError, toFilterTuples, topLevelIncludeNames, traverseValueProperty, traverseValuesProperties, updateDateAutoValues, updateUserAutoValues, wrapAsEntityData, wrapAsSdkData };
5442
6747
 
5443
6748
  //# sourceMappingURL=index.es.js.map