@rebasepro/common 0.17.3 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +4 -0
  2. package/dist/collections/CollectionRegistry.d.ts +1 -1
  3. package/dist/collections/default-collections.d.ts +15 -84
  4. package/dist/data/buildRebaseData.d.ts +1 -1
  5. package/dist/data/filter-dialect.d.ts +11 -0
  6. package/dist/data/sort-dialect.d.ts +15 -3
  7. package/dist/index.es.js +375 -63
  8. package/dist/index.es.js.map +1 -1
  9. package/dist/util/builders.d.ts +69 -24
  10. package/dist/util/callback-errors.d.ts +77 -0
  11. package/dist/util/callback-errors.test.d.ts +1 -0
  12. package/dist/util/index.d.ts +1 -0
  13. package/dist/util/policy/evaluatePolicy.d.ts +6 -0
  14. package/dist/util/relations.d.ts +41 -0
  15. package/dist/util/table-name.test.d.ts +1 -0
  16. package/package.json +26 -22
  17. package/src/collections/CollectionRegistry.ts +0 -485
  18. package/src/collections/default-collections.ts +0 -109
  19. package/src/collections/index.ts +0 -2
  20. package/src/data/buildRebaseData.ts +0 -816
  21. package/src/data/buildRoutedRebaseData.ts +0 -103
  22. package/src/data/filter-conditions.ts +0 -46
  23. package/src/data/filter-dialect.ts +0 -737
  24. package/src/data/paginate.ts +0 -334
  25. package/src/data/query_builder.ts +0 -176
  26. package/src/data/resolveDataSource.ts +0 -135
  27. package/src/data/sort-dialect.ts +0 -237
  28. package/src/index.ts +0 -11
  29. package/src/table-classification.ts +0 -109
  30. package/src/types/json-logic-js.d.ts +0 -8
  31. package/src/util/auth-default-policies.ts +0 -215
  32. package/src/util/builders.ts +0 -82
  33. package/src/util/callbacks.ts +0 -122
  34. package/src/util/collections.ts +0 -117
  35. package/src/util/common.ts +0 -2
  36. package/src/util/conditions.ts +0 -168
  37. package/src/util/email.ts +0 -32
  38. package/src/util/entities.ts +0 -282
  39. package/src/util/enums.ts +0 -26
  40. package/src/util/identity.ts +0 -202
  41. package/src/util/index.ts +0 -21
  42. package/src/util/internal-tables.test.ts +0 -188
  43. package/src/util/internal-tables.ts +0 -197
  44. package/src/util/junction-policies.ts +0 -355
  45. package/src/util/paths.ts +0 -27
  46. package/src/util/permissions.test.ts +0 -866
  47. package/src/util/permissions.ts +0 -206
  48. package/src/util/pg-column-to-property.ts +0 -377
  49. package/src/util/policy/evaluatePolicy.ts +0 -194
  50. package/src/util/policy/index.ts +0 -4
  51. package/src/util/policy/policyToPostgres.ts +0 -263
  52. package/src/util/policy/securityRuleToConditions.ts +0 -67
  53. package/src/util/policy/sqlToPolicy.ts +0 -422
  54. package/src/util/relations.ts +0 -236
  55. package/src/util/resolutions.ts +0 -534
  56. package/src/util/resolve-relation.ts +0 -243
  57. package/src/util/storage.ts +0 -177
  58. package/src/util/string-column-length.ts +0 -31
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_ROLES_SQL, RLS_UID_SQL, getDataSourceCapabilities, getDeclaredSubcollections, isAnonymousUid, isManyToMany, isPostgresCollectionConfig, isRelationAggregateSort, isRelationalCollectionConfig, policy, rewriteLegacyRlsFunctions, sortKeyToString, toCanonicalOp } 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, 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";
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";
@@ -490,8 +490,7 @@ function resolveRelation(relation, sourceCollection, propertyKey) {
490
490
  targetSlug: targetCollection.slug,
491
491
  onUpdate: relation.onUpdate,
492
492
  onDelete: relation.onDelete,
493
- overrides: relation.overrides,
494
- validation: relation.validation
493
+ overrides: relation.overrides
495
494
  };
496
495
  const sourceName = toSnakeCase(sourceCollection.slug ?? sourceCollection.name);
