@rebasepro/common 0.14.1 → 0.14.2-canary.g27a129e
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/data/sort-dialect.d.ts +7 -0
- package/dist/index.es.js +59 -16
- package/dist/index.es.js.map +1 -1
- package/package.json +3 -3
- package/src/data/sort-dialect.ts +32 -8
- package/src/util/internal-tables.ts +1 -0
- package/src/util/resolve-relation.ts +55 -11
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rebasepro/common",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.14.
|
|
4
|
+
"version": "0.14.2-canary.g27a129e",
|
|
5
5
|
"description": "Rebase shared core — collection registry, data driver adapter and fluent query builder. No React dependency.",
|
|
6
6
|
"funding": {
|
|
7
7
|
"url": "https://github.com/sponsors/rebaseco"
|
|
@@ -40,8 +40,8 @@
|
|
|
40
40
|
"dependencies": {
|
|
41
41
|
"fast-equals": "6.0.2",
|
|
42
42
|
"json-logic-js": "^2.0.5",
|
|
43
|
-
"@rebasepro/
|
|
44
|
-
"@rebasepro/
|
|
43
|
+
"@rebasepro/utils": "0.14.2-canary.g27a129e",
|
|
44
|
+
"@rebasepro/types": "0.14.2-canary.g27a129e"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
47
|
"@jest/globals": "^30.4.1",
|
package/src/data/sort-dialect.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { OrderBySpec, OrderByTuple } from "@rebasepro/types";
|
|
1
|
+
import type { OrderBySortTuple, OrderBySpec, OrderByTuple } from "@rebasepro/types";
|
|
2
|
+
import { isRelationAggregateSort, sortKeyToString } from "@rebasepro/types";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Sort-order wire codec.
|
|
@@ -20,16 +21,27 @@ import type { OrderBySpec, OrderByTuple } from "@rebasepro/types";
|
|
|
20
21
|
* the same value; the two are told apart by whether the first element is
|
|
21
22
|
* itself an array, which no field name ever is.
|
|
22
23
|
*
|
|
24
|
+
* This is also where a {@link RelationAggregateSort} object stops being an
|
|
25
|
+
* object. Above this function a sort key may be either spelling; below it,
|
|
26
|
+
* every key is a string — which is what `OrderByTuple`, the REST parameter, the
|
|
27
|
+
* driver contract and the cursor all already were. Doing it here means the one
|
|
28
|
+
* place that already collapses the two *shapes* of a sort also collapses the
|
|
29
|
+
* two *spellings* of a key, rather than every consumer learning about both.
|
|
30
|
+
*
|
|
23
31
|
* @returns The keys in order of significance, or `undefined` for no sort. An
|
|
24
32
|
* empty list also returns `undefined` — "sort by nothing" is no sort, and
|
|
25
33
|
* letting `[]` through would have every layer below re-deciding what it meant.
|
|
26
34
|
*/
|
|
27
35
|
export function normalizeOrderBy(orderBy?: OrderBySpec): OrderByTuple[] | undefined {
|
|
28
36
|
if (!orderBy || orderBy.length === 0) return undefined;
|
|
37
|
+
// An aggregate key is an object, so the first element being an array still
|
|
38
|
+
// tells the list form from the single-tuple one — no field name is an
|
|
39
|
+
// array, and neither is an aggregate key.
|
|
29
40
|
const list = Array.isArray(orderBy[0])
|
|
30
|
-
? orderBy as
|
|
31
|
-
: [orderBy as
|
|
32
|
-
|
|
41
|
+
? orderBy as OrderBySortTuple[]
|
|
42
|
+
: [orderBy as OrderBySortTuple];
|
|
43
|
+
if (list.length === 0) return undefined;
|
|
44
|
+
return list.map(([key, direction]) => [sortKeyToString(key), direction] as OrderByTuple);
|
|
33
45
|
}
|
|
34
46
|
|
|
35
47
|
/**
|
|
@@ -99,21 +111,30 @@ export function parseOrderBySpecStrict(raw: unknown, order?: "asc" | "desc"): Or
|
|
|
99
111
|
throw new OrderBySpecError(`${typeof raw} is not a field name or a list of sort keys`);
|
|
100
112
|
}
|
|
101
113
|
|
|
102
|
-
// The single-tuple spelling, `["created_at", "desc"]
|
|
103
|
-
|
|
114
|
+
// The single-tuple spelling, `["created_at", "desc"]` — or the same shape
|
|
115
|
+
// with an aggregate key in place of the field name.
|
|
116
|
+
if (typeof raw[0] === "string" || isRelationAggregateSort(raw[0])) return [toStrictTuple(raw, 0)];
|
|
104
117
|
|
|
105
118
|
return raw.map(toStrictTuple);
|
|
106
119
|
}
|
|
107
120
|
|
|
108
121
|
function toStrictTuple(raw: unknown, index: number): OrderByTuple {
|
|
109
|
-
if (!Array.isArray(raw)
|
|
122
|
+
if (!Array.isArray(raw)) {
|
|
123
|
+
throw new OrderBySpecError(`entry ${index} has no field name`);
|
|
124
|
+
}
|
|
125
|
+
// The object spelling of an aggregate key, from an untyped caller that did
|
|
126
|
+
// not go through `normalizeOrderBy`. Encoded rather than refused: it is a
|
|
127
|
+
// sort this understands, and rejecting the shape a typed caller writes
|
|
128
|
+
// would be a distinction between the two spellings that nothing else makes.
|
|
129
|
+
const key = isRelationAggregateSort(raw[0]) ? sortKeyToString(raw[0]) : raw[0];
|
|
130
|
+
if (typeof key !== "string" || key.trim() === "") {
|
|
110
131
|
throw new OrderBySpecError(`entry ${index} has no field name`);
|
|
111
132
|
}
|
|
112
133
|
const direction = raw[1];
|
|
113
134
|
if (direction !== undefined && direction !== "asc" && direction !== "desc") {
|
|
114
135
|
throw new OrderBySpecError(`entry ${index} has direction '${String(direction)}'`);
|
|
115
136
|
}
|
|
116
|
-
return [
|
|
137
|
+
return [key, direction ?? "asc"];
|
|
117
138
|
}
|
|
118
139
|
|
|
119
140
|
/**
|
|
@@ -143,6 +164,9 @@ export function serializeOrderBy(orderBy?: OrderBySpec | string): string | undef
|
|
|
143
164
|
if (!orderBy) return undefined;
|
|
144
165
|
// Runtime tolerance: pass through a pre-serialized wire string unchanged.
|
|
145
166
|
if (typeof orderBy === "string") return orderBy;
|
|
167
|
+
// `normalizeOrderBy` has already encoded any aggregate key to its string
|
|
168
|
+
// spelling, which is why the shorthand below can assume a string: neither
|
|
169
|
+
// `min(applications.created_at)` nor `count(applications)` contains a `:`.
|
|
146
170
|
const list = normalizeOrderBy(orderBy);
|
|
147
171
|
if (!list) return undefined;
|
|
148
172
|
if (list.length === 1) return `${list[0][0]}:${list[0][1]}`;
|
|
@@ -46,7 +46,14 @@ export function resolveRelation(
|
|
|
46
46
|
|
|
47
47
|
const shared: Pick<ResolvedRelation, "relationName" | "target" | "targetSlug" | "onUpdate" | "onDelete" | "overrides" | "validation"> = {
|
|
48
48
|
relationName,
|
|
49
|
-
target
|
|
49
|
+
// Normalised, not the thunk as written. Resolution reads the target once
|
|
50
|
+
// and every later consumer calls it again — the driver building a join,
|
|
51
|
+
// the DDL and policy generators, the admin's relation fields — so
|
|
52
|
+
// handing back the raw thunk would give all of them the module namespace
|
|
53
|
+
// `callTarget` just looked past, and the fix would hold only for the
|
|
54
|
+
// fields resolution happens to read here. Still lazy: same call at the
|
|
55
|
+
// same moment, one unwrap on the way out.
|
|
56
|
+
target: () => unwrapModuleNamespace(target()) as CollectionConfig,
|
|
50
57
|
targetSlug: targetCollection.slug,
|
|
51
58
|
onUpdate: relation.onUpdate,
|
|
52
59
|
onDelete: relation.onDelete,
|
|
@@ -139,25 +146,58 @@ function describe(relation: Relation, sourceCollection: CollectionConfig, proper
|
|
|
139
146
|
}
|
|
140
147
|
|
|
141
148
|
/**
|
|
142
|
-
*
|
|
143
|
-
*
|
|
149
|
+
* A module namespace, unwrapped to the collection it exports.
|
|
150
|
+
*
|
|
151
|
+
* A cycle transpiled to CommonJS does not hand the importing module the
|
|
152
|
+
* *default export* — it hands it the module object, `{ __esModule: true,
|
|
153
|
+
* default: … }`, captured before the exporting module finished evaluating. The
|
|
154
|
+
* `default` slot fills in later, so by the time a lazy `target` thunk runs the
|
|
155
|
+
* collection is sitting right there, one level down. Returning the namespace is
|
|
156
|
+
* never a thing a thunk means to do, and there is exactly one reading of it.
|
|
157
|
+
*
|
|
158
|
+
* Only unwrapped when the inner value is itself a collection: a `default` that
|
|
159
|
+
* is not one is a genuinely wrong thunk, and it should reach the error below
|
|
160
|
+
* rather than be quietly swapped in.
|
|
161
|
+
*/
|
|
162
|
+
function unwrapModuleNamespace(value: unknown): unknown {
|
|
163
|
+
if (!value || typeof value !== "object") return value;
|
|
164
|
+
if ((value as { slug?: unknown }).slug) return value;
|
|
165
|
+
const inner = (value as { default?: unknown }).default;
|
|
166
|
+
return inner && typeof inner === "object" && (inner as { slug?: unknown }).slug ? inner : value;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Call the `target` thunk, and translate the ways an import cycle breaks it into
|
|
171
|
+
* an error that names the cause — or, where the value is recoverable, into the
|
|
172
|
+
* collection the thunk meant.
|
|
144
173
|
*
|
|
145
174
|
* The thunk exists to defer the reference until every module has finished
|
|
146
|
-
* evaluating, and for a cycle that closes at import time it does.
|
|
147
|
-
*
|
|
148
|
-
* two shapes of that:
|
|
175
|
+
* evaluating, and for a cycle that closes at import time it does. Two cycles
|
|
176
|
+
* leave the binding permanently unusable:
|
|
149
177
|
*
|
|
150
178
|
* - **ESM/TDZ.** `const` and `class` bindings in a not-yet-evaluated module are
|
|
151
179
|
* in the temporal dead zone, so reading one throws `ReferenceError: x is not
|
|
152
180
|
* defined`. The stack points at the thunk — a one-line arrow function that is
|
|
153
181
|
* obviously fine — and says nothing about the cycle that made it throw.
|
|
154
|
-
* - **CJS interop.** The half-initialised module object has no
|
|
155
|
-
* the import resolves to `undefined`, and the thunk returns it
|
|
156
|
-
* complaint. That one used to surface here as "did not resolve to a
|
|
182
|
+
* - **CJS interop, unresolved.** The half-initialised module object has no
|
|
183
|
+
* `default` yet, the import resolves to `undefined`, and the thunk returns it
|
|
184
|
+
* without complaint. That one used to surface here as "did not resolve to a
|
|
157
185
|
* collection", which is true and unhelpful.
|
|
158
186
|
*
|
|
159
187
|
* Both mean the same thing, and the fix for both is the same: break the cycle,
|
|
160
188
|
* or move the relation into the collection that does not close it.
|
|
189
|
+
*
|
|
190
|
+
* A third shape is *not* an error, and used to be reported as one. A loader that
|
|
191
|
+
* transpiles ESM to CJS — jiti, which is what `rebase generate-sdk` and
|
|
192
|
+
* `rebase build` load collections with — gives the module entered second in a
|
|
193
|
+
* cycle a namespace object rather than the default export, and never replaces it
|
|
194
|
+
* with a live binding. The thunk then returns `{ __esModule: true, default: … }`
|
|
195
|
+
* holding the fully-initialised collection. Native ESM resolves the same thunk
|
|
196
|
+
* to the collection directly, so this was a loader artefact reported as an
|
|
197
|
+
* authoring mistake, and the advice it gave — make the target a lazy thunk — was
|
|
198
|
+
* already satisfied by the code it was rejecting. Bidirectional relations make
|
|
199
|
+
* these cycles unavoidable, and the lazy thunk is this framework's own answer to
|
|
200
|
+
* them, so {@link unwrapModuleNamespace} takes the collection and moves on.
|
|
161
201
|
*/
|
|
162
202
|
function callTarget(
|
|
163
203
|
relation: Relation,
|
|
@@ -167,7 +207,7 @@ function callTarget(
|
|
|
167
207
|
): ReturnType<Relation["target"]> {
|
|
168
208
|
let targetCollection: ReturnType<Relation["target"]> | undefined;
|
|
169
209
|
try {
|
|
170
|
-
targetCollection = target()
|
|
210
|
+
targetCollection = unwrapModuleNamespace(target()) as ReturnType<Relation["target"]>;
|
|
171
211
|
} catch (error) {
|
|
172
212
|
// A ReferenceError from inside the thunk is a binding that was never
|
|
173
213
|
// initialised — nothing else in a one-expression arrow can raise one.
|
|
@@ -191,7 +231,11 @@ function callTarget(
|
|
|
191
231
|
? "Under CommonJS interop an import cycle resolves the default import to `undefined`, " +
|
|
192
232
|
"so check whether this collection and its target import each other. Otherwise the thunk " +
|
|
193
233
|
"is returning the wrong value — it must return the collection itself, not a promise or a module."
|
|
194
|
-
:
|
|
234
|
+
: typeof (targetCollection as { then?: unknown }).then === "function"
|
|
235
|
+
? "The thunk returned a promise — `target: () => import(\"./other\")` is asynchronous. " +
|
|
236
|
+
"Import the collection at the top of the file and return the binding: " +
|
|
237
|
+
"`target: () => otherCollection`."
|
|
238
|
+
: "The thunk must return a collection config with a `slug`.")
|
|
195
239
|
);
|
|
196
240
|
}
|
|
197
241
|
|