@protobase/server 0.23.3 → 0.23.4

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.
@@ -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 history. Fails on what it cannot change safely: a
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([...plan.setup ? models.filter(isCaptured) : plan.create.map(({ model }) => model), ...plan.history ? models.filter((model) => model.history) : []].map((model) => model.name))];
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 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
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 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
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 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
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 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
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 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.`);
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 both. */
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 both. */
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
@@ -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)
@@ -33,6 +33,9 @@ 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";
@@ -96,7 +99,8 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
96
99
  checkOwnerFields(registry, auth);
97
100
  checkPages(pageModels, {
98
101
  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)))
102
+ actions: (resource) => new Set(viewModels.filter((view) => view.resource === resource).flatMap((view) => view.actions.map((action) => action.name))),
103
+ createPages: new Set(viewModels.filter((view) => view.create).map((view) => view.resource))
100
104
  });
101
105
  const forms = createFormsRuntime({
102
106
  registry,
@@ -248,6 +252,14 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
248
252
  app.route("/", batchRoute(deps, authenticate, meta));
249
253
  app.route("/", fileRoutes(deps, authenticate));
250
254
  if (forms) app.route(`${systemPath}/forms`, formRoutes(deps, forms));
255
+ if (appModels.some((model) => model.live)) {
256
+ const feed = tableFeed({
257
+ db,
258
+ schema: changes,
259
+ report: options.onUnhandledError ?? ((error) => console.error(error))
260
+ });
261
+ app.route(systemPath || "/", guarded((live) => void live.route("/", liveRoutes(feed, (session) => visibleNotices(deps, session))), withAnonymous(authenticate, options.roles)));
262
+ }
251
263
  app.route(basePath, api);
252
264
  app.route(`${deps.systemPath}/meta`, metaApp);
253
265
  app.route(deps.systemPath || "/", system);
@@ -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 };
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 views = visible.flatMap((access) => {
39
+ const picked = visible.flatMap((access) => {
40
40
  const view = pickView(deps.views, session.user.roles, access.entry.name);
41
- return view ? [pruneView(view, access.readable, listed, prompted)] : [];
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,
@@ -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",
3
+ "version": "0.23.4",
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.3",
35
- "@protobase/query": "0.23.3",
36
- "@protobase/schema": "0.23.3",
34
+ "@protobase/layout": "0.23.4",
35
+ "@protobase/query": "0.23.4",
36
+ "@protobase/schema": "0.23.4",
37
37
  "@react-email/render": "^2.1.0",
38
38
  "@scalar/hono-api-reference": "^0.12.10",
39
39
  "@sentry/core": "^11.7.0",