@sixb/pg 0.1.19 → 0.1.21

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.
Files changed (56) hide show
  1. package/dist/agents/threads.d.ts +4 -0
  2. package/dist/agents/threads.d.ts.map +1 -1
  3. package/dist/auth-storage/auth-storage.d.ts.map +1 -1
  4. package/dist/auth-storage/magic-links.d.ts.map +1 -1
  5. package/dist/auth-storage/oidc-attempts.d.ts.map +1 -1
  6. package/dist/auth-storage/rows.d.ts +0 -3
  7. package/dist/auth-storage/rows.d.ts.map +1 -1
  8. package/dist/auth-storage/sessions.d.ts +2 -11
  9. package/dist/auth-storage/sessions.d.ts.map +1 -1
  10. package/dist/auth-storage/shared.d.ts +1 -2
  11. package/dist/auth-storage/shared.d.ts.map +1 -1
  12. package/dist/index.d.ts +4 -0
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +984 -80
  15. package/dist/index.js.map +20 -18
  16. package/dist/migrations.d.ts.map +1 -1
  17. package/dist/ontology-storage/replacement-plans.d.ts.map +1 -1
  18. package/dist/ontology-storage/sources.d.ts +2 -1
  19. package/dist/ontology-storage/sources.d.ts.map +1 -1
  20. package/dist/pg-action-run-storage.d.ts +4 -0
  21. package/dist/pg-action-run-storage.d.ts.map +1 -1
  22. package/dist/pg-activity-storage.d.ts +22 -0
  23. package/dist/pg-activity-storage.d.ts.map +1 -0
  24. package/dist/pg-artifact-storage.d.ts +19 -0
  25. package/dist/pg-artifact-storage.d.ts.map +1 -0
  26. package/dist/pg-execution-storage.d.ts +2 -1
  27. package/dist/pg-execution-storage.d.ts.map +1 -1
  28. package/dist/pg-workflow-run-storage.d.ts +4 -0
  29. package/dist/pg-workflow-run-storage.d.ts.map +1 -1
  30. package/dist/share-grant-storage.d.ts +2 -1
  31. package/dist/share-grant-storage.d.ts.map +1 -1
  32. package/package.json +3 -3
  33. package/src/agents/threads.ts +23 -0
  34. package/src/auth-storage/auth-storage.ts +1 -22
  35. package/src/auth-storage/magic-links.ts +0 -4
  36. package/src/auth-storage/oidc-attempts.ts +0 -4
  37. package/src/auth-storage/rows.ts +0 -6
  38. package/src/auth-storage/sessions.ts +18 -30
  39. package/src/auth-storage/shared.ts +3 -35
  40. package/src/index.ts +10 -0
  41. package/src/migrations/062-drop-auth-audience.sql +19 -0
  42. package/src/migrations/063-artifacts.sql +67 -0
  43. package/src/migrations/064-drop-output-diagnostics.sql +14 -0
  44. package/src/migrations/065-plan-work-payload-statistics.sql +5 -0
  45. package/src/migrations/066-activity.sql +61 -0
  46. package/src/migrations/067-activity-timeline.sql +15 -0
  47. package/src/migrations/068-activity-interactions.sql +20 -0
  48. package/src/migrations.ts +20 -0
  49. package/src/ontology-storage/replacement-plans.ts +25 -7
  50. package/src/ontology-storage/sources.ts +37 -0
  51. package/src/pg-action-run-storage.ts +23 -0
  52. package/src/pg-activity-storage.ts +486 -0
  53. package/src/pg-artifact-storage.ts +325 -0
  54. package/src/pg-execution-storage.ts +52 -0
  55. package/src/pg-workflow-run-storage.ts +23 -0
  56. package/src/share-grant-storage.ts +21 -0
