@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.
@@ -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
- const seriesRange = async (deps, access, params, scope) => {
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, params.field, params.from),
111
- to: bound(model, params.field, to)
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, params.field, from),
118
- to: bound(model, params.field, to)
118
+ from: bound(model, field, from),
119
+ to: bound(model, field, to)
119
120
  };
120
121
  }
121
- const lowest = await earliest(deps, access, params.field, scope);
122
+ const lowest = await earliest(deps, access, field, scope);
122
123
  return lowest ? {
123
- from: bound(model, params.field, lowest),
124
- to: bound(model, params.field, to)
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 };
@@ -1,7 +1,7 @@
1
1
  import { runningJobs } from "../jobs/current.js";
2
2
  import { dispatchJobName } from "./capture-sql.js";
3
3
  //#region src/changes/actor.ts
4
- /** The setting a write transaction sets so the change capture records who wrote, when a resource has events. */
4
+ /** The setting a write transaction sets so the change capture records who wrote, when a resource has events or history. */
5
5
  const actorSettings = (deps, session) => {
6
6
  if (!deps.actorSetting) return {};
7
7
  const actor = {
@@ -1,4 +1,5 @@
1
- import { captureBody, captureFunctionSql, createTriggerSql, dropTriggerSql, outboxSql, quoteIdent, resourceTriggers, tableSql } from "./capture-sql.js";
1
+ import { captureBody, captureFunctionSql, createTriggerSql, dropTriggerSql, isCaptured, outboxSql, quoteIdent, resourceTriggers, tableSql } from "./capture-sql.js";
2
+ import { historyTableSql } from "../history/history-sql.js";
2
3
  //#region src/changes/capture-plan.ts
3
4
  const sameTrigger = (found, wanted) => found.type === wanted.type && found.args.length === wanted.args.length && found.args.every((arg, index) => arg === wanted.args[index]);
4
5
  const tableOf = (state, model) => {
@@ -7,14 +8,14 @@ const tableOf = (state, model) => {
7
8
  return table;
8
9
  };
9
10
  /**
10
- * Compares the database with the resources that declare events. Fails on what it cannot change safely: a missing
11
- * table, two resources with events on one table, or a trigger of the same name that calls another function.
11
+ * Compares the database with the resources that declare events or history. Fails on what it cannot change safely: a
12
+ * missing table, two captured resources on one table, or a trigger of the same name that calls another function.
12
13
  */
13
14
  const capturePlan = (schema, models, state) => {
14
- const withEvents = models.filter((model) => (model.events ?? []).length > 0);
15
+ const captured = models.filter(isCaptured);
15
16
  const capture = state.capture;
16
17
  const current = capture !== void 0 && capture.definer && capture.source === captureBody(schema);
17
- const wanted = withEvents.flatMap((model) => resourceTriggers(model).map((trigger) => ({
18
+ const wanted = captured.flatMap((model) => resourceTriggers(model).map((trigger) => ({
18
19
  model,
19
20
  trigger,
20
21
  table: tableOf(state, model)
@@ -22,17 +23,18 @@ const capturePlan = (schema, models, state) => {
22
23
  const tables = /* @__PURE__ */ new Map();
23
24
  for (const { model, table } of wanted) {
24
25
  const other = tables.get(table);
25
- if (other && other !== model.name) throw new Error(`Not implemented: resources "${other}" and "${model.name}" both have events on table ${tableSql(model)}; declare them on one`);
26
+ if (other && other !== model.name) throw new Error(`Not implemented: resources "${other}" and "${model.name}" both have events or history on table ${tableSql(model)}; declare them on one`);
26
27
  tables.set(table, model.name);
27
28
  }
28
29
  const create = wanted.filter(({ model, trigger, table }) => {
29
30
  const found = state.triggers.find((candidate) => candidate.table === table && candidate.name === trigger.name);
30
- if (found && found.function !== capture?.oid) throw new Error(`Table ${tableSql(model)} has a trigger "${trigger.name}" of its own; rename it, since events need that name`);
31
+ if (found && found.function !== capture?.oid) throw new Error(`Table ${tableSql(model)} has a trigger "${trigger.name}" of its own; rename it, since events and history need that name`);
31
32
  return !found || !sameTrigger(found, trigger);
32
33
  });
33
34
  const drop = state.triggers.filter((found) => found.function === capture?.oid).filter((found) => !wanted.some(({ trigger, table }) => table === found.table && trigger.name === found.name));
34
35
  return {
35
- setup: withEvents.length > 0 && (!state.outbox || !current),
36
+ setup: captured.length > 0 && (!state.outbox || !current),
37
+ history: models.some((model) => model.history) && !state.history,
36
38
  create: create.map(({ model, trigger }) => ({
37
39
  model,
38
40
  trigger
@@ -51,12 +53,13 @@ const planSql = (schema, plan) => {
51
53
  outboxSql(schema),
52
54
  captureFunctionSql(schema)
53
55
  ] : [],
56
+ ...plan.history ? [`create schema if not exists ${quoteIdent(schema)}`, historyTableSql(schema)] : [],
54
57
  ...plan.drop.map(({ name, tableSql }) => dropTriggerSql(name, tableSql)),
55
58
  ...plan.create.flatMap(({ model, trigger }) => [dropTriggerSql(trigger.name, tableSql(model)), createTriggerSql(schema, trigger, tableSql(model))])
56
59
  ];
57
60
  return statements.length === 0 ? void 0 : `${statements.join(";\n\n")};`;
58
61
  };
59
- /** The resources whose changes would not be captured as the config declares: what a server refuses to start with. */
60
- const uncaptured = (plan, models) => plan.setup ? models.filter((model) => (model.events ?? []).length > 0).map((model) => model.name) : [...new Set(plan.create.map(({ model }) => model.name))];
62
+ /** The resources whose changes would not be captured or kept as the config declares: what a server refuses to start with. */
63
+ const uncaptured = (plan, models) => [...new Set([...plan.setup ? models.filter(isCaptured) : plan.create.map(({ model }) => model), ...plan.history ? models.filter((model) => model.history) : []].map((model) => model.name))];
61
64
  //#endregion
62
65
  export { capturePlan, planSql, uncaptured };
@@ -5,14 +5,16 @@ import { Db } from "@protobase/query";
5
5
  /** The command that writes what the config needs as a migration file of the project. */
6
6
  export declare const migrationCommand = "protobase migration";
7
7
  /**
8
- * The SQL that brings the database to the capture the resources' events need: the schema with the outbox and
9
- * `capture()`, and a trigger pair per resource with events, recreated when its fields changed; triggers of resources
10
- * without events any more are dropped. `undefined` when the database is current. Tables are never dropped.
8
+ * The SQL that brings the database to the capture the resources' events and history need: the schema with the outbox
9
+ * and `capture()`, the history table, and a trigger pair per resource with either, recreated when its fields changed;
10
+ * triggers of resources without either any more are dropped. `undefined` when the database is current. Tables are
11
+ * never dropped.
11
12
  */
12
13
  export declare const changesMigration: (db: Db, models: readonly ResourceModel[], options?: EventsOptions) => Promise<string | undefined>;
13
14
  /**
14
- * Refuses to go on while a resource's events would not be captured as declared: a missing or out-of-date schema, or
15
- * trigger. The server never changes the schema itself: the project's migrations do, before the new version serves.
15
+ * Refuses to go on while a resource's events or history would not be captured as declared: a missing or out-of-date
16
+ * schema, history table or trigger. The server never changes the schema itself: the project's migrations do, before the
17
+ * new version serves.
16
18
  */
17
19
  export declare const checkChanges: (db: Db, models: readonly ResourceModel[], options?: EventsOptions) => Promise<void>;
18
20
  //#endregion
@@ -1,4 +1,4 @@
1
- import { quoteIdent, tableSql } from "./capture-sql.js";
1
+ import { isCaptured, quoteIdent, tableSql } from "./capture-sql.js";
2
2
  import { changesSchema } from "./events-options.js";
3
3
  import { capturePlan, planSql, uncaptured } from "./capture-plan.js";
4
4
  import { sql } from "kysely";
@@ -11,7 +11,7 @@ const triggerArgs = (hex) => {
11
11
  };
12
12
  const rowsOf = async (db, query) => (await query.execute(db)).rows;
13
13
  const readCaptureState = async (db, schema, models) => {
14
- const [outbox] = await rowsOf(db, sql`select to_regclass(${`${quoteIdent(schema)}."outbox"`}) is not null as found`);
14
+ const exists = async (table) => (await rowsOf(db, sql`select to_regclass(${`${quoteIdent(schema)}.${quoteIdent(table)}`}) is not null as found`))[0]?.found ?? false;
15
15
  const [capture] = await rowsOf(db, sql`select p.oid::text as oid, p.prosrc as source, p.prosecdef as definer from pg_proc p join pg_namespace n on n.oid = p.pronamespace where n.nspname = ${schema} and p.proname = 'capture' and p.pronargs = 0`);
16
16
  const tables = {};
17
17
  for (const model of models) {
@@ -24,7 +24,8 @@ const readCaptureState = async (db, schema, models) => {
24
24
  from pg_trigger t join pg_class c on c.oid = t.tgrelid join pg_namespace ns on ns.oid = c.relnamespace
25
25
  where not t.tgisinternal and (t.tgfoid::text = ${capture?.oid ?? ""} or t.tgrelid::text = any(${oids}::text[]))`);
26
26
  return {
27
- outbox: outbox?.found ?? false,
27
+ outbox: await exists("outbox"),
28
+ history: await exists("history"),
28
29
  ...capture && { capture },
29
30
  triggers: triggers.map((trigger) => ({
30
31
  ...trigger,
@@ -35,31 +36,32 @@ const readCaptureState = async (db, schema, models) => {
35
36
  };
36
37
  const plan = async (db, models, options) => {
37
38
  const schema = changesSchema(options);
38
- const withEvents = models.filter((model) => (model.events ?? []).length > 0);
39
39
  return {
40
40
  schema,
41
- plan: capturePlan(schema, models, await readCaptureState(db, schema, withEvents))
41
+ plan: capturePlan(schema, models, await readCaptureState(db, schema, models.filter(isCaptured)))
42
42
  };
43
43
  };
44
44
  /**
45
- * The SQL that brings the database to the capture the resources' events need: the schema with the outbox and
46
- * `capture()`, and a trigger pair per resource with events, recreated when its fields changed; triggers of resources
47
- * without events any more are dropped. `undefined` when the database is current. Tables are never dropped.
45
+ * The SQL that brings the database to the capture the resources' events and history need: the schema with the outbox
46
+ * and `capture()`, the history table, and a trigger pair per resource with either, recreated when its fields changed;
47
+ * triggers of resources without either any more are dropped. `undefined` when the database is current. Tables are
48
+ * never dropped.
48
49
  */
49
50
  const changesMigration = async (db, models, options) => {
50
51
  const { schema, plan: found } = await plan(db, models, options);
51
52
  return planSql(schema, found);
52
53
  };
53
54
  /**
54
- * Refuses to go on while a resource's events would not be captured as declared: a missing or out-of-date schema, or
55
- * trigger. The server never changes the schema itself: the project's migrations do, before the new version serves.
55
+ * Refuses to go on while a resource's events or history would not be captured as declared: a missing or out-of-date
56
+ * schema, history table or trigger. The server never changes the schema itself: the project's migrations do, before the
57
+ * new version serves.
56
58
  */
57
59
  const checkChanges = async (db, models, options) => {
58
- if (!models.some((model) => model.events)) return;
60
+ if (!models.some(isCaptured)) return;
59
61
  const { plan: found } = await plan(db, models, options);
60
62
  const missing = uncaptured(found, models);
61
63
  if (missing.length === 0) return;
62
- throw new Error(`The changes of ${missing.join(", ")} are not captured as their events need: their triggers or the changes schema are missing or out of date. Write the change as a migration with \`${migrationCommand}\`, apply it with the project's migrations, then start again.`);
64
+ throw new Error(`The changes of ${missing.join(", ")} are not captured as their events or history need: their triggers, the history table or the changes schema are missing or out of date. Write the change as a migration with \`${migrationCommand}\`, apply it with the project's migrations, then start again.`);
63
65
  };
64
66
  //#endregion
65
67
  export { changesMigration, checkChanges, migrationCommand };
@@ -0,0 +1,5 @@
1
+ import { ResourceModel } from "@protobase/schema";
2
+ //#region src/changes/capture-sql.d.ts
3
+ /** Whether the triggers capture a resource's changes: for its events, its history, or both. */
4
+ export declare const isCaptured: (model: ResourceModel) => boolean;
5
+ //#endregion
@@ -16,9 +16,12 @@ const identifier = (name) => {
16
16
  };
17
17
  /** The table as SQL names it, qualified when the resource names a schema. */
18
18
  const tableSql = (model) => qualified(model.table);
19
+ /** Whether the triggers capture a resource's changes: for its events, its history, or both. */
20
+ const isCaptured = (model) => (model.events ?? []).length > 0 || model.history === true;
19
21
  /**
20
22
  * The outbox: one row per changed row, written in the writer's transaction. `transaction` is the writer's transaction
21
- * id; `dispatch_after` holds back a change that the process which wrote it is delivering itself.
23
+ * id; `database_role` the role that wrote; `dispatch_after` holds back a change that the process which wrote it is
24
+ * delivering itself. The `alter` adds the role to an outbox of an earlier version.
22
25
  */
23
26
  const outboxSql = (schema) => `create table if not exists ${quoteIdent(schema)}."outbox" (
24
27
  "id" bigint generated always as identity primary key,
@@ -29,16 +32,19 @@ const outboxSql = (schema) => `create table if not exists ${quoteIdent(schema)}.
29
32
  "new" jsonb,
30
33
  "changed" text[],
31
34
  "actor" jsonb,
35
+ "database_role" text,
32
36
  "dispatch_after" timestamptz not null default now(),
33
37
  "created_at" timestamptz not null default now()
34
- )`;
38
+ );
39
+ alter table ${quoteIdent(schema)}."outbox" add column if not exists "database_role" text`;
35
40
  /** The job that turns captured changes into events and deliveries: one per writing transaction, run one at a time. */
36
41
  const dispatchJobName = "changes.dispatch";
37
42
  /** Every dispatch job's group in its ordered queue, so only one runs at a time, across processes too. */
38
43
  const dispatchGroup = "changes";
39
44
  /**
40
45
  * The body of `capture()`: its arguments are the resource, the columns to keep and the sensitive columns, whose names
41
- * it lists when they change but whose values it never keeps. It skips a transaction that set `<schema>.capture` to
46
+ * it lists when they change but whose values it never keeps. It keeps the writing role: the one `set role` chose, else
47
+ * the session's, since the function itself runs as its owner. It skips a transaction that set `<schema>.capture` to
42
48
  * `off`, and an update that changed none of those columns. The first row a transaction captures enqueues a dispatch
43
49
  * job in the same transaction; `<schema>.dispatch` remembers that for the rest of it, and a rolled-back savepoint
44
50
  * forgets it with the job.
@@ -68,7 +74,7 @@ begin
68
74
  return null;
69
75
  end if;
70
76
  end if;
71
- insert into ${quoteIdent(schema)}."outbox" ("transaction", "resource", "op", "old", "new", "changed", "actor")
77
+ insert into ${quoteIdent(schema)}."outbox" ("transaction", "resource", "op", "old", "new", "changed", "actor", "database_role")
72
78
  values (
73
79
  pg_current_xact_id()::text::bigint,
74
80
  TG_ARGV[0],
@@ -76,7 +82,8 @@ begin
76
82
  (select jsonb_object_agg(o.key, o.value) from jsonb_each(old_row) as o where o.key = any(kept)),
77
83
  (select jsonb_object_agg(n.key, n.value) from jsonb_each(new_row) as n where n.key = any(kept)),
78
84
  changed,
79
- nullif(current_setting('${schema}.actor', true), '')::jsonb
85
+ nullif(current_setting('${schema}.actor', true), '')::jsonb,
86
+ coalesce(nullif(current_setting('role'), 'none'), session_user)
80
87
  );
81
88
  if current_setting('${schema}.dispatch', true) is distinct from 'queued' then
82
89
  perform ${jobsSchema}.enqueue('${dispatchJobName}', null, null, null, '${dispatchGroup}');
@@ -90,7 +97,7 @@ end
90
97
  * service writing as its own role keeps working once a resource has events.
91
98
  */
92
99
  const captureFunctionSql = (schema) => `create or replace function ${quoteIdent(schema)}."capture"() returns trigger language plpgsql security definer set search_path = pg_catalog, pg_temp as $capture$${captureBody(schema)}$capture$`;
93
- /** The two triggers of a resource with events: inserts and deletes, and updates that change the row. */
100
+ /** The two triggers of a resource whose changes are captured: inserts and deletes, and updates that change the row. */
94
101
  const resourceTriggers = (model) => {
95
102
  const fields = Object.values(model.fields);
96
103
  const args = [
@@ -116,4 +123,4 @@ const createTriggerSql = (schema, trigger, table) => {
116
123
  return `create trigger ${quoteIdent(trigger.name)} ${timing} on ${table} for each row${when} execute function ${quoteIdent(schema)}."capture"(${args})`;
117
124
  };
118
125
  //#endregion
119
- export { captureBody, captureFunctionSql, createTriggerSql, dispatchGroup, dispatchJobName, dropTriggerSql, outboxSql, quoteIdent, resourceTriggers, tableSql };
126
+ export { captureBody, captureFunctionSql, createTriggerSql, dispatchGroup, dispatchJobName, dropTriggerSql, isCaptured, outboxSql, quoteIdent, resourceTriggers, tableSql };
@@ -14,6 +14,8 @@ export type CapturedRow = {
14
14
  /** The columns an update changed, sensitive ones included; `null` for inserts and deletes. */
15
15
  changed: string[] | null;
16
16
  actor: EventActor | null;
17
+ /** The database role that wrote; `null` for a row captured before Protobase kept it. */
18
+ databaseRole: string | null;
17
19
  /** When the writing transaction started, ISO 8601. */
18
20
  at: string;
19
21
  };
@@ -28,6 +30,8 @@ export type CoalescedChange = {
28
30
  after: Row | null;
29
31
  changed: string[];
30
32
  actor: EventActor | null;
33
+ /** The database role of its last change. */
34
+ databaseRole: string | null;
31
35
  /** The id of its last outbox row. */
32
36
  sequence: string;
33
37
  /** When its last change's transaction started. */
@@ -17,6 +17,7 @@ const merged = ({ first, last, actor, listed }) => {
17
17
  transaction: first.transaction,
18
18
  resource: first.resource,
19
19
  actor,
20
+ databaseRole: last.databaseRole,
20
21
  sequence: last.id,
21
22
  at: last.at
22
23
  };
@@ -30,7 +30,7 @@ const parseColumns = (text, model) => {
30
30
  const readOutbox = async (db, schema, models, limit) => {
31
31
  const byName = new Map(models.map((model) => [model.name, model]));
32
32
  const { rows } = await sql`select "id"::text as id, "transaction"::text as transaction, "resource", "op", "old"::text as old, "new"::text as new, "changed",
33
- "actor"::text as actor, to_char("created_at" at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') as at
33
+ "actor"::text as actor, "database_role" as "databaseRole", to_char("created_at" at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') as at
34
34
  from ${sql.raw(`${quoteIdent(schema)}."outbox"`)} where "dispatch_after" <= now() order by "id" limit ${limit}`.execute(db);
35
35
  return rows.map((row) => {
36
36
  const model = byName.get(row.resource);
@@ -15,6 +15,8 @@ export type ResourceChange = {
15
15
  changed: string[];
16
16
  actor: EventActor | null;
17
17
  source: 'api' | 'database';
18
+ /** The database role that wrote; `null` when unknown. */
19
+ databaseRole: string | null;
18
20
  tenant?: TenantValue;
19
21
  transaction: string;
20
22
  /** Grows with every change of a record, so receivers can drop stale events. */
@@ -34,6 +34,7 @@ const resourceChange = (model, change) => {
34
34
  changed: fieldsOf(model, change.changed),
35
35
  actor: change.actor,
36
36
  source: change.actor ? "api" : "database",
37
+ databaseRole: change.databaseRole,
37
38
  ...tenant != null && { tenant },
38
39
  transaction: change.transaction,
39
40
  sequence: change.sequence,
@@ -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
  ];
@@ -2,10 +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
8
  import { editableErrors } from "./view-editable.js";
8
- import { actionInputErrors, addressErrors, addressPlaceholders, boardErrors, displayFields, fieldRefsIn, hrefField, hrefPlaceholders, linkActionError } from "@protobase/schema/internal";
9
+ import { actionInputErrors, addressErrors, addressPlaceholders, boardErrors, chartFields, displayFields, fieldRefsIn, hrefField, hrefPlaceholders, linkActionError } from "@protobase/schema/internal";
9
10
  //#region src/check-views.ts
10
11
  const linkFields = (run) => run?.kind !== "link" ? [] : "to" in run ? addressPlaceholders(run.to) : hrefPlaceholders(run.href);
11
12
  const sensitiveErrors = (view, model) => {
@@ -20,7 +21,7 @@ const sensitiveErrors = (view, model) => {
20
21
  ]],
21
22
  ["a list display", (view.list?.displays ?? []).flatMap(displayFields)],
22
23
  ["a filter", view.filters.flatMap((widget) => widget.kind === "flags" ? widget.fields : [widget.field])],
23
- ["the chart", view.chart ? [view.chart.field] : []],
24
+ ["the chart", view.chart ? chartFields(view.chart) : []],
24
25
  ["the sidebar", view.layout.flatMap((item) => item.kind === "sidebar" ? item.fields : [])],
25
26
  ["a map", view.layout.flatMap((item) => item.kind === "map" ? [item.latitude, item.longitude] : [])],
26
27
  ["a gallery", view.layout.flatMap((item) => item.kind === "gallery" && item.image !== void 0 ? [item.image] : [])],
@@ -111,7 +112,7 @@ const listOfErrors = (model, name, models) => {
111
112
  * edits a list column in its cell that is not a field with an editor of one line the API may write,
112
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
113
114
  * `searchesOf` that do not fit the resources, cards
114
- * displays of one list that name different photos, a map that cannot show the
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
115
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`.
116
117
  */
117
118
  const checkViews = (registry, views, pages = [], fieldTypes = {}) => {
@@ -131,6 +132,8 @@ const checkViews = (registry, views, pages = [], fieldTypes = {}) => {
131
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}`) : []];
132
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}`) : [])];
133
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}`) : [];
136
+ const history = model.history ? [] : view.layout.flatMap((item) => item.kind === "history" ? [`history "${item.title}" needs .history() on resource "${view.resource}"`] : []);
134
137
  return [
135
138
  ...sensitiveErrors(view, model),
136
139
  ...unknownFieldErrors(view, model),
@@ -145,7 +148,9 @@ const checkViews = (registry, views, pages = [], fieldTypes = {}) => {
145
148
  ...listOf,
146
149
  ...searches,
147
150
  ...maps,
151
+ ...history,
148
152
  ...boards,
153
+ ...chart,
149
154
  ...linkErrors(view, model, models, pageNames),
150
155
  ...inputErrors(view, models),
151
156
  ...createErrors(view, pageNames)
@@ -1,5 +1,6 @@
1
1
  import { AdminAuth } from "./better-auth/create-auth.js";
2
2
  import { EventsOptions } from "./changes/events-options.js";
3
+ import { HistoryOptions } from "./history/options.js";
3
4
  import { JobDefinition } from "./jobs/job-types.js";
4
5
  import { Admin, Authenticator, PageSource, ViewSource } from "./types.js";
5
6
  import { WebhooksOptions } from "./webhooks/table/options.js";
@@ -30,7 +31,9 @@ export type CreateAdminInput = {
30
31
  files?: FilesOptions;
31
32
  /** Where the changes that resources' events are raised from are captured (see `EventsOptions`). */
32
33
  events?: EventsOptions;
33
- /** The app's jobs (see `defineJob`): with any, or a resource with events, admins get the failed jobs page (`failed_jobs`). */
34
+ /** How long the history of resources with `.history()` is kept (see `HistoryOptions`). */
35
+ history?: HistoryOptions;
36
+ /** The app's jobs (see `defineJob`): with any, or a resource with events or history, admins get the failed jobs page (`failed_jobs`). */
34
37
  jobs?: JobDefinition[];
35
38
  /** Where the resources' events are delivered (`subscription(...)` exports of the config module). */
36
39
  subscriptions?: SubscriptionDefinition[];
@@ -51,5 +54,5 @@ export type CreateAdminInput = {
51
54
  options?: AdminOptions;
52
55
  };
53
56
  /** The admin REST API as a Hono app. It uses only Web APIs, so it runs on Node, Workers and anywhere else Hono does. */
54
- export declare const createAdmin: ({ resources: appResources, views: appViews, pages, userMenu, bottomBars, db, authenticate, auth, functions, files: filesOptions, events, jobs, subscriptions, webhooks: webhooksOptions, bundlePlugins, fieldTypes, options }: CreateAdminInput) => Admin;
57
+ export declare const createAdmin: ({ resources: appResources, views: appViews, pages, userMenu, bottomBars, db, authenticate, auth, functions, files: filesOptions, events, history: historyOptions, jobs, subscriptions, webhooks: webhooksOptions, bundlePlugins, fieldTypes, options }: CreateAdminInput) => Admin;
55
58
  //#endregion