@protobase/server 0.25.4 → 0.25.5
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.
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { HttpProblem } from "../problem.js";
|
|
2
|
+
import { checkedFilter } from "../filter-input.js";
|
|
3
|
+
import { tenantScope } from "../tenant.js";
|
|
4
|
+
import { columnId, filterCondition, scopeConditions, tableId, whereClause } from "../sql-parts.js";
|
|
5
|
+
import { newlyAbove } from "./rank-pairs.js";
|
|
6
|
+
import { encodeKey } from "@protobase/schema/internal";
|
|
7
|
+
import { sql } from "kysely";
|
|
8
|
+
//#region src/batch/rank-after.ts
|
|
9
|
+
/**
|
|
10
|
+
* Refuses a reorder by the resource's position that puts a record at or above a record it depends on, or one of those
|
|
11
|
+
* at or below it (see `PositionAfterModel`), in one query of the pairs that involve the changed records. Pairs out of
|
|
12
|
+
* order before the reorder do not count, so existing data never blocks a move that did not break it.
|
|
13
|
+
*/
|
|
14
|
+
const checkRankAfter = async (trx, deps, access, field, moved) => {
|
|
15
|
+
const { full, entry, session } = access;
|
|
16
|
+
const after = full.position?.after;
|
|
17
|
+
if (!after || full.position.field !== field || moved.length === 0) return;
|
|
18
|
+
const via = deps.registry.find(after.resource).model;
|
|
19
|
+
const id = columnId(full.fields[full.primaryKey[0]]);
|
|
20
|
+
const rank = columnId(full.fields[field]);
|
|
21
|
+
const from = columnId(via.fields[after.field]);
|
|
22
|
+
const to = columnId(via.fields[after.key]);
|
|
23
|
+
const keys = sql.join(moved.map((record) => record.key[0]));
|
|
24
|
+
const links = [
|
|
25
|
+
...scopeConditions(via, tenantScope(deps.registry.find(after.resource), session)?.tenantValue),
|
|
26
|
+
...filterCondition(via, checkedFilter(via, after.filter)),
|
|
27
|
+
sql`(${from} in (${keys}) or ${to} in (${keys}))`
|
|
28
|
+
];
|
|
29
|
+
const tenant = tenantScope(entry, session)?.tenantValue;
|
|
30
|
+
const ranked = (pending) => {
|
|
31
|
+
const conditions = [...scopeConditions(full, tenant), ...pending ? filterCondition(full, checkedFilter(full, after.while)) : []];
|
|
32
|
+
return sql`(select ${id} as id, ${rank} as rank from ${tableId(full)} as t${whereClause(conditions)})`;
|
|
33
|
+
};
|
|
34
|
+
const { rows } = await sql`select l.dependent, l.depended, d.rank as dependent_rank, p.rank as depended_rank
|
|
35
|
+
from (select ${from} as dependent, ${to} as depended from ${tableId(via)} as t${whereClause(links)}) as l
|
|
36
|
+
join ${ranked(false)} as d on d.id = l.dependent
|
|
37
|
+
join ${ranked(true)} as p on p.id = l.depended`.execute(trx);
|
|
38
|
+
const broken = newlyAbove(rows, new Map(moved.map((record) => [encodeKey(record.key), record.before])));
|
|
39
|
+
if (!broken) return;
|
|
40
|
+
throw new HttpProblem(409, "ranks-above-dependency", "Conflict", `${entry.name} ${broken.dependent} cannot rank above ${broken.depended}, which it depends on`, {
|
|
41
|
+
record: broken.dependent,
|
|
42
|
+
dependsOn: broken.depended
|
|
43
|
+
});
|
|
44
|
+
};
|
|
45
|
+
//#endregion
|
|
46
|
+
export { checkRankAfter };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { encodeKey } from "@protobase/schema/internal";
|
|
2
|
+
//#region src/batch/rank-pairs.ts
|
|
3
|
+
const atOrAbove = (dependent, depended) => dependent !== null && depended !== null && Number(dependent) <= Number(depended);
|
|
4
|
+
/**
|
|
5
|
+
* The first pair whose record now ranks at or above the record it depends on but did not before: `before` holds the
|
|
6
|
+
* ranks the records a reorder changed had, by their encoded key. A pair out of order already stays as it is, so a
|
|
7
|
+
* reorder never fails on an order it did not break.
|
|
8
|
+
*/
|
|
9
|
+
const newlyAbove = (pairs, before) => {
|
|
10
|
+
const was = (key, now) => {
|
|
11
|
+
const name = encodeKey([key]);
|
|
12
|
+
return before.has(name) ? before.get(name) : now;
|
|
13
|
+
};
|
|
14
|
+
return pairs.find((pair) => atOrAbove(pair.dependent_rank, pair.depended_rank) && !atOrAbove(was(pair.dependent, pair.dependent_rank), was(pair.depended, pair.depended_rank)));
|
|
15
|
+
};
|
|
16
|
+
//#endregion
|
|
17
|
+
export { newlyAbove };
|
package/dist/batch/reorder.js
CHANGED
|
@@ -6,6 +6,7 @@ import { fetchRecord } from "../records.js";
|
|
|
6
6
|
import { validateBody } from "../validate-body.js";
|
|
7
7
|
import { missing, shown } from "../write-shared.js";
|
|
8
8
|
import { applyUpdate, planUpdate } from "../write-update.js";
|
|
9
|
+
import { checkRankAfter } from "./rank-after.js";
|
|
9
10
|
import { encodeKey } from "@protobase/schema/internal";
|
|
10
11
|
import { sql } from "kysely";
|
|
11
12
|
//#region src/batch/reorder.ts
|
|
@@ -42,7 +43,8 @@ const moveAside = async (trx, access, field, keys) => {
|
|
|
42
43
|
* `reorder`: the listed records end up with `field` = `start`, `start` + 1, ... in the given order, `start` 1 unless
|
|
43
44
|
* given, so a client sends only the stretch of a long list that moved. Only records whose value changes
|
|
44
45
|
* are written, each as an ordinary update (validation, access, ETag, hooks); they are first moved aside (see `moveAside`)
|
|
45
|
-
* so that a unique index such as (invoice_id, position) is never violated half way. Everything checks out before any row moves
|
|
46
|
+
* so that a unique index such as (invoice_id, position) is never violated half way. Everything checks out before any row moves,
|
|
47
|
+
* and with the position's `after` the new order must keep each record below the records it depends on (see `checkRankAfter`).
|
|
46
48
|
*/
|
|
47
49
|
const reorderIn = async (trx, deps, access, input) => {
|
|
48
50
|
const { entry, full } = access;
|
|
@@ -74,6 +76,10 @@ const reorderIn = async (trx, deps, access, input) => {
|
|
|
74
76
|
const plan = plans.get(i);
|
|
75
77
|
results.push(await shown(access, plan ? await applyUpdate(trx, deps, access, key, plan) : current[i]));
|
|
76
78
|
}
|
|
79
|
+
await checkRankAfter(trx, deps, access, input.field, changing.map((i) => ({
|
|
80
|
+
key: input.keys[i],
|
|
81
|
+
before: current[i].record[input.field]
|
|
82
|
+
})));
|
|
77
83
|
return results;
|
|
78
84
|
};
|
|
79
85
|
//#endregion
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { filterErrors, matchErrors } from "./view-related.js";
|
|
2
|
+
//#region src/check-position-after.ts
|
|
3
|
+
/**
|
|
4
|
+
* Stops startup on a `.position(..., { after })` that cannot find the records each record ranks after: a join resource
|
|
5
|
+
* the app does not have, `field` and `key` that are no filterable relations to the ranked resource, or a `filter` or
|
|
6
|
+
* `while` that does not fit the join or the ranked resource.
|
|
7
|
+
*/
|
|
8
|
+
const checkPositionAfter = (registry) => {
|
|
9
|
+
const problems = registry.entries.flatMap(({ name, model }) => {
|
|
10
|
+
const after = model.position?.after;
|
|
11
|
+
if (!after) return [];
|
|
12
|
+
const via = registry.find(after.resource)?.model;
|
|
13
|
+
return (via ? [
|
|
14
|
+
...matchErrors(via, after.field, name),
|
|
15
|
+
...matchErrors(via, after.key, name),
|
|
16
|
+
...filterErrors(via, after.filter).map((error) => `filter: ${error}`),
|
|
17
|
+
...filterErrors(model, after.while).map((error) => `while: ${error}`)
|
|
18
|
+
] : [`unknown resource "${after.resource}"`]).map((error) => `${name} position after: ${error}`);
|
|
19
|
+
});
|
|
20
|
+
if (problems.length > 0) throw new Error(`The positions cannot find the records they rank after:\n${problems.map((problem) => ` - ${problem}`).join("\n")}`);
|
|
21
|
+
};
|
|
22
|
+
//#endregion
|
|
23
|
+
export { checkPositionAfter };
|
package/dist/create-admin.js
CHANGED
|
@@ -32,6 +32,7 @@ import { visitorsBrowse, withAnonymous } from "./anonymous.js";
|
|
|
32
32
|
import { checkBottomBars, checkShell } from "./check-shell.js";
|
|
33
33
|
import { checkOwnerFields } from "./check-owners.js";
|
|
34
34
|
import { checkRoleLimits } from "./check-roles.js";
|
|
35
|
+
import { checkPositionAfter } from "./check-position-after.js";
|
|
35
36
|
import { checkViews } from "./check-views.js";
|
|
36
37
|
import { liveRoutes } from "./live/live-route.js";
|
|
37
38
|
import { tableFeed } from "./live/notice-feed.js";
|
|
@@ -99,6 +100,7 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
|
|
|
99
100
|
checkViews(registry, viewModels, pageModels.map((entry) => entry.name), fieldTypes);
|
|
100
101
|
checkRoleLimits(registry, options.roles?.limits ?? []);
|
|
101
102
|
checkOwnerFields(registry, auth);
|
|
103
|
+
checkPositionAfter(registry);
|
|
102
104
|
checkPages(pageModels, {
|
|
103
105
|
models: Object.fromEntries(registry.entries.map((entry) => [entry.name, entry.model])),
|
|
104
106
|
actions: (resource) => new Set(viewModels.filter((view) => view.resource === resource).flatMap((view) => view.actions.map((action) => action.name))),
|
package/dist/view-related.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protobase/server",
|
|
3
|
-
"version": "0.25.
|
|
3
|
+
"version": "0.25.5",
|
|
4
4
|
"description": "The Protobase REST API as a Hono app, with Better Auth sign-in.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -32,9 +32,9 @@
|
|
|
32
32
|
"dependencies": {
|
|
33
33
|
"@better-auth/passkey": "^1.7.7",
|
|
34
34
|
"@better-auth/sso": "^1.7.7",
|
|
35
|
-
"@protobase/layout": "0.25.
|
|
36
|
-
"@protobase/query": "0.25.
|
|
37
|
-
"@protobase/schema": "0.25.
|
|
35
|
+
"@protobase/layout": "0.25.5",
|
|
36
|
+
"@protobase/query": "0.25.5",
|
|
37
|
+
"@protobase/schema": "0.25.5",
|
|
38
38
|
"@react-email/render": "^2.1.0",
|
|
39
39
|
"@scalar/hono-api-reference": "^0.12.10",
|
|
40
40
|
"@sentry/core": "^11.7.0",
|