@@ -0,0 +1,486 @@
1
+ import {
2
+ ACTIVITY_MAX_VISIT_MS,
3
+ type ActivitySourceState,
4
+ activityActor,
5
+ activityCalendarDays,
6
+ parseActivityAttribution,
7
+ planActivityAppend,
8
+ type StoredActivityEvent,
9
+ serializeActivityAttribution,
10
+ validateActivityAppend,
11
+ } from "@sixb/core/internal/storage"
12
+ import type {
13
+ ActivityCalendarInput,
14
+ ActivityCursor,
15
+ ActivityDay,
16
+ ActivityInteraction,
17
+ ActivityPageView,
18
+ ActivityPeriodInput,
19
+ ActivitySessionVisits,
20
+ ActivityStorage,
21
+ ActivitySummary,
22
+ ActivityUsersPage,
23
+ ActivityVisit,
24
+ ActivityVisitsPage,
25
+ AppendActivityInput,
26
+ AppendActivityResult,
27
+ ListActivitySessionVisitsInput,
28
+ ListActivityUsersInput,
29
+ ListActivityVisitsInput,
30
+ ReadActivityVisitInput,
31
+ StoredActivityVisitPage,
32
+ } from "@sixb/core/storage"
33
+ import type { SQLClient } from "./pg-client"
34
+ import { lockAdvisoryKeys, type PgStoreClient, runPgTransaction } from "./transactions"
35
+
36
+ // Sorts after every stored timestamp, so a first page starts from the newest row.
37
+ const NO_CURSOR = { at: new Date("9999-12-31T23:59:59.999Z"), id: "" }
38
+
39
+ /** PostgreSQL activity. An advisory lock per session serializes its batches across replicas. */
40
+ export class PgActivityStorage implements ActivityStorage {
41
+ constructor(private readonly sql: PgStoreClient) {}
42
+
43
+ async append(input: AppendActivityInput): Promise<AppendActivityResult> {
44
+ validateActivityAppend(input)
45
+
46
+ return runPgTransaction(this.sql, async (sql) => {
47
+ await lockAdvisoryKeys(sql, [
48
+ JSON.stringify(["activity", input.projectId, input.actor.type, input.sessionId]),
49
+ ])
50
+
51
+ const plan = planActivityAppend(
52
+ input,
53
+ await readSource(sql, input),
54
+ await readStoredEvents(sql, input)
55
+ )
56
+ if (plan.status !== "append") {
57
+ return { status: plan.status === "duplicate" ? "accepted" : "throttled", accepted: 0 }
58
+ }
59
+
60
+ const { state } = plan
61
+ const lastSeenAt = new Date(state.lastSeenAt)
62
+
63
+ if (plan.newVisit) {
64
+ const attribution =
65
+ plan.newVisit.attribution === null
66
+ ? null
67
+ : serializeActivityAttribution(plan.newVisit.attribution)
68
+
69
+ await sql`
70
+ INSERT INTO activity_visits (
71
+ project_id, id, actor_type, user_id, grant_id, session_id,
72
+ started_at, last_seen_at, page_views, interactions, attribution
73
+ ) VALUES (
74
+ ${input.projectId}, ${state.visitId}, ${input.actor.type},
75
+ ${input.actor.type === "user" ? input.actor.userId : null},
76
+ ${input.actor.type === "share" ? input.actor.grantId : null},
77
+ ${input.sessionId}, ${lastSeenAt}, ${lastSeenAt}, ${plan.pageViews.length},
78
+ ${plan.interactions.length}, ${attribution}::text::jsonb
79
+ )
80
+ `
81
+ } else {
82
+ await sql`
83
+ UPDATE activity_visits
84
+ SET
85
+ last_seen_at = ${lastSeenAt},
86
+ page_views = page_views + ${plan.pageViews.length},
87
+ interactions = interactions + ${plan.interactions.length}
88
+ WHERE project_id = ${input.projectId} AND id = ${state.visitId}
89
+ `
90
+ }
91
+
92
+ for (const pageView of plan.pageViews) {
93
+ await sql`
94
+ INSERT INTO activity_page_views (
95
+ project_id, visit_id, sequence, id, route, received_at, attribution
96
+ ) VALUES (
97
+ ${input.projectId}, ${pageView.visitId}, ${pageView.sequence}, ${pageView.id},
98
+ ${pageView.route}, ${pageView.receivedAt},
99
+ ${serializeActivityAttribution(pageView.attribution)}::text::jsonb
100
+ )
101
+ `
102
+ }
103
+
104
+ for (const interaction of plan.interactions) {
105
+ await sql`
106
+ INSERT INTO activity_interactions (
107
+ project_id, visit_id, sequence, id, name, route, received_at
108
+ ) VALUES (
109
+ ${input.projectId}, ${interaction.visitId}, ${interaction.sequence},
110
+ ${interaction.id}, ${interaction.name}, ${interaction.route}, ${interaction.receivedAt}
111
+ )
112
+ `
113
+ }
114
+
115
+ await sql`
116
+ INSERT INTO activity_sources (
117
+ project_id, actor_type, session_id, visit_id, last_seen_at, sequence,
118
+ window_start, window_count
119
+ ) VALUES (
120
+ ${input.projectId}, ${input.actor.type}, ${input.sessionId}, ${state.visitId},
121
+ ${lastSeenAt}, ${state.sequence}, ${new Date(state.windowStart)}, ${state.windowCount}
122
+ )
123
+ ON CONFLICT (project_id, actor_type, session_id) DO UPDATE SET
124
+ visit_id = excluded.visit_id,
125
+ last_seen_at = excluded.last_seen_at,
126
+ sequence = excluded.sequence,
127
+ window_start = excluded.window_start,
128
+ window_count = excluded.window_count
129
+ `
130
+
131
+ return { status: "accepted", accepted: plan.accepted }
132
+ })
133
+ }
134
+
135
+ async list(input: ListActivityVisitsInput): Promise<ActivityVisitsPage> {
136
+ const before = input.before ?? NO_CURSOR
137
+ const rows = await this.sql<VisitRow[]>`
138
+ SELECT ${this.visitColumns()}
139
+ FROM activity_visits v
140
+ WHERE ${this.overlapping(input)}
141
+ AND (
142
+ v.last_seen_at < ${before.at}
143
+ OR (v.last_seen_at = ${before.at} AND v.id < ${before.id})
144
+ )
145
+ ORDER BY v.last_seen_at DESC, v.id DESC
146
+ LIMIT ${input.limit + 1}
147
+ `
148
+
149
+ return {
150
+ visits: rows.slice(0, input.limit).map(visitFromRow),
151
+ hasMore: rows.length > input.limit,
152
+ }
153
+ }
154
+
155
+ async users(input: ListActivityUsersInput): Promise<ActivityUsersPage> {
156
+ const before: ActivityCursor = input.before ?? NO_CURSOR
157
+ const rows = await this.sql<UserRow[]>`
158
+ SELECT
159
+ v.user_id,
160
+ MAX(v.last_seen_at) AS last_seen_at,
161
+ COUNT(DISTINCT v.id) AS visits,
162
+ COUNT(p.id) AS page_views
163
+ FROM activity_visits v
164
+ LEFT JOIN activity_page_views p
165
+ ON p.project_id = v.project_id
166
+ AND p.visit_id = v.id
167
+ AND p.received_at >= ${input.from}
168
+ AND p.received_at < ${input.to}
169
+ WHERE ${this.overlapping(input)} AND v.actor_type = 'user'
170
+ GROUP BY v.user_id
171
+ HAVING MAX(v.last_seen_at) < ${before.at}
172
+ OR (MAX(v.last_seen_at) = ${before.at} AND v.user_id < ${before.id})
173
+ ORDER BY last_seen_at DESC, v.user_id DESC
174
+ LIMIT ${input.limit + 1}
175
+ `
176
+
177
+ return {
178
+ users: rows.slice(0, input.limit).map((row) => ({
179
+ userId: row.user_id,
180
+ lastSeenAt: new Date(row.last_seen_at),
181
+ visits: Number(row.visits),
182
+ pageViews: Number(row.page_views),
183
+ })),
184
+ hasMore: rows.length > input.limit,
185
+ }
186
+ }
187
+
188
+ async summarize(input: ActivityPeriodInput): Promise<ActivitySummary> {
189
+ const [row] = await this.sql<SummaryRow[]>`
190
+ SELECT
191
+ COUNT(DISTINCT v.user_id) AS users,
192
+ COUNT(DISTINCT v.id) AS visits,
193
+ COUNT(DISTINCT v.id) FILTER (WHERE v.actor_type = 'share') AS shared_visits,
194
+ COUNT(p.id) AS page_views
195
+ FROM activity_visits v
196
+ LEFT JOIN activity_page_views p
197
+ ON p.project_id = v.project_id
198
+ AND p.visit_id = v.id
199
+ AND p.received_at >= ${input.from}
200
+ AND p.received_at < ${input.to}
201
+ WHERE ${this.overlapping(input)}
202
+ `
203
+
204
+ return {
205
+ users: Number(row?.users ?? 0),
206
+ visits: Number(row?.visits ?? 0),
207
+ sharedVisits: Number(row?.shared_visits ?? 0),
208
+ pageViews: Number(row?.page_views ?? 0),
209
+ }
210
+ }
211
+
212
+ async calendar(input: ActivityCalendarInput): Promise<readonly ActivityDay[]> {
213
+ const visits = await this.sql<{ started_at: Date | string; last_seen_at: Date | string }[]>`
214
+ SELECT v.started_at, v.last_seen_at
215
+ FROM activity_visits v
216
+ WHERE ${this.overlapping(input)}
217
+ `
218
+ const days = await this.sql<{ day: string; page_views: string }[]>`
219
+ SELECT
220
+ to_char(p.received_at AT TIME ZONE 'UTC', 'YYYY-MM-DD') AS day,
221
+ COUNT(*) AS page_views
222
+ FROM activity_visits v
223
+ JOIN activity_page_views p ON p.project_id = v.project_id AND p.visit_id = v.id
224
+ WHERE ${this.overlapping(input)}
225
+ AND p.received_at >= ${input.from}
226
+ AND p.received_at < ${input.to}
227
+ GROUP BY day
228
+ `
229
+
230
+ return activityCalendarDays(
231
+ input,
232
+ visits.map((visit) => ({
233
+ startedAt: new Date(visit.started_at),
234
+ lastSeenAt: new Date(visit.last_seen_at),
235
+ })),
236
+ new Map(days.map((row) => [row.day, Number(row.page_views)]))
237
+ )
238
+ }
239
+
240
+ async read(input: ReadActivityVisitInput): Promise<StoredActivityVisitPage | null> {
241
+ const [visit] = await this.sql<(VisitRow & { readonly session_id: string })[]>`
242
+ SELECT ${this.visitColumns()}, v.session_id
243
+ FROM activity_visits v
244
+ WHERE v.project_id = ${input.projectId} AND v.id = ${input.id}
245
+ `
246
+ if (!visit) {
247
+ return null
248
+ }
249
+
250
+ // Page views and interactions share the visit's sequence, so one bound covers both.
251
+ const rows = await this.sql<EventRow[]>`
252
+ SELECT id, visit_id, sequence, route, NULL::text AS name, received_at
253
+ FROM activity_page_views
254
+ WHERE project_id = ${input.projectId}
255
+ AND visit_id = ${input.id}
256
+ UNION ALL
257
+ SELECT id, visit_id, sequence, route, name, received_at
258
+ FROM activity_interactions
259
+ WHERE project_id = ${input.projectId}
260
+ AND visit_id = ${input.id}
261
+ ORDER BY sequence
262
+ LIMIT ${input.limit + 1}
263
+ `
264
+
265
+ const pageViews: ActivityPageView[] = []
266
+ const interactions: ActivityInteraction[] = []
267
+ for (const row of rows.slice(0, input.limit)) {
268
+ if (row.name === null) {
269
+ pageViews.push(pageViewFromRow(row))
270
+ } else {
271
+ interactions.push({ ...pageViewFromRow(row), name: row.name })
272
+ }
273
+ }
274
+
275
+ return {
276
+ visit: visitFromRow(visit),
277
+ sessionId: visit.session_id,
278
+ pageViews,
279
+ interactions,
280
+ hasMore: rows.length > input.limit,
281
+ }
282
+ }
283
+
284
+ async sessionVisits(
285
+ input: ListActivitySessionVisitsInput
286
+ ): Promise<readonly ActivitySessionVisits[]> {
287
+ if (input.sessionIds.length === 0) {
288
+ return []
289
+ }
290
+
291
+ // A session opens a visit per return after inactivity, so each has a few to rank.
292
+ const rows = await this.sql<SessionVisitRow[]>`
293
+ SELECT *
294
+ FROM (
295
+ SELECT
296
+ ${this.visitColumns()},
297
+ v.session_id,
298
+ ROW_NUMBER() OVER (
299
+ PARTITION BY v.session_id ORDER BY v.started_at DESC, v.id DESC
300
+ ) AS position,
301
+ COUNT(*) OVER (PARTITION BY v.session_id) AS total
302
+ FROM activity_visits v
303
+ WHERE v.project_id = ${input.projectId}
304
+ AND v.actor_type = 'user'
305
+ AND v.session_id = ANY(${this.sql.array([...input.sessionIds])}::text[])
306
+ AND v.user_id = ${input.userId}
307
+ ) ranked
308
+ WHERE position <= ${input.limit}
309
+ ORDER BY session_id, position
310
+ `
311
+
312
+ const bySession = new Map<string, { visits: number; latest: ActivityVisit[] }>()
313
+ for (const row of rows) {
314
+ const session = bySession.get(row.session_id) ?? { visits: Number(row.total), latest: [] }
315
+ session.latest.push(visitFromRow(row))
316
+ bySession.set(row.session_id, session)
317
+ }
318
+
319
+ return [...bySession].map(([sessionId, session]) => ({ sessionId, ...session }))
320
+ }
321
+
322
+ /**
323
+ * The visits `v` active in the period, and only the account's when one is asked for. A visit
324
+ * lasts less than a day, so it started at most a day before the period: that bounds the index
325
+ * range.
326
+ */
327
+ private overlapping(input: ActivityPeriodInput | ActivityCalendarInput) {
328
+ const user = input.userId === undefined ? this.sql`` : this.sql`AND v.user_id = ${input.userId}`
329
+ const earliestStart = new Date(input.from.getTime() - ACTIVITY_MAX_VISIT_MS)
330
+
331
+ return this.sql`
332
+ v.project_id = ${input.projectId}
333
+ AND v.started_at >= ${earliestStart}
334
+ AND v.started_at < ${input.to}
335
+ AND v.last_seen_at >= ${input.from}
336
+ ${user}
337
+ `
338
+ }
339
+
340
+ private visitColumns() {
341
+ return this.sql`
342
+ v.id, v.actor_type, v.user_id, v.grant_id, v.started_at, v.last_seen_at, v.page_views,
343
+ v.interactions, v.attribution::text AS attribution
344
+ `
345
+ }
346
+ }
347
+
348
+ async function readSource(
349
+ sql: SQLClient,
350
+ input: AppendActivityInput
351
+ ): Promise<ActivitySourceState | undefined> {
352
+ const [row] = await sql<SourceRow[]>`
353
+ SELECT
354
+ s.visit_id, v.started_at AS visit_started_at, s.last_seen_at, s.sequence,
355
+ s.window_start, s.window_count
356
+ FROM activity_sources s
357
+ LEFT JOIN activity_visits v ON v.project_id = s.project_id AND v.id = s.visit_id
358
+ WHERE s.project_id = ${input.projectId}
359
+ AND s.actor_type = ${input.actor.type}
360
+ AND s.session_id = ${input.sessionId}
361
+ `
362
+ if (!row) {
363
+ return undefined
364
+ }
365
+
366
+ // A deleted visit leaves its source behind; the next batch starts another visit.
367
+ return {
368
+ visitId: row.visit_id,
369
+ visitStartedAt: row.visit_started_at === null ? null : new Date(row.visit_started_at).getTime(),
370
+ lastSeenAt: new Date(row.last_seen_at).getTime(),
371
+ sequence: Number(row.sequence),
372
+ windowStart: new Date(row.window_start).getTime(),
373
+ windowCount: Number(row.window_count),
374
+ }
375
+ }
376
+
377
+ /** The session's page views and interactions with an id of the batch, to recognize retries. */
378
+ async function readStoredEvents(
379
+ sql: SQLClient,
380
+ input: AppendActivityInput
381
+ ): Promise<StoredActivityEvent[]> {
382
+ const ids = input.events.map((event) => event.id)
383
+ const rows = await sql<
384
+ { id: string; route: string; name: string | null; attribution: string | null }[]
385
+ >`
386
+ SELECT p.id, p.route, NULL::text AS name, p.attribution::text AS attribution
387
+ FROM activity_page_views p
388
+ JOIN activity_visits v ON v.project_id = p.project_id AND v.id = p.visit_id
389
+ WHERE p.project_id = ${input.projectId}
390
+ AND p.id = ANY(${sql.array(ids)}::text[])
391
+ AND v.actor_type = ${input.actor.type}
392
+ AND v.session_id = ${input.sessionId}
393
+ UNION ALL
394
+ SELECT i.id, i.route, i.name, NULL::text AS attribution
395
+ FROM activity_interactions i
396
+ JOIN activity_visits v ON v.project_id = i.project_id AND v.id = i.visit_id
397
+ WHERE i.project_id = ${input.projectId}
398
+ AND i.id = ANY(${sql.array(ids)}::text[])
399
+ AND v.actor_type = ${input.actor.type}
400
+ AND v.session_id = ${input.sessionId}
401
+ `
402
+
403
+ return rows.map((row) => {
404
+ if (row.name !== null) {
405
+ return { id: row.id, route: row.route, name: row.name }
406
+ }
407
+
408
+ const attribution = parseActivityAttribution(row.attribution)
409
+ return { id: row.id, route: row.route, ...(attribution ? { attribution } : {}) }
410
+ })
411
+ }
412
+
413
+ interface SourceRow {
414
+ readonly visit_id: string
415
+ readonly visit_started_at: Date | string | null
416
+ readonly last_seen_at: Date | string
417
+ readonly sequence: number
418
+ readonly window_start: Date | string
419
+ readonly window_count: number
420
+ }
421
+
422
+ interface VisitRow {
423
+ readonly id: string
424
+ readonly actor_type: string
425
+ readonly user_id: string | null
426
+ readonly grant_id: string | null
427
+ readonly started_at: Date | string
428
+ readonly last_seen_at: Date | string
429
+ readonly page_views: number
430
+ readonly interactions: number
431
+ readonly attribution: string | null
432
+ }
433
+
434
+ interface SessionVisitRow extends VisitRow {
435
+ readonly session_id: string
436
+ readonly total: number | string
437
+ }
438
+
439
+ interface UserRow {
440
+ readonly user_id: string
441
+ readonly last_seen_at: Date | string
442
+ readonly visits: string
443
+ readonly page_views: string
444
+ }
445
+
446
+ interface SummaryRow {
447
+ readonly users: string
448
+ readonly visits: string
449
+ readonly shared_visits: string
450
+ readonly page_views: string
451
+ }
452
+
453
+ interface PageViewRow {
454
+ readonly id: string
455
+ readonly visit_id: string
456
+ readonly sequence: number
457
+ readonly route: string
458
+ readonly received_at: Date | string
459
+ }
460
+
461
+ /** A page view or, with a name, an interaction. */
462
+ interface EventRow extends PageViewRow {
463
+ readonly name: string | null
464
+ }
465
+
466
+ function visitFromRow(row: VisitRow): ActivityVisit {
467
+ return {
468
+ id: row.id,
469
+ actor: activityActor(row.actor_type, row.user_id, row.grant_id),
470
+ attribution: parseActivityAttribution(row.attribution),
471
+ startedAt: new Date(row.started_at),
472
+ lastSeenAt: new Date(row.last_seen_at),
473
+ pageViews: Number(row.page_views),
474
+ interactions: Number(row.interactions),
475
+ }
476
+ }
477
+
478
+ function pageViewFromRow(row: PageViewRow): ActivityPageView {
479
+ return {
480
+ id: row.id,
481
+ visitId: row.visit_id,
482
+ sequence: Number(row.sequence),
483
+ route: row.route,
484
+ receivedAt: new Date(row.received_at),
485
+ }
486
+ }