497
496
  switch (relation.kind) {
@@ -669,6 +668,45 @@ function resolveCollectionRelations(collection) {
669
668
  return relations;
670
669
  }
671
670
  /**
671
+ * The `type: "relation"` property that declares a link, or `undefined` for one
672
+ * that only exists in the collection's `relations` array.
673
+ *
674
+ * Both declaration sites end up in {@link resolveCollectionRelations}, and only
675
+ * one of them has a property to carry field-level facts — `name`, `admin`, and
676
+ * the one this exists for, `validation.required`.
677
+ */
678
+ function relationDeclaringProperty(collection, relation) {
679
+ const resolved = resolveCollectionRelations(collection);
680
+ for (const [key, raw] of Object.entries(collection.properties ?? {})) {
681
+ const prop = raw;
682
+ if (prop?.type !== "relation") continue;
683
+ if (resolved[key] === relation) return prop;
684
+ const addressed = prop.relation?.relationName;
685
+ if (addressed && findRelation(resolved, addressed) === relation) return prop;
686
+ }
687
+ }
688
+ /**
689
+ * Must every row of this collection point at a target through this link?
690
+ *
691
+ * Read from the declaring property's `validation.required` — the same key every
692
+ * other field uses, and the only place it lives.
693
+ *
694
+ * `RelationBase` carried its own `validation.required` until 0.18, which made
695
+ * this two questions rather than one. They were answered by different readers:
696
+ * the Postgres DDL generator asked the property (so the foreign-key column was
697
+ * `NOT NULL`) and the SDK type generator asked the relation (so the generated
698
+ * `Insert` type made the field optional). A `create()` that left the relation
699
+ * out therefore typechecked and then failed at the database with a not-null
700
+ * violation, and the two `required`s had to be written twice, identically, for
701
+ * the pair to agree.
702
+ *
703
+ * A relation with no declaring property — an entry in `relations` nothing
704
+ * points at — is not required. There is no field to fill in.
705
+ */
706
+ function isRelationRequired(collection, relation) {
707
+ return Boolean(relationDeclaringProperty(collection, relation)?.validation?.required);
708
+ }
709
+ /**
672
710
  * The path of the collection a relation property points at, derived from the
673
711
  * property alone.
674
712
  *
@@ -695,9 +733,21 @@ function getRelationTargetPath(property) {
695
733
  return;
696
734
  }
697
735
  }
736
+ /**
737
+ * The table a collection reads and writes.
738
+ *
739
+ * `table` when it is set, otherwise `toSnakeCase(slug)` — which is what made it
740
+ * safe to drop `table` from the required fields on the config type: the runtime
741
+ * had always derived it, and the type was demanding a value it did not need.
742
+ *
743
+ * The `||` chain is load-bearing. `toSnakeCase(undefined)` returns `""`, not
744
+ * `undefined`, so the previous `??` chain short-circuited on the empty string
745
+ * and the name fallback could never run — a safety net that read like one and
746
+ * caught nothing. It was unreachable while `slug` was required; it stops being
747
+ * unreachable the moment anything constructs a config without one.
748
+ */
698
749
  function getTableName(collection) {
699
- if (isRelationalCollectionConfig(collection)) return collection.table ?? toSnakeCase(collection.slug) ?? toSnakeCase(collection.name);
700
- return toSnakeCase(collection.slug) ?? toSnakeCase(collection.name);
750
+ return (isRelationalCollectionConfig(collection) ? collection.table : void 0) || toSnakeCase(collection.slug) || toSnakeCase(collection.name);
701
751
  }
702
752
  /**
703
753
  * A JavaScript identifier: what a generated `export const <name> =` needs.
@@ -1483,6 +1533,7 @@ function compile(expr, scope) {
1483
1533
  case "rolesOverlap": return `string_to_array(${RLS_ROLES_SQL}, ',') && ${rolesArraySql(expr.roles)}`;
1484
1534
  case "rolesContain": return `string_to_array(${RLS_ROLES_SQL}, ',') @> ${rolesArraySql(expr.roles)}`;
1485
1535
  case "authenticated": return `${RLS_UID_SQL} IS NOT NULL AND ${RLS_UID_SQL} NOT IN (${ANONYMOUS_USER_IDS.map(quoteLiteral).join(", ")})`;
1536
+ case "registered": return `${RLS_UID_SQL} IS NOT NULL AND ${RLS_UID_SQL} NOT IN (${ANONYMOUS_USER_IDS.map(quoteLiteral).join(", ")}) AND NOT ${RLS_IS_ANONYMOUS_SQL}`;
1486
1537
  case "serverContext": return `${RLS_UID_SQL} IS NULL`;
1487
1538
  case "existsIn": return compileExistsIn(expr, scope);
1488
1539
  case "raw": return rewriteLegacyRlsFunctions(expr.sql).replace(/\{(\w+)\}/g, (_, col) => `${outerQualifier(scope)}${resolveColumnName(col, scope.outerCollection)}`);
@@ -1719,6 +1770,7 @@ function evaluatePolicy(expr, ctx) {
1719
1770
  return expr.roles.every((r) => r === "public" || userRoles.includes(r));
1720
1771
  }
1721
1772
  case "authenticated": return ctx.uid != null && !isAnonymousUid(ctx.uid);
1773
+ case "registered": return ctx.uid != null && !isAnonymousUid(ctx.uid) && ctx.isAnonymous !== true;
1722
1774
  case "serverContext": return false;
1723
1775
  case "existsIn": return "unknown";
1724
1776
  case "raw": return "unknown";
@@ -1895,11 +1947,13 @@ function canDeleteEntity(collection, authContext, path, entity) {
1895
1947
  //#endregion
1896
1948
  //#region src/util/builders.ts
1897
1949
  /**
1898
- * Implementation delegates to the correct overload at the type level.
1899
- * At runtime this is a plain identity function.
1950
+ * At runtime this is a plain identity function: a resource handle written where
1951
+ * a key belongs `dataSource: analytics` becomes its key, so past this point
1952
+ * a collection is plain data. The signature above is the rest of the point.
1953
+ * @group Builder
1900
1954
  */
1901
1955
  function defineCollection(collection) {
1902
- return collection;
1956
+ return resolveResourceRefs(collection);
1903
1957
  }
1904
1958
  //#endregion
1905
1959
  //#region src/util/storage.ts
@@ -2055,6 +2109,107 @@ var buildPropertyCallbacks = (properties) => {
2055
2109
  return Object.keys(propertyCallbacks).length > 0 ? propertyCallbacks : void 0;
2056
2110
  };
2057
2111
  //#endregion
2112
+ //#region src/util/callback-errors.ts
2113
+ /**
2114
+ * The code a write carries when a collection callback rejected it and did not
2115
+ * say how. Distinct from `INVALID_INPUT`, which the framework's own validation
2116
+ * raises: this one means *your* rule refused, so the message is the author's.
2117
+ *
2118
+ * `details.stage` names which callback refused — `beforeSave`, `beforeDelete`,
2119
+ * `afterSave` or `afterDelete`. An `after*` hook runs inside the write's
2120
+ * transaction, so a throw there rolls the row back too; the caller is told the
2121
+ * write did not happen and which hook decided that.
2122
+ */
2123
+ var CALLBACK_REJECTED = "CALLBACK_REJECTED";
2124
+ /**
2125
+ * Turn whatever a user callback threw into something the API layer can answer
2126
+ * with.
2127
+ *
2128
+ * ### Why a plain `throw` has to mean 400
2129
+ *
2130
+ * Both `docs/collections/callbacks.md` ("Throw an error to **block the save**")
2131
+ * and `docs/backend/hooks.md` ("the operation is rejected with an HTTP 400
2132
+ * error response") promised this, and neither delivered it: an `Error` thrown
2133
+ * from `beforeSave` reached the client as
2134
+ *
2135
+ * 500 {"error":{"message":"Internal Server Error","code":"INTERNAL_ERROR"}}
2136
+ *
2137
+ * with the author's message visible only in the server log, because the error
2138
+ * normalizer masks 5xx bodies — correctly, since a 500 is by definition
2139
+ * something the caller must not be told about.
2140
+ *
2141
+ * But a callback is not the server failing. It is the application speaking, in
2142
+ * code its author wrote, about a request its author judged invalid. The
2143
+ * conservative reading — "an unrecognised throw might be a real bug, so 500" —
2144
+ * costs every validation rule its message and makes the documented example
2145
+ * wrong. A rule that wants a 500 can still raise one explicitly.
2146
+ *
2147
+ * ### Why `after*` comes through here too
2148
+ *
2149
+ * `afterSave` and `afterDelete` run inside the write's transaction and are
2150
+ * awaited, so a throw in one aborts the transaction: the row is not there when
2151
+ * the request ends. Left unconverted, the caller saw a 500 for a write that a
2152
+ * rule deliberately undid, and had no way to tell that from a database outage.
2153
+ * Converted, it is the same 400 `CALLBACK_REJECTED` a `before*` hook produces,
2154
+ * with `stage` naming the hook that refused.
2155
+ *
2156
+ * ### What passes through untouched
2157
+ *
2158
+ * Anything that already carries a status: `RebaseApiError` from
2159
+ * `@rebasepro/types` (the browser-safe class a `config/collections/*.ts` file
2160
+ * can import — the collection file is bundled into the admin SPA, so it may not
2161
+ * import the server package), and the server's own `ApiError`, recognised
2162
+ * structurally rather than by `instanceof` because a monorepo can resolve two
2163
+ * copies of a package and `instanceof` is false across them.
2164
+ *
2165
+ * @param error What the callback threw.
2166
+ * @param stage The callback name, for the log line.
2167
+ * @param path The collection path, for the log line.
2168
+ */
2169
+ function toCallbackError(error, stage, path) {
2170
+ if (error !== null && typeof error === "object") {
2171
+ const carried = error;
2172
+ if (typeof carried.statusCode === "number" || typeof carried.status === "number") return error;
2173
+ }
2174
+ return new RebaseApiError(error instanceof Error ? error.message : typeof error === "string" ? error : `${stage} rejected the write`, {
2175
+ status: 400,
2176
+ code: CALLBACK_REJECTED,
2177
+ details: {
2178
+ stage,
2179
+ path
2180
+ },
2181
+ cause: error
2182
+ });
2183
+ }
2184
+ /**
2185
+ * The refusal a callback expresses by returning `false` rather than throwing.
2186
+ *
2187
+ * `beforeDelete` is typed `boolean | void` and documented as "return false or
2188
+ * throw to block deletion". Returning `false` did stop the delete — and then the
2189
+ * route answered `204 No Content`, which says the row is gone. The admin panel
2190
+ * removed it from the list, a client that trusted the status dropped it from its
2191
+ * cache, and the next reload brought it back. A veto that reports success is
2192
+ * worse than no veto.
2193
+ *
2194
+ * 403, not the 400 a throw produces: a throw carries the author's message and
2195
+ * reads as "this input is wrong", while `false` is a flat refusal with no
2196
+ * explanation — the server understood the request and will not perform it. The
2197
+ * code is the same either way, so a client can handle both in one branch.
2198
+ *
2199
+ * @param stage The callback name, for `details.stage`.
2200
+ * @param path The collection path, for `details.path`.
2201
+ */
2202
+ function callbackRefusal(stage, path) {
2203
+ return new RebaseApiError(`${stage} refused the operation`, {
2204
+ status: 403,
2205
+ code: CALLBACK_REJECTED,
2206
+ details: {
2207
+ stage,
2208
+ path
2209
+ }
2210
+ });
2211
+ }
2212
+ //#endregion
2058
2213
  //#region src/util/auth-default-policies.ts
2059
2214
  /**
2060
2215
  * Default RLS policies injected by the schema generator.
@@ -3048,7 +3203,7 @@ var CollectionRegistry = class {
3048
3203
  dataSources = {};
3049
3204
  /**
3050
3205
  * Global lifecycle callbacks applied to every collection.
3051
- * Runs on all data paths (REST, WebSocket, `rebase.data`).
3206
+ * Runs on all data paths (REST, WebSocket, `rebase.dataAsAdmin`).
3052
3207
  * Execution order: global → collection → property callbacks.
3053
3208
  */
3054
3209
  _globalCallbacks;
@@ -3186,7 +3341,7 @@ var CollectionRegistry = class {
3186
3341
  else {
3187
3342
  const declared = resolveCollectionRelations(collection)[key];
3188
3343
  if (declared) relationProperty.resolvedRelation = declared;
3189
- else console.warn(`Relation property '${key}' on '${collection.slug}' declares no \`relation\`, and the collection has no relation of that name.`);
3344
+ else console.warn(`Relation property '${key}' on '${collection.slug}' names no relation: it has no \`relation\` block, and the collection's \`relations\` array has no entry called '${key}'. The field will render no picker, generate no foreign key, and return nothing from \`include()\`.`);
3190
3345
  }
3191
3346
  }
3192
3347
  return newProperty;
@@ -3430,7 +3585,7 @@ function normalizeOrderBy(orderBy) {
3430
3585
  if (!orderBy || orderBy.length === 0) return void 0;
3431
3586
  const list = Array.isArray(orderBy[0]) ? orderBy : [orderBy];
3432
3587
  if (list.length === 0) return void 0;
3433
- return list.map(([key, direction]) => [sortKeyToString(key), direction]);
3588
+ return list.map((entry, index) => toStrictTuple(entry, index));
3434
3589
  }
3435
3590
  /**
3436
3591
  * The most significant sort key, for a caller that can only express one —
@@ -3533,23 +3688,37 @@ function serializeOrderBy(orderBy) {
3533
3688
  /**
3534
3689
  * Deserialize a wire-format `"field:direction"` string into an {@link OrderByTuple}.
3535
3690
  *
3536
- * Lenient parsing (matches existing server behaviour):
3691
+ * Lenient parsing:
3537
3692
  * - Bare field name (no colon): `"name"` → `["name", "asc"]`
3538
3693
  * - Unknown direction: `"name:foo"` → `["name", "asc"]`
3539
- * - Empty / falsy input: → `undefined`
3694
+ * - Empty / falsy input, or a blank field name: → `undefined`
3695
+ *
3696
+ * The leniency is this end's alone; the *server* refuses the same value. This
3697
+ * used to say "matches existing server behaviour", and it stopped being true
3698
+ * when `parseOrderByParam` grew a strict direction check: `?orderBy=name:foo`
3699
+ * now answers `400 INVALID_ORDER_BY` ("entry 0 has direction 'foo'"). The split
3700
+ * is deliberate — see {@link parseOrderBySpecStrict} — because a value this
3701
+ * function is handed was produced by {@link serializeOrderBy} a moment earlier,
3702
+ * and one that reaches the server came from a stranger.
3703
+ *
3704
+ * A blank field is `undefined` rather than `[" ", "asc"]`: whitespace is not a
3705
+ * field name, and the tuple it used to produce could not be re-encoded — the
3706
+ * only value in this codec that survived a decode and failed the next encode.
3540
3707
  *
3541
3708
  * Reads the single-key shorthand only. For a value that may carry several keys,
3542
3709
  * use {@link deserializeOrderByList} — handed a JSON array this returns the
3543
3710
  * whole array as one nonsensical field name.
3544
3711
  *
3545
3712
  * @param raw - The wire-format string from an HTTP query parameter.
3546
- * @returns The canonical tuple, or `undefined` if the input is empty/falsy.
3713
+ * @returns The canonical tuple, or `undefined` if the input names no field.
3547
3714
  */
3548
3715
  function deserializeOrderBy(raw) {
3549
3716
  if (!raw) return void 0;
3550
3717
  const idx = raw.indexOf(":");
3551
- if (idx === -1) return [raw, "asc"];
3552
- return [raw.slice(0, idx), raw.slice(idx + 1) === "desc" ? "desc" : "asc"];
3718
+ if (idx === -1) return raw.trim() === "" ? void 0 : [raw, "asc"];
3719
+ const field = raw.slice(0, idx);
3720
+ if (field.trim() === "") return void 0;
3721
+ return [field, raw.slice(idx + 1) === "desc" ? "desc" : "asc"];
3553
3722
  }
3554
3723
  /**
3555
3724
  * Deserialize either wire spelling — the single-key shorthand or the JSON
@@ -3607,7 +3776,11 @@ var QueryBuilder = class {
3607
3776
  }
3608
3777
  where(columnOrCondition, operator, value) {
3609
3778
  if (typeof columnOrCondition === "object" && columnOrCondition !== null && "type" in columnOrCondition) {
3610
- this.params.logical = columnOrCondition;
3779
+ const next = columnOrCondition;
3780
+ this.params.logical = this.params.logical ? {
3781
+ type: "and",
3782
+ conditions: [this.params.logical, next]
3783
+ } : next;
3611
3784
  return this;
3612
3785
  }
3613
3786
  if (!this.params.where) this.params.where = {};
@@ -4219,6 +4392,36 @@ function readTuple(field, raw) {
4219
4392
  if (isOperatorShaped(op)) throw new UnknownFilterOperatorError(field, op);
4220
4393
  }
4221
4394
  /**
4395
+ * Encode the `<op>.<value>` half of a wire condition.
4396
+ *
4397
+ * This is the single leaf encoder. Both wire positions that carry a condition
4398
+ * — a top-level query parameter (`?status=eq.active`) and a leaf inside an
4399
+ * `and(...)`/`or(...)` group (`or(status.eq.active,…)`) — go through it, so a
4400
+ * rule expressed here holds in both. The group serializer used to carry its
4401
+ * own copy, and the copy had drifted on every rule that matters: `null` went
4402
+ * out as the four-character string, the empty list as `()`, and an operator
4403
+ * this dialect does not have was silently rewritten to `eq` — a filter that
4404
+ * ran, returned rows, and answered a different question than the one asked.
4405
+ *
4406
+ * `escapeScalar` is the one thing the two positions legitimately disagree
4407
+ * about. A scalar in a query parameter owns the whole value and needs no
4408
+ * escaping; a scalar inside a group sits between the same commas a list item
4409
+ * does, so a comma in it would end the condition early.
4410
+ */
4411
+ function serializeOperatorAndValue(op, value, { escapeScalar, where }) {
4412
+ if (typeof op !== "string") throw new TypeError(`${where}: operator must be a string, got ${typeof op}`);
4413
+ const restOp = CANONICAL_OP_LOOKUP.get(op);
4414
+ if (!restOp) throw new TypeError(`${where}: unknown operator "${op}". Valid operators: ${Object.keys(CANONICAL_TO_REST).join(", ")}`);
4415
+ if (value === null && (op === "==" || op === "!=")) return op === "==" ? "isnull.null" : "notnull.null";
4416
+ if (NULL_OPS.has(op)) return `${restOp}.null`;
4417
+ if (Array.isArray(value)) {
4418
+ if (value.length === 0) return `${restOp}.(${EMPTY_LIST_TOKEN})`;
4419
+ return `${restOp}.(${value.map((v) => escapeWireValue(stringifyValue(v))).join(",")})`;
4420
+ }
4421
+ const scalar = stringifyValue(value);
4422
+ return `${restOp}.${escapeScalar ? escapeWireValue(scalar) : scalar}`;
4423
+ }
4424
+ /**
4222
4425
  * Serialize a single canonical condition tuple to a PostgREST dot-string.
4223
4426
  *
4224
4427
  * Throws `TypeError` if the input is not a valid `[WhereFilterOp, unknown]` tuple.
@@ -4231,15 +4434,10 @@ function readTuple(field, raw) {
4231
4434
  function serializeTuple(tuple) {
4232
4435
  if (!Array.isArray(tuple) || tuple.length !== 2) throw new TypeError(`serializeTuple: expected a [WhereFilterOp, value] tuple, got ${JSON.stringify(tuple)}`);
4233
4436
  const [op, value] = tuple;
4234
- if (typeof op !== "string") throw new TypeError(`serializeTuple: operator must be a string, got ${typeof op}`);
4235
- const restOp = CANONICAL_OP_LOOKUP.get(op);
4236
- if (!restOp) throw new TypeError(`serializeTuple: unknown operator "${op}". Valid operators: ${Object.keys(CANONICAL_TO_REST).join(", ")}`);
4237
- if (value === null && (op === "==" || op === "!=")) return op === "==" ? "isnull.null" : "notnull.null";
4238
- if (Array.isArray(value)) {
4239
- if (value.length === 0) return `${restOp}.(${EMPTY_LIST_TOKEN})`;
4240
- return `${restOp}.(${value.map((v) => escapeWireValue(stringifyValue(v))).join(",")})`;
4241
- }
4242
- return `${restOp}.${stringifyValue(value)}`;
4437
+ return serializeOperatorAndValue(op, value, {
4438
+ escapeScalar: false,
4439
+ where: "serializeTuple"
4440
+ });
4243
4441
  }
4244
4442
  /**
4245
4443
  * Convert `FilterValues` (or `WireFilterValues`) to a PostgREST-style
@@ -4344,10 +4542,21 @@ function deserializeFilter(query) {
4344
4542
  /**
4345
4543
  * Serialize a `LogicalCondition` or `FilterCondition` to its wire-format string.
4346
4544
  *
4545
+ * Leaf encoding is {@link serializeOperatorAndValue}, the same function
4546
+ * `serializeTuple` uses, so `null`, the empty list and an unknown operator
4547
+ * behave identically inside a group and in a query parameter.
4548
+ *
4549
+ * @throws {TypeError} when a leaf names an operator this dialect does not have.
4550
+ * It used to fall back to `eq`, which turned `age >= 18` into `age = 18` with
4551
+ * no diagnostic anywhere.
4552
+ *
4347
4553
  * @example
4348
4554
  * serializeLogicalCondition({ column: "status", operator: "==", value: "active" })
4349
4555
  * // → "status.eq.active"
4350
4556
  *
4557
+ * serializeLogicalCondition({ column: "deleted_at", operator: "==", value: null })
4558
+ * // → "deleted_at.isnull.null"
4559
+ *
4351
4560
  * serializeLogicalCondition({ type: "or", conditions: [...] })
4352
4561
  * // → "or(status.eq.active,status.eq.pending)"
4353
4562
  */
@@ -4356,12 +4565,60 @@ function serializeLogicalCondition(cond) {
4356
4565
  const inner = (cond.conditions ?? []).map(serializeLogicalCondition).join(",");
4357
4566
  return `${cond.type}(${inner})`;
4358
4567
  }
4359
- const restOp = CANONICAL_OP_LOOKUP.get(cond.operator) ?? "eq";
4360
- if (Array.isArray(cond.value)) {
4361
- const items = cond.value.map((v) => escapeWireValue(stringifyValue(v))).join(",");
4362
- return `${cond.column}.${restOp}.(${items})`;
4568
+ return `${escapeWireValue(cond.column)}.${serializeOperatorAndValue(cond.operator, cond.value, {
4569
+ escapeScalar: true,
4570
+ where: "serializeLogicalCondition"
4571
+ })}`;
4572
+ }
4573
+ /**
4574
+ * Split a leaf condition into `column`, operator token and value.
4575
+ *
4576
+ * The naive reading — column is everything before the first dot, operator is
4577
+ * everything up to the second — cannot express a relation path. A filter on
4578
+ * `author.name` serializes to `author.name.eq.bob` and came back as the column
4579
+ * `author` with the operator `name`, which resolves to nothing, so the
4580
+ * fallback made it `author == "eq.bob"`: a condition that runs and matches
4581
+ * nothing, on a column the caller never named.
4582
+ *
4583
+ * So the operator is found rather than assumed: it is the first dot-separated
4584
+ * segment after the column that resolves to a real operator. Everything before
4585
+ * it is the column, everything after is the value. `version.eq.1.2.3` reads as
4586
+ * `version == "1.2.3"` because the scan stops at the first match — the `eq` at
4587
+ * offset 1, not a later segment — and `metadata->>x.eq.5` never had dots in the
4588
+ * column to begin with.
4589
+ *
4590
+ * Returns `undefined` when no segment resolves — `status.active`, an equality
4591
+ * written without an operator, which the caller handles.
4592
+ */
4593
+ function splitLeafCondition(str) {
4594
+ let limit = str.length;
4595
+ for (let i = 0; i < str.length; i++) {
4596
+ if (str[i] === "\\") {
4597
+ i++;
4598
+ continue;
4599
+ }
4600
+ if (str[i] === "(") {
4601
+ limit = i;
4602
+ break;
4603
+ }
4604
+ }
4605
+ const dots = [];
4606
+ for (let i = 0; i < limit; i++) {
4607
+ if (str[i] === "\\") {
4608
+ i++;
4609
+ continue;
4610
+ }
4611
+ if (str[i] === ".") dots.push(i);
4612
+ }
4613
+ for (let i = 1; i < dots.length; i++) {
4614
+ const operator = toCanonicalOp(str.substring(dots[i - 1] + 1, dots[i]));
4615
+ if (!operator) continue;
4616
+ return {
4617
+ column: unescapeWireValue(str.substring(0, dots[i - 1])),
4618
+ operator,
4619
+ value: str.substring(dots[i] + 1)
4620
+ };
4363
4621
  }
4364
- return `${cond.column}.${restOp}.${escapeWireValue(stringifyValue(cond.value))}`;
4365
4622
  }
4366
4623
  /**
4367
4624
  * Parse a logical condition wire-format string back into a
@@ -4399,28 +4656,34 @@ function deserializeLogicalCondition(str, nesting = 0) {
4399
4656
  conditions: splitGroupItems(innerStr).map((part) => deserializeLogicalCondition(part, nesting + 1))
4400
4657
  };
4401
4658
  }
4402
- const firstDot = str.indexOf(".");
4403
- if (firstDot === -1) return {
4404
- column: str,
4405
- operator: "==",
4406
- value: true
4407
- };
4408
- const column = str.substring(0, firstDot);
4409
- const rest = str.substring(firstDot + 1);
4410
- const secondDot = rest.indexOf(".");
4411
- if (secondDot === -1) return {
4412
- column,
4413
- operator: "==",
4414
- value: unescapeWireValue(rest)
4415
- };
4416
- const opStr = rest.substring(0, secondDot);
4417
- const valueStr = rest.substring(secondDot + 1);
4418
- const operator = toCanonicalOp(opStr) ?? "==";
4419
- if (valueStr.startsWith("(") && valueStr.endsWith(")")) return {
4659
+ const leaf = splitLeafCondition(str);
4660
+ if (!leaf) {
4661
+ const firstDot = str.indexOf(".");
4662
+ if (firstDot === -1) return {
4663
+ column: unescapeWireValue(str),
4664
+ operator: "==",
4665
+ value: true
4666
+ };
4667
+ return {
4668
+ column: unescapeWireValue(str.substring(0, firstDot)),
4669
+ operator: "==",
4670
+ value: unescapeWireValue(str.substring(firstDot + 1))
4671
+ };
4672
+ }
4673
+ const { column, operator, value: valueStr } = leaf;
4674
+ if (NULL_OPS.has(operator)) return {
4420
4675
  column,
4421
4676
  operator,
4422
- value: splitListItems(valueStr.slice(1, -1))
4677
+ value: null
4423
4678
  };
4679
+ if (valueStr.startsWith("(") && valueStr.endsWith(")")) {
4680
+ const inner = valueStr.slice(1, -1);
4681
+ return {
4682
+ column,
4683
+ operator,
4684
+ value: inner === EMPTY_LIST_TOKEN ? [] : splitListItems(inner)
4685
+ };
4686
+ }
4424
4687
  return {
4425
4688
  column,
4426
4689
  operator,
@@ -4429,6 +4692,15 @@ function deserializeLogicalCondition(str, nesting = 0) {
4429
4692
  }
4430
4693
  //#endregion
4431
4694
  //#region src/data/buildRebaseData.ts
4695
+ /**
4696
+ * What a client says when its data source cannot subscribe.
4697
+ *
4698
+ * Named rather than inlined so the sentence a caller sees does not depend on
4699
+ * which of the two adapters below happened to build the client.
4700
+ */
4701
+ var noRealtime = (slug) => `Realtime is not available for "${slug}": its data source does not support subscriptions.`;
4702
+ /** What a client says when its data source cannot count. */
4703
+ var noCount = (slug) => `Counting is not available for "${slug}": its data source does not support it.`;
4432
4704
  function createPrimaryKeyResolver(options) {
4433
4705
  const cache = /* @__PURE__ */ new Map();
4434
4706
  const warned = /* @__PURE__ */ new Set();
@@ -4723,7 +4995,11 @@ var SdkQueryBuilder = class {
4723
4995
  }
4724
4996
  where(columnOrCondition, operator, value) {
4725
4997
  if (typeof columnOrCondition === "object" && columnOrCondition !== null && "type" in columnOrCondition) {
4726
- this.params.logical = columnOrCondition;
4998
+ const next = columnOrCondition;
4999
+ this.params.logical = this.params.logical ? {
5000
+ type: "and",
5001
+ conditions: [this.params.logical, next]
5002
+ } : next;
4727
5003
  return this;
4728
5004
  }
4729
5005
  if (!this.params.where) this.params.where = {};
@@ -4743,7 +5019,7 @@ var SdkQueryBuilder = class {
4743
5019
  /** Called again, this adds a tie-breaker rather than replacing the sort. */
4744
5020
  orderBy(column, direction = "asc") {
4745
5021
  const existing = normalizeOrderBy(this.params.orderBy) ?? [];
4746
- this.params.orderBy = [...existing, [column, direction]];
5022
+ this.params.orderBy = [...existing, [sortKeyToString(column), direction]];
4747
5023
  return this;
4748
5024
  }
4749
5025
  limit(count) {
@@ -4775,11 +5051,39 @@ var SdkQueryBuilder = class {
4775
5051
  async find() {
4776
5052
  return this.client.find(this.params);
4777
5053
  }
5054
+ /**
5055
+ * Page through everything this query matches, one row at a time.
5056
+ *
5057
+ * `.limit()` on the builder becomes the page size, so the ceiling on a
5058
+ * single `find()` is not a ceiling on what the query can read.
5059
+ */
5060
+ iterate(options) {
5061
+ return this.client.iterate({
5062
+ ...this.params,
5063
+ ...this.params.limit !== void 0 && { pageSize: this.params.limit },
5064
+ ...options
5065
+ });
5066
+ }
5067
+ /** Collect everything this query matches into one array. */
5068
+ findAll(options) {
5069
+ return this.client.findAll({
5070
+ ...this.params,
5071
+ ...this.params.limit !== void 0 && { pageSize: this.params.limit },
5072
+ ...options
5073
+ });
5074
+ }
5075
+ /**
5076
+ * Count the records matching this query.
5077
+ *
5078
+ * This used to answer `0` when the client had no `count` — a number, from a
5079
+ * source that had not counted anything, indistinguishable from an empty
5080
+ * collection. It now does what the client does, which on a source that
5081
+ * cannot count is throw and say so.
5082
+ */
4778
5083
  async count() {
4779
- return this.client.count ? this.client.count(this.params) : 0;
5084
+ return this.client.count(this.params);
4780
5085
  }
4781
5086
  listen(onUpdate, onError) {
4782
- if (!this.client.listen) throw new Error("Listen is only available when the driver supports realtime.");
4783
5087
  return this.client.listen(this.params, onUpdate, onError);
4784
5088
  }
4785
5089
  };
@@ -4807,6 +5111,14 @@ function toSdkCollectionClient(snap, slug = "collection") {
4807
5111
  const s = await snap.findById(id);
4808
5112
  return s ? entityToRow(s) : void 0;
4809
5113
  },
5114
+ async get(id) {
5115
+ const s = await snap.findById(id);
5116
+ if (!s) throw new RebaseApiError(`No record with id ${JSON.stringify(String(id))} in "${slug}".`, {
5117
+ status: 404,
5118
+ code: "NOT_FOUND"
5119
+ });
5120
+ return entityToRow(s);
5121
+ },
4810
5122
  async create(data, id) {
4811
5123
  return entityToRow(await snap.create(data, id));
4812
5124
  },
@@ -4837,12 +5149,12 @@ function toSdkCollectionClient(snap, slug = "collection") {
4837
5149
  if (!snap.deleteMany) throw new Error("Bulk deletes are not supported by this collection's data source. Fall back to delete() per record.");
4838
5150
  await snap.deleteMany(ids);
4839
5151
  },
4840
- count: snap.count ? (params) => snap.count(params) : void 0,
5152
+ count: snap.count ? (params) => snap.count(params) : unsupportedMethod(noCount(slug)),
4841
5153
  listen: snap.listen ? (params, onUpdate, onError) => snap.listen(params, (res) => onUpdate({
4842
5154
  data: res.data.map(entityToRow),
4843
5155
  meta: res.meta
4844
- }), onError) : void 0,
4845
- listenById: snap.listenById ? (id, onUpdate, onError) => snap.listenById(id, (s) => onUpdate(s ? entityToRow(s) : void 0), onError) : void 0,
5156
+ }), onError) : unsupportedMethod(noRealtime(slug)),
5157
+ listenById: snap.listenById ? (id, onUpdate, onError) => snap.listenById(id, (s) => onUpdate(s ? entityToRow(s) : void 0), onError) : unsupportedMethod(noRealtime(slug)),
4846
5158
  where(columnOrCondition, operator, value) {
4847
5159
  const builder = new SdkQueryBuilder(client);
4848
5160
  if (typeof columnOrCondition === "object") return builder.where(columnOrCondition);
@@ -4889,12 +5201,12 @@ function toEntityAccessor(sdk, slug, getPks = () => []) {
4889
5201
  delete(id) {
4890
5202
  return sdk.delete(id);
4891
5203
  },
4892
- count: sdk.count ? (params) => sdk.count(params) : void 0,
4893
- listen: sdk.listen ? (params, onUpdate, onError) => sdk.listen(params, (res) => onUpdate({
5204
+ count: isUnsupported(sdk.count) ? void 0 : (params) => sdk.count(params),
5205
+ listen: isUnsupported(sdk.listen) ? void 0 : (params, onUpdate, onError) => sdk.listen(params, (res) => onUpdate({
4894
5206
  data: res.data.map((row) => rowToEntity(row, slug, getPks())),
4895
5207
  meta: res.meta
4896
- }), onError) : void 0,
4897
- listenById: sdk.listenById ? (id, onUpdate, onError) => sdk.listenById(id, (row) => onUpdate(row ? rowToEntity(row, slug, getPks()) : void 0), onError) : void 0,
5208
+ }), onError),
5209
+ listenById: isUnsupported(sdk.listenById) ? void 0 : (id, onUpdate, onError) => sdk.listenById(id, (row) => onUpdate(row ? rowToEntity(row, slug, getPks()) : void 0), onError),
4898
5210
  where(columnOrCondition, operator, value) {
4899
5211
  const builder = new QueryBuilder(accessor);
4900
5212
  if (typeof columnOrCondition === "object") return builder.where(columnOrCondition);
@@ -4974,7 +5286,7 @@ function wrapAsSdkData(entityData) {
4974
5286
  * Build a flat {@link RebaseSdkData} from a `DataDriver`.
4975
5287
  *
4976
5288
  * This is the developer-facing SDK data layer used by backend framework
4977
- * callbacks & scripts (`context.data` / `rebase.data`). It returns flat rows —
5289
+ * callbacks & scripts (`context.data` / `rebase.dataAsAdmin`). It returns flat rows —
4978
5290
  * identical in shape to the frontend SDK client, down to how a relation is
4979
5291
  * served: a foreign key stays a foreign key, and a relation named in `include`
4980
5292
  * arrives as the target's own columns. The `{ __type: "relation" }` envelope is
@@ -5126,6 +5438,6 @@ async function detectJunctionTables(executeSql) {
5126
5438
  return junctionTables;
5127
5439
  }
5128
5440
  //#endregion
5129
- export { COLLECTION_PATH_SEPARATOR, COMPOSITE_ID_SEPARATOR, CollectionRegistry, DEFAULT_FIND_ALL_MAX_ROWS, DEFAULT_MAX_PAGES, DEFAULT_ONE_OF_TYPE, DEFAULT_ONE_OF_VALUE, DEFAULT_PAGE_SIZE, DEFAULT_STRING_COLUMN_LENGTH, JUNCTION_TABLES_SQL, MAX_LOGICAL_NESTING_DEPTH, OrderBySpecError, QueryBuilder, REBASE_INTERNAL_PREFIXES, REBASE_INTERNAL_SCHEMAS, REBASE_INTERNAL_TABLES, REBASE_USER_ROLE, RebasePaginationError, UnknownFilterOperatorError, and, buildCollectionFromTableMetadata, buildCompositeId, buildConditionContext, buildPropertyCallbacks, buildRebaseData, buildRoutedRebaseData, buildSdkData, canCreateEntity, canDeleteEntity, canEditEntity, canReadCollection, checkOperation, classifyTable, collectAllPages, cond, createDataSourceRegistry, createPaginationHelpers, createRelationRef, createRelationRefWithData, defaultUsersCollection, defineCollection, deserializeFilter, deserializeLogicalCondition, deserializeOrderBy, deserializeOrderByList, detectJunctionTables, embedParentExpression, enumToObjectEntries, evaluateCondition, evaluatePolicy, fieldKeyForColumn, findAnonymousGrants, findRelation, fullPathToCollectionSegments, getArrayResolvedProperties, getChildViewDeclaringProperties, getChildViewRelationPropertyKeys, getColumnName, getDeclaredPrimaryKeys, getDefaultValueFor, getDefaultValueFortype, getDefaultValuesFor, getEffectiveSecurityRules, getEntityChildViews, getEnumVarName, getGeneratedPolicyNames, getInjectedSecurityRules, getJunctionCollectionConfig, getJunctionSecurityRules, getLabelOrConfigFrom, getPrimaryKeys, getReferenceFrom, getRelationFrom, getRelationTargetPath, getSubcollections, getTableName, getTableVarName, isAddressableId, isJunctionBackedRelation, isPropertyBuilder, isRebaseInternalTable, isRelationalCollection, normalizeDriverOrderBy, normalizeEmail, normalizeOrderBy, normalizeToEntityRelation, or, paginateFind, parseIdValues, parseOrderBySpecStrict, policyToPostgres, primaryOrderBy, registerConditionOperations, relationalCollections, resolveArrayProperties, resolveCollectionRelations, resolveDataSource, resolveEnumValues, resolveFindWindow, resolveJunctionSpecs, resolvePrimaryKeys, resolveProperties, resolveProperty, resolvePropertyEnum, resolveRelation, resolveRelationProperty, resolveStorageFilenameString, resolveStoragePathString, resolveStorageSource, resolveStringColumnLength, revokeInternalTableAccess, revokeInternalTableSql, sanitizeData, securityRuleToConditions, segmentsToStrippedPath, serializeFilter, serializeLogicalCondition, serializeOrderBy, sortCollectionsBySlug, sortProperties, sqlToPolicy, stripCollectionPath, toFilterTuples, traverseValueProperty, traverseValuesProperties, updateDateAutoValues, wrapAsEntityData, wrapAsSdkData };
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 };
5130
5442
 
5131
5443
  //# sourceMappingURL=index.es.js.map