@protobase/server 0.23.3 → 0.23.5
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/admin-options.d.ts +6 -0
- package/dist/changes/capture-plan.js +9 -2
- package/dist/changes/capture-schema.d.ts +5 -5
- package/dist/changes/capture-schema.js +7 -6
- package/dist/changes/capture-sql.d.ts +1 -1
- package/dist/changes/capture-sql.js +2 -2
- package/dist/check-views.js +5 -3
- package/dist/create-admin.js +16 -1
- package/dist/deps.d.ts +1 -0
- package/dist/live/live-route.js +84 -0
- package/dist/live/notice-feed.js +61 -0
- package/dist/live/notices-sql.js +59 -0
- package/dist/live/visible-notices.js +18 -0
- package/dist/locale/locale-option.d.ts +5 -0
- package/dist/locale/locale-option.js +10 -0
- package/dist/meta.js +9 -2
- package/dist/view-access.js +11 -9
- package/dist/webhooks/webhooks.js +2 -0
- package/package.json +4 -4
package/dist/admin-options.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { PluginOption } from "./plugins/plugin-settings.js";
|
|
|
5
5
|
import { SourceOption } from "./sources/source-settings.js";
|
|
6
6
|
import { CorsOrigins } from "./api-cors.js";
|
|
7
7
|
import { AssistantOptions } from "./assistant/assistant-settings.js";
|
|
8
|
+
import { LocaleOption } from "./locale/locale-option.js";
|
|
8
9
|
import { RuntimeOptions } from "./runtime/runtime-settings.js";
|
|
9
10
|
import { ResolveOptions, RoleLimit } from "@protobase/schema";
|
|
10
11
|
//#region src/admin-options.d.ts
|
|
@@ -59,6 +60,11 @@ export type AdminOptions = {
|
|
|
59
60
|
* with `false`, the app draws no maps.
|
|
60
61
|
*/
|
|
61
62
|
maps?: MapOptions | false;
|
|
63
|
+
/**
|
|
64
|
+
* The language of the admin app's built-in screens and of its numbers and dates, for every user: `'en'` or `'nl'`.
|
|
65
|
+
* Unset, each user sees their browser's language when the screens come in it, and English otherwise.
|
|
66
|
+
*/
|
|
67
|
+
locale?: LocaleOption;
|
|
62
68
|
/**
|
|
63
69
|
* The plugins the admin app loads (see `PluginOption`), before the ones the platform lists in `PROTOBASE_PLUGINS`
|
|
64
70
|
* and, for those that load before sign-in, `PROTOBASE_PLUGINS_BEFORE_SIGN_IN`; `false` turns all of them off.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { captureBody, captureFunctionSql, createTriggerSql, dropTriggerSql, isCaptured, outboxSql, quoteIdent, resourceTriggers, tableSql } from "./capture-sql.js";
|
|
2
2
|
import { historyTableSql } from "../history/history-sql.js";
|
|
3
|
+
import { noticesTableSql } from "../live/notices-sql.js";
|
|
3
4
|
//#region src/changes/capture-plan.ts
|
|
4
5
|
const sameTrigger = (found, wanted) => found.type === wanted.type && found.args.length === wanted.args.length && found.args.every((arg, index) => arg === wanted.args[index]);
|
|
5
6
|
const tableOf = (state, model) => {
|
|
@@ -8,7 +9,7 @@ const tableOf = (state, model) => {
|
|
|
8
9
|
return table;
|
|
9
10
|
};
|
|
10
11
|
/**
|
|
11
|
-
* Compares the database with the resources that declare events or
|
|
12
|
+
* Compares the database with the resources that declare events, history or live updates. Fails on what it cannot change safely: a
|
|
12
13
|
* missing table, two captured resources on one table, or a trigger of the same name that calls another function.
|
|
13
14
|
*/
|
|
14
15
|
const capturePlan = (schema, models, state) => {
|
|
@@ -35,6 +36,7 @@ const capturePlan = (schema, models, state) => {
|
|
|
35
36
|
return {
|
|
36
37
|
setup: captured.length > 0 && (!state.outbox || !current),
|
|
37
38
|
history: models.some((model) => model.history) && !state.history,
|
|
39
|
+
notices: models.some((model) => model.live) && !state.notices,
|
|
38
40
|
create: create.map(({ model, trigger }) => ({
|
|
39
41
|
model,
|
|
40
42
|
trigger
|
|
@@ -54,12 +56,17 @@ const planSql = (schema, plan) => {
|
|
|
54
56
|
captureFunctionSql(schema)
|
|
55
57
|
] : [],
|
|
56
58
|
...plan.history ? [`create schema if not exists ${quoteIdent(schema)}`, historyTableSql(schema)] : [],
|
|
59
|
+
...plan.notices ? [`create schema if not exists ${quoteIdent(schema)}`, noticesTableSql(schema)] : [],
|
|
57
60
|
...plan.drop.map(({ name, tableSql }) => dropTriggerSql(name, tableSql)),
|
|
58
61
|
...plan.create.flatMap(({ model, trigger }) => [dropTriggerSql(trigger.name, tableSql(model)), createTriggerSql(schema, trigger, tableSql(model))])
|
|
59
62
|
];
|
|
60
63
|
return statements.length === 0 ? void 0 : `${statements.join(";\n\n")};`;
|
|
61
64
|
};
|
|
62
65
|
/** 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([
|
|
66
|
+
const uncaptured = (plan, models) => [...new Set([
|
|
67
|
+
...plan.setup ? models.filter(isCaptured) : plan.create.map(({ model }) => model),
|
|
68
|
+
...plan.history ? models.filter((model) => model.history) : [],
|
|
69
|
+
...plan.notices ? models.filter((model) => model.live) : []
|
|
70
|
+
].map((model) => model.name))];
|
|
64
71
|
//#endregion
|
|
65
72
|
export { capturePlan, planSql, uncaptured };
|
|
@@ -5,15 +5,15 @@ 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 and
|
|
9
|
-
* and `capture()`, the history
|
|
10
|
-
* triggers of resources without
|
|
8
|
+
* The SQL that brings the database to the capture the resources' events, history and live updates need: the schema
|
|
9
|
+
* with the outbox and `capture()`, the history and notices tables, and a trigger pair per resource with any of them, recreated when its fields changed;
|
|
10
|
+
* triggers of resources without any of them any more are dropped. `undefined` when the database is current. Tables are
|
|
11
11
|
* never dropped.
|
|
12
12
|
*/
|
|
13
13
|
export declare const changesMigration: (db: Db, models: readonly ResourceModel[], options?: EventsOptions) => Promise<string | undefined>;
|
|
14
14
|
/**
|
|
15
|
-
* Refuses to go on while a resource's events or
|
|
16
|
-
* schema, history table or 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, history or live updates would not be captured as declared: a missing or
|
|
16
|
+
* out-of-date schema, history or notices table, or trigger. The server never changes the schema itself: the project's migrations do, before the
|
|
17
17
|
* new version serves.
|
|
18
18
|
*/
|
|
19
19
|
export declare const checkChanges: (db: Db, models: readonly ResourceModel[], options?: EventsOptions) => Promise<void>;
|
|
@@ -26,6 +26,7 @@ const readCaptureState = async (db, schema, models) => {
|
|
|
26
26
|
return {
|
|
27
27
|
outbox: await exists("outbox"),
|
|
28
28
|
history: await exists("history"),
|
|
29
|
+
notices: await exists("notices"),
|
|
29
30
|
...capture && { capture },
|
|
30
31
|
triggers: triggers.map((trigger) => ({
|
|
31
32
|
...trigger,
|
|
@@ -42,9 +43,9 @@ const plan = async (db, models, options) => {
|
|
|
42
43
|
};
|
|
43
44
|
};
|
|
44
45
|
/**
|
|
45
|
-
* The SQL that brings the database to the capture the resources' events and
|
|
46
|
-
* and `capture()`, the history
|
|
47
|
-
* triggers of resources without
|
|
46
|
+
* The SQL that brings the database to the capture the resources' events, history and live updates need: the schema
|
|
47
|
+
* with the outbox and `capture()`, the history and notices tables, and a trigger pair per resource with any of them, recreated when its fields changed;
|
|
48
|
+
* triggers of resources without any of them any more are dropped. `undefined` when the database is current. Tables are
|
|
48
49
|
* never dropped.
|
|
49
50
|
*/
|
|
50
51
|
const changesMigration = async (db, models, options) => {
|
|
@@ -52,8 +53,8 @@ const changesMigration = async (db, models, options) => {
|
|
|
52
53
|
return planSql(schema, found);
|
|
53
54
|
};
|
|
54
55
|
/**
|
|
55
|
-
* Refuses to go on while a resource's events or
|
|
56
|
-
* schema, history table or trigger. The server never changes the schema itself: the project's migrations do, before the
|
|
56
|
+
* Refuses to go on while a resource's events, history or live updates would not be captured as declared: a missing or
|
|
57
|
+
* out-of-date schema, history or notices table, or trigger. The server never changes the schema itself: the project's migrations do, before the
|
|
57
58
|
* new version serves.
|
|
58
59
|
*/
|
|
59
60
|
const checkChanges = async (db, models, options) => {
|
|
@@ -61,7 +62,7 @@ const checkChanges = async (db, models, options) => {
|
|
|
61
62
|
const { plan: found } = await plan(db, models, options);
|
|
62
63
|
const missing = uncaptured(found, models);
|
|
63
64
|
if (missing.length === 0) return;
|
|
64
|
-
throw new Error(`The changes of ${missing.join(", ")} are not captured as their events or
|
|
65
|
+
throw new Error(`The changes of ${missing.join(", ")} are not captured as their events, history or live updates need: their triggers, the history or notices 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.`);
|
|
65
66
|
};
|
|
66
67
|
//#endregion
|
|
67
68
|
export { changesMigration, checkChanges, migrationCommand };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ResourceModel } from "@protobase/schema";
|
|
2
2
|
//#region src/changes/capture-sql.d.ts
|
|
3
|
-
/** Whether the triggers capture a resource's changes: for its events, its history, or
|
|
3
|
+
/** Whether the triggers capture a resource's changes: for its events, its history, its live updates, or several. */
|
|
4
4
|
export declare const isCaptured: (model: ResourceModel) => boolean;
|
|
5
5
|
//#endregion
|
|
@@ -16,8 +16,8 @@ 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
|
|
20
|
-
const isCaptured = (model) => (model.events ?? []).length > 0 || model.history === true;
|
|
19
|
+
/** Whether the triggers capture a resource's changes: for its events, its history, its live updates, or several. */
|
|
20
|
+
const isCaptured = (model) => (model.events ?? []).length > 0 || model.history === true || model.live === true;
|
|
21
21
|
/**
|
|
22
22
|
* The outbox: one row per changed row, written in the writer's transaction. `transaction` is the writer's transaction
|
|
23
23
|
* id; `database_role` the role that wrote; `dispatch_after` holds back a change that the process which wrote it is
|
package/dist/check-views.js
CHANGED
|
@@ -60,7 +60,7 @@ const textHintErrors = (view, model) => Object.entries(view.fields).flatMap(([na
|
|
|
60
60
|
const format = hints.format !== void 0 && textFormats.has(hints.format) ? [`format('${hints.format}') of "${name}" needs a text field, not ${field.type}`] : [];
|
|
61
61
|
return [...textHints.filter((hint) => hints[hint]).map((hint) => `${hint} "${name}" needs a text field, not ${field.type}`), ...format];
|
|
62
62
|
});
|
|
63
|
-
const linkErrors = (view, model, models, pages) => view.actions.flatMap(({ name, label, run }) => {
|
|
63
|
+
const linkErrors = (view, model, models, pages, createPages) => view.actions.flatMap(({ name, label, run }) => {
|
|
64
64
|
if (run?.kind !== "link") return [];
|
|
65
65
|
const error = linkActionError(label, run);
|
|
66
66
|
if (error) return [`action "${name}": ${error}`];
|
|
@@ -69,7 +69,8 @@ const linkErrors = (view, model, models, pages) => view.actions.flatMap(({ name,
|
|
|
69
69
|
return "to" in run ? addressErrors(run.to, {
|
|
70
70
|
models,
|
|
71
71
|
pages,
|
|
72
|
-
source: model
|
|
72
|
+
source: model,
|
|
73
|
+
createPages
|
|
73
74
|
}).map((error) => `action "${name}": ${error}`) : [];
|
|
74
75
|
});
|
|
75
76
|
const aiActionErrors = (view, model) => view.actions.flatMap(({ name, run }) => {
|
|
@@ -151,6 +152,7 @@ const listOfErrors = (model, name, models) => {
|
|
|
151
152
|
const checkViews = (registry, views, pages = [], fieldTypes = {}) => {
|
|
152
153
|
const models = Object.fromEntries(registry.entries.map((entry) => [entry.name, entry.model]));
|
|
153
154
|
const pageNames = new Set(pages);
|
|
155
|
+
const createPages = new Set(views.filter((view) => view.create).map((view) => view.resource));
|
|
154
156
|
const viewProblems = (view, model) => {
|
|
155
157
|
const related = [...layoutItems(view.layout).flatMap((item) => item.kind === "related" ? relatedErrors(item, view.resource, models).map((error) => `related "${item.title}": ${error}`) : []), ...relatedColumnsErrors(view, model, models)];
|
|
156
158
|
const photos = [
|
|
@@ -187,7 +189,7 @@ const checkViews = (registry, views, pages = [], fieldTypes = {}) => {
|
|
|
187
189
|
...boards,
|
|
188
190
|
...calendars,
|
|
189
191
|
...chart,
|
|
190
|
-
...linkErrors(view, model, models, pageNames),
|
|
192
|
+
...linkErrors(view, model, models, pageNames, createPages),
|
|
191
193
|
...aiActionErrors(view, model),
|
|
192
194
|
...inputErrors(view, models),
|
|
193
195
|
...createErrors(view, pageNames)
|
package/dist/create-admin.js
CHANGED
|
@@ -33,12 +33,16 @@ import { checkBottomBars, checkShell } from "./check-shell.js";
|
|
|
33
33
|
import { checkOwnerFields } from "./check-owners.js";
|
|
34
34
|
import { checkRoleLimits } from "./check-roles.js";
|
|
35
35
|
import { checkViews } from "./check-views.js";
|
|
36
|
+
import { liveRoutes } from "./live/live-route.js";
|
|
37
|
+
import { tableFeed } from "./live/notice-feed.js";
|
|
38
|
+
import { visibleNotices } from "./live/visible-notices.js";
|
|
36
39
|
import { errorReportingSettings } from "./error-reporting/error-reporting-settings.js";
|
|
37
40
|
import { createFilesRuntime } from "./files/runtime.js";
|
|
38
41
|
import { fileRoutes } from "./files/routes.js";
|
|
39
42
|
import { formRoutes } from "./forms/routes.js";
|
|
40
43
|
import { createFormsRuntime } from "./forms/runtime.js";
|
|
41
44
|
import { functionRoutes } from "./functions/routes.js";
|
|
45
|
+
import { localeSetting } from "./locale/locale-option.js";
|
|
42
46
|
import { mapSettings } from "./maps/map-settings.js";
|
|
43
47
|
import { pluginOptions, pluginsBeforeSignIn } from "./plugins/plugin-settings.js";
|
|
44
48
|
import { runtimeUrl } from "./runtime/runtime-settings.js";
|
|
@@ -96,7 +100,8 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
|
|
|
96
100
|
checkOwnerFields(registry, auth);
|
|
97
101
|
checkPages(pageModels, {
|
|
98
102
|
models: Object.fromEntries(registry.entries.map((entry) => [entry.name, entry.model])),
|
|
99
|
-
actions: (resource) => new Set(viewModels.filter((view) => view.resource === resource).flatMap((view) => view.actions.map((action) => action.name)))
|
|
103
|
+
actions: (resource) => new Set(viewModels.filter((view) => view.resource === resource).flatMap((view) => view.actions.map((action) => action.name))),
|
|
104
|
+
createPages: new Set(viewModels.filter((view) => view.create).map((view) => view.resource))
|
|
100
105
|
});
|
|
101
106
|
const forms = createFormsRuntime({
|
|
102
107
|
registry,
|
|
@@ -110,6 +115,7 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
|
|
|
110
115
|
const runtime = runtimeUrl(options.runtime, globalThis.process?.env ?? {});
|
|
111
116
|
const errorReporting = errorReportingSettings(options.errorReporting, globalThis.process?.env ?? {});
|
|
112
117
|
const maps = mapSettings(options.maps, globalThis.process?.env ?? {});
|
|
118
|
+
const locale = localeSetting(options.locale);
|
|
113
119
|
const plugins = pluginOptions(options.plugins, globalThis.process?.env ?? {}, bundlePlugins);
|
|
114
120
|
const sources = sourceOptions(options.sources, globalThis.process?.env ?? {});
|
|
115
121
|
checkSourceNames(sources, {
|
|
@@ -152,6 +158,7 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
|
|
|
152
158
|
...runtime && { runtime },
|
|
153
159
|
...errorReporting && { errorReporting },
|
|
154
160
|
...maps && { maps },
|
|
161
|
+
...locale && { locale },
|
|
155
162
|
plugins,
|
|
156
163
|
sources,
|
|
157
164
|
...files && { files },
|
|
@@ -248,6 +255,14 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
|
|
|
248
255
|
app.route("/", batchRoute(deps, authenticate, meta));
|
|
249
256
|
app.route("/", fileRoutes(deps, authenticate));
|
|
250
257
|
if (forms) app.route(`${systemPath}/forms`, formRoutes(deps, forms));
|
|
258
|
+
if (appModels.some((model) => model.live)) {
|
|
259
|
+
const feed = tableFeed({
|
|
260
|
+
db,
|
|
261
|
+
schema: changes,
|
|
262
|
+
report: options.onUnhandledError ?? ((error) => console.error(error))
|
|
263
|
+
});
|
|
264
|
+
app.route(systemPath || "/", guarded((live) => void live.route("/", liveRoutes(feed, (session) => visibleNotices(deps, session))), withAnonymous(authenticate, options.roles)));
|
|
265
|
+
}
|
|
251
266
|
app.route(basePath, api);
|
|
252
267
|
app.route(`${deps.systemPath}/meta`, metaApp);
|
|
253
268
|
app.route(deps.systemPath || "/", system);
|
package/dist/deps.d.ts
CHANGED
|
@@ -6,5 +6,6 @@ import "./plugins/plugin-settings.js";
|
|
|
6
6
|
import "./registry.js";
|
|
7
7
|
import "./scan-guard.js";
|
|
8
8
|
import { PageModel } from "@protobase/layout";
|
|
9
|
+
import { Locale } from "@protobase/schema/internal";
|
|
9
10
|
import { ResolveOptions, ViewModel } from "@protobase/schema";
|
|
10
11
|
import { Db } from "@protobase/query";
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { parseParams } from "../params.js";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { streamSSE } from "hono/streaming";
|
|
5
|
+
//#region src/live/live-route.ts
|
|
6
|
+
const params = z.object({ after: z.string().regex(/^\d+$/, "Use the cursor of an earlier event").optional() });
|
|
7
|
+
/** Notices a reconnecting page catches up on at most; one that missed more reads everything again. */
|
|
8
|
+
const replayLimit = 1e3;
|
|
9
|
+
const heartbeatMs = 25e3;
|
|
10
|
+
const later = (id, than) => BigInt(id) > BigInt(than);
|
|
11
|
+
/**
|
|
12
|
+
* `GET /live`: the changes of the live resources the caller may read, as they come, keys only. `visibleTo` says which
|
|
13
|
+
* notices a caller hears of (`visibleNotices`).
|
|
14
|
+
*/
|
|
15
|
+
const liveRoutes = (feed, visibleTo) => {
|
|
16
|
+
const app = new Hono();
|
|
17
|
+
app.get("/live", async (c) => {
|
|
18
|
+
const { after } = parseParams(params, c.req.query());
|
|
19
|
+
const visible = await visibleTo(c.get("session"));
|
|
20
|
+
const changesOf = (notices) => notices.filter(visible).map(({ resource, key, operation }) => ({
|
|
21
|
+
resource,
|
|
22
|
+
key,
|
|
23
|
+
operation
|
|
24
|
+
}));
|
|
25
|
+
const queue = [];
|
|
26
|
+
let wake = () => {};
|
|
27
|
+
const held = [];
|
|
28
|
+
let deliver = (notices) => void held.push(...notices);
|
|
29
|
+
let heartbeat;
|
|
30
|
+
let stop = () => {};
|
|
31
|
+
const follow = async (stream) => {
|
|
32
|
+
stream.onAbort(() => wake());
|
|
33
|
+
const subscription = await feed.subscribe((notices) => deliver(notices));
|
|
34
|
+
stop = subscription.stop;
|
|
35
|
+
const caught = after === void 0 ? [] : await feed.replay(after, subscription.cursor, replayLimit);
|
|
36
|
+
let cursor = after !== void 0 && caught && later(after, subscription.cursor) ? after : subscription.cursor;
|
|
37
|
+
if (!caught) queue.push({
|
|
38
|
+
type: "resync",
|
|
39
|
+
cursor
|
|
40
|
+
});
|
|
41
|
+
else if (changesOf(caught).length > 0) queue.push({
|
|
42
|
+
type: "changes",
|
|
43
|
+
cursor,
|
|
44
|
+
changes: changesOf(caught)
|
|
45
|
+
});
|
|
46
|
+
else queue.push({
|
|
47
|
+
type: "current",
|
|
48
|
+
cursor
|
|
49
|
+
});
|
|
50
|
+
deliver = (notices) => {
|
|
51
|
+
const fresh = notices.filter((notice) => later(notice.id, cursor));
|
|
52
|
+
if (fresh.length === 0) return;
|
|
53
|
+
cursor = fresh.at(-1).id;
|
|
54
|
+
const changes = changesOf(fresh);
|
|
55
|
+
if (changes.length > 0) queue.push({
|
|
56
|
+
type: "changes",
|
|
57
|
+
cursor,
|
|
58
|
+
changes
|
|
59
|
+
});
|
|
60
|
+
wake();
|
|
61
|
+
};
|
|
62
|
+
deliver(held);
|
|
63
|
+
heartbeat = setInterval(() => {
|
|
64
|
+
queue.push({
|
|
65
|
+
type: "current",
|
|
66
|
+
cursor
|
|
67
|
+
});
|
|
68
|
+
wake();
|
|
69
|
+
}, heartbeatMs);
|
|
70
|
+
while (!stream.aborted) {
|
|
71
|
+
const event = queue.shift();
|
|
72
|
+
if (event) await stream.writeSSE({ data: JSON.stringify(event) });
|
|
73
|
+
else await new Promise((resolve) => wake = resolve);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
return streamSSE(c, (stream) => follow(stream).finally(() => {
|
|
77
|
+
clearInterval(heartbeat);
|
|
78
|
+
stop();
|
|
79
|
+
}));
|
|
80
|
+
});
|
|
81
|
+
return app;
|
|
82
|
+
};
|
|
83
|
+
//#endregion
|
|
84
|
+
export { liveRoutes };
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { lastNotice, noticeKept, noticesAfter } from "./notices-sql.js";
|
|
2
|
+
//#region src/live/notice-feed.ts
|
|
3
|
+
/** Notices read from a batch at most. */
|
|
4
|
+
const batchSize = 1e3;
|
|
5
|
+
const after = (id, than) => BigInt(id) > BigInt(than);
|
|
6
|
+
/**
|
|
7
|
+
* The notices table, read once every `intervalMs` by this process while at least one page listens, however many do:
|
|
8
|
+
* the cost of open pages is one indexed query a second per process.
|
|
9
|
+
*/
|
|
10
|
+
const tableFeed = ({ db, schema, intervalMs = 1e3, report }) => {
|
|
11
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
12
|
+
let cursor;
|
|
13
|
+
const read = async (from) => {
|
|
14
|
+
for (;;) {
|
|
15
|
+
const notices = await noticesAfter(db, schema, from, batchSize);
|
|
16
|
+
if (notices.length === 0) return from;
|
|
17
|
+
from = notices.at(-1).id;
|
|
18
|
+
for (const listener of listeners) listener(notices);
|
|
19
|
+
if (notices.length < batchSize) return from;
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
const tick = async (last) => {
|
|
23
|
+
if (listeners.size === 0) {
|
|
24
|
+
cursor = void 0;
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
cursor = read(last).catch((error) => {
|
|
28
|
+
report(error);
|
|
29
|
+
return last;
|
|
30
|
+
});
|
|
31
|
+
const next = await cursor;
|
|
32
|
+
setTimeout(() => void tick(next), intervalMs);
|
|
33
|
+
};
|
|
34
|
+
const start = () => {
|
|
35
|
+
const first = lastNotice(db, schema);
|
|
36
|
+
cursor = first;
|
|
37
|
+
first.then((last) => setTimeout(() => void tick(last), intervalMs), () => cursor = void 0);
|
|
38
|
+
return first;
|
|
39
|
+
};
|
|
40
|
+
return {
|
|
41
|
+
subscribe: (listener) => {
|
|
42
|
+
listeners.add(listener);
|
|
43
|
+
const stop = () => void listeners.delete(listener);
|
|
44
|
+
return (cursor ?? start()).then((at) => ({
|
|
45
|
+
cursor: at,
|
|
46
|
+
stop
|
|
47
|
+
}), (error) => {
|
|
48
|
+
stop();
|
|
49
|
+
throw error;
|
|
50
|
+
});
|
|
51
|
+
},
|
|
52
|
+
replay: async (from, upTo, limit) => {
|
|
53
|
+
if (!await noticeKept(db, schema, from)) return void 0;
|
|
54
|
+
if (!after(upTo, from)) return [];
|
|
55
|
+
const notices = (await noticesAfter(db, schema, from, limit + 1)).filter((notice) => !after(notice.id, upTo));
|
|
56
|
+
return notices.length > limit ? void 0 : notices;
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
//#endregion
|
|
61
|
+
export { tableFeed };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { quoteIdent } from "../changes/capture-sql.js";
|
|
2
|
+
import { sql } from "kysely";
|
|
3
|
+
//#region src/live/notices-sql.ts
|
|
4
|
+
/**
|
|
5
|
+
* The recent changes of the resources with `.live()`, which open pages refresh from: a row per record and transaction,
|
|
6
|
+
* its key and never its values. `id` grows in commit order, since one dispatch writes at a time.
|
|
7
|
+
*/
|
|
8
|
+
const noticesTableSql = (schema) => {
|
|
9
|
+
const table = `${quoteIdent(schema)}."notices"`;
|
|
10
|
+
return `create table if not exists ${table} (
|
|
11
|
+
"id" bigint generated always as identity primary key,
|
|
12
|
+
"resource" text not null,
|
|
13
|
+
"record_key" text not null,
|
|
14
|
+
"tenant_id" text,
|
|
15
|
+
"operation" text not null check ("operation" in ('create', 'update', 'delete', 'undelete')),
|
|
16
|
+
"at" timestamptz not null default now()
|
|
17
|
+
);
|
|
18
|
+
create index if not exists "notices_at" on ${table} ("at")`;
|
|
19
|
+
};
|
|
20
|
+
const noticeOf = (row) => ({
|
|
21
|
+
id: row.id,
|
|
22
|
+
resource: row.resource,
|
|
23
|
+
key: row.record_key,
|
|
24
|
+
operation: row.operation,
|
|
25
|
+
tenant: row.tenant_id
|
|
26
|
+
});
|
|
27
|
+
const tableOf = (schema) => sql.raw(`${quoteIdent(schema)}."notices"`);
|
|
28
|
+
/**
|
|
29
|
+
* Adds the changes as notices, in their order, and deletes those older than `keptMinutes`, but never the newest, so a
|
|
30
|
+
* reader's last notice is gone only once a later one was pruned. One dispatch's transaction does both.
|
|
31
|
+
*/
|
|
32
|
+
const writeNotices = async (db, schema, changes) => {
|
|
33
|
+
if (changes.length === 0) return;
|
|
34
|
+
const table = tableOf(schema);
|
|
35
|
+
const rows = JSON.stringify(changes.map((change) => ({
|
|
36
|
+
resource: change.resource,
|
|
37
|
+
record_key: change.key,
|
|
38
|
+
tenant_id: change.tenant === void 0 ? null : String(change.tenant),
|
|
39
|
+
operation: change.operation
|
|
40
|
+
})));
|
|
41
|
+
await sql`insert into ${table} ("resource", "record_key", "tenant_id", "operation")
|
|
42
|
+
select "resource", "record_key", "tenant_id", "operation" from rows from (jsonb_to_recordset(${rows}::jsonb) as ("resource" text, "record_key" text, "tenant_id" text, "operation" text)) with ordinality as r("resource", "record_key", "tenant_id", "operation", "position")
|
|
43
|
+
order by "position"`.execute(db);
|
|
44
|
+
await sql`delete from ${table} where "at" < now() - make_interval(mins => ${10}) and "id" < (select max("id") from ${table})`.execute(db);
|
|
45
|
+
};
|
|
46
|
+
/** The newest notice's id, `0` without any. */
|
|
47
|
+
const lastNotice = async (db, schema) => (await sql`select coalesce(max("id"), 0)::text as id from ${tableOf(schema)}`.execute(db)).rows[0].id;
|
|
48
|
+
/** At most `limit` notices after `after`, in order. */
|
|
49
|
+
const noticesAfter = async (db, schema, after, limit) => {
|
|
50
|
+
const { rows } = await sql`select "id"::text as id, "resource", "record_key", "tenant_id", "operation" from ${tableOf(schema)}
|
|
51
|
+
where "id" > ${after}::bigint order by "id" limit ${limit}`.execute(db);
|
|
52
|
+
return rows.map(noticeOf);
|
|
53
|
+
};
|
|
54
|
+
/** Whether the notice `id` is still kept; `0`, the cursor of an empty table, only while the table is empty still. */
|
|
55
|
+
const noticeKept = async (db, schema, id) => {
|
|
56
|
+
return (await (id === "0" ? sql`select not exists (select from ${tableOf(schema)}) as kept` : sql`select exists (select from ${tableOf(schema)} where "id" = ${id}::bigint) as kept`).execute(db)).rows[0].kept;
|
|
57
|
+
};
|
|
58
|
+
//#endregion
|
|
59
|
+
export { lastNotice, noticeKept, noticesAfter, noticesTableSql, writeNotices };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { requestAccess } from "../request-access.js";
|
|
2
|
+
//#region src/live/visible-notices.ts
|
|
3
|
+
/**
|
|
4
|
+
* Which notices a caller hears of: those of the live resources they may list or read, in their tenant. A notice names
|
|
5
|
+
* a record, never its values, and the page reads the record again as the caller, so no row filter applies here.
|
|
6
|
+
*/
|
|
7
|
+
const visibleNotices = async (deps, session) => {
|
|
8
|
+
const readable = /* @__PURE__ */ new Map();
|
|
9
|
+
for (const entry of deps.registry.entries.filter((candidate) => candidate.model.live)) {
|
|
10
|
+
const { operations } = (await requestAccess(deps, entry, session)).resolved;
|
|
11
|
+
if (!operations.list && !operations.read) continue;
|
|
12
|
+
if (entry.model.tenant && session.tenant === void 0) continue;
|
|
13
|
+
readable.set(entry.name, entry.model.tenant ? String(session.tenant) : void 0);
|
|
14
|
+
}
|
|
15
|
+
return (notice) => readable.has(notice.resource) && (readable.get(notice.resource) === void 0 || readable.get(notice.resource) === notice.tenant);
|
|
16
|
+
};
|
|
17
|
+
//#endregion
|
|
18
|
+
export { visibleNotices };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { isLocale, locales } from "@protobase/schema/internal";
|
|
2
|
+
//#region src/locale/locale-option.ts
|
|
3
|
+
/** The option, checked: a language the screens do not come in stops startup. */
|
|
4
|
+
const localeSetting = (option) => {
|
|
5
|
+
if (option === void 0) return void 0;
|
|
6
|
+
if (isLocale(option)) return option;
|
|
7
|
+
throw new Error(`options.locale is ${JSON.stringify(option)}: use one of ${locales.map((locale) => `'${locale}'`).join(", ")}`);
|
|
8
|
+
};
|
|
9
|
+
//#endregion
|
|
10
|
+
export { localeSetting };
|
package/dist/meta.js
CHANGED
|
@@ -36,11 +36,17 @@ const createMeta = (deps, appName) => {
|
|
|
36
36
|
const listed = Object.fromEntries(visible.map((access) => [access.entry.name, access.model]));
|
|
37
37
|
const signIn = session.anonymous ? accesses.filter((access) => !visible.includes(access)).flatMap((access) => signInView(pickView(deps.views, session.user.roles, access.entry.name))) : [];
|
|
38
38
|
const prompted = new Set(signIn.map((entry) => entry.resource));
|
|
39
|
-
const
|
|
39
|
+
const picked = visible.flatMap((access) => {
|
|
40
40
|
const view = pickView(deps.views, session.user.roles, access.entry.name);
|
|
41
|
-
return view ? [
|
|
41
|
+
return view ? [{
|
|
42
|
+
access,
|
|
43
|
+
view
|
|
44
|
+
}] : [];
|
|
42
45
|
});
|
|
46
|
+
const createPages = new Set(picked.filter(({ view }) => view.create).map(({ view }) => view.resource));
|
|
47
|
+
const views = picked.map(({ access, view }) => pruneView(view, access.readable, listed, prompted, createPages));
|
|
43
48
|
const callerAccess = {
|
|
49
|
+
createPages,
|
|
44
50
|
models: listed,
|
|
45
51
|
permissions: Object.fromEntries(visible.map(({ entry, resolved }) => [entry.name, {
|
|
46
52
|
create: resolved.operations.create,
|
|
@@ -80,6 +86,7 @@ const createMeta = (deps, appName) => {
|
|
|
80
86
|
...deps.runtime && seesRuntime(session.user.roles) && { runtime: { url: deps.runtime } },
|
|
81
87
|
...deps.errorReporting && { errorReporting: deps.errorReporting },
|
|
82
88
|
...deps.maps && { maps: deps.maps },
|
|
89
|
+
...deps.locale && { locale: deps.locale },
|
|
83
90
|
...deps.files && deps.files.trustedHosts.length > 0 && { files: { trustedHosts: deps.files.trustedHosts } },
|
|
84
91
|
...plugins.length > 0 && { plugins },
|
|
85
92
|
...sources.length > 0 && { sources }
|
package/dist/view-access.js
CHANGED
|
@@ -233,10 +233,11 @@ const layoutShape = (resource, model, models) => {
|
|
|
233
233
|
}, { requires: ["items"] })
|
|
234
234
|
});
|
|
235
235
|
};
|
|
236
|
-
const linkShape = (model, models) => shapes.map((run) => {
|
|
236
|
+
const linkShape = (model, models, createPages) => shapes.map((run) => {
|
|
237
237
|
return ("to" in run ? addressErrors(run.to, {
|
|
238
238
|
models,
|
|
239
|
-
source: model
|
|
239
|
+
source: model,
|
|
240
|
+
createPages
|
|
240
241
|
}).length === 0 : hrefPlaceholders(run.href).every((name) => Object.hasOwn(model.fields, name))) ? run : void 0;
|
|
241
242
|
});
|
|
242
243
|
const aiShape = (model) => shapes.map((run) => {
|
|
@@ -246,7 +247,7 @@ const aiShape = (model) => shapes.map((run) => {
|
|
|
246
247
|
} : void 0;
|
|
247
248
|
});
|
|
248
249
|
const inputsShape = (models) => shapes.map((inputs) => inputs.every((input) => input.resource === void 0 || Object.hasOwn(models, input.resource)) ? inputs : void 0);
|
|
249
|
-
const actionShape = (model, models) => shapes.object({
|
|
250
|
+
const actionShape = (model, models, createPages) => shapes.object({
|
|
250
251
|
name: shapes.data,
|
|
251
252
|
label: shapes.data,
|
|
252
253
|
icon: shapes.data,
|
|
@@ -258,12 +259,12 @@ const actionShape = (model, models) => shapes.object({
|
|
|
258
259
|
values: shapes.keys({ all: true })
|
|
259
260
|
}, { requires: ["values"] }),
|
|
260
261
|
delete: shapes.data,
|
|
261
|
-
link: linkShape(model, models),
|
|
262
|
+
link: linkShape(model, models, createPages),
|
|
262
263
|
ai: aiShape(model)
|
|
263
264
|
}),
|
|
264
265
|
inputs: inputsShape(models)
|
|
265
266
|
}, { requires: ["run", "inputs"] });
|
|
266
|
-
const viewShape = (resource, model, models, prompted) => {
|
|
267
|
+
const viewShape = (resource, model, models, prompted, createPages) => {
|
|
267
268
|
const nav = shapes.object({
|
|
268
269
|
hidden: shapes.data,
|
|
269
270
|
group: shapes.data,
|
|
@@ -291,7 +292,7 @@ const viewShape = (resource, model, models, prompted) => {
|
|
|
291
292
|
recordMode: shapes.data,
|
|
292
293
|
helpWhen: shapes.data,
|
|
293
294
|
saveFeedback: shapes.data,
|
|
294
|
-
actions: shapes.list(actionShape(model, models)),
|
|
295
|
+
actions: shapes.list(actionShape(model, models, createPages)),
|
|
295
296
|
listOf: shapes.field,
|
|
296
297
|
signIn: shapes.data,
|
|
297
298
|
saveSearch: shapes.map((search) => prompted.has(search.resource) || models[search.resource] ? search : void 0),
|
|
@@ -317,10 +318,11 @@ const viewShape = (resource, model, models, prompted) => {
|
|
|
317
318
|
* actions that set or read one, and the sidebar's `recent` group. Nothing in it may name a hidden field. Related sections, related list columns
|
|
318
319
|
* and links are checked against `models`, the caller's view of every resource, and so are `searchesOf`, cards' favorite
|
|
319
320
|
* buttons and the "Save search" button; the buttons stay for a visitor when their resource is one of `prompted`, those
|
|
320
|
-
* that ask the visitor to sign in.
|
|
321
|
+
* that ask the visitor to sign in. A create link may give values of any name to a resource of `createPages`, whose view
|
|
322
|
+
* for the caller opens a create page.
|
|
321
323
|
*/
|
|
322
|
-
const pruneView = (view, model, models, prompted = /* @__PURE__ */ new Set()) => {
|
|
323
|
-
const pruned = pruneByShape(view, viewShape(view.resource, model, models, prompted), (name) => Object.hasOwn(model.fields, name));
|
|
324
|
+
const pruneView = (view, model, models, prompted = /* @__PURE__ */ new Set(), createPages = /* @__PURE__ */ new Set()) => {
|
|
325
|
+
const pruned = pruneByShape(view, viewShape(view.resource, model, models, prompted, createPages), (name) => Object.hasOwn(model.fields, name));
|
|
324
326
|
if (!pruned) throw new Error(`The view of ${view.resource} is not an object`);
|
|
325
327
|
return pruned;
|
|
326
328
|
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { dispatchJobName, isCaptured } from "../changes/capture-sql.js";
|
|
2
2
|
import { defineJob } from "../jobs/define-job.js";
|
|
3
3
|
import { FatalJobError } from "../jobs/run-job.js";
|
|
4
|
+
import { writeNotices } from "../live/notices-sql.js";
|
|
4
5
|
import { coalesce } from "../changes/coalesce.js";
|
|
5
6
|
import { EventCodeError } from "../changes/event-code-error.js";
|
|
6
7
|
import { eventMatcher } from "../changes/match-events.js";
|
|
@@ -233,6 +234,7 @@ const createWebhooks = ({ deps, subscriptions, functions, schema, log, table })
|
|
|
233
234
|
await queueOrdered(trx, deliveries);
|
|
234
235
|
for (const { change } of failures) await dispatchChange.enqueue(trx, { change });
|
|
235
236
|
await writeHistory(trx, schema, changes.filter((change) => deps.registry.find(change.resource)?.model.history));
|
|
237
|
+
await writeNotices(trx, schema, changes.filter((change) => deps.registry.find(change.resource)?.model.live));
|
|
236
238
|
await deleteOutbox(trx, schema, rows.map((row) => row.id));
|
|
237
239
|
});
|
|
238
240
|
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.23.
|
|
3
|
+
"version": "0.23.5",
|
|
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": {
|
|
@@ -31,9 +31,9 @@
|
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"@better-auth/passkey": "^1.7.7",
|
|
33
33
|
"@better-auth/sso": "^1.7.7",
|
|
34
|
-
"@protobase/layout": "0.23.
|
|
35
|
-
"@protobase/query": "0.23.
|
|
36
|
-
"@protobase/schema": "0.23.
|
|
34
|
+
"@protobase/layout": "0.23.5",
|
|
35
|
+
"@protobase/query": "0.23.5",
|
|
36
|
+
"@protobase/schema": "0.23.5",
|
|
37
37
|
"@react-email/render": "^2.1.0",
|
|
38
38
|
"@scalar/hono-api-reference": "^0.12.10",
|
|
39
39
|
"@sentry/core": "^11.7.0",
|