@protobase/server 0.19.16 → 0.21.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.
@@ -8,12 +8,14 @@ import { deletedRows, selectedFields } from "../params.js";
8
8
  import { returnPreference } from "./preferences.js";
9
9
  import { resolveTarget } from "./target.js";
10
10
  import { revealField } from "../reveal-service.js";
11
+ import { readHistory } from "../history/read-history.js";
11
12
  import { Hono } from "hono";
12
13
  //#region src/routes/record.ts
13
14
  const recordRoutes = (deps) => {
14
15
  const app = new Hono();
15
16
  app.get("/:resource/:key", async (c) => {
16
17
  const { entry } = resolveTarget(deps, c.req.param("resource"));
18
+ if (c.req.param("key").endsWith(":history")) return c.json(await readHistory(deps, entry, c.get("session"), keyFromPath(c, entry, ":history"), c.req.query()));
17
19
  const deleted = deletedRows(entry.model, c.req.query("show_deleted") === "true");
18
20
  const found = await readRecord(deps, entry, c.get("session"), keyFromPath(c, entry), deleted);
19
21
  const columns = selectedFields({
@@ -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;
@@ -172,7 +172,13 @@ const layoutShape = (resource, model, models) => shapes.oneOf({
172
172
  sidebar: shapes.data,
173
173
  when: shapes.data
174
174
  }, { requires: ["latitude", "longitude"] }),
175
- gallery: shapes.map((item) => pruneGallery(item, resource, model, models))
175
+ gallery: shapes.map((item) => pruneGallery(item, resource, model, models)),
176
+ history: shapes.object({
177
+ kind: shapes.data,
178
+ title: shapes.data,
179
+ limit: shapes.data,
180
+ when: shapes.data
181
+ })
176
182
  });
177
183
  const linkShape = (model, models) => shapes.map((run) => {
178
184
  return ("to" in run ? addressErrors(run.to, {
@@ -218,11 +224,7 @@ const viewShape = (resource, model, models, prompted) => shapes.object({
218
224
  list: listShape(resource, model, models, prompted),
219
225
  filters: shapes.list(widgetShape),
220
226
  layout: shapes.list(layoutShape(resource, model, models)),
221
- chart: shapes.object({
222
- field: shapes.field,
223
- range: shapes.data,
224
- granularity: shapes.data
225
- }, { requires: ["field"] }),
227
+ chart: shapes.map((chart) => chartFields(chart).every((name) => Object.hasOwn(model.fields, name)) ? chart : void 0),
226
228
  recordMode: shapes.data,
227
229
  helpWhen: shapes.data,
228
230
  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 };
@@ -1,11 +1,12 @@
1
- import { dispatchJobName } from "../changes/capture-sql.js";
1
+ import { dispatchJobName, isCaptured } from "../changes/capture-sql.js";
2
+ import { defineJob } from "../jobs/define-job.js";
2
3
  import { coalesce } from "../changes/coalesce.js";
3
4
  import { EventCodeError } from "../changes/event-code-error.js";
4
5
  import { eventMatcher } from "../changes/match-events.js";
5
6
  import { deleteOutbox, readOutbox } from "../changes/outbox.js";
6
7
  import { resourceChange } from "../changes/resource-change.js";
7
8
  import { callerRecords } from "../functions/records.js";
8
- import { defineJob } from "../jobs/define-job.js";
9
+ import { writeHistory } from "../history/write-history.js";
9
10
  import { FatalJobError } from "../jobs/run-job.js";
10
11
  import { maxTries, nextRetryDelay } from "./retry-schedule.js";
11
12
  import { subscriberEvents } from "./subscriber-view.js";
@@ -40,7 +41,8 @@ const deliveryGroup = ({ subscription, change }) => `${subscription.name}:${chan
40
41
  *
41
42
  * - `changes.dispatch`, which `capture()` enqueues once per writing transaction, in one ordered group so one runs at a
42
43
  * time: it turns the committed outbox rows into events, makes the first try of every unordered delivery itself,
43
- * queues the failures and the ordered deliveries, and deletes the rows in that same transaction. A process that dies
44
+ * queues the failures and the ordered deliveries, keeps the changes of resources with history, and deletes the rows
45
+ * in that same transaction. A process that dies
44
46
  * before that commits leaves the rows, and the job's retry delivers them again, under the same `webhook-id`.
45
47
  * - `webhooks.dispatch-change`, one change whose app code threw in dispatch: an event's `match` or filter, a
46
48
  * subscription's `when`, or an access rule of its roles. Kept as failed, so dispatch moves on to later changes; a
@@ -52,14 +54,14 @@ const deliveryGroup = ({ subscription, change }) => `${subscription.name}:${chan
52
54
  *
53
55
  * With `table`, also `webhooks.send-test`, which Send test event queues, and with its log `webhooks.prune-log`, daily.
54
56
  *
55
- * No jobs without a resource with events; the subscriptions must name declared events.
57
+ * No jobs without a resource with events or history; the subscriptions must name declared events.
56
58
  */
57
59
  const createWebhooks = ({ deps, subscriptions, functions, schema, log, table }) => {
58
60
  const entries = deps.registry.entries;
59
61
  const models = entries.map((entry) => entry.model);
60
62
  const problems = subscriptionProblems(subscriptions, models, Object.keys(functions));
61
63
  if (problems.length > 0) throw new Error(problems.join("; "));
62
- if (!models.some((model) => model.events)) return { jobs: [] };
64
+ if (!models.some(isCaptured)) return { jobs: [] };
63
65
  const matchers = new Map(entries.map((entry) => [entry.name, eventMatcher(entry.model, entry.source.eventMatches?.() ?? {})]));
64
66
  const keyColumns = (resource) => {
65
67
  const model = deps.registry.find(resource)?.model;
@@ -230,6 +232,7 @@ const createWebhooks = ({ deps, subscriptions, functions, schema, log, table })
230
232
  }
231
233
  await queueOrdered(trx, deliveries);
232
234
  for (const { change } of failures) await dispatchChange.enqueue(trx, { change });
235
+ await writeHistory(trx, schema, changes.filter((change) => deps.registry.find(change.resource)?.model.history));
233
236
  await deleteOutbox(trx, schema, rows.map((row) => row.id));
234
237
  });
235
238
  for (const [index, { subscription, event }] of now.entries()) report(subscription.name, event, 1, results[index]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/server",
3
- "version": "0.19.16",
3
+ "version": "0.21.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.19.16",
34
- "@protobase/query": "0.19.16",
35
- "@protobase/schema": "0.19.16",
33
+ "@protobase/layout": "0.21.0",
34
+ "@protobase/query": "0.21.0",
35
+ "@protobase/schema": "0.21.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",