@protobase/server 0.19.16 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 withJobs = jobs.length > 0 || appResources.some((resource) => resource.toModel().events);
51
- const resources = withJobs ? [...appResources, failedJobs] : appResources;
52
- const views = withJobs ? [...appViews, failedJobsView] : appViews;
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
- ...registry.entries.some((entry) => entry.model.events) && { actorSetting: `${changesSchema(events)}.actor` }
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: changesSchema(events),
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 };
@@ -130,6 +130,7 @@ const needs = {
130
130
  list: "list",
131
131
  search: "list",
132
132
  facets: "list",
133
+ aggregate: "list",
133
134
  series: "list",
134
135
  histogram: "list",
135
136
  map: "list",
@@ -324,6 +325,77 @@ const resourcePaths = (access) => {
324
325
  ...keyErrors
325
326
  }
326
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
+ } } },
327
399
  [`/${entry.name}:facets`]: { get: {
328
400
  operationId: `facets_${entry.name}`,
329
401
  tags: tag,
@@ -363,6 +435,71 @@ const resourcePaths = (access) => {
363
435
  ...auth
364
436
  }
365
437
  } },
438
+ [`/${entry.name}:aggregate`]: { get: {
439
+ operationId: `aggregate_${entry.name}`,
440
+ tags: tag,
441
+ summary: `Totals per group of ${entry.name} records`,
442
+ description: "Groups the records the filter finds, its own terms on `group_by` included, by the value of a field, or by the hour, day, week, month or year of a date or timestamp field with the empty ones included. Each group has its count and, with `sum` or `average`, that of a numeric field, `null` when no record of the group has a value. Value groups come largest first. An index must serve the grouped field.",
443
+ parameters: [
444
+ query("group_by", "A filterable field", { type: "string" }, true),
445
+ param("filter"),
446
+ query("sum", "A numeric field to add up per group"),
447
+ query("average", "A numeric field to average per group, instead of `sum`"),
448
+ query("limit", "Most value groups to return, default 100, at most 1000", { type: "integer" }),
449
+ query("label", "For a relation to a single-key resource: the field of that resource whose value each group carries as `label`"),
450
+ query("granularity", "For a date group_by: the bucket, default day", {
451
+ type: "string",
452
+ enum: [
453
+ "hour",
454
+ "day",
455
+ "week",
456
+ "month",
457
+ "year"
458
+ ]
459
+ }),
460
+ query("range", "For a date group_by: the relative range ending now (default 30d); `all` starts at the earliest row", {
461
+ type: "string",
462
+ enum: [
463
+ "7d",
464
+ "30d",
465
+ "90d",
466
+ "1y",
467
+ "all"
468
+ ]
469
+ }),
470
+ query("from", "For a date group_by: the explicit start (ISO 8601); with `to`, replaces range"),
471
+ query("to", "For a date group_by: the exclusive end (ISO 8601), default now"),
472
+ query("time_zone", "For a date group_by: the IANA time zone of the buckets, default UTC")
473
+ ],
474
+ responses: {
475
+ 200: {
476
+ description: "The groups; a date group's `value` is its bucket in local ISO time",
477
+ content: json({
478
+ type: "object",
479
+ properties: {
480
+ group_by: { type: "string" },
481
+ granularity: { type: "string" },
482
+ time_zone: { type: "string" },
483
+ groups: {
484
+ type: "array",
485
+ items: {
486
+ type: "object",
487
+ properties: {
488
+ value: {},
489
+ label: { type: "string" },
490
+ count: { type: "integer" },
491
+ sum: { type: ["number", "null"] },
492
+ average: { type: ["number", "null"] }
493
+ }
494
+ }
495
+ }
496
+ }
497
+ })
498
+ },
499
+ 400: error("BadRequest"),
500
+ ...auth
501
+ }
502
+ } },
366
503
  [`/${entry.name}:series`]: { get: {
367
504
  operationId: `series_${entry.name}`,
368
505
  tags: tag,
@@ -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. */
@@ -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 }
@@ -1,6 +1,7 @@
1
1
  import { runWrite } from "../write-pipeline.js";
2
2
  import { listRecords } from "../list-service.js";
3
3
  import { facets, numericHistogram, timeSeries } from "../aggregate-service.js";
4
+ import { aggregate } from "../aggregate-groups.js";
4
5
  import { mapRecords } from "../map-service.js";
5
6
  import { uploadFile } from "../files/upload.js";
6
7
  import { returnPreference } from "./preferences.js";
@@ -25,6 +26,10 @@ const collectionRoutes = (deps) => {
25
26
  const { body, warning } = await facets(deps, entry, session, query);
26
27
  return c.json(body, 200, warningHeader(warning));
27
28
  }
29
+ if (action === "aggregate") {
30
+ const { body, warning } = await aggregate(deps, entry, session, query);
31
+ return c.json(body, 200, warningHeader(warning));
32
+ }
28
33
  if (action === "series") return c.json(await timeSeries(deps, entry, session, query));
29
34
  if (action === "histogram") return c.json(await numericHistogram(deps, entry, session, query));
30
35
  if (action === "map") {