@protobase/server 0.19.15 → 0.20.0
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/aggregate-groups.js +137 -0
- package/dist/aggregate-service.js +11 -10
- package/dist/better-auth/create-auth.d.ts +16 -1
- package/dist/better-auth/create-auth.js +8 -2
- package/dist/check-owners.js +15 -0
- package/dist/check-view-queries.js +2 -1
- package/dist/check-views.js +13 -4
- package/dist/create-admin.js +2 -0
- package/dist/openapi/resource-paths.js +66 -0
- package/dist/owner-id.js +21 -0
- package/dist/registry.js +10 -0
- package/dist/request-access.js +2 -0
- package/dist/routes/collection.js +5 -0
- package/dist/validate-body.js +14 -2
- package/dist/view-access.js +29 -9
- package/dist/view-chart.js +33 -0
- package/dist/view-editable.js +23 -0
- package/package.json +4 -4
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { badRequest } from "./problem.js";
|
|
2
|
+
import { readTransaction } from "./transactions.js";
|
|
3
|
+
import { parseParams } from "./params.js";
|
|
4
|
+
import { labelRequests, relatedLabels } from "./related-labels.js";
|
|
5
|
+
import { scoped, seriesRange } from "./aggregate-service.js";
|
|
6
|
+
import { groupTotals, series } from "@protobase/query";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
//#region src/aggregate-groups.ts
|
|
9
|
+
const name = z.string().min(1);
|
|
10
|
+
const aggregateParams = z.object({
|
|
11
|
+
group_by: name,
|
|
12
|
+
filter: z.string().optional(),
|
|
13
|
+
sum: name.optional(),
|
|
14
|
+
average: name.optional(),
|
|
15
|
+
limit: z.coerce.number().int().optional(),
|
|
16
|
+
label: name.optional(),
|
|
17
|
+
range: z.enum([
|
|
18
|
+
"7d",
|
|
19
|
+
"30d",
|
|
20
|
+
"90d",
|
|
21
|
+
"1y",
|
|
22
|
+
"all"
|
|
23
|
+
]).optional(),
|
|
24
|
+
from: z.string().optional(),
|
|
25
|
+
to: z.string().optional(),
|
|
26
|
+
granularity: z.enum([
|
|
27
|
+
"hour",
|
|
28
|
+
"day",
|
|
29
|
+
"week",
|
|
30
|
+
"month",
|
|
31
|
+
"year"
|
|
32
|
+
]).optional(),
|
|
33
|
+
time_zone: z.string().optional()
|
|
34
|
+
});
|
|
35
|
+
const invalid = (parameter, message) => badRequest("invalid-parameter", `Invalid parameter ${parameter}: ${message}`, { errors: [{
|
|
36
|
+
parameter,
|
|
37
|
+
message
|
|
38
|
+
}] });
|
|
39
|
+
const measureOf = (params) => {
|
|
40
|
+
if (params.sum !== void 0 && params.average !== void 0) throw invalid("average", "Give sum or average, not both");
|
|
41
|
+
if (params.sum !== void 0) return {
|
|
42
|
+
kind: "sum",
|
|
43
|
+
field: params.sum
|
|
44
|
+
};
|
|
45
|
+
if (params.average !== void 0) return {
|
|
46
|
+
kind: "average",
|
|
47
|
+
field: params.average
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
const optionErrors = (params, dated) => {
|
|
51
|
+
const given = (names) => names.filter((option) => params[option] !== void 0);
|
|
52
|
+
if (dated) return given(["limit", "label"]).map((option) => invalid(option, `Not for a date or timestamp group_by`));
|
|
53
|
+
return given([
|
|
54
|
+
"range",
|
|
55
|
+
"from",
|
|
56
|
+
"to",
|
|
57
|
+
"granularity",
|
|
58
|
+
"time_zone"
|
|
59
|
+
]).map((option) => invalid(option, "Only for a date or timestamp group_by"));
|
|
60
|
+
};
|
|
61
|
+
const isDated = (model, field) => {
|
|
62
|
+
const type = model.fields[field]?.type;
|
|
63
|
+
return type === "date" || type === "timestamp";
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* Records per value of a field, or per hour, day, week, month or year of a date field with the empty ones included,
|
|
67
|
+
* each with how many there are and, with `sum` or `average`, that of a numeric field. Read as a list is: the caller's
|
|
68
|
+
* filter on the model as they see it, inside their tenant and row filter; the scan guard judges the grouped field.
|
|
69
|
+
*/
|
|
70
|
+
const aggregate = async (deps, entry, session, input) => {
|
|
71
|
+
const params = parseParams(aggregateParams, input);
|
|
72
|
+
const { access, model, filter, scope } = await scoped(deps, entry, session, params.filter);
|
|
73
|
+
if (!Object.hasOwn(model.fields, params.group_by)) throw invalid("group_by", `Unknown field "${params.group_by}"`);
|
|
74
|
+
const measure = measureOf(params);
|
|
75
|
+
const dated = isDated(model, params.group_by);
|
|
76
|
+
const [problem] = optionErrors(params, dated);
|
|
77
|
+
if (problem) throw problem;
|
|
78
|
+
const warning = await deps.guard(entry, {
|
|
79
|
+
...filter && { filter },
|
|
80
|
+
groupBy: params.group_by
|
|
81
|
+
}, {
|
|
82
|
+
model,
|
|
83
|
+
scope
|
|
84
|
+
});
|
|
85
|
+
if (dated) {
|
|
86
|
+
const granularity = params.granularity ?? "day";
|
|
87
|
+
const timeZone = params.time_zone ?? "UTC";
|
|
88
|
+
const range = await seriesRange(deps, access, params.group_by, params, scope);
|
|
89
|
+
const groups = (range ? await readTransaction(deps.db, deps.statementTimeoutMs, (trx) => series(trx, model, params.group_by, {
|
|
90
|
+
range,
|
|
91
|
+
granularity,
|
|
92
|
+
timeZone,
|
|
93
|
+
...filter && { filter },
|
|
94
|
+
...measure && { measure },
|
|
95
|
+
scope
|
|
96
|
+
})) : []).map(({ bucket, ...totals }) => ({
|
|
97
|
+
value: bucket,
|
|
98
|
+
...totals
|
|
99
|
+
}));
|
|
100
|
+
return {
|
|
101
|
+
body: {
|
|
102
|
+
group_by: params.group_by,
|
|
103
|
+
granularity,
|
|
104
|
+
time_zone: timeZone,
|
|
105
|
+
groups
|
|
106
|
+
},
|
|
107
|
+
warning
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
const labels = params.label === void 0 ? [] : await labelRequests(deps, session, model, [params.group_by], `${params.group_by}.${params.label}`);
|
|
111
|
+
return {
|
|
112
|
+
body: await readTransaction(deps.db, deps.statementTimeoutMs, async (trx) => {
|
|
113
|
+
const totals = await groupTotals(trx, model, params.group_by, {
|
|
114
|
+
statementTimeoutMs: deps.statementTimeoutMs,
|
|
115
|
+
...filter && { filter },
|
|
116
|
+
...measure && { measure },
|
|
117
|
+
...params.limit !== void 0 && { limit: params.limit },
|
|
118
|
+
scope
|
|
119
|
+
});
|
|
120
|
+
const names = (await relatedLabels(trx, labels, totals.map((group) => ({ [params.group_by]: group.value }))))[params.group_by] ?? {};
|
|
121
|
+
const groups = totals.map((group) => {
|
|
122
|
+
const label = group.value === null ? void 0 : names[String(group.value)];
|
|
123
|
+
return label === void 0 ? group : {
|
|
124
|
+
...group,
|
|
125
|
+
label
|
|
126
|
+
};
|
|
127
|
+
});
|
|
128
|
+
return {
|
|
129
|
+
group_by: params.group_by,
|
|
130
|
+
groups
|
|
131
|
+
};
|
|
132
|
+
}),
|
|
133
|
+
warning
|
|
134
|
+
};
|
|
135
|
+
};
|
|
136
|
+
//#endregion
|
|
137
|
+
export { aggregate };
|
|
@@ -103,25 +103,26 @@ const earliest = async (deps, access, fieldName, scope) => {
|
|
|
103
103
|
return (await readTransaction(deps.db, deps.statementTimeoutMs, (trx) => sql`select min(${columnId(target)})::text as lowest from ${tableId(access.full)} as t${whereClause(conditions)}`.execute(trx))).rows[0]?.lowest ?? void 0;
|
|
104
104
|
};
|
|
105
105
|
const bound = (model, fieldName, value) => model.fields[fieldName]?.type === "date" ? value.slice(0, 10) : value;
|
|
106
|
-
|
|
106
|
+
/** The span a series of `field` covers: `from` and `to`, else the `range` up to now, `all` from the earliest row. */
|
|
107
|
+
const seriesRange = async (deps, access, field, params, scope) => {
|
|
107
108
|
const to = params.to ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
108
109
|
const { model } = access;
|
|
109
110
|
if (params.from) return {
|
|
110
|
-
from: bound(model,
|
|
111
|
-
to: bound(model,
|
|
111
|
+
from: bound(model, field, params.from),
|
|
112
|
+
to: bound(model, field, to)
|
|
112
113
|
};
|
|
113
114
|
const range = params.range ?? "30d";
|
|
114
115
|
if (range !== "all") {
|
|
115
116
|
const from = (/* @__PURE__ */ new Date(Date.now() - rangeDays[range] * 864e5)).toISOString();
|
|
116
117
|
return {
|
|
117
|
-
from: bound(model,
|
|
118
|
-
to: bound(model,
|
|
118
|
+
from: bound(model, field, from),
|
|
119
|
+
to: bound(model, field, to)
|
|
119
120
|
};
|
|
120
121
|
}
|
|
121
|
-
const lowest = await earliest(deps, access,
|
|
122
|
+
const lowest = await earliest(deps, access, field, scope);
|
|
122
123
|
return lowest ? {
|
|
123
|
-
from: bound(model,
|
|
124
|
-
to: bound(model,
|
|
124
|
+
from: bound(model, field, lowest),
|
|
125
|
+
to: bound(model, field, to)
|
|
125
126
|
} : void 0;
|
|
126
127
|
};
|
|
127
128
|
/** Row counts per calendar bucket of a date or timestamp field, empty buckets included. */
|
|
@@ -132,7 +133,7 @@ const timeSeries = async (deps, entry, session, input) => {
|
|
|
132
133
|
parameter: "field",
|
|
133
134
|
message: "Unknown field"
|
|
134
135
|
}] });
|
|
135
|
-
const range = await seriesRange(deps, access, params, scope);
|
|
136
|
+
const range = await seriesRange(deps, access, params.field, params, scope);
|
|
136
137
|
const timeZone = params.time_zone ?? "UTC";
|
|
137
138
|
const points = range ? await readTransaction(deps.db, deps.statementTimeoutMs, (trx) => series(trx, model, params.field, {
|
|
138
139
|
range,
|
|
@@ -159,4 +160,4 @@ const numericHistogram = async (deps, entry, session, input) => {
|
|
|
159
160
|
};
|
|
160
161
|
};
|
|
161
162
|
//#endregion
|
|
162
|
-
export { facets, numericHistogram, timeSeries };
|
|
163
|
+
export { facets, numericHistogram, scoped, seriesRange, timeSeries };
|
|
@@ -77,6 +77,11 @@ export type CreateAuthOptions = {
|
|
|
77
77
|
encryptOAuthTokens?: boolean;
|
|
78
78
|
/** Runs after a user is created, by any route: `createUser`, an admin, or a social sign-up. Default none. */
|
|
79
79
|
onUserCreated?: UserCreatedHook;
|
|
80
|
+
/**
|
|
81
|
+
* The id of a new user, by any route. Default Better Auth's random 32-character text. `.owner()` fields hold it: a
|
|
82
|
+
* `text` column for the default, or an integer column when this takes the next value of the app's own users sequence.
|
|
83
|
+
*/
|
|
84
|
+
generateUserId?: () => string | Promise<string>;
|
|
80
85
|
/**
|
|
81
86
|
* The identity provider the operator's staff sign in with to sign in as people of this app, for support. Defaults
|
|
82
87
|
* to the one the platform passes in `PROTOBASE_OPERATOR_ISSUER`, `PROTOBASE_OPERATOR_CLIENT_ID` and
|
|
@@ -3744,7 +3749,15 @@ export declare const createAuth: (options: CreateAuthOptions) => import("better-
|
|
|
3744
3749
|
name?: string;
|
|
3745
3750
|
slug?: string;
|
|
3746
3751
|
logo?: string | null;
|
|
3747
|
-
metadata
|
|
3752
|
+
metadata?
|
|
3753
|
+
/**
|
|
3754
|
+
* The options `createAuth` gives Better Auth, with the roles, default role, mailer, operator provider and platform
|
|
3755
|
+
* sign-in provider already resolved.
|
|
3756
|
+
*/
|
|
3757
|
+
/**
|
|
3758
|
+
* The options `createAuth` gives Better Auth, with the roles, default role, mailer, operator provider and platform
|
|
3759
|
+
* sign-in provider already resolved.
|
|
3760
|
+
*/ : Record<string, any>;
|
|
3748
3761
|
[key: string]: any;
|
|
3749
3762
|
};
|
|
3750
3763
|
}>;
|
|
@@ -4354,6 +4367,8 @@ export declare const createAuth: (options: CreateAuthOptions) => import("better-
|
|
|
4354
4367
|
roleDefinitions: RoleDefinitions;
|
|
4355
4368
|
organizations: ResolvedOrganizations | undefined;
|
|
4356
4369
|
defaultRole: string | undefined;
|
|
4370
|
+
/** New users get their ids from `generateUserId` instead of Better Auth's random text. */
|
|
4371
|
+
generatesUserIds: boolean;
|
|
4357
4372
|
mail: boolean;
|
|
4358
4373
|
passwordReset: boolean;
|
|
4359
4374
|
/** Open sign-up is on and offered: the app asks for it, and can mail the codes. */
|
|
@@ -163,10 +163,14 @@ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, defini
|
|
|
163
163
|
const given = String(user.role ?? "").split(",").map((name) => name.trim()).filter(Boolean);
|
|
164
164
|
const unknown = given.find((name) => !roles.includes(name));
|
|
165
165
|
if (given.length === 0 || unknown !== void 0) throw new APIError("BAD_REQUEST", { message: `Choose a role for the new user: ${roles.join(", ")}` });
|
|
166
|
-
|
|
166
|
+
const withId = options.generateUserId ? {
|
|
167
167
|
...user,
|
|
168
|
+
id: await options.generateUserId()
|
|
169
|
+
} : user;
|
|
170
|
+
return { data: context?.path === "/admin/create-user" ? {
|
|
171
|
+
...withId,
|
|
168
172
|
emailVerified: true
|
|
169
|
-
} :
|
|
173
|
+
} : withId };
|
|
170
174
|
},
|
|
171
175
|
...options.onUserCreated && { after: options.onUserCreated }
|
|
172
176
|
} },
|
|
@@ -266,6 +270,8 @@ const createAuth = (options) => {
|
|
|
266
270
|
roleDefinitions: definitions,
|
|
267
271
|
organizations,
|
|
268
272
|
defaultRole,
|
|
273
|
+
/** New users get their ids from `generateUserId` instead of Better Auth's random text. */
|
|
274
|
+
generatesUserIds: Boolean(options.generateUserId),
|
|
269
275
|
mail: Boolean(mailer),
|
|
270
276
|
passwordReset: Boolean(mailer),
|
|
271
277
|
/** Open sign-up is on and offered: the app asks for it, and can mail the codes. */
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
//#region src/check-owners.ts
|
|
2
|
+
/**
|
|
3
|
+
* Stops startup on an `.owner()` field that cannot hold the ids of the auth instance's users: Better Auth's own ids
|
|
4
|
+
* are random text, so the field must be text unless `generateUserId` makes the ids. Without an auth instance the
|
|
5
|
+
* ids come from the app's own authenticator, which this cannot see.
|
|
6
|
+
*/
|
|
7
|
+
const checkOwnerFields = (registry, auth) => {
|
|
8
|
+
if (!auth || auth.generatesUserIds) return;
|
|
9
|
+
for (const entry of registry.entries) {
|
|
10
|
+
if (entry.ownerType === void 0 || entry.ownerType === "text") continue;
|
|
11
|
+
throw new Error(`${entry.name}.${entry.model.owner} is the owner of its records, so it holds the signed-in user's id, but it holds ${entry.ownerType} values and Better Auth's user ids are random text: make it a text column, or give createAuth a generateUserId that makes ${entry.ownerType} ids`);
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
//#endregion
|
|
15
|
+
export { checkOwnerFields };
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isGroupChart } from "@protobase/schema/internal";
|
|
1
2
|
//#region src/check-view-queries.ts
|
|
2
3
|
const widgetName = {
|
|
3
4
|
facets: "facets filter",
|
|
@@ -24,7 +25,7 @@ const searchErrors = (model, view) => (view.list?.search ?? []).filter((name) =>
|
|
|
24
25
|
*/
|
|
25
26
|
const viewQueryErrors = (view, model) => [
|
|
26
27
|
...view.filters.flatMap((widget) => filterableErrors(model, widget.kind === "flags" ? widget.fields : [widget.field], `the ${widgetName[widget.kind]}`)),
|
|
27
|
-
...view.chart ? filterableErrors(model, [view.chart.field], "the chart") : [],
|
|
28
|
+
...view.chart ? filterableErrors(model, [isGroupChart(view.chart) ? view.chart.groupBy : view.chart.field], "the chart") : [],
|
|
28
29
|
...listSortErrors(model, view),
|
|
29
30
|
...searchErrors(model, view)
|
|
30
31
|
];
|
package/dist/check-views.js
CHANGED
|
@@ -2,9 +2,11 @@ import { relatedErrors } from "./view-related.js";
|
|
|
2
2
|
import { cardPhotosErrors } from "./view-card-photos.js";
|
|
3
3
|
import { cardFavoriteErrors, favoriteOwnerErrors } from "./view-card-favorite.js";
|
|
4
4
|
import { saveSearchErrors, searchesOfErrors } from "./view-saved-search.js";
|
|
5
|
+
import { chartErrors } from "./view-chart.js";
|
|
5
6
|
import { mapDisplayErrors, mapErrors } from "./view-map.js";
|
|
6
7
|
import { relatedColumnErrors } from "./view-related-column.js";
|
|
7
|
-
import {
|
|
8
|
+
import { editableErrors } from "./view-editable.js";
|
|
9
|
+
import { actionInputErrors, addressErrors, addressPlaceholders, boardErrors, chartFields, displayFields, fieldRefsIn, hrefField, hrefPlaceholders, linkActionError } from "@protobase/schema/internal";
|
|
8
10
|
//#region src/check-views.ts
|
|
9
11
|
const linkFields = (run) => run?.kind !== "link" ? [] : "to" in run ? addressPlaceholders(run.to) : hrefPlaceholders(run.href);
|
|
10
12
|
const sensitiveErrors = (view, model) => {
|
|
@@ -19,7 +21,7 @@ const sensitiveErrors = (view, model) => {
|
|
|
19
21
|
]],
|
|
20
22
|
["a list display", (view.list?.displays ?? []).flatMap(displayFields)],
|
|
21
23
|
["a filter", view.filters.flatMap((widget) => widget.kind === "flags" ? widget.fields : [widget.field])],
|
|
22
|
-
["the chart", view.chart ?
|
|
24
|
+
["the chart", view.chart ? chartFields(view.chart) : []],
|
|
23
25
|
["the sidebar", view.layout.flatMap((item) => item.kind === "sidebar" ? item.fields : [])],
|
|
24
26
|
["a map", view.layout.flatMap((item) => item.kind === "map" ? [item.latitude, item.longitude] : [])],
|
|
25
27
|
["a gallery", view.layout.flatMap((item) => item.kind === "gallery" && item.image !== void 0 ? [item.image] : [])],
|
|
@@ -93,6 +95,7 @@ const relatedColumnsErrors = (view, model, models) => (view.list?.relatedColumns
|
|
|
93
95
|
...view.list.columns.includes(column.name) ? [] : ["it is not among the list's columns"],
|
|
94
96
|
...relatedColumnErrors(column, view.resource, models)
|
|
95
97
|
].map((error) => `related column "${column.name}": ${error}`));
|
|
98
|
+
const listEditErrors = (view, model) => (view.list?.editable ?? []).flatMap((name) => [...view.list.columns.includes(name) ? [] : ["it is not among the list's columns"], ...editableErrors(name, model, view.fields[name])].map((error) => `editable field "${name}": ${error}`));
|
|
96
99
|
const galleryErrors = (item, resource, model, models) => {
|
|
97
100
|
const image = item.image === void 0 ? void 0 : model.fields[item.image];
|
|
98
101
|
return [...image && image.type !== "file" ? [`"${item.image}" is not a file field`] : [], ...item.photos ? cardPhotosErrors(item.photos, resource, models).map((error) => `photos: ${error}`) : []];
|
|
@@ -106,10 +109,11 @@ const listOfErrors = (model, name, models) => {
|
|
|
106
109
|
* Fails at startup when a view uses a sensitive field where it would show empty, a display or custom section names a
|
|
107
110
|
* field the resource does not have, labels or colours values its enum does not have, shows a field with a field type of
|
|
108
111
|
* `fieldTypes` (each with the storage types it shows) that does not show it,
|
|
112
|
+
* edits a list column in its cell that is not a field with an editor of one line the API may write,
|
|
109
113
|
* has a related section or related list column, a cards display's or gallery's photos or a cards display's favorite button, a `listOf`, a "Save search" button or a
|
|
110
114
|
* `searchesOf` that do not fit the resources, cards
|
|
111
|
-
* displays of one list that name different photos, a map that cannot show the
|
|
112
|
-
* record's position or a map display that cannot find the records in its view, a link to an address the app does not have, action inputs that do not fit, or a create page that is not one of `pages`.
|
|
115
|
+
* displays of one list that name different photos, a chart per value that cannot group or add up its fields, a map that cannot show the
|
|
116
|
+
* record's position or a map display that cannot find the records in its view, a board whose columns are no filterable enum, a link to an address the app does not have, action inputs that do not fit, or a create page that is not one of `pages`.
|
|
113
117
|
*/
|
|
114
118
|
const checkViews = (registry, views, pages = [], fieldTypes = {}) => {
|
|
115
119
|
const models = Object.fromEntries(registry.entries.map((entry) => [entry.name, entry.model]));
|
|
@@ -127,12 +131,15 @@ const checkViews = (registry, views, pages = [], fieldTypes = {}) => {
|
|
|
127
131
|
const listOf = view.listOf === void 0 ? [] : listOfErrors(model, view.listOf, models).map((error) => `listOf: ${error}`);
|
|
128
132
|
const searches = [...view.saveSearch ? saveSearchErrors(view.saveSearch, view.resource, models, views).map((error) => `saveSearch: ${error}`) : [], ...view.searchesOf ? searchesOfErrors(view.searchesOf, view.resource, models).map((error) => `searchesOf: ${error}`) : []];
|
|
129
133
|
const maps = [...view.layout.flatMap((item) => item.kind === "map" ? mapErrors(item, model).map((error) => `map "${item.title}": ${error}`) : []), ...(view.list?.displays ?? []).flatMap((display) => display.kind === "map" ? mapDisplayErrors(display, model).map((error) => `display "${display.name}": ${error}`) : [])];
|
|
134
|
+
const boards = (view.list?.displays ?? []).flatMap((display) => display.kind === "board" ? boardErrors(display, model).map((error) => `display "${display.name}": ${error}`) : []);
|
|
135
|
+
const chart = view.chart ? chartErrors(view.chart, model).map((error) => `chart: ${error}`) : [];
|
|
130
136
|
return [
|
|
131
137
|
...sensitiveErrors(view, model),
|
|
132
138
|
...unknownFieldErrors(view, model),
|
|
133
139
|
...valueLabelErrors(view, model),
|
|
134
140
|
...valueColorErrors(view, model),
|
|
135
141
|
...textHintErrors(view, model),
|
|
142
|
+
...listEditErrors(view, model),
|
|
136
143
|
...widgetErrors(view, model, fieldTypes),
|
|
137
144
|
...related,
|
|
138
145
|
...photos,
|
|
@@ -140,6 +147,8 @@ const checkViews = (registry, views, pages = [], fieldTypes = {}) => {
|
|
|
140
147
|
...listOf,
|
|
141
148
|
...searches,
|
|
142
149
|
...maps,
|
|
150
|
+
...boards,
|
|
151
|
+
...chart,
|
|
143
152
|
...linkErrors(view, model, models, pageNames),
|
|
144
153
|
...inputErrors(view, models),
|
|
145
154
|
...createErrors(view, pageNames)
|
package/dist/create-admin.js
CHANGED
|
@@ -16,6 +16,7 @@ import { webhookTable } from "./webhooks/table/webhook-table.js";
|
|
|
16
16
|
import { subscriptionWriteCheck } from "./webhooks/table/validate-hook.js";
|
|
17
17
|
import { withAnonymous } from "./anonymous.js";
|
|
18
18
|
import { checkBottomBars, checkShell } from "./check-shell.js";
|
|
19
|
+
import { checkOwnerFields } from "./check-owners.js";
|
|
19
20
|
import { checkRoleLimits } from "./check-roles.js";
|
|
20
21
|
import { checkViews } from "./check-views.js";
|
|
21
22
|
import { errorReportingSettings } from "./error-reporting/error-reporting-settings.js";
|
|
@@ -59,6 +60,7 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
|
|
|
59
60
|
checkBottomBars(registry, bottomBarModels, pageModels.map((entry) => entry.name), declaredRoles(auth, options.roles));
|
|
60
61
|
checkViews(registry, viewModels, pageModels.map((entry) => entry.name), fieldTypes);
|
|
61
62
|
checkRoleLimits(registry, options.roles?.limits ?? []);
|
|
63
|
+
checkOwnerFields(registry, auth);
|
|
62
64
|
checkPages(pageModels, {
|
|
63
65
|
models: Object.fromEntries(registry.entries.map((entry) => [entry.name, entry.model])),
|
|
64
66
|
actions: (resource) => new Set(viewModels.filter((view) => view.resource === resource).flatMap((view) => view.actions.map((action) => action.name)))
|
|
@@ -130,6 +130,7 @@ const needs = {
|
|
|
130
130
|
list: "list",
|
|
131
131
|
search: "list",
|
|
132
132
|
facets: "list",
|
|
133
|
+
aggregate: "list",
|
|
133
134
|
series: "list",
|
|
134
135
|
histogram: "list",
|
|
135
136
|
map: "list",
|
|
@@ -363,6 +364,71 @@ const resourcePaths = (access) => {
|
|
|
363
364
|
...auth
|
|
364
365
|
}
|
|
365
366
|
} },
|
|
367
|
+
[`/${entry.name}:aggregate`]: { get: {
|
|
368
|
+
operationId: `aggregate_${entry.name}`,
|
|
369
|
+
tags: tag,
|
|
370
|
+
summary: `Totals per group of ${entry.name} records`,
|
|
371
|
+
description: "Groups the records the filter finds, its own terms on `group_by` included, by the value of a field, or by the hour, day, week, month or year of a date or timestamp field with the empty ones included. Each group has its count and, with `sum` or `average`, that of a numeric field, `null` when no record of the group has a value. Value groups come largest first. An index must serve the grouped field.",
|
|
372
|
+
parameters: [
|
|
373
|
+
query("group_by", "A filterable field", { type: "string" }, true),
|
|
374
|
+
param("filter"),
|
|
375
|
+
query("sum", "A numeric field to add up per group"),
|
|
376
|
+
query("average", "A numeric field to average per group, instead of `sum`"),
|
|
377
|
+
query("limit", "Most value groups to return, default 100, at most 1000", { type: "integer" }),
|
|
378
|
+
query("label", "For a relation to a single-key resource: the field of that resource whose value each group carries as `label`"),
|
|
379
|
+
query("granularity", "For a date group_by: the bucket, default day", {
|
|
380
|
+
type: "string",
|
|
381
|
+
enum: [
|
|
382
|
+
"hour",
|
|
383
|
+
"day",
|
|
384
|
+
"week",
|
|
385
|
+
"month",
|
|
386
|
+
"year"
|
|
387
|
+
]
|
|
388
|
+
}),
|
|
389
|
+
query("range", "For a date group_by: the relative range ending now (default 30d); `all` starts at the earliest row", {
|
|
390
|
+
type: "string",
|
|
391
|
+
enum: [
|
|
392
|
+
"7d",
|
|
393
|
+
"30d",
|
|
394
|
+
"90d",
|
|
395
|
+
"1y",
|
|
396
|
+
"all"
|
|
397
|
+
]
|
|
398
|
+
}),
|
|
399
|
+
query("from", "For a date group_by: the explicit start (ISO 8601); with `to`, replaces range"),
|
|
400
|
+
query("to", "For a date group_by: the exclusive end (ISO 8601), default now"),
|
|
401
|
+
query("time_zone", "For a date group_by: the IANA time zone of the buckets, default UTC")
|
|
402
|
+
],
|
|
403
|
+
responses: {
|
|
404
|
+
200: {
|
|
405
|
+
description: "The groups; a date group's `value` is its bucket in local ISO time",
|
|
406
|
+
content: json({
|
|
407
|
+
type: "object",
|
|
408
|
+
properties: {
|
|
409
|
+
group_by: { type: "string" },
|
|
410
|
+
granularity: { type: "string" },
|
|
411
|
+
time_zone: { type: "string" },
|
|
412
|
+
groups: {
|
|
413
|
+
type: "array",
|
|
414
|
+
items: {
|
|
415
|
+
type: "object",
|
|
416
|
+
properties: {
|
|
417
|
+
value: {},
|
|
418
|
+
label: { type: "string" },
|
|
419
|
+
count: { type: "integer" },
|
|
420
|
+
sum: { type: ["number", "null"] },
|
|
421
|
+
average: { type: ["number", "null"] }
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
})
|
|
427
|
+
},
|
|
428
|
+
400: error("BadRequest"),
|
|
429
|
+
...auth
|
|
430
|
+
}
|
|
431
|
+
} },
|
|
366
432
|
[`/${entry.name}:series`]: { get: {
|
|
367
433
|
operationId: `series_${entry.name}`,
|
|
368
434
|
tags: tag,
|
package/dist/owner-id.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { HttpProblem } from "./problem.js";
|
|
2
|
+
import { fitsKeyType } from "@protobase/schema";
|
|
3
|
+
//#region src/owner-id.ts
|
|
4
|
+
const mentions = (filter, name) => {
|
|
5
|
+
if (filter.kind === "and" || filter.kind === "or") return filter.args.some((arg) => mentions(arg, name));
|
|
6
|
+
if (filter.kind === "not") return mentions(filter.arg, name);
|
|
7
|
+
return "field" in filter && filter.field.name === name;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Refuses a request whose row filters compare the `.owner()` field with a user id it cannot hold, such as Better
|
|
11
|
+
* Auth's random text in an integer column, naming the field, instead of failing as a value of the wrong format.
|
|
12
|
+
*/
|
|
13
|
+
const checkOwnerId = (entry, user, resolved) => {
|
|
14
|
+
const { ownerType, model } = entry;
|
|
15
|
+
if (ownerType === void 0 || model.owner === void 0 || fitsKeyType(String(user.id), ownerType)) return;
|
|
16
|
+
const owner = model.owner;
|
|
17
|
+
if (!Object.values(resolved.rowFilter).some((filter) => filter && mentions(filter, owner))) return;
|
|
18
|
+
throw new HttpProblem(500, "owner-id-mismatch", "Internal Server Error", `${entry.name}.${owner} holds ${ownerType} user ids, and the signed-in user's id "${user.id}" is not one, so their own records cannot be found`);
|
|
19
|
+
};
|
|
20
|
+
//#endregion
|
|
21
|
+
export { checkOwnerId };
|
package/dist/registry.js
CHANGED
|
@@ -13,6 +13,14 @@ const etagSource = (model) => {
|
|
|
13
13
|
kind: "timestamp"
|
|
14
14
|
};
|
|
15
15
|
};
|
|
16
|
+
const isKeyType = (type) => type === "text" || type === "integer" || type === "bigint" || type === "uuid";
|
|
17
|
+
const ownerType = (model, resolve) => {
|
|
18
|
+
const field = model.owner === void 0 ? void 0 : model.fields[model.owner];
|
|
19
|
+
if (!field) return void 0;
|
|
20
|
+
if (isKeyType(field.type)) return field.type;
|
|
21
|
+
const target = field.type === "relation" && field.relation ? resolve(field.relation.resource) : void 0;
|
|
22
|
+
return target && keyTypes(target, resolve)[0];
|
|
23
|
+
};
|
|
16
24
|
const buildRegistry = (resources) => {
|
|
17
25
|
const built = resources.map((source) => ({
|
|
18
26
|
source,
|
|
@@ -25,11 +33,13 @@ const buildRegistry = (resources) => {
|
|
|
25
33
|
const types = keyTypes(model, (name) => models.get(name));
|
|
26
34
|
if (types.length !== model.primaryKey.length) throw new Error(`Not implemented: resource "${model.name}" has a primary key field that points at a composite-key resource; the server maps one key part per key field`);
|
|
27
35
|
const etag = etagSource(model);
|
|
36
|
+
const owner = ownerType(model, (name) => models.get(name));
|
|
28
37
|
return {
|
|
29
38
|
name: model.name,
|
|
30
39
|
source,
|
|
31
40
|
model,
|
|
32
41
|
keyTypes: types,
|
|
42
|
+
...owner && { ownerType: owner },
|
|
33
43
|
...etag && { etag }
|
|
34
44
|
};
|
|
35
45
|
});
|
package/dist/request-access.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { forbidden, notFound, unauthorized } from "./problem.js";
|
|
2
2
|
import { checkedFilter } from "./filter-input.js";
|
|
3
|
+
import { checkOwnerId } from "./owner-id.js";
|
|
3
4
|
import { tenantScope } from "./tenant.js";
|
|
4
5
|
import { checkFilter, resolveAccess, restrictModel } from "@protobase/schema";
|
|
5
6
|
//#region src/request-access.ts
|
|
@@ -16,6 +17,7 @@ const requestAccess = async (deps, entry, session) => {
|
|
|
16
17
|
},
|
|
17
18
|
...organizationOf(session)
|
|
18
19
|
}, deps.accessOptions);
|
|
20
|
+
checkOwnerId(entry, session.user, resolved);
|
|
19
21
|
const readable = restrictModel(entry.model, resolved);
|
|
20
22
|
return {
|
|
21
23
|
entry,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { runWrite } from "../write-pipeline.js";
|
|
2
2
|
import { listRecords } from "../list-service.js";
|
|
3
3
|
import { facets, numericHistogram, timeSeries } from "../aggregate-service.js";
|
|
4
|
+
import { aggregate } from "../aggregate-groups.js";
|
|
4
5
|
import { mapRecords } from "../map-service.js";
|
|
5
6
|
import { uploadFile } from "../files/upload.js";
|
|
6
7
|
import { returnPreference } from "./preferences.js";
|
|
@@ -25,6 +26,10 @@ const collectionRoutes = (deps) => {
|
|
|
25
26
|
const { body, warning } = await facets(deps, entry, session, query);
|
|
26
27
|
return c.json(body, 200, warningHeader(warning));
|
|
27
28
|
}
|
|
29
|
+
if (action === "aggregate") {
|
|
30
|
+
const { body, warning } = await aggregate(deps, entry, session, query);
|
|
31
|
+
return c.json(body, 200, warningHeader(warning));
|
|
32
|
+
}
|
|
28
33
|
if (action === "series") return c.json(await timeSeries(deps, entry, session, query));
|
|
29
34
|
if (action === "histogram") return c.json(await numericHistogram(deps, entry, session, query));
|
|
30
35
|
if (action === "map") {
|
package/dist/validate-body.js
CHANGED
|
@@ -4,10 +4,13 @@ import { writableFields } from "./request-access.js";
|
|
|
4
4
|
const fail = (errors) => badRequest("invalid-record", `The request body is invalid: ${errors[0].field}: ${errors[0].message}`, { errors });
|
|
5
5
|
const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
6
6
|
const reserved = ["etag", "permissions"];
|
|
7
|
+
const isEmptyText = (field, value) => field.type === "text" && !field.nullable && value === "";
|
|
7
8
|
/**
|
|
8
9
|
* Checks a create or update body for this caller. A field the caller cannot read is an unknown field, one they can read
|
|
9
10
|
* but not write is read-only (the tenant column and the API's own `etag` and `permissions` keys are always read-only);
|
|
10
|
-
* every provided value goes through its field's zod schema, then `.validate()` rules run on the result.
|
|
11
|
+
* every provided value goes through its field's zod schema, then `.validate()` rules run on the result. An empty text
|
|
12
|
+
* for a text field that may not be null counts as not given: a create fills in its default or reports it required,
|
|
13
|
+
* an update reports it required. Only the values written are checked, so a stored '' still reads.
|
|
11
14
|
* Returns only the values to write: on create the defaults are filled in, on update only the provided fields.
|
|
12
15
|
* `current` is the stored record an update merges into for `.validate()` rules.
|
|
13
16
|
*/
|
|
@@ -36,7 +39,16 @@ const validateBody = (access, mode, body, current = {}) => {
|
|
|
36
39
|
for (const [name, source] of Object.entries(entry.source.state.fields ?? {})) {
|
|
37
40
|
const field = full.fields[name];
|
|
38
41
|
if (!source || !field || !writable.has(name) || name === full.tenant) continue;
|
|
39
|
-
const
|
|
42
|
+
const empty = Object.hasOwn(body, name) && isEmptyText(field, body[name]);
|
|
43
|
+
if (empty && mode === "update") {
|
|
44
|
+
errors.push({
|
|
45
|
+
field: name,
|
|
46
|
+
code: "required",
|
|
47
|
+
message: `"${name}" is required`
|
|
48
|
+
});
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const provided = Object.hasOwn(body, name) && !empty;
|
|
40
52
|
if (!provided && mode === "update") continue;
|
|
41
53
|
if (!provided && field.default && "db" in field.default) continue;
|
|
42
54
|
const parsed = source.schema.safeParse(provided ? body[name] : void 0);
|
package/dist/view-access.js
CHANGED
|
@@ -4,7 +4,7 @@ import { cardFavoriteErrors } from "./view-card-favorite.js";
|
|
|
4
4
|
import { searchesOfFor } from "./view-saved-search.js";
|
|
5
5
|
import { relatedColumnErrors } from "./view-related-column.js";
|
|
6
6
|
import { resolveFieldPath } from "@protobase/layout";
|
|
7
|
-
import { addressErrors, hrefPlaceholders, navRecentErrors, pruneByShape, shapes, tableDisplay } from "@protobase/schema/internal";
|
|
7
|
+
import { addressErrors, chartFields, hrefPlaceholders, navRecentErrors, pruneByShape, shapes, tableDisplay } from "@protobase/schema/internal";
|
|
8
8
|
//#region src/view-access.ts
|
|
9
9
|
const pruneRelated = (item, resource, models) => {
|
|
10
10
|
const { columns, ...rest } = item;
|
|
@@ -27,15 +27,26 @@ const pruneGallery = (item, resource, model, models) => {
|
|
|
27
27
|
...item.when !== void 0 && { when: item.when }
|
|
28
28
|
};
|
|
29
29
|
};
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
const
|
|
30
|
+
const withKeptDots = (display, related = []) => {
|
|
31
|
+
if (display.kind !== "cards" || display.dots === void 0) return display;
|
|
32
|
+
const { dots, ...card } = display;
|
|
33
|
+
const kept = dots.filter((name) => related.some((column) => column.name === name));
|
|
34
|
+
return kept.length > 0 ? {
|
|
35
|
+
...card,
|
|
36
|
+
dots: kept
|
|
37
|
+
} : card;
|
|
38
|
+
};
|
|
39
|
+
const tidyList = (model) => ({ default: opens, displays, columns, relatedColumns, editable, ...list }) => {
|
|
33
40
|
const related = relatedColumns && relatedColumns.length > 0 ? relatedColumns : void 0;
|
|
41
|
+
const shown = displays && displays.length > 0 ? displays.map((display) => withKeptDots(display, related)) : void 0;
|
|
42
|
+
const kept = opens === tableDisplay || shown?.some((display) => display.name === opens);
|
|
34
43
|
const visible = columns.filter((name) => Object.hasOwn(model.fields, name) || related?.some((column) => column.name === name));
|
|
44
|
+
const writable = editable?.filter((name) => visible.includes(name) && !model.fields[name].readOnly);
|
|
35
45
|
return {
|
|
36
46
|
...list,
|
|
37
47
|
columns: visible,
|
|
38
48
|
...related && { relatedColumns: related },
|
|
49
|
+
...writable && writable.length > 0 && { editable: writable },
|
|
39
50
|
...shown && { displays: shown },
|
|
40
51
|
...opens !== void 0 && kept && { default: opens }
|
|
41
52
|
};
|
|
@@ -45,6 +56,7 @@ const listShape = (resource, model, models, prompted) => shapes.object({
|
|
|
45
56
|
relatedColumns: shapes.list(shapes.map((column) => relatedColumnErrors(column, resource, models).length === 0 ? column : void 0)),
|
|
46
57
|
sort: shapes.list(shapes.tuple(shapes.field, shapes.data)),
|
|
47
58
|
search: shapes.list(shapes.field),
|
|
59
|
+
editable: shapes.list(shapes.field),
|
|
48
60
|
displays: shapes.list(shapes.oneOf({
|
|
49
61
|
cards: shapes.object({
|
|
50
62
|
kind: shapes.data,
|
|
@@ -59,6 +71,7 @@ const listShape = (resource, model, models, prompted) => shapes.object({
|
|
|
59
71
|
meta: shapes.list(shapes.field),
|
|
60
72
|
footer: shapes.list(shapes.field),
|
|
61
73
|
flags: shapes.list(shapes.field),
|
|
74
|
+
dots: shapes.data,
|
|
62
75
|
card: shapes.data,
|
|
63
76
|
photos: shapes.map((photos) => cardPhotosErrors(photos, resource, models).length === 0 ? photos : void 0),
|
|
64
77
|
favorite: shapes.map((favorite) => prompted.has(favorite.resource) || cardFavoriteErrors(favorite, resource, models).length === 0 ? favorite : void 0)
|
|
@@ -77,6 +90,17 @@ const listShape = (resource, model, models, prompted) => shapes.object({
|
|
|
77
90
|
defaultRadius: shapes.data,
|
|
78
91
|
area: shapes.field
|
|
79
92
|
}, { requires: ["latitude", "longitude"] }),
|
|
93
|
+
board: shapes.object({
|
|
94
|
+
kind: shapes.data,
|
|
95
|
+
name: shapes.data,
|
|
96
|
+
label: shapes.data,
|
|
97
|
+
icon: shapes.data,
|
|
98
|
+
by: shapes.field,
|
|
99
|
+
title: shapes.field,
|
|
100
|
+
subtitle: shapes.field,
|
|
101
|
+
badge: shapes.field,
|
|
102
|
+
meta: shapes.list(shapes.field)
|
|
103
|
+
}, { requires: ["by"] }),
|
|
80
104
|
custom: shapes.object({
|
|
81
105
|
kind: shapes.data,
|
|
82
106
|
name: shapes.data,
|
|
@@ -194,11 +218,7 @@ const viewShape = (resource, model, models, prompted) => shapes.object({
|
|
|
194
218
|
list: listShape(resource, model, models, prompted),
|
|
195
219
|
filters: shapes.list(widgetShape),
|
|
196
220
|
layout: shapes.list(layoutShape(resource, model, models)),
|
|
197
|
-
chart: shapes.
|
|
198
|
-
field: shapes.field,
|
|
199
|
-
range: shapes.data,
|
|
200
|
-
granularity: shapes.data
|
|
201
|
-
}, { requires: ["field"] }),
|
|
221
|
+
chart: shapes.map((chart) => chartFields(chart).every((name) => Object.hasOwn(model.fields, name)) ? chart : void 0),
|
|
202
222
|
recordMode: shapes.data,
|
|
203
223
|
helpWhen: shapes.data,
|
|
204
224
|
saveFeedback: shapes.data,
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { isGroupChart } from "@protobase/schema/internal";
|
|
2
|
+
//#region src/view-chart.ts
|
|
3
|
+
const numericTypes = [
|
|
4
|
+
"integer",
|
|
5
|
+
"bigint",
|
|
6
|
+
"decimal"
|
|
7
|
+
];
|
|
8
|
+
const ungroupable = [
|
|
9
|
+
"date",
|
|
10
|
+
"timestamp",
|
|
11
|
+
"json",
|
|
12
|
+
"file"
|
|
13
|
+
];
|
|
14
|
+
/**
|
|
15
|
+
* What is wrong with a list's chart per value of a field: a field to group by that has no values to share out, or one
|
|
16
|
+
* to add up that is no number. The filterable and sensitive checks of every chart are with the other fields'.
|
|
17
|
+
*/
|
|
18
|
+
const chartErrors = (chart, model) => {
|
|
19
|
+
if (!isGroupChart(chart)) return [];
|
|
20
|
+
const fieldOf = (name) => Object.hasOwn(model.fields, name) ? model.fields[name] : void 0;
|
|
21
|
+
const group = fieldOf(chart.groupBy);
|
|
22
|
+
const measured = chart.sum ?? chart.average;
|
|
23
|
+
const measure = measured === void 0 ? void 0 : fieldOf(measured);
|
|
24
|
+
return [
|
|
25
|
+
...!group ? [`groupBy: unknown field "${chart.groupBy}" on ${model.name}`] : [],
|
|
26
|
+
...group && ungroupable.includes(group.type) ? [`groupBy: "${chart.groupBy}" is a ${group.type} field; chart a date with { field, range, granularity }, and group by a field of values`] : [],
|
|
27
|
+
...measured !== void 0 && !measure ? [`unknown field "${measured}" on ${model.name} to add up`] : [],
|
|
28
|
+
...measure && !numericTypes.includes(measure.type) ? [`"${measured}" is ${measure.type}, and only a number can be added up`] : [],
|
|
29
|
+
...chart.type === "donut" && chart.average !== void 0 ? ["a donut shares out a total, and averages add up to none: count or sum instead"] : []
|
|
30
|
+
];
|
|
31
|
+
};
|
|
32
|
+
//#endregion
|
|
33
|
+
export { chartErrors };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
//#region src/view-editable.ts
|
|
2
|
+
const cellTypes = /* @__PURE__ */ new Set([
|
|
3
|
+
"text",
|
|
4
|
+
"integer",
|
|
5
|
+
"bigint",
|
|
6
|
+
"decimal",
|
|
7
|
+
"date",
|
|
8
|
+
"enum",
|
|
9
|
+
"boolean",
|
|
10
|
+
"currency",
|
|
11
|
+
"country"
|
|
12
|
+
]);
|
|
13
|
+
/** What is wrong with editing the field `name` of the list in its cell; empty when it can be. */
|
|
14
|
+
const editableErrors = (name, model, hints) => {
|
|
15
|
+
const field = Object.hasOwn(model.fields, name) ? model.fields[name] : void 0;
|
|
16
|
+
if (!field) return [`"${name}" is not a field of ${model.name}`];
|
|
17
|
+
if (field.readOnly) return [`"${name}" is read-only`];
|
|
18
|
+
if (!cellTypes.has(field.type)) return [`"${name}" is a ${field.type} field, which is not edited in a cell`];
|
|
19
|
+
if (hints?.multiline || hints?.markdown) return [`"${name}" runs over several lines, which a cell does not hold`];
|
|
20
|
+
return [];
|
|
21
|
+
};
|
|
22
|
+
//#endregion
|
|
23
|
+
export { editableErrors };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protobase/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
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": {
|
|
@@ -30,9 +30,9 @@
|
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"@better-auth/passkey": "^1.7.7",
|
|
33
|
-
"@protobase/layout": "0.
|
|
34
|
-
"@protobase/query": "0.
|
|
35
|
-
"@protobase/schema": "0.
|
|
33
|
+
"@protobase/layout": "0.20.0",
|
|
34
|
+
"@protobase/query": "0.20.0",
|
|
35
|
+
"@protobase/schema": "0.20.0",
|
|
36
36
|
"@react-email/render": "^2.1.0",
|
|
37
37
|
"@scalar/hono-api-reference": "^0.12.10",
|
|
38
38
|
"@sentry/core": "^11.7.0",
|