@rebasepro/common 0.19.1 → 0.19.2-canary.g08eed46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/collections/field-access.d.ts +76 -0
- package/dist/collections/index.d.ts +1 -0
- package/dist/data/buildRebaseData.d.ts +11 -12
- package/dist/data/cursor.d.ts +102 -0
- package/dist/data/include-spec.d.ts +128 -0
- package/dist/data/paginate.d.ts +8 -5
- package/dist/data/query_builder.d.ts +15 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.es.js +1415 -110
- package/dist/index.es.js.map +1 -1
- package/dist/util/entities.d.ts +79 -0
- package/dist/util/index.d.ts +1 -0
- package/dist/util/junction-policies.d.ts +17 -1
- package/dist/util/tenant.d.ts +146 -0
- package/package.json +3 -3
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
|
|
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
|
|
1530
|
-
const
|
|
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
|
|
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
|
|
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
|
-
|
|
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)
|
|
3683
|
-
|
|
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
|
-
|
|
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")
|
|
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
|
|
3999
|
-
|
|
4000
|
-
|
|
4001
|
-
|
|
4002
|
-
|
|
4003
|
-
|
|
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
|
|
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 (
|
|
4021
|
-
if (
|
|
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 (
|
|
4030
|
-
const
|
|
4031
|
-
if (
|
|
4032
|
-
if (
|
|
4033
|
-
|
|
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
|
-
*
|
|
4482
|
-
*
|
|
4483
|
-
*
|
|
4484
|
-
*
|
|
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))
|
|
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
|
|
4761
|
-
*
|
|
4762
|
-
*
|
|
4763
|
-
*
|
|
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
|
|
4767
|
-
*
|
|
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
|
|
5996
|
+
const fetched = fetchService ? await fetchService.fetchCollectionForRest(slug, {
|
|
4787
5997
|
filter,
|
|
4788
5998
|
logical: params?.logical,
|
|
4789
|
-
limit,
|
|
4790
|
-
offset: driverOffset,
|
|
4791
|
-
|
|
4792
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|