@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.
- package/dist/aggregate-groups.js +137 -0
- package/dist/aggregate-service.js +11 -10
- package/dist/changes/actor.js +1 -1
- package/dist/changes/capture-plan.js +13 -10
- package/dist/changes/capture-schema.d.ts +7 -5
- package/dist/changes/capture-schema.js +14 -12
- package/dist/changes/capture-sql.d.ts +5 -0
- package/dist/changes/capture-sql.js +14 -7
- package/dist/changes/coalesce.d.ts +4 -0
- package/dist/changes/coalesce.js +1 -0
- package/dist/changes/outbox.js +1 -1
- package/dist/changes/resource-change.d.ts +2 -0
- package/dist/changes/resource-change.js +1 -0
- package/dist/check-view-queries.js +2 -1
- package/dist/check-views.js +8 -3
- package/dist/create-admin.d.ts +5 -2
- package/dist/create-admin.js +28 -7
- package/dist/history/activity.js +75 -0
- package/dist/history/actor-names.js +9 -0
- package/dist/history/history-sql.js +31 -0
- package/dist/history/options.d.ts +7 -0
- package/dist/history/options.js +9 -0
- package/dist/history/prune-history.js +26 -0
- package/dist/history/read-history.js +72 -0
- package/dist/history/write-history.js +43 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -2
- package/dist/openapi/resource-paths.js +137 -0
- package/dist/project-config.d.ts +3 -0
- package/dist/project-config.js +3 -1
- package/dist/routes/collection.js +5 -0
- package/dist/routes/record.js +2 -0
- package/dist/view-access.js +9 -7
- package/dist/view-chart.js +33 -0
- package/dist/webhooks/webhooks.js +8 -5
- 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 };
|
package/dist/changes/actor.js
CHANGED
|
@@ -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
|
|
11
|
-
* table, two resources
|
|
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
|
|
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 =
|
|
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:
|
|
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) =>
|
|
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
|
|
9
|
-
* `capture()`, and a trigger pair per resource with
|
|
10
|
-
* without
|
|
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
|
|
15
|
-
* trigger. The server never changes the schema itself: the project's migrations do, before the
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
46
|
-
* `capture()`, and a trigger pair per resource with
|
|
47
|
-
* without
|
|
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
|
|
55
|
-
* trigger. The server never changes the schema itself: the project's migrations do, before the
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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. */
|
package/dist/changes/coalesce.js
CHANGED
package/dist/changes/outbox.js
CHANGED
|
@@ -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
|
];
|
package/dist/check-views.js
CHANGED
|
@@ -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 ?
|
|
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)
|
package/dist/create-admin.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|