cursedbelt-server 4.26.1 → 4.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/server/activity/d1.d.ts +70 -0
- package/dist/server/activity/d1.js +68 -0
- package/dist/server/activity/migrations.d.ts +1 -3
- package/dist/server/activity/migrations.js +7 -32
- package/dist/server/activity/query.d.ts +123 -0
- package/dist/server/activity/query.js +171 -0
- package/dist/server/activity/schema.d.ts +13 -0
- package/dist/server/activity/schema.js +43 -0
- package/dist/server/activity/shareSource.d.ts +1 -1
- package/dist/server/activity/shareSource.js +1 -1
- package/dist/server/activity/store.d.ts +4 -62
- package/dist/server/activity/store.js +14 -145
- package/dist/server/engagement/d1.d.ts +67 -0
- package/dist/server/engagement/d1.js +81 -0
- package/dist/server/engagement/manifestPlaces.d.ts +9 -0
- package/dist/server/engagement/manifestPlaces.js +17 -0
- package/dist/server/engagement/places.js +2 -18
- package/dist/server/notifications/d1.d.ts +80 -0
- package/dist/server/notifications/d1.js +226 -0
- package/dist/server/notifications/service.d.ts +2 -20
- package/dist/server/notifications/types.d.ts +27 -0
- package/dist/server/notifications/types.js +1 -0
- package/dist/server/sharing/contract.d.ts +94 -0
- package/dist/server/sharing/contract.js +3 -0
- package/dist/server/sharing/d1.d.ts +77 -0
- package/dist/server/sharing/d1.js +309 -0
- package/dist/server/sharing/index.d.ts +1 -0
- package/dist/server/sharing/migrations.d.ts +1 -4
- package/dist/server/sharing/migrations.js +7 -72
- package/dist/server/sharing/router.d.ts +8 -4
- package/dist/server/sharing/router.js +7 -5
- package/dist/server/sharing/schema.d.ts +19 -0
- package/dist/server/sharing/schema.js +88 -0
- package/dist/server/sharing/sql.js +1 -1
- package/dist/server/sharing/statements.d.ts +116 -0
- package/dist/server/sharing/statements.js +139 -0
- package/dist/server/sharing/store.d.ts +3 -28
- package/dist/server/sharing/store.js +32 -105
- package/docs/activity.md +7 -0
- package/docs/engagement.md +6 -0
- package/docs/notifications.md +9 -0
- package/package.json +25 -1
- package/src/server/activity/d1.spec.ts +169 -0
- package/src/server/activity/d1.ts +172 -0
- package/src/server/activity/migrations.ts +6 -40
- package/src/server/activity/query.ts +283 -0
- package/src/server/activity/schema.ts +45 -0
- package/src/server/activity/shareSource.ts +2 -2
- package/src/server/activity/store.ts +45 -257
- package/src/server/engagement/d1.spec.ts +102 -0
- package/src/server/engagement/d1.ts +141 -0
- package/src/server/engagement/manifestPlaces.ts +24 -0
- package/src/server/engagement/places.ts +2 -16
- package/src/server/notifications/d1.spec.ts +179 -0
- package/src/server/notifications/d1.ts +334 -0
- package/src/server/notifications/service.ts +7 -23
- package/src/server/notifications/types.ts +29 -0
- package/src/server/sharing/contract.ts +109 -0
- package/src/server/sharing/d1.spec.ts +242 -0
- package/src/server/sharing/d1.ts +430 -0
- package/src/server/sharing/index.ts +3 -0
- package/src/server/sharing/migrations.ts +12 -85
- package/src/server/sharing/router.ts +21 -11
- package/src/server/sharing/schema.ts +90 -0
- package/src/server/sharing/sql.ts +1 -1
- package/src/server/sharing/statements.ts +213 -0
- package/src/server/sharing/store.ts +46 -221
- package/src/server/telemetryIsWorkerSafe.spec.ts +64 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The activity stream's contract and SQL with no driver attached — ONE copy for both the
|
|
3
|
+
* `bun:sqlite` log (`./store.ts`) and the D1 log (`./d1.ts`).
|
|
4
|
+
*
|
|
5
|
+
* The two logs differ only in how they execute a statement. What an event looks like, how a
|
|
6
|
+
* source joins the union, what each filter means, how a page is bounded and how a facet is
|
|
7
|
+
* counted are all decided here, once — so "an action ending in a dot is a prefix" cannot be
|
|
8
|
+
* true on the Mac and false on a Worker. Driver-free on purpose: `./d1.ts` runs on `workerd`.
|
|
9
|
+
*/
|
|
10
|
+
import {
|
|
11
|
+
type ActivityEvent,
|
|
12
|
+
type ActivityEventInput,
|
|
13
|
+
type ActivityOutcome,
|
|
14
|
+
isActivityAction,
|
|
15
|
+
isActivityOutcome,
|
|
16
|
+
} from "cursedbelt-core/activity/model";
|
|
17
|
+
import { ACTIVITY_EVENTS_TABLE } from "./schema.js";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A value a source may bind. The intersection of what `bun:sqlite` and D1 both accept, so a
|
|
21
|
+
* source written once contributes to either log.
|
|
22
|
+
*/
|
|
23
|
+
export type ActivityBindable = string | number | bigint | boolean | null | Uint8Array;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A read-only stream contributor: a SELECT projecting some other table into the
|
|
27
|
+
* union's eleven columns, in this order and with these names —
|
|
28
|
+
*
|
|
29
|
+
* `source, seq, at, app, actor, action, kind, resource_id, outcome, note, data`
|
|
30
|
+
*
|
|
31
|
+
* `seq` is that table's own row id: it breaks ties within one `at`, and it is
|
|
32
|
+
* what makes the event id (`"<source>:<seq>"`) stable and unique across sources.
|
|
33
|
+
*/
|
|
34
|
+
export interface ActivitySource {
|
|
35
|
+
/** Short, stable, unique within the log — it prefixes every id it produces. */
|
|
36
|
+
name: string;
|
|
37
|
+
/** The projection. See the column contract above. */
|
|
38
|
+
sql: string;
|
|
39
|
+
/** Bound in order, before any filter parameter. */
|
|
40
|
+
params?: readonly ActivityBindable[];
|
|
41
|
+
/**
|
|
42
|
+
* A table this projection needs. When it does not exist the source is skipped
|
|
43
|
+
* for that call rather than throwing — see property 3 in `./store.ts`'s header.
|
|
44
|
+
*/
|
|
45
|
+
requiresTable?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Everything the stream can be narrowed by. Every field is optional; the empty
|
|
49
|
+
* query is "the newest `limit` events". */
|
|
50
|
+
export interface ActivityQuery {
|
|
51
|
+
/** ISO-8601, inclusive. */
|
|
52
|
+
since?: string;
|
|
53
|
+
/** ISO-8601, inclusive. */
|
|
54
|
+
until?: string;
|
|
55
|
+
/** Case-insensitive exact match on the account. */
|
|
56
|
+
actor?: string;
|
|
57
|
+
/** Exact, or a PREFIX when it ends in a dot: `"share."` matches `share.grant`. */
|
|
58
|
+
action?: string;
|
|
59
|
+
kind?: string;
|
|
60
|
+
resourceId?: string;
|
|
61
|
+
outcome?: ActivityOutcome;
|
|
62
|
+
/** Restrict to one contributor (`"log"`, `"share"`). */
|
|
63
|
+
source?: string;
|
|
64
|
+
/** Substring, over actor + action + note + resource id. */
|
|
65
|
+
q?: string;
|
|
66
|
+
/** Default 100, hard cap 1000. */
|
|
67
|
+
limit?: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface ActivityPage {
|
|
71
|
+
events: ActivityEvent[];
|
|
72
|
+
/** The limit actually applied, after defaulting and capping. */
|
|
73
|
+
limit: number;
|
|
74
|
+
/** There is at least one more row older than the last one returned. */
|
|
75
|
+
truncated: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** One dimension of "what is in this result", for the filter UI. */
|
|
79
|
+
export interface ActivityFacet {
|
|
80
|
+
value: string;
|
|
81
|
+
count: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface ActivityFacets {
|
|
85
|
+
actions: ActivityFacet[];
|
|
86
|
+
actors: ActivityFacet[];
|
|
87
|
+
kinds: ActivityFacet[];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface StreamRow {
|
|
91
|
+
source: string;
|
|
92
|
+
seq: number;
|
|
93
|
+
at: string;
|
|
94
|
+
app: string;
|
|
95
|
+
actor: string;
|
|
96
|
+
action: string;
|
|
97
|
+
kind: string | null;
|
|
98
|
+
resource_id: string | null;
|
|
99
|
+
outcome: ActivityOutcome;
|
|
100
|
+
note: string;
|
|
101
|
+
data: string | null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const DEFAULT_LIMIT = 100;
|
|
105
|
+
const MAX_LIMIT = 1000;
|
|
106
|
+
export const MAX_FACET = 25;
|
|
107
|
+
|
|
108
|
+
/** `%` and `_` are wildcards and `\` is the escape — a note containing any of
|
|
109
|
+
* them must still search literally. */
|
|
110
|
+
const likeContains = (value: string): string => `%${value.replace(/[\\%_]/g, (ch) => `\\${ch}`)}%`;
|
|
111
|
+
|
|
112
|
+
export const toStreamEvent = (row: StreamRow): ActivityEvent => ({
|
|
113
|
+
id: `${row.source}:${row.seq}`,
|
|
114
|
+
source: row.source,
|
|
115
|
+
at: row.at,
|
|
116
|
+
app: row.app,
|
|
117
|
+
actor: row.actor,
|
|
118
|
+
action: row.action,
|
|
119
|
+
resource:
|
|
120
|
+
row.kind === null || row.resource_id === null ? null : { app: row.app, kind: row.kind, id: row.resource_id },
|
|
121
|
+
outcome: isActivityOutcome(row.outcome) ? row.outcome : "ok",
|
|
122
|
+
note: row.note ?? "",
|
|
123
|
+
data: parseData(row.data),
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
/** A row whose `data` is unreadable must not take the page down with it — the
|
|
127
|
+
* event still says who did what, which is the part that matters. */
|
|
128
|
+
function parseData(raw: string | null): Record<string, unknown> | null {
|
|
129
|
+
if (!raw) return null;
|
|
130
|
+
try {
|
|
131
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
132
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
133
|
+
? (parsed as Record<string, unknown>)
|
|
134
|
+
: { value: parsed };
|
|
135
|
+
} catch {
|
|
136
|
+
return { unparsed: raw };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export const ACTIVITY_SQL = {
|
|
141
|
+
/** Binds at, app, actor, action, kind, resource_id, outcome, note, data. */
|
|
142
|
+
insert: `INSERT INTO ${ACTIVITY_EVENTS_TABLE}
|
|
143
|
+
(at, app, actor, action, kind, resource_id, outcome, note, data)
|
|
144
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
145
|
+
tableExists: "SELECT 1 AS ok FROM sqlite_master WHERE type = 'table' AND name = ?",
|
|
146
|
+
ownSelect: `SELECT 'log' AS source, event_id AS seq, at, app, actor, action,
|
|
147
|
+
kind, resource_id, outcome, note, data
|
|
148
|
+
FROM ${ACTIVITY_EVENTS_TABLE}`,
|
|
149
|
+
/** One row past the limit, so "there is more" is a fact rather than a guess made from
|
|
150
|
+
* `rows.length === limit`. Binds the stream's params, then `limit + 1`. */
|
|
151
|
+
page: (stream: string): string => `${stream} ORDER BY at DESC, seq DESC LIMIT ?`,
|
|
152
|
+
count: (stream: string): string => `SELECT COUNT(*) AS n FROM (${stream})`,
|
|
153
|
+
/** Binds the stream's params, then the facet cap. `column` is one of three literals. */
|
|
154
|
+
facet: (stream: string, column: "action" | "actor" | "kind"): string =>
|
|
155
|
+
`SELECT ${column} AS value, COUNT(*) AS count FROM (${stream})
|
|
156
|
+
WHERE ${column} IS NOT NULL AND ${column} <> ''
|
|
157
|
+
GROUP BY ${column} ORDER BY count DESC, value ASC LIMIT ?`,
|
|
158
|
+
} as const;
|
|
159
|
+
|
|
160
|
+
/** The limit a page actually applies: default 100, never above 1000 or below 1. */
|
|
161
|
+
export const pageLimit = (limit: number | undefined): number =>
|
|
162
|
+
Math.min(MAX_LIMIT, Math.max(1, Math.floor(limit ?? DEFAULT_LIMIT)));
|
|
163
|
+
|
|
164
|
+
/** The facet cap: default {@link MAX_FACET}, never above it or below 1. */
|
|
165
|
+
export const facetLimit = (limit: number): number => Math.min(MAX_FACET, Math.max(1, Math.floor(limit)));
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The union of the log's own rows and every LIVE source, plus the WHERE the caller asked for,
|
|
169
|
+
* plus their parameters in bind order (sources first — they are inside the FROM).
|
|
170
|
+
*/
|
|
171
|
+
export function buildStream(
|
|
172
|
+
query: ActivityQuery,
|
|
173
|
+
liveSources: readonly ActivitySource[],
|
|
174
|
+
): { sql: string; params: ActivityBindable[] } {
|
|
175
|
+
const params: ActivityBindable[] = [];
|
|
176
|
+
const selects: string[] = [ACTIVITY_SQL.ownSelect];
|
|
177
|
+
for (const source of liveSources) {
|
|
178
|
+
if (query.source && query.source !== source.name) continue;
|
|
179
|
+
selects.push(source.sql);
|
|
180
|
+
params.push(...(source.params ?? []));
|
|
181
|
+
}
|
|
182
|
+
const where: string[] = [];
|
|
183
|
+
if (query.source) {
|
|
184
|
+
where.push("source = ?");
|
|
185
|
+
params.push(query.source);
|
|
186
|
+
}
|
|
187
|
+
if (query.since) {
|
|
188
|
+
where.push("at >= ?");
|
|
189
|
+
params.push(query.since);
|
|
190
|
+
}
|
|
191
|
+
if (query.until) {
|
|
192
|
+
where.push("at <= ?");
|
|
193
|
+
params.push(query.until);
|
|
194
|
+
}
|
|
195
|
+
if (query.actor) {
|
|
196
|
+
where.push("LOWER(actor) = LOWER(?)");
|
|
197
|
+
params.push(query.actor);
|
|
198
|
+
}
|
|
199
|
+
if (query.action) {
|
|
200
|
+
if (query.action.endsWith(".")) {
|
|
201
|
+
where.push("action LIKE ? ESCAPE '\\'");
|
|
202
|
+
params.push(`${query.action.replace(/[\\%_]/g, (ch) => `\\${ch}`)}%`);
|
|
203
|
+
} else {
|
|
204
|
+
where.push("action = ?");
|
|
205
|
+
params.push(query.action);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (query.kind) {
|
|
209
|
+
where.push("kind = ?");
|
|
210
|
+
params.push(query.kind);
|
|
211
|
+
}
|
|
212
|
+
if (query.resourceId) {
|
|
213
|
+
where.push("resource_id = ?");
|
|
214
|
+
params.push(query.resourceId);
|
|
215
|
+
}
|
|
216
|
+
if (query.outcome) {
|
|
217
|
+
where.push("outcome = ?");
|
|
218
|
+
params.push(query.outcome);
|
|
219
|
+
}
|
|
220
|
+
if (query.q) {
|
|
221
|
+
where.push(
|
|
222
|
+
"(actor LIKE ? ESCAPE '\\' OR action LIKE ? ESCAPE '\\' OR note LIKE ? ESCAPE '\\' OR IFNULL(resource_id, '') LIKE ? ESCAPE '\\')",
|
|
223
|
+
);
|
|
224
|
+
const like = likeContains(query.q);
|
|
225
|
+
params.push(like, like, like, like);
|
|
226
|
+
}
|
|
227
|
+
// `query.source` naming a contributor that is not live leaves only the own
|
|
228
|
+
// select, filtered to a source it cannot be — an empty result, which is the
|
|
229
|
+
// honest answer rather than every row.
|
|
230
|
+
const sql = `SELECT * FROM (${selects.join("\n UNION ALL\n")}) AS stream${
|
|
231
|
+
where.length ? ` WHERE ${where.join(" AND ")}` : ""
|
|
232
|
+
}`;
|
|
233
|
+
return { sql, params };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Validate one event for the write and return the row to bind plus the event as it will be
|
|
238
|
+
* read back (minus its id, which only the insert can know). Throws on an action that cannot be
|
|
239
|
+
* filtered or an event that cannot say who — property 4 of `./store.ts`'s header.
|
|
240
|
+
*/
|
|
241
|
+
export function prepareRecord(
|
|
242
|
+
app: string,
|
|
243
|
+
input: ActivityEventInput,
|
|
244
|
+
now: () => string,
|
|
245
|
+
): { binds: [string, string, string, string, string | null, string | null, string, string, string | null]; event: Omit<ActivityEvent, "id"> } {
|
|
246
|
+
if (!isActivityAction(input.action)) {
|
|
247
|
+
throw new Error(
|
|
248
|
+
`activity: "${input.action}" is not an action — use lowercase subject.verb, e.g. "backup.delete"`,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
const actor = input.actor?.trim();
|
|
252
|
+
if (!actor) {
|
|
253
|
+
// An event that cannot say who is a log line, not an audit trail.
|
|
254
|
+
throw new Error(`activity: ${input.action} has no actor`);
|
|
255
|
+
}
|
|
256
|
+
const at = input.at ?? now();
|
|
257
|
+
const outcome = input.outcome ?? "ok";
|
|
258
|
+
const data = input.data ? JSON.stringify(input.data) : null;
|
|
259
|
+
return {
|
|
260
|
+
binds: [
|
|
261
|
+
at,
|
|
262
|
+
app,
|
|
263
|
+
actor,
|
|
264
|
+
input.action,
|
|
265
|
+
input.resource?.kind ?? null,
|
|
266
|
+
input.resource?.id ?? null,
|
|
267
|
+
outcome,
|
|
268
|
+
input.note ?? "",
|
|
269
|
+
data,
|
|
270
|
+
],
|
|
271
|
+
event: {
|
|
272
|
+
source: "log",
|
|
273
|
+
at,
|
|
274
|
+
app,
|
|
275
|
+
actor,
|
|
276
|
+
action: input.action,
|
|
277
|
+
resource: input.resource ? { app, ...input.resource } : null,
|
|
278
|
+
outcome,
|
|
279
|
+
note: input.note ?? "",
|
|
280
|
+
data: input.data ?? null,
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The activity table's name and DDL, with no driver attached — the ONE copy both the
|
|
3
|
+
* `bun:sqlite` migration (`./migrations.ts`) and a D1 app's `db/schema.sql` are built from.
|
|
4
|
+
*
|
|
5
|
+
* Import-free on purpose: `./d1.ts` runs on `workerd`, and a Worker app generates its schema
|
|
6
|
+
* file from these statements rather than running DDL at request time. Why the table is ONE
|
|
7
|
+
* append-only stream is `./migrations.ts`'s header.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** The one table this primitive owns. Exported so an app can declare a retention
|
|
11
|
+
* target over it (see this module's `index.ts` for the recipe). */
|
|
12
|
+
export const ACTIVITY_EVENTS_TABLE = "activity_events";
|
|
13
|
+
|
|
14
|
+
/** Every statement of the `0001_activity_events` migration, in order. Idempotent. */
|
|
15
|
+
export const ACTIVITY_DDL: readonly string[] = [
|
|
16
|
+
`
|
|
17
|
+
CREATE TABLE IF NOT EXISTS ${ACTIVITY_EVENTS_TABLE} (
|
|
18
|
+
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
19
|
+
at TEXT NOT NULL,
|
|
20
|
+
app TEXT NOT NULL,
|
|
21
|
+
actor TEXT NOT NULL,
|
|
22
|
+
action TEXT NOT NULL,
|
|
23
|
+
kind TEXT,
|
|
24
|
+
resource_id TEXT,
|
|
25
|
+
outcome TEXT NOT NULL DEFAULT 'ok'
|
|
26
|
+
CHECK (outcome IN ('ok', 'denied', 'error')),
|
|
27
|
+
note TEXT NOT NULL DEFAULT '',
|
|
28
|
+
data TEXT
|
|
29
|
+
)
|
|
30
|
+
`,
|
|
31
|
+
// The stream itself, newest first — the read every screen starts from.
|
|
32
|
+
`CREATE INDEX IF NOT EXISTS idx_activity_events_at
|
|
33
|
+
ON ${ACTIVITY_EVENTS_TABLE} (at DESC, event_id DESC)`,
|
|
34
|
+
// "Everything this person did" — the offboarding and the who-broke-it
|
|
35
|
+
// question, and the one a fleet-wide search runs first.
|
|
36
|
+
`CREATE INDEX IF NOT EXISTS idx_activity_events_actor
|
|
37
|
+
ON ${ACTIVITY_EVENTS_TABLE} (actor, event_id DESC)`,
|
|
38
|
+
// "Everything that ever happened to this record."
|
|
39
|
+
`CREATE INDEX IF NOT EXISTS idx_activity_events_resource
|
|
40
|
+
ON ${ACTIVITY_EVENTS_TABLE} (kind, resource_id, event_id DESC)`,
|
|
41
|
+
// "Everything backups ever did" — an action-prefix filter is a range
|
|
42
|
+
// scan on this index, which is why the vocabulary is dotted.
|
|
43
|
+
`CREATE INDEX IF NOT EXISTS idx_activity_events_action
|
|
44
|
+
ON ${ACTIVITY_EVENTS_TABLE} (action, event_id DESC)`,
|
|
45
|
+
];
|
|
@@ -25,8 +25,8 @@
|
|
|
25
25
|
* fleet-wide board merges on that column and an unlabeled row would be
|
|
26
26
|
* unattributable.
|
|
27
27
|
*/
|
|
28
|
-
import { SHARE_EVENTS_TABLE } from "../sharing/
|
|
29
|
-
import type { ActivitySource } from "./
|
|
28
|
+
import { SHARE_EVENTS_TABLE } from "../sharing/schema.js";
|
|
29
|
+
import type { ActivitySource } from "./query.js";
|
|
30
30
|
|
|
31
31
|
export interface ShareEventsSourceOptions {
|
|
32
32
|
/** The satellite this log speaks for — the same value passed to
|
|
@@ -34,80 +34,36 @@
|
|
|
34
34
|
* cannot be filtered, and filtering is the whole point of the column.
|
|
35
35
|
*/
|
|
36
36
|
import type { Database, SQLQueryBindings } from "bun:sqlite";
|
|
37
|
-
import {
|
|
38
|
-
type ActivityEvent,
|
|
39
|
-
type ActivityEventInput,
|
|
40
|
-
type ActivityOutcome,
|
|
41
|
-
isActivityAction,
|
|
42
|
-
isActivityOutcome,
|
|
43
|
-
} from "cursedbelt-core/activity/model";
|
|
37
|
+
import type { ActivityEvent, ActivityEventInput } from "cursedbelt-core/activity/model";
|
|
44
38
|
import { runMigrations } from "../migration/runner.js";
|
|
45
|
-
import {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
/** ISO-8601, inclusive. */
|
|
74
|
-
since?: string;
|
|
75
|
-
/** ISO-8601, inclusive. */
|
|
76
|
-
until?: string;
|
|
77
|
-
/** Case-insensitive exact match on the account. */
|
|
78
|
-
actor?: string;
|
|
79
|
-
/** Exact, or a PREFIX when it ends in a dot: `"share."` matches `share.grant`. */
|
|
80
|
-
action?: string;
|
|
81
|
-
kind?: string;
|
|
82
|
-
resourceId?: string;
|
|
83
|
-
outcome?: ActivityOutcome;
|
|
84
|
-
/** Restrict to one contributor (`"log"`, `"share"`). */
|
|
85
|
-
source?: string;
|
|
86
|
-
/** Substring, over actor + action + note + resource id. */
|
|
87
|
-
q?: string;
|
|
88
|
-
/** Default 100, hard cap 1000. */
|
|
89
|
-
limit?: number;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
export interface ActivityPage {
|
|
93
|
-
events: ActivityEvent[];
|
|
94
|
-
/** The limit actually applied, after defaulting and capping. */
|
|
95
|
-
limit: number;
|
|
96
|
-
/** There is at least one more row older than the last one returned. */
|
|
97
|
-
truncated: boolean;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/** One dimension of "what is in this result", for the filter UI. */
|
|
101
|
-
export interface ActivityFacet {
|
|
102
|
-
value: string;
|
|
103
|
-
count: number;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
export interface ActivityFacets {
|
|
107
|
-
actions: ActivityFacet[];
|
|
108
|
-
actors: ActivityFacet[];
|
|
109
|
-
kinds: ActivityFacet[];
|
|
110
|
-
}
|
|
39
|
+
import { ACTIVITY_MIGRATIONS } from "./migrations.js";
|
|
40
|
+
import {
|
|
41
|
+
ACTIVITY_SQL,
|
|
42
|
+
type ActivityFacet,
|
|
43
|
+
type ActivityFacets,
|
|
44
|
+
type ActivityPage,
|
|
45
|
+
type ActivityQuery,
|
|
46
|
+
type ActivitySource,
|
|
47
|
+
buildStream,
|
|
48
|
+
facetLimit,
|
|
49
|
+
MAX_FACET,
|
|
50
|
+
pageLimit,
|
|
51
|
+
prepareRecord,
|
|
52
|
+
type StreamRow,
|
|
53
|
+
toStreamEvent,
|
|
54
|
+
} from "./query.js";
|
|
55
|
+
|
|
56
|
+
// The contract and the SQL have no driver in them, so `./d1.ts` (which runs on `workerd`)
|
|
57
|
+
// shares them without reaching this file. Re-exported so `cursedbelt-server/activity` is
|
|
58
|
+
// unchanged.
|
|
59
|
+
export type {
|
|
60
|
+
ActivityBindable,
|
|
61
|
+
ActivityFacet,
|
|
62
|
+
ActivityFacets,
|
|
63
|
+
ActivityPage,
|
|
64
|
+
ActivityQuery,
|
|
65
|
+
ActivitySource,
|
|
66
|
+
} from "./query.js";
|
|
111
67
|
|
|
112
68
|
export interface ActivityLogOptions {
|
|
113
69
|
db: Database;
|
|
@@ -136,59 +92,6 @@ export interface ActivityLog {
|
|
|
136
92
|
sources(): string[];
|
|
137
93
|
}
|
|
138
94
|
|
|
139
|
-
interface StreamRow {
|
|
140
|
-
source: string;
|
|
141
|
-
seq: number;
|
|
142
|
-
at: string;
|
|
143
|
-
app: string;
|
|
144
|
-
actor: string;
|
|
145
|
-
action: string;
|
|
146
|
-
kind: string | null;
|
|
147
|
-
resource_id: string | null;
|
|
148
|
-
outcome: ActivityOutcome;
|
|
149
|
-
note: string;
|
|
150
|
-
data: string | null;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
const DEFAULT_LIMIT = 100;
|
|
154
|
-
const MAX_LIMIT = 1000;
|
|
155
|
-
const MAX_FACET = 25;
|
|
156
|
-
|
|
157
|
-
/** `%` and `_` are wildcards and `\` is the escape — a note containing any of
|
|
158
|
-
* them must still search literally. */
|
|
159
|
-
const likeContains = (value: string): string =>
|
|
160
|
-
`%${value.replace(/[\\%_]/g, (ch) => `\\${ch}`)}%`;
|
|
161
|
-
|
|
162
|
-
const toEvent = (row: StreamRow): ActivityEvent => ({
|
|
163
|
-
id: `${row.source}:${row.seq}`,
|
|
164
|
-
source: row.source,
|
|
165
|
-
at: row.at,
|
|
166
|
-
app: row.app,
|
|
167
|
-
actor: row.actor,
|
|
168
|
-
action: row.action,
|
|
169
|
-
resource:
|
|
170
|
-
row.kind === null || row.resource_id === null
|
|
171
|
-
? null
|
|
172
|
-
: { app: row.app, kind: row.kind, id: row.resource_id },
|
|
173
|
-
outcome: isActivityOutcome(row.outcome) ? row.outcome : "ok",
|
|
174
|
-
note: row.note ?? "",
|
|
175
|
-
data: parseData(row.data),
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
/** A row whose `data` is unreadable must not take the page down with it — the
|
|
179
|
-
* event still says who did what, which is the part that matters. */
|
|
180
|
-
function parseData(raw: string | null): Record<string, unknown> | null {
|
|
181
|
-
if (!raw) return null;
|
|
182
|
-
try {
|
|
183
|
-
const parsed = JSON.parse(raw) as unknown;
|
|
184
|
-
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
185
|
-
? (parsed as Record<string, unknown>)
|
|
186
|
-
: { value: parsed };
|
|
187
|
-
} catch {
|
|
188
|
-
return { unparsed: raw };
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
95
|
export function createActivityLog(options: ActivityLogOptions): ActivityLog {
|
|
193
96
|
const { db, app } = options;
|
|
194
97
|
const now = options.now ?? (() => new Date().toISOString());
|
|
@@ -196,146 +99,37 @@ export function createActivityLog(options: ActivityLogOptions): ActivityLog {
|
|
|
196
99
|
|
|
197
100
|
runMigrations(db, ACTIVITY_MIGRATIONS);
|
|
198
101
|
|
|
199
|
-
const insert = db.query(
|
|
200
|
-
`INSERT INTO ${ACTIVITY_EVENTS_TABLE}
|
|
201
|
-
(at, app, actor, action, kind, resource_id, outcome, note, data)
|
|
202
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
203
|
-
);
|
|
102
|
+
const insert = db.query(ACTIVITY_SQL.insert);
|
|
204
103
|
|
|
205
|
-
const tableExists = (name: string): boolean =>
|
|
206
|
-
!!db
|
|
207
|
-
.query("SELECT 1 AS ok FROM sqlite_master WHERE type = 'table' AND name = ?")
|
|
208
|
-
.get(name);
|
|
104
|
+
const tableExists = (name: string): boolean => !!db.query(ACTIVITY_SQL.tableExists).get(name);
|
|
209
105
|
|
|
210
106
|
/** The contributors that can actually answer right now. */
|
|
211
107
|
const liveSources = (): ActivitySource[] =>
|
|
212
108
|
registered.filter((source) => !source.requiresTable || tableExists(source.requiresTable));
|
|
213
109
|
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
FROM ${ACTIVITY_EVENTS_TABLE}`;
|
|
217
|
-
|
|
218
|
-
/** The union, plus the WHERE the caller asked for, plus their parameters in
|
|
219
|
-
* bind order (sources first — they are inside the FROM). */
|
|
220
|
-
const stream = (query: ActivityQuery): { sql: string; params: SQLQueryBindings[] } => {
|
|
221
|
-
const params: SQLQueryBindings[] = [];
|
|
222
|
-
const selects = [OWN_SELECT];
|
|
223
|
-
for (const source of liveSources()) {
|
|
224
|
-
if (query.source && query.source !== source.name) continue;
|
|
225
|
-
selects.push(source.sql);
|
|
226
|
-
params.push(...(source.params ?? []));
|
|
227
|
-
}
|
|
228
|
-
const where: string[] = [];
|
|
229
|
-
if (query.source) {
|
|
230
|
-
where.push("source = ?");
|
|
231
|
-
params.push(query.source);
|
|
232
|
-
}
|
|
233
|
-
if (query.since) {
|
|
234
|
-
where.push("at >= ?");
|
|
235
|
-
params.push(query.since);
|
|
236
|
-
}
|
|
237
|
-
if (query.until) {
|
|
238
|
-
where.push("at <= ?");
|
|
239
|
-
params.push(query.until);
|
|
240
|
-
}
|
|
241
|
-
if (query.actor) {
|
|
242
|
-
where.push("LOWER(actor) = LOWER(?)");
|
|
243
|
-
params.push(query.actor);
|
|
244
|
-
}
|
|
245
|
-
if (query.action) {
|
|
246
|
-
if (query.action.endsWith(".")) {
|
|
247
|
-
where.push("action LIKE ? ESCAPE '\\'");
|
|
248
|
-
params.push(`${query.action.replace(/[\\%_]/g, (ch) => `\\${ch}`)}%`);
|
|
249
|
-
} else {
|
|
250
|
-
where.push("action = ?");
|
|
251
|
-
params.push(query.action);
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
if (query.kind) {
|
|
255
|
-
where.push("kind = ?");
|
|
256
|
-
params.push(query.kind);
|
|
257
|
-
}
|
|
258
|
-
if (query.resourceId) {
|
|
259
|
-
where.push("resource_id = ?");
|
|
260
|
-
params.push(query.resourceId);
|
|
261
|
-
}
|
|
262
|
-
if (query.outcome) {
|
|
263
|
-
where.push("outcome = ?");
|
|
264
|
-
params.push(query.outcome);
|
|
265
|
-
}
|
|
266
|
-
if (query.q) {
|
|
267
|
-
where.push(
|
|
268
|
-
"(actor LIKE ? ESCAPE '\\' OR action LIKE ? ESCAPE '\\' OR note LIKE ? ESCAPE '\\' OR IFNULL(resource_id, '') LIKE ? ESCAPE '\\')",
|
|
269
|
-
);
|
|
270
|
-
const like = likeContains(query.q);
|
|
271
|
-
params.push(like, like, like, like);
|
|
272
|
-
}
|
|
273
|
-
// `query.source` naming a contributor that is not live leaves only the own
|
|
274
|
-
// select, filtered to a source it cannot be — an empty result, which is the
|
|
275
|
-
// honest answer rather than every row.
|
|
276
|
-
const sql = `SELECT * FROM (${selects.join("\n UNION ALL\n")}) AS stream${
|
|
277
|
-
where.length ? ` WHERE ${where.join(" AND ")}` : ""
|
|
278
|
-
}`;
|
|
279
|
-
return { sql, params };
|
|
280
|
-
};
|
|
110
|
+
const stream = (query: ActivityQuery): { sql: string; params: SQLQueryBindings[] } =>
|
|
111
|
+
buildStream(query, liveSources());
|
|
281
112
|
|
|
282
113
|
return {
|
|
283
114
|
app,
|
|
284
115
|
|
|
285
116
|
record(input: ActivityEventInput): ActivityEvent {
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
);
|
|
290
|
-
}
|
|
291
|
-
const actor = input.actor?.trim();
|
|
292
|
-
if (!actor) {
|
|
293
|
-
// An event that cannot say who is a log line, not an audit trail.
|
|
294
|
-
throw new Error(`activity: ${input.action} has no actor`);
|
|
295
|
-
}
|
|
296
|
-
const at = input.at ?? now();
|
|
297
|
-
const outcome = input.outcome ?? "ok";
|
|
298
|
-
const data = input.data ? JSON.stringify(input.data) : null;
|
|
299
|
-
const info = insert.run(
|
|
300
|
-
at,
|
|
301
|
-
app,
|
|
302
|
-
actor,
|
|
303
|
-
input.action,
|
|
304
|
-
input.resource?.kind ?? null,
|
|
305
|
-
input.resource?.id ?? null,
|
|
306
|
-
outcome,
|
|
307
|
-
input.note ?? "",
|
|
308
|
-
data,
|
|
309
|
-
);
|
|
310
|
-
return {
|
|
311
|
-
id: `log:${Number(info.lastInsertRowid)}`,
|
|
312
|
-
source: "log",
|
|
313
|
-
at,
|
|
314
|
-
app,
|
|
315
|
-
actor,
|
|
316
|
-
action: input.action,
|
|
317
|
-
resource: input.resource ? { app, ...input.resource } : null,
|
|
318
|
-
outcome,
|
|
319
|
-
note: input.note ?? "",
|
|
320
|
-
data: input.data ?? null,
|
|
321
|
-
};
|
|
117
|
+
const { binds, event } = prepareRecord(app, input, now);
|
|
118
|
+
const info = insert.run(...binds);
|
|
119
|
+
return { id: `log:${Number(info.lastInsertRowid)}`, ...event };
|
|
322
120
|
},
|
|
323
121
|
|
|
324
122
|
query(query: ActivityQuery = {}): ActivityPage {
|
|
325
|
-
const limit =
|
|
123
|
+
const limit = pageLimit(query.limit);
|
|
326
124
|
const { sql, params } = stream(query);
|
|
327
|
-
|
|
328
|
-
// guess made from `rows.length === limit`.
|
|
329
|
-
const rows = db
|
|
330
|
-
.query(`${sql} ORDER BY at DESC, seq DESC LIMIT ?`)
|
|
331
|
-
.all(...params, limit + 1) as StreamRow[];
|
|
125
|
+
const rows = db.query(ACTIVITY_SQL.page(sql)).all(...params, limit + 1) as StreamRow[];
|
|
332
126
|
const truncated = rows.length > limit;
|
|
333
|
-
return { events: rows.slice(0, limit).map(
|
|
127
|
+
return { events: rows.slice(0, limit).map(toStreamEvent), limit, truncated };
|
|
334
128
|
},
|
|
335
129
|
|
|
336
130
|
count(query: ActivityQuery = {}): number {
|
|
337
131
|
const { sql, params } = stream(query);
|
|
338
|
-
const row = db.query(
|
|
132
|
+
const row = db.query(ACTIVITY_SQL.count(sql)).get(...params) as {
|
|
339
133
|
n: number;
|
|
340
134
|
} | null;
|
|
341
135
|
return row?.n ?? 0;
|
|
@@ -343,17 +137,11 @@ export function createActivityLog(options: ActivityLogOptions): ActivityLog {
|
|
|
343
137
|
|
|
344
138
|
facets(query: ActivityQuery = {}, limit = MAX_FACET): ActivityFacets {
|
|
345
139
|
const { sql, params } = stream(query);
|
|
346
|
-
const capped =
|
|
347
|
-
const dimension = (column:
|
|
348
|
-
(
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
`SELECT ${column} AS value, COUNT(*) AS count FROM (${sql})
|
|
352
|
-
WHERE ${column} IS NOT NULL AND ${column} <> ''
|
|
353
|
-
GROUP BY ${column} ORDER BY count DESC, value ASC LIMIT ?`,
|
|
354
|
-
)
|
|
355
|
-
.all(...params, capped) as { value: string; count: number }[]
|
|
356
|
-
).map((row) => ({ value: row.value, count: row.count }));
|
|
140
|
+
const capped = facetLimit(limit);
|
|
141
|
+
const dimension = (column: "action" | "actor" | "kind"): ActivityFacet[] =>
|
|
142
|
+
(db.query(ACTIVITY_SQL.facet(sql, column)).all(...params, capped) as { value: string; count: number }[]).map(
|
|
143
|
+
(row) => ({ value: row.value, count: row.count }),
|
|
144
|
+
);
|
|
357
145
|
return {
|
|
358
146
|
actions: dimension("action"),
|
|
359
147
|
actors: dimension("actor"),
|