@protobase/server 0.20.0 → 0.21.1
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/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-views.js +2 -0
- 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 +71 -0
- package/dist/project-config.d.ts +3 -0
- package/dist/project-config.js +3 -1
- package/dist/routes/record.js +2 -0
- package/dist/validate-body.js +15 -5
- package/dist/view-access.js +7 -1
- package/dist/webhooks/webhooks.js +8 -5
- package/package.json +4 -4
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,
|
package/dist/check-views.js
CHANGED
|
@@ -133,6 +133,7 @@ const checkViews = (registry, views, pages = [], fieldTypes = {}) => {
|
|
|
133
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
134
|
const boards = (view.list?.displays ?? []).flatMap((display) => display.kind === "board" ? boardErrors(display, model).map((error) => `display "${display.name}": ${error}`) : []);
|
|
135
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}"`] : []);
|
|
136
137
|
return [
|
|
137
138
|
...sensitiveErrors(view, model),
|
|
138
139
|
...unknownFieldErrors(view, model),
|
|
@@ -147,6 +148,7 @@ const checkViews = (registry, views, pages = [], fieldTypes = {}) => {
|
|
|
147
148
|
...listOf,
|
|
148
149
|
...searches,
|
|
149
150
|
...maps,
|
|
151
|
+
...history,
|
|
150
152
|
...boards,
|
|
151
153
|
...chart,
|
|
152
154
|
...linkErrors(view, model, models, 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
|
package/dist/create-admin.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { assistantSettings } from "./assistant/assistant-settings.js";
|
|
2
2
|
import { createErrorHandler } from "./error-handler.js";
|
|
3
|
+
import { isCaptured } from "./changes/capture-sql.js";
|
|
3
4
|
import { assistantRecords } from "./assistant/records.js";
|
|
4
5
|
import { builtInAssistant } from "./assistant/built-in-assistant.js";
|
|
5
6
|
import { consoleAuditQueue } from "./audit/console-queue.js";
|
|
@@ -9,6 +10,10 @@ import { authBasePath } from "./better-auth/create-auth.js";
|
|
|
9
10
|
import { organizationRoutes } from "./better-auth/organizations/admin-routes.js";
|
|
10
11
|
import { statusRoute } from "./better-auth/status-route.js";
|
|
11
12
|
import { changesSchema } from "./changes/events-options.js";
|
|
13
|
+
import { activityResource, activityView } from "./history/activity.js";
|
|
14
|
+
import { betterAuthActorNames } from "./history/actor-names.js";
|
|
15
|
+
import { historyDays } from "./history/options.js";
|
|
16
|
+
import { pruneHistoryJob } from "./history/prune-history.js";
|
|
12
17
|
import { callerRecords } from "./functions/records.js";
|
|
13
18
|
import { toApiFunction } from "./functions/define-function.js";
|
|
14
19
|
import { createWebhooks } from "./webhooks/webhooks.js";
|
|
@@ -45,11 +50,23 @@ const declaredRoles = (auth, roles) => {
|
|
|
45
50
|
return names.length > 0 ? [...new Set(names)] : void 0;
|
|
46
51
|
};
|
|
47
52
|
/** The admin REST API as a Hono app. It uses only Web APIs, so it runs on Node, Workers and anywhere else Hono does. */
|
|
48
|
-
const createAdmin = ({ resources: appResources, views: appViews = [], pages = [], userMenu, bottomBars = [], db, authenticate, auth, functions = {}, files: filesOptions, events, jobs = [], subscriptions = [], webhooks: webhooksOptions, bundlePlugins = [], fieldTypes = {}, options = {} }) => {
|
|
53
|
+
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 = {} }) => {
|
|
49
54
|
const basePath = options.basePath ?? "/api/v1";
|
|
50
|
-
const
|
|
51
|
-
const
|
|
52
|
-
const
|
|
55
|
+
const appModels = appResources.map((resource) => resource.toModel());
|
|
56
|
+
const withJobs = jobs.length > 0 || appModels.some(isCaptured);
|
|
57
|
+
const withHistory = appModels.some((model) => model.history);
|
|
58
|
+
const changes = changesSchema(events);
|
|
59
|
+
const days = historyDays(historyOptions);
|
|
60
|
+
const resources = [
|
|
61
|
+
...appResources,
|
|
62
|
+
...withJobs ? [failedJobs] : [],
|
|
63
|
+
...withHistory ? [activityResource(changes, { tenant: appModels.some((model) => model.history && model.tenant) })] : []
|
|
64
|
+
];
|
|
65
|
+
const views = [
|
|
66
|
+
...appViews,
|
|
67
|
+
...withJobs ? [failedJobsView] : [],
|
|
68
|
+
...withHistory ? [activityView] : []
|
|
69
|
+
];
|
|
53
70
|
const registry = buildRegistry(resources);
|
|
54
71
|
const apiFunctions = Object.fromEntries(Object.entries(functions).map(([name, source]) => [name, toApiFunction(name, source)]));
|
|
55
72
|
const viewModels = views.map((view) => view.toModel());
|
|
@@ -108,7 +125,11 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
|
|
|
108
125
|
plugins,
|
|
109
126
|
sources,
|
|
110
127
|
...files && { files },
|
|
111
|
-
...
|
|
128
|
+
...appModels.some(isCaptured) && { actorSetting: `${changes}.actor` },
|
|
129
|
+
...withHistory && { history: {
|
|
130
|
+
schema: changes,
|
|
131
|
+
...auth && { actorNames: betterAuthActorNames(auth) }
|
|
132
|
+
} }
|
|
112
133
|
};
|
|
113
134
|
const meta = createMeta(deps);
|
|
114
135
|
const onError = createErrorHandler(options.onUnhandledError);
|
|
@@ -194,11 +215,11 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
|
|
|
194
215
|
deps,
|
|
195
216
|
subscriptions,
|
|
196
217
|
functions: apiFunctions,
|
|
197
|
-
schema:
|
|
218
|
+
schema: changes,
|
|
198
219
|
log: (line) => console.log(line),
|
|
199
220
|
...table && { table }
|
|
200
221
|
});
|
|
201
|
-
return Object.assign(app, { jobs: webhooks.jobs });
|
|
222
|
+
return Object.assign(app, { jobs: [...webhooks.jobs, ...withHistory && days !== void 0 ? [pruneHistoryJob(changes, days)] : []] });
|
|
202
223
|
};
|
|
203
224
|
//#endregion
|
|
204
225
|
export { createAdmin };
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { f, l, resource, view } from "@protobase/schema";
|
|
2
|
+
//#region src/history/activity.ts
|
|
3
|
+
const admins = ({ user }) => user?.roles?.includes("admin") ?? false;
|
|
4
|
+
/**
|
|
5
|
+
* Every kept change of every resource with history, over the history table of `schema`: the admin app lists them as
|
|
6
|
+
* Activity. Admins only, whatever the project's roles say, since a change holds the record's fields before and after;
|
|
7
|
+
* read-only. With `tenant`, when a resource with history has one, it is scoped to the caller's tenant.
|
|
8
|
+
*/
|
|
9
|
+
const activityResource = (schema, { tenant }) => {
|
|
10
|
+
const changes = resource("record_history").table(`${schema}.history`).fields({
|
|
11
|
+
id: f.bigint().readOnly().sortable(),
|
|
12
|
+
resource: f.text().readOnly().filterable(),
|
|
13
|
+
record_key: f.text().readOnly().filterable(),
|
|
14
|
+
tenant_id: f.text().optional().readOnly(),
|
|
15
|
+
operation: f.enum([
|
|
16
|
+
"create",
|
|
17
|
+
"update",
|
|
18
|
+
"delete",
|
|
19
|
+
"undelete"
|
|
20
|
+
]).readOnly().filterable(),
|
|
21
|
+
before: f.json().optional().readOnly(),
|
|
22
|
+
after: f.json().optional().readOnly(),
|
|
23
|
+
changed: f.json().readOnly(),
|
|
24
|
+
actor_id: f.text().optional().readOnly().filterable(),
|
|
25
|
+
actor_roles: f.json().optional().readOnly(),
|
|
26
|
+
database_role: f.text().optional().readOnly().filterable(),
|
|
27
|
+
transaction: f.bigint().readOnly().filterable(),
|
|
28
|
+
at: f.timestamp().readOnly().filterable()
|
|
29
|
+
}).primaryKey((r) => r.id).access({
|
|
30
|
+
list: admins,
|
|
31
|
+
read: admins,
|
|
32
|
+
create: () => false,
|
|
33
|
+
update: () => false,
|
|
34
|
+
delete: () => false
|
|
35
|
+
});
|
|
36
|
+
return tenant ? changes.tenant((r) => r.tenant_id) : changes;
|
|
37
|
+
};
|
|
38
|
+
const activityView = view("record_history").names({
|
|
39
|
+
singular: "Change",
|
|
40
|
+
plural: "Activity"
|
|
41
|
+
}).icon("history").title((r) => r.record_key).help("Every change of the records whose resource keeps history, from the app or straight in the database, newest first.").fields((r) => ({
|
|
42
|
+
record_key: r.record_key.label("Record"),
|
|
43
|
+
actor_id: r.actor_id.label("By"),
|
|
44
|
+
actor_roles: r.actor_roles.label("Acting as"),
|
|
45
|
+
database_role: r.database_role.label("Database role"),
|
|
46
|
+
changed: r.changed.label("Changed fields"),
|
|
47
|
+
at: r.at.label("When")
|
|
48
|
+
})).list((r) => ({
|
|
49
|
+
columns: [
|
|
50
|
+
r.at,
|
|
51
|
+
r.resource,
|
|
52
|
+
r.record_key,
|
|
53
|
+
r.operation,
|
|
54
|
+
r.actor_id,
|
|
55
|
+
r.changed
|
|
56
|
+
],
|
|
57
|
+
sort: [[r.id, "desc"]]
|
|
58
|
+
})).layout((r) => [
|
|
59
|
+
l.section("Change", [
|
|
60
|
+
r.resource,
|
|
61
|
+
r.record_key,
|
|
62
|
+
r.operation,
|
|
63
|
+
r.changed
|
|
64
|
+
]),
|
|
65
|
+
l.section("Before and after", [r.before, r.after]),
|
|
66
|
+
l.sidebar([
|
|
67
|
+
r.at,
|
|
68
|
+
r.actor_id,
|
|
69
|
+
r.actor_roles,
|
|
70
|
+
r.database_role,
|
|
71
|
+
r.transaction
|
|
72
|
+
])
|
|
73
|
+
]);
|
|
74
|
+
//#endregion
|
|
75
|
+
export { activityResource, activityView };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
//#region src/history/actor-names.ts
|
|
2
|
+
/** The names Better Auth knows of these user ids; a user who is gone, or signed in elsewhere, has none. */
|
|
3
|
+
const betterAuthActorNames = (auth) => async (ids) => {
|
|
4
|
+
const { internalAdapter } = await auth.$context;
|
|
5
|
+
const users = await Promise.all(ids.map((id) => internalAdapter.findUserById(id)));
|
|
6
|
+
return Object.fromEntries(users.flatMap((user) => user?.name ? [[user.id, user.name]] : []));
|
|
7
|
+
};
|
|
8
|
+
//#endregion
|
|
9
|
+
export { betterAuthActorNames };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { quoteIdent } from "../changes/capture-sql.js";
|
|
2
|
+
//#region src/history/history-sql.ts
|
|
3
|
+
/**
|
|
4
|
+
* The history of the resources with `.history()`: a row per record and transaction, as events see the change. `before`
|
|
5
|
+
* and `after` are the fields by name, without sensitive ones; `changed` names the fields an update changed, sensitive
|
|
6
|
+
* ones included. `actor_id` and `actor_roles` are the API's caller, `null` for a write from elsewhere; `database_role`
|
|
7
|
+
* is the role that wrote, `null` for a change captured before Protobase kept it.
|
|
8
|
+
*/
|
|
9
|
+
const historyTableSql = (schema) => {
|
|
10
|
+
const table = `${quoteIdent(schema)}."history"`;
|
|
11
|
+
return `create table if not exists ${table} (
|
|
12
|
+
"id" bigint generated always as identity primary key,
|
|
13
|
+
"resource" text not null,
|
|
14
|
+
"record_key" text not null,
|
|
15
|
+
"tenant_id" text,
|
|
16
|
+
"operation" text not null check ("operation" in ('create', 'update', 'delete', 'undelete')),
|
|
17
|
+
"before" jsonb,
|
|
18
|
+
"after" jsonb,
|
|
19
|
+
"changed" jsonb not null,
|
|
20
|
+
"actor_id" text,
|
|
21
|
+
"actor_roles" jsonb,
|
|
22
|
+
"database_role" text,
|
|
23
|
+
"transaction" bigint not null,
|
|
24
|
+
"at" timestamptz not null
|
|
25
|
+
);
|
|
26
|
+
create index if not exists "history_record" on ${table} ("resource", "record_key", "id");
|
|
27
|
+
create index if not exists "history_tenant" on ${table} ("tenant_id", "id");
|
|
28
|
+
create index if not exists "history_at" on ${table} ("at")`;
|
|
29
|
+
};
|
|
30
|
+
//#endregion
|
|
31
|
+
export { historyTableSql };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
//#region src/history/options.d.ts
|
|
2
|
+
/** How long record history is kept (`history` in `protobase.config.ts`). */
|
|
3
|
+
export type HistoryOptions = {
|
|
4
|
+
/** Days a change is kept; a daily job deletes older ones. Kept forever when omitted. */
|
|
5
|
+
days?: number;
|
|
6
|
+
};
|
|
7
|
+
//#endregion
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
//#region src/history/options.ts
|
|
2
|
+
/** The days of the options, checked: a positive whole number, or none. */
|
|
3
|
+
const historyDays = (options = {}) => {
|
|
4
|
+
const { days } = options;
|
|
5
|
+
if (days !== void 0 && (!Number.isInteger(days) || days < 1)) throw new Error(`history.days must be a positive whole number of days, not ${days}`);
|
|
6
|
+
return days;
|
|
7
|
+
};
|
|
8
|
+
//#endregion
|
|
9
|
+
export { historyDays };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { quoteIdent } from "../changes/capture-sql.js";
|
|
2
|
+
import { defineJob } from "../jobs/define-job.js";
|
|
3
|
+
import { sql } from "kysely";
|
|
4
|
+
//#region src/history/prune-history.ts
|
|
5
|
+
/** Rows one delete removes, so a first prune of a long history does not hold one long transaction. */
|
|
6
|
+
const batchSize = 1e4;
|
|
7
|
+
/** Deletes the changes older than `days`, in batches; returns how many went. */
|
|
8
|
+
const pruneHistory = async (db, schema, days) => {
|
|
9
|
+
const table = sql.raw(`${quoteIdent(schema)}."history"`);
|
|
10
|
+
let total = 0;
|
|
11
|
+
for (;;) {
|
|
12
|
+
const result = await sql`delete from ${table} where "id" in (select "id" from ${table} where "at" < now() - make_interval(days => ${days}) order by "id" limit ${batchSize})`.execute(db);
|
|
13
|
+
const deleted = Number(result.numAffectedRows ?? 0);
|
|
14
|
+
total += deleted;
|
|
15
|
+
if (deleted < batchSize) return total;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
/** The daily job that keeps record history to `days`. */
|
|
19
|
+
const pruneHistoryJob = (schema, days) => defineJob({
|
|
20
|
+
name: "history.prune",
|
|
21
|
+
schedule: "41 3 * * *",
|
|
22
|
+
timeout: "30 minutes",
|
|
23
|
+
run: async (_payload, { db }) => void await pruneHistory(db, schema, days)
|
|
24
|
+
});
|
|
25
|
+
//#endregion
|
|
26
|
+
export { pruneHistory, pruneHistoryJob };
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { notFound } from "../problem.js";
|
|
2
|
+
import { tenantScope } from "../tenant.js";
|
|
3
|
+
import { requestAccess, requireOperation } from "../request-access.js";
|
|
4
|
+
import { readTransaction } from "../transactions.js";
|
|
5
|
+
import { fetchReadable } from "../read-service.js";
|
|
6
|
+
import { quoteIdent } from "../changes/capture-sql.js";
|
|
7
|
+
import { parseParams } from "../params.js";
|
|
8
|
+
import { encodeKey } from "@protobase/schema/internal";
|
|
9
|
+
import { sql } from "kysely";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
//#region src/history/read-history.ts
|
|
12
|
+
const params = z.object({
|
|
13
|
+
page_size: z.coerce.number().int().min(0).max(100).optional(),
|
|
14
|
+
page_token: z.string().regex(/^\d+$/, "Use the next_page_token of the previous page").optional()
|
|
15
|
+
});
|
|
16
|
+
const defaultPageSize = 20;
|
|
17
|
+
const picked = (row, names) => row ? Object.fromEntries(Object.entries(row).filter(([name]) => names.has(name))) : void 0;
|
|
18
|
+
const shown = (row, readable, names) => {
|
|
19
|
+
const values = new Set(Object.values(readable.fields).filter((field) => !field.sensitive && !field.file).map((field) => field.name));
|
|
20
|
+
const changed = row.changed.filter((name) => Object.hasOwn(readable.fields, name));
|
|
21
|
+
if (row.operation === "update" && changed.length === 0) return [];
|
|
22
|
+
const before = picked(row.before, values);
|
|
23
|
+
const after = picked(row.after, values);
|
|
24
|
+
const name = row.actor_id === null ? void 0 : names[row.actor_id];
|
|
25
|
+
return [{
|
|
26
|
+
id: row.id,
|
|
27
|
+
at: row.at,
|
|
28
|
+
operation: row.operation,
|
|
29
|
+
changed,
|
|
30
|
+
...before && { before },
|
|
31
|
+
...after && { after },
|
|
32
|
+
actor: row.actor_id === null ? null : {
|
|
33
|
+
id: row.actor_id,
|
|
34
|
+
...name !== void 0 && { name }
|
|
35
|
+
},
|
|
36
|
+
database_role: row.database_role
|
|
37
|
+
}];
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* A page of a record's history, newest first, for a caller who may read the record: the same tenant, row filter and
|
|
41
|
+
* record-level rule as a get, a soft-deleted record included. Each change carries only the fields the caller may read.
|
|
42
|
+
*/
|
|
43
|
+
const readHistory = async (deps, entry, session, key, query) => {
|
|
44
|
+
const { schema, actorNames } = deps.history ?? {};
|
|
45
|
+
if (!schema || !entry.model.history) throw notFound(`${entry.name} keeps no history`);
|
|
46
|
+
const { page_size, page_token } = parseParams(params, query);
|
|
47
|
+
const size = page_size || defaultPageSize;
|
|
48
|
+
const access = await requestAccess(deps, entry, session);
|
|
49
|
+
requireOperation(access, "read");
|
|
50
|
+
const keyFields = access.full.primaryKey.map((part) => access.full.fields[part]);
|
|
51
|
+
const tenant = tenantScope(entry, session)?.tenantValue;
|
|
52
|
+
const table = sql.raw(`${quoteIdent(schema)}."history"`);
|
|
53
|
+
const rows = await readTransaction(deps.db, deps.statementTimeoutMs, async (trx) => {
|
|
54
|
+
if (!await fetchReadable(trx, access, key, entry.model.softDelete ? "show" : "hide", { fields: keyFields })) return void 0;
|
|
55
|
+
const { rows } = await sql`select "id"::text as id, to_char("at" at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') as at, "operation", "changed", "before", "after", "actor_id", "database_role"
|
|
56
|
+
from ${table} where "resource" = ${entry.name} and "record_key" = ${encodeKey(key)}
|
|
57
|
+
${tenant === void 0 ? sql`` : sql`and "tenant_id" = ${String(tenant)}`}
|
|
58
|
+
${page_token === void 0 ? sql`` : sql`and "id" < ${page_token}::bigint`}
|
|
59
|
+
order by "id" desc limit ${size + 1}`.execute(trx);
|
|
60
|
+
return rows;
|
|
61
|
+
});
|
|
62
|
+
if (!rows) throw notFound(`No ${entry.name} with this key`);
|
|
63
|
+
const page = rows.slice(0, size);
|
|
64
|
+
const ids = [...new Set(page.flatMap((row) => row.actor_id === null ? [] : [row.actor_id]))];
|
|
65
|
+
const names = ids.length > 0 && actorNames ? await actorNames(ids) : {};
|
|
66
|
+
return {
|
|
67
|
+
items: page.flatMap((row) => shown(row, access.readable, names)),
|
|
68
|
+
next_page_token: rows.length > size ? page.at(-1).id : ""
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
//#endregion
|
|
72
|
+
export { readHistory };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { quoteIdent } from "../changes/capture-sql.js";
|
|
2
|
+
import { sql } from "kysely";
|
|
3
|
+
//#region src/history/write-history.ts
|
|
4
|
+
const historyRow = (change) => ({
|
|
5
|
+
resource: change.resource,
|
|
6
|
+
record_key: change.key,
|
|
7
|
+
tenant_id: change.tenant === void 0 ? null : String(change.tenant),
|
|
8
|
+
operation: change.operation,
|
|
9
|
+
before: change.before ?? null,
|
|
10
|
+
after: change.after ?? null,
|
|
11
|
+
changed: change.changed,
|
|
12
|
+
actor_id: change.actor?.id ?? null,
|
|
13
|
+
actor_roles: change.actor?.roles ?? null,
|
|
14
|
+
database_role: change.databaseRole,
|
|
15
|
+
transaction: change.transaction,
|
|
16
|
+
at: change.at
|
|
17
|
+
});
|
|
18
|
+
const typed = [
|
|
19
|
+
["resource", "text"],
|
|
20
|
+
["record_key", "text"],
|
|
21
|
+
["tenant_id", "text"],
|
|
22
|
+
["operation", "text"],
|
|
23
|
+
["before", "jsonb"],
|
|
24
|
+
["after", "jsonb"],
|
|
25
|
+
["changed", "jsonb"],
|
|
26
|
+
["actor_id", "text"],
|
|
27
|
+
["actor_roles", "jsonb"],
|
|
28
|
+
["database_role", "text"],
|
|
29
|
+
["transaction", "bigint"],
|
|
30
|
+
["at", "timestamptz"]
|
|
31
|
+
];
|
|
32
|
+
const columns = typed.map(([name]) => `"${name}"`).join(", ");
|
|
33
|
+
const types = typed.map(([name, type]) => `"${name}" ${type}`).join(", ");
|
|
34
|
+
/** Adds the changes to the history table, in their order: one statement, in the caller's transaction. */
|
|
35
|
+
const writeHistory = async (db, schema, changes) => {
|
|
36
|
+
if (changes.length === 0) return;
|
|
37
|
+
const rows = JSON.stringify(changes.map(historyRow));
|
|
38
|
+
await sql`insert into ${sql.raw(`${quoteIdent(schema)}."history"`)} (${sql.raw(columns)})
|
|
39
|
+
select ${sql.raw(columns)} from rows from (jsonb_to_recordset(${rows}::jsonb) as (${sql.raw(types)})) with ordinality as r(${sql.raw(columns)}, "position")
|
|
40
|
+
order by "position"`.execute(db);
|
|
41
|
+
};
|
|
42
|
+
//#endregion
|
|
43
|
+
export { writeHistory };
|
package/dist/index.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { PlatformSignIn, readPlatformSignIn } from "./better-auth/platform-sign-
|
|
|
9
9
|
import { EmailComponent, EmailOptions, EmailTemplate, RenderedEmail, defineEmail } from "./mail/define-email.js";
|
|
10
10
|
import { AdminAuth, CreateAuthOptions, UserCreatedHook, createAuth, normalizeRoles } from "./better-auth/create-auth.js";
|
|
11
11
|
import { EventsOptions } from "./changes/events-options.js";
|
|
12
|
+
import { HistoryOptions } from "./history/options.js";
|
|
12
13
|
import { StandardSchema, readJson } from "./functions/read-json.js";
|
|
13
14
|
import { EnqueueOptions, JobContext, JobDefinition, JobOptions } from "./jobs/job-types.js";
|
|
14
15
|
import { Admin, AuditEvent, AuditQueue, Authenticator, PipelineHook, ScanGuardMode, Session, SessionOrganization, TenantValue, User, ViewSource, WriteEvent, WriteOperation } from "./types.js";
|
|
@@ -51,6 +52,7 @@ import { IssueTokenOptions, issueToken, maxTokenTtlSeconds } from "./better-auth
|
|
|
51
52
|
import { webauthnPath } from "./better-auth/origins.js";
|
|
52
53
|
import { authSchemaMigration, checkAuthSchema } from "./better-auth/auth-schema.js";
|
|
53
54
|
import { changesMigration, checkChanges, migrationCommand } from "./changes/capture-schema.js";
|
|
55
|
+
import { isCaptured } from "./changes/capture-sql.js";
|
|
54
56
|
import { CapturedRow, CoalescedChange, coalesce } from "./changes/coalesce.js";
|
|
55
57
|
import { ResourceChange, recordOf, resourceChange } from "./changes/resource-change.js";
|
|
56
58
|
import { eventMatcher } from "./changes/match-events.js";
|
|
@@ -84,4 +86,4 @@ import { FatalJobError } from "./jobs/run-job.js";
|
|
|
84
86
|
import { DrainResult, JobsRuntime, StartJobsOptions, startJobs } from "./jobs/runtime.js";
|
|
85
87
|
import { jobsDrainApp } from "./jobs/drain-route.js";
|
|
86
88
|
import { checkJobsSchema, jobsSchema, jobsSchemaMigration } from "./jobs/schema.js";
|
|
87
|
-
export { type Admin, type AdminAuth, type AdminOptions, type ApiFunction, type ApprovalAnswer, type ApprovalRequest, type AssistantBackend, type AssistantOptions, type AssistantSettings, type AssistantTool, type AuditEvent, type AuditQueue, type Authenticator, type BetterAuthAuthenticatorOptions, type CapturedRow, type ChatMessage, type ChatTextPart, type CleanupEntry, type CleanupReport, type CleanupSchedule, type CoalescedChange, type Completion, type CompletionOptions, type Conversation, type ConversationRecord, type ConversationStore, type ConversationsOptions, type CorsOptions, type CorsOrigins, type CreateAdminInput, type CreateAuthOptions, type DrainResult, type EmailComponent, type EmailOptions, type EmailTemplate, type EnqueueOptions, type ErrorReporter, type ErrorReportingOptions, type ErrorReportingSettings, type EventData, type EventsOptions, FatalJobError, type FileCleanupInput, type FileLimits, type FileObject, type FileProvider, type FileRef, type FileStore, type FileVariantObject, type FilesOptions, type FunctionApp, type FunctionCaller, type FunctionContext, type FunctionEnv, type FunctionHandler, type FunctionOptions, type FunctionRecords, type FunctionSource, HttpProblem, type IssueTokenOptions, type JobContext, type JobDefinition, type JobOptions, type JobsRuntime, type JwtOptions, type LinkedAccount, type ListParams, type LocalFilesOptions, type MailMessage, type Mailer, type MapLayer, type MapOptions, type MapSettings, type MemberInput, MissingCredentials, ModelError, type ModelSettings, type NewOrganization, type NewUser, type OperatorProvider, type OrganizationRole, type OrganizationsOptions, type PipelineHook, type PlatformSignIn, type PluginOption, type PluginRef, type ProjectConfig, type ProtectedFunctionOptions, type PublicFunctionContext, type PublicFunctionEnv, type PublicFunctionHandler, type PublicFunctionOptions, type QueryOptions, type QueryResult, type ReasoningDetail, type ReasoningEffort, type RecordKeyInput, type RecordPage, type RenderedEmail, type ResourceChange, type ResourceSource, type RoleDefinition, type RolesInput, type RunContext, type RuntimeOptions, type S3Bucket, type S3FilesOptions, type S3ListPage, type S3Object, type ScanGuardMode, type ScheduledDelete, type Session, type SessionOrganization, type Sharp, type SignInPolicy, type SignInRule, type SignatureOptions, type SourceOption, type SourceRef, type StaffAccessRule, type StaffSignIn, type StandardSchema, type StandardWebhookOptions, type StartJobsOptions, type StoreFileInput, type StoredMember, type StoredObject, type StoredOrganization, type StoredUser, type SubscriptionDefinition, type SubscriptionEvent, type SubscriptionOptions, type TenantValue, type ToolCall, type ToolContext, type ToolRecord, type ToolRecords, type ToolSpec, type TrustedApp, type TurnOptions, type User, type UserCreatedHook, type ViewSource, type WebhooksOptions, type WidgetOptions, type WriteEvent, type WriteOperation, addAppRoleToOwners, addMember, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaMigration, badRequest, beforeSignInPluginsVariable, betterAuthAuthenticator, changesMigration, checkAuthSchema, checkChanges, checkJobsSchema, checkSourceNames, checkViewQueries, checkWebhooksSchema, coalesce, configExports, consoleAuditQueue, conversationKey, createAdmin, createAuth, createConversations, createFileCleanup, createOrganization, createUser, defaultSignInPolicy, defineConfig, defineEmail, defineFunction, defineJob, defineTool, deleteUser, errorReportingSettings, errorReportingVariable, eventMatcher, fileConversationStore, fileSize, fileType, forbidden, formatUri, functionApp, hasUsers, imageSize, isJobDefinition, issueToken, jobDefinitions, jobsDrainApp, jobsSchema, jobsSchemaMigration, jwtAuthenticator, limitedFileStore, listOrganizations, listUsers, localFileStore, localFiles, mapSettings, mapStyleUrl, mapStyleVariable, maxTokenTtlSeconds, maxWidgetPropsBytes, mergeConfig, migrationCommand, newWebhookSecret, normalizeRoles, notFound, parseColumns, parseRoles, pluginOptions, pluginsBeforeSignIn, pluginsFor, pluginsVariable, processor, readJson, readOnlyQueryTool, readOperatorSettings, readOutbox, readPlatformSignIn, readWriteQueryTool, recordOf, removeMember, renderVariant, requestApproval, requireEnv, resourceChange, roleChoices, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, s3FileStore, s3Files, seesAssistant, seesRuntime, setMemberRoles, setUserBanned, setUserRole, signInPolicyMethods, sourceOptions, sourcesFor, sourcesVariable, startErrorReporting, startJobs, storageCleanupSchedule, storeFile, streamChatCompletion, subscription, thumbHash, unauthorized, variantPath, verifySignature, verifyStandardWebhook, webauthnPath, webhooks, webhooksMigration, widget, withCacheBreakpoints, withTrustedApps };
|
|
89
|
+
export { type Admin, type AdminAuth, type AdminOptions, type ApiFunction, type ApprovalAnswer, type ApprovalRequest, type AssistantBackend, type AssistantOptions, type AssistantSettings, type AssistantTool, type AuditEvent, type AuditQueue, type Authenticator, type BetterAuthAuthenticatorOptions, type CapturedRow, type ChatMessage, type ChatTextPart, type CleanupEntry, type CleanupReport, type CleanupSchedule, type CoalescedChange, type Completion, type CompletionOptions, type Conversation, type ConversationRecord, type ConversationStore, type ConversationsOptions, type CorsOptions, type CorsOrigins, type CreateAdminInput, type CreateAuthOptions, type DrainResult, type EmailComponent, type EmailOptions, type EmailTemplate, type EnqueueOptions, type ErrorReporter, type ErrorReportingOptions, type ErrorReportingSettings, type EventData, type EventsOptions, FatalJobError, type FileCleanupInput, type FileLimits, type FileObject, type FileProvider, type FileRef, type FileStore, type FileVariantObject, type FilesOptions, type FunctionApp, type FunctionCaller, type FunctionContext, type FunctionEnv, type FunctionHandler, type FunctionOptions, type FunctionRecords, type FunctionSource, type HistoryOptions, HttpProblem, type IssueTokenOptions, type JobContext, type JobDefinition, type JobOptions, type JobsRuntime, type JwtOptions, type LinkedAccount, type ListParams, type LocalFilesOptions, type MailMessage, type Mailer, type MapLayer, type MapOptions, type MapSettings, type MemberInput, MissingCredentials, ModelError, type ModelSettings, type NewOrganization, type NewUser, type OperatorProvider, type OrganizationRole, type OrganizationsOptions, type PipelineHook, type PlatformSignIn, type PluginOption, type PluginRef, type ProjectConfig, type ProtectedFunctionOptions, type PublicFunctionContext, type PublicFunctionEnv, type PublicFunctionHandler, type PublicFunctionOptions, type QueryOptions, type QueryResult, type ReasoningDetail, type ReasoningEffort, type RecordKeyInput, type RecordPage, type RenderedEmail, type ResourceChange, type ResourceSource, type RoleDefinition, type RolesInput, type RunContext, type RuntimeOptions, type S3Bucket, type S3FilesOptions, type S3ListPage, type S3Object, type ScanGuardMode, type ScheduledDelete, type Session, type SessionOrganization, type Sharp, type SignInPolicy, type SignInRule, type SignatureOptions, type SourceOption, type SourceRef, type StaffAccessRule, type StaffSignIn, type StandardSchema, type StandardWebhookOptions, type StartJobsOptions, type StoreFileInput, type StoredMember, type StoredObject, type StoredOrganization, type StoredUser, type SubscriptionDefinition, type SubscriptionEvent, type SubscriptionOptions, type TenantValue, type ToolCall, type ToolContext, type ToolRecord, type ToolRecords, type ToolSpec, type TrustedApp, type TurnOptions, type User, type UserCreatedHook, type ViewSource, type WebhooksOptions, type WidgetOptions, type WriteEvent, type WriteOperation, addAppRoleToOwners, addMember, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaMigration, badRequest, beforeSignInPluginsVariable, betterAuthAuthenticator, changesMigration, checkAuthSchema, checkChanges, checkJobsSchema, checkSourceNames, checkViewQueries, checkWebhooksSchema, coalesce, configExports, consoleAuditQueue, conversationKey, createAdmin, createAuth, createConversations, createFileCleanup, createOrganization, createUser, defaultSignInPolicy, defineConfig, defineEmail, defineFunction, defineJob, defineTool, deleteUser, errorReportingSettings, errorReportingVariable, eventMatcher, fileConversationStore, fileSize, fileType, forbidden, formatUri, functionApp, hasUsers, imageSize, isCaptured, isJobDefinition, issueToken, jobDefinitions, jobsDrainApp, jobsSchema, jobsSchemaMigration, jwtAuthenticator, limitedFileStore, listOrganizations, listUsers, localFileStore, localFiles, mapSettings, mapStyleUrl, mapStyleVariable, maxTokenTtlSeconds, maxWidgetPropsBytes, mergeConfig, migrationCommand, newWebhookSecret, normalizeRoles, notFound, parseColumns, parseRoles, pluginOptions, pluginsBeforeSignIn, pluginsFor, pluginsVariable, processor, readJson, readOnlyQueryTool, readOperatorSettings, readOutbox, readPlatformSignIn, readWriteQueryTool, recordOf, removeMember, renderVariant, requestApproval, requireEnv, resourceChange, roleChoices, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, s3FileStore, s3Files, seesAssistant, seesRuntime, setMemberRoles, setUserBanned, setUserRole, signInPolicyMethods, sourceOptions, sourcesFor, sourcesVariable, startErrorReporting, startJobs, storageCleanupSchedule, storeFile, streamChatCompletion, subscription, thumbHash, unauthorized, variantPath, verifySignature, verifyStandardWebhook, webauthnPath, webhooks, webhooksMigration, widget, withCacheBreakpoints, withTrustedApps };
|
package/dist/index.js
CHANGED
|
@@ -11,6 +11,7 @@ import { withCacheBreakpoints } from "./assistant/cache-breakpoints.js";
|
|
|
11
11
|
import { ModelError, streamChatCompletion } from "./assistant/chat-completions.js";
|
|
12
12
|
import { formatUri, variantPath } from "./files/uri.js";
|
|
13
13
|
import { checkJobsSchema, jobsSchema, jobsSchemaMigration } from "./jobs/schema.js";
|
|
14
|
+
import { isCaptured } from "./changes/capture-sql.js";
|
|
14
15
|
import { runTurn } from "./assistant/turn.js";
|
|
15
16
|
import { consoleAuditQueue } from "./audit/console-queue.js";
|
|
16
17
|
import { webauthnPath } from "./better-auth/origins.js";
|
|
@@ -21,11 +22,11 @@ import { readPlatformSignIn } from "./better-auth/platform-sign-in.js";
|
|
|
21
22
|
import { createAuth, normalizeRoles } from "./better-auth/create-auth.js";
|
|
22
23
|
import { addAppRoleToOwners, addMember, createOrganization, listOrganizations, removeMember, setMemberRoles } from "./better-auth/organizations/host.js";
|
|
23
24
|
import { createUser, deleteUser, hasUsers, listUsers, roleChoices, setUserBanned, setUserRole } from "./better-auth/users.js";
|
|
25
|
+
import { defineJob, isJobDefinition, jobDefinitions } from "./jobs/define-job.js";
|
|
24
26
|
import { coalesce } from "./changes/coalesce.js";
|
|
25
27
|
import { eventMatcher } from "./changes/match-events.js";
|
|
26
28
|
import { parseColumns, readOutbox } from "./changes/outbox.js";
|
|
27
29
|
import { recordOf, resourceChange } from "./changes/resource-change.js";
|
|
28
|
-
import { defineJob, isJobDefinition, jobDefinitions } from "./jobs/define-job.js";
|
|
29
30
|
import { FatalJobError } from "./jobs/run-job.js";
|
|
30
31
|
import { defineFunction } from "./functions/define-function.js";
|
|
31
32
|
import { newWebhookSecret } from "./webhooks/standard-webhooks.js";
|
|
@@ -64,4 +65,4 @@ import { aspectRatio, fileSize, fileType, imageSize, processor } from "./files/p
|
|
|
64
65
|
import { defineEmail } from "./mail/define-email.js";
|
|
65
66
|
import { startJobs } from "./jobs/runtime.js";
|
|
66
67
|
import { jobsDrainApp } from "./jobs/drain-route.js";
|
|
67
|
-
export { FatalJobError, HttpProblem, MissingCredentials, ModelError, addAppRoleToOwners, addMember, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaMigration, badRequest, beforeSignInPluginsVariable, betterAuthAuthenticator, changesMigration, checkAuthSchema, checkChanges, checkJobsSchema, checkSourceNames, checkViewQueries, checkWebhooksSchema, coalesce, configExports, consoleAuditQueue, conversationKey, createAdmin, createAuth, createConversations, createFileCleanup, createOrganization, createUser, defaultSignInPolicy, defineConfig, defineEmail, defineFunction, defineJob, defineTool, deleteUser, errorReportingSettings, errorReportingVariable, eventMatcher, fileConversationStore, fileSize, fileType, forbidden, formatUri, functionApp, hasUsers, imageSize, isJobDefinition, issueToken, jobDefinitions, jobsDrainApp, jobsSchema, jobsSchemaMigration, jwtAuthenticator, limitedFileStore, listOrganizations, listUsers, localFileStore, localFiles, mapSettings, mapStyleUrl, mapStyleVariable, maxTokenTtlSeconds, maxWidgetPropsBytes, mergeConfig, migrationCommand, newWebhookSecret, normalizeRoles, notFound, parseColumns, parseRoles, pluginOptions, pluginsBeforeSignIn, pluginsFor, pluginsVariable, processor, readJson, readOnlyQueryTool, readOperatorSettings, readOutbox, readPlatformSignIn, readWriteQueryTool, recordOf, removeMember, renderVariant, requestApproval, requireEnv, resourceChange, roleChoices, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, s3FileStore, s3Files, seesAssistant, seesRuntime, setMemberRoles, setUserBanned, setUserRole, signInPolicyMethods, sourceOptions, sourcesFor, sourcesVariable, startErrorReporting, startJobs, storageCleanupSchedule, storeFile, streamChatCompletion, subscription, thumbHash, unauthorized, variantPath, verifySignature, verifyStandardWebhook, webauthnPath, webhooks, webhooksMigration, widget, withCacheBreakpoints, withTrustedApps };
|
|
68
|
+
export { FatalJobError, HttpProblem, MissingCredentials, ModelError, addAppRoleToOwners, addMember, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaMigration, badRequest, beforeSignInPluginsVariable, betterAuthAuthenticator, changesMigration, checkAuthSchema, checkChanges, checkJobsSchema, checkSourceNames, checkViewQueries, checkWebhooksSchema, coalesce, configExports, consoleAuditQueue, conversationKey, createAdmin, createAuth, createConversations, createFileCleanup, createOrganization, createUser, defaultSignInPolicy, defineConfig, defineEmail, defineFunction, defineJob, defineTool, deleteUser, errorReportingSettings, errorReportingVariable, eventMatcher, fileConversationStore, fileSize, fileType, forbidden, formatUri, functionApp, hasUsers, imageSize, isCaptured, isJobDefinition, issueToken, jobDefinitions, jobsDrainApp, jobsSchema, jobsSchemaMigration, jwtAuthenticator, limitedFileStore, listOrganizations, listUsers, localFileStore, localFiles, mapSettings, mapStyleUrl, mapStyleVariable, maxTokenTtlSeconds, maxWidgetPropsBytes, mergeConfig, migrationCommand, newWebhookSecret, normalizeRoles, notFound, parseColumns, parseRoles, pluginOptions, pluginsBeforeSignIn, pluginsFor, pluginsVariable, processor, readJson, readOnlyQueryTool, readOperatorSettings, readOutbox, readPlatformSignIn, readWriteQueryTool, recordOf, removeMember, renderVariant, requestApproval, requireEnv, resourceChange, roleChoices, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, s3FileStore, s3Files, seesAssistant, seesRuntime, setMemberRoles, setUserBanned, setUserRole, signInPolicyMethods, sourceOptions, sourcesFor, sourcesVariable, startErrorReporting, startJobs, storageCleanupSchedule, storeFile, streamChatCompletion, subscription, thumbHash, unauthorized, variantPath, verifySignature, verifyStandardWebhook, webauthnPath, webhooks, webhooksMigration, widget, withCacheBreakpoints, withTrustedApps };
|
|
@@ -325,6 +325,77 @@ const resourcePaths = (access) => {
|
|
|
325
325
|
...keyErrors
|
|
326
326
|
}
|
|
327
327
|
} } },
|
|
328
|
+
...entry.model.history && { [`/${entry.name}/{key}:history`]: { get: {
|
|
329
|
+
operationId: `history_${entry.name}`,
|
|
330
|
+
tags: tag,
|
|
331
|
+
summary: `The changes of a ${entry.name} record`,
|
|
332
|
+
description: "Newest first, with the same access as a get, a deleted record included. Each change carries only the fields the caller may read, never a sensitive value; an update of only other fields is left out.",
|
|
333
|
+
parameters: [
|
|
334
|
+
param("key"),
|
|
335
|
+
query("page_size", "Changes per page, at most 100 (default 20)", { type: "integer" }),
|
|
336
|
+
query("page_token", "The next_page_token of the previous page")
|
|
337
|
+
],
|
|
338
|
+
responses: {
|
|
339
|
+
200: {
|
|
340
|
+
description: "A page of changes",
|
|
341
|
+
content: json({
|
|
342
|
+
type: "object",
|
|
343
|
+
required: ["items", "next_page_token"],
|
|
344
|
+
properties: {
|
|
345
|
+
items: {
|
|
346
|
+
type: "array",
|
|
347
|
+
items: {
|
|
348
|
+
type: "object",
|
|
349
|
+
required: [
|
|
350
|
+
"id",
|
|
351
|
+
"at",
|
|
352
|
+
"operation",
|
|
353
|
+
"changed",
|
|
354
|
+
"actor",
|
|
355
|
+
"database_role"
|
|
356
|
+
],
|
|
357
|
+
properties: {
|
|
358
|
+
id: { type: "string" },
|
|
359
|
+
at: {
|
|
360
|
+
type: "string",
|
|
361
|
+
format: "date-time"
|
|
362
|
+
},
|
|
363
|
+
operation: {
|
|
364
|
+
type: "string",
|
|
365
|
+
enum: [
|
|
366
|
+
"create",
|
|
367
|
+
"update",
|
|
368
|
+
"delete",
|
|
369
|
+
"undelete"
|
|
370
|
+
]
|
|
371
|
+
},
|
|
372
|
+
changed: {
|
|
373
|
+
type: "array",
|
|
374
|
+
items: { type: "string" }
|
|
375
|
+
},
|
|
376
|
+
before: { type: "object" },
|
|
377
|
+
after: { type: "object" },
|
|
378
|
+
actor: {
|
|
379
|
+
type: ["object", "null"],
|
|
380
|
+
properties: {
|
|
381
|
+
id: { type: "string" },
|
|
382
|
+
name: { type: "string" }
|
|
383
|
+
}
|
|
384
|
+
},
|
|
385
|
+
database_role: { type: ["string", "null"] }
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
},
|
|
389
|
+
next_page_token: {
|
|
390
|
+
type: "string",
|
|
391
|
+
description: "Empty on the last page"
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
})
|
|
395
|
+
},
|
|
396
|
+
...keyErrors
|
|
397
|
+
}
|
|
398
|
+
} } },
|
|
328
399
|
[`/${entry.name}:facets`]: { get: {
|
|
329
400
|
operationId: `facets_${entry.name}`,
|
|
330
401
|
tags: tag,
|
package/dist/project-config.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 { Authenticator } from "./types.js";
|
|
4
5
|
import { WebhooksOptions } from "./webhooks/table/options.js";
|
|
5
6
|
import { FilesOptions } from "./files/options.js";
|
|
@@ -25,6 +26,8 @@ export type ProjectConfig = {
|
|
|
25
26
|
files?: FilesOptions;
|
|
26
27
|
/** Where the changes that resources' events are raised from are captured (see `EventsOptions`). */
|
|
27
28
|
events?: EventsOptions;
|
|
29
|
+
/** How long the history of resources with `.history()` is kept (see `HistoryOptions`). */
|
|
30
|
+
history?: HistoryOptions;
|
|
28
31
|
/** Webhook subscriptions made in the admin app; set by `extends: [webhooks()]` (see `WebhooksOptions`). */
|
|
29
32
|
webhooks?: WebhooksOptions;
|
|
30
33
|
/** The module of the app's jobs, e.g. `import * as jobs from './config/jobs'`: every `defineJob` it exports. */
|
package/dist/project-config.js
CHANGED
|
@@ -143,11 +143,12 @@ const mergedOptions = (named) => {
|
|
|
143
143
|
*/
|
|
144
144
|
const mergeConfig = (...configs) => {
|
|
145
145
|
const named = configs.flatMap((config, index) => flatten(config, config.name ?? `config ${index + 1}`));
|
|
146
|
-
const { db, authenticate, auth, events, webhooks } = lastSet(named.map(({ config }) => ({
|
|
146
|
+
const { db, authenticate, auth, events, history, webhooks } = lastSet(named.map(({ config }) => ({
|
|
147
147
|
db: config.db,
|
|
148
148
|
authenticate: config.authenticate,
|
|
149
149
|
auth: config.auth,
|
|
150
150
|
events: config.events,
|
|
151
|
+
history: config.history,
|
|
151
152
|
webhooks: config.webhooks
|
|
152
153
|
})));
|
|
153
154
|
const module = mergedModule(named);
|
|
@@ -165,6 +166,7 @@ const mergeConfig = (...configs) => {
|
|
|
165
166
|
...functions && { functions },
|
|
166
167
|
...files && { files },
|
|
167
168
|
...events && { events },
|
|
169
|
+
...history && { history },
|
|
168
170
|
...webhooks && { webhooks },
|
|
169
171
|
...jobs && { jobs },
|
|
170
172
|
...options && { options }
|
package/dist/routes/record.js
CHANGED
|
@@ -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({
|
package/dist/validate-body.js
CHANGED
|
@@ -2,6 +2,20 @@ import { badRequest } from "./problem.js";
|
|
|
2
2
|
import { writableFields } from "./request-access.js";
|
|
3
3
|
//#region src/validate-body.ts
|
|
4
4
|
const fail = (errors) => badRequest("invalid-record", `The request body is invalid: ${errors[0].field}: ${errors[0].message}`, { errors });
|
|
5
|
+
const paramsOf = (issue) => {
|
|
6
|
+
if (issue.code === "too_small") return { min: Number(issue.minimum) };
|
|
7
|
+
if (issue.code === "too_big") return { max: Number(issue.maximum) };
|
|
8
|
+
if (issue.code === "custom") return issue.params;
|
|
9
|
+
};
|
|
10
|
+
const invalidValue = (field, issue) => {
|
|
11
|
+
const params = paramsOf(issue);
|
|
12
|
+
return {
|
|
13
|
+
field,
|
|
14
|
+
code: "invalid-value",
|
|
15
|
+
message: issue.message,
|
|
16
|
+
...params && { params }
|
|
17
|
+
};
|
|
18
|
+
};
|
|
5
19
|
const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
6
20
|
const reserved = ["etag", "permissions"];
|
|
7
21
|
const isEmptyText = (field, value) => field.type === "text" && !field.nullable && value === "";
|
|
@@ -62,11 +76,7 @@ const validateBody = (access, mode, body, current = {}) => {
|
|
|
62
76
|
code: "required",
|
|
63
77
|
message: `"${name}" is required`
|
|
64
78
|
});
|
|
65
|
-
else errors.push(...parsed.error.issues.map((issue) => (
|
|
66
|
-
field: name,
|
|
67
|
-
code: "invalid-value",
|
|
68
|
-
message: issue.message
|
|
69
|
-
})));
|
|
79
|
+
else errors.push(...parsed.error.issues.map((issue) => invalidValue(name, issue)));
|
|
70
80
|
}
|
|
71
81
|
if (errors.length > 0) throw fail(errors);
|
|
72
82
|
const merged = {
|
package/dist/view-access.js
CHANGED
|
@@ -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, {
|
|
@@ -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 {
|
|
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,
|
|
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(
|
|
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.
|
|
3
|
+
"version": "0.21.1",
|
|
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.21.1",
|
|
34
|
+
"@protobase/query": "0.21.1",
|
|
35
|
+
"@protobase/schema": "0.21.1",
|
|
36
36
|
"@react-email/render": "^2.1.0",
|
|
37
37
|
"@scalar/hono-api-reference": "^0.12.10",
|
|
38
38
|
"@sentry/core": "^11.7.0",
|