@sixb/pg 0.1.20 → 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.
- package/dist/agents/threads.d.ts +4 -0
- package/dist/agents/threads.d.ts.map +1 -1
- package/dist/auth-storage/sessions.d.ts +2 -1
- package/dist/auth-storage/sessions.d.ts.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +959 -7
- package/dist/index.js.map +15 -13
- package/dist/migrations.d.ts.map +1 -1
- package/dist/ontology-storage/replacement-plans.d.ts.map +1 -1
- package/dist/ontology-storage/sources.d.ts +2 -1
- package/dist/ontology-storage/sources.d.ts.map +1 -1
- package/dist/pg-action-run-storage.d.ts +4 -0
- package/dist/pg-action-run-storage.d.ts.map +1 -1
- package/dist/pg-activity-storage.d.ts +22 -0
- package/dist/pg-activity-storage.d.ts.map +1 -0
- package/dist/pg-artifact-storage.d.ts +19 -0
- package/dist/pg-artifact-storage.d.ts.map +1 -0
- package/dist/pg-execution-storage.d.ts +2 -1
- package/dist/pg-execution-storage.d.ts.map +1 -1
- package/dist/pg-workflow-run-storage.d.ts +4 -0
- package/dist/pg-workflow-run-storage.d.ts.map +1 -1
- package/dist/share-grant-storage.d.ts +2 -1
- package/dist/share-grant-storage.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/agents/threads.ts +23 -0
- package/src/auth-storage/sessions.ts +20 -0
- package/src/index.ts +10 -0
- package/src/migrations/063-artifacts.sql +67 -0
- package/src/migrations/064-drop-output-diagnostics.sql +14 -0
- package/src/migrations/065-plan-work-payload-statistics.sql +5 -0
- package/src/migrations/066-activity.sql +61 -0
- package/src/migrations/067-activity-timeline.sql +15 -0
- package/src/migrations/068-activity-interactions.sql +20 -0
- package/src/migrations.ts +18 -0
- package/src/ontology-storage/replacement-plans.ts +25 -7
- package/src/ontology-storage/sources.ts +37 -0
- package/src/pg-action-run-storage.ts +23 -0
- package/src/pg-activity-storage.ts +486 -0
- package/src/pg-artifact-storage.ts +325 -0
- package/src/pg-execution-storage.ts +52 -0
- package/src/pg-workflow-run-storage.ts +23 -0
- package/src/share-grant-storage.ts +21 -0
|
@@ -50,11 +50,13 @@ const IDENTITIES = "ontology_replacement_plan_identities"
|
|
|
50
50
|
const SORT_KEY = "encode(convert_to(identity_key, 'UTF8'), 'hex')"
|
|
51
51
|
|
|
52
52
|
/**
|
|
53
|
-
* Rows a plan writes
|
|
54
|
-
*
|
|
55
|
-
*
|
|
53
|
+
* Rows a plan writes before its tables are analyzed again: identities when it opens or expands,
|
|
54
|
+
* work once a stream has staged it. Every plan fills and empties the same tables, so their
|
|
55
|
+
* statistics describe the plans that were there when they were last analyzed. Until they describe
|
|
56
|
+
* this one, the planner expects none of its rows and reads its whole work for each key it looks
|
|
57
|
+
* up. Below this many rows, those reads cost less than an ANALYZE.
|
|
56
58
|
*/
|
|
57
|
-
const ANALYZE_ROWS =
|
|
59
|
+
const ANALYZE_ROWS = 1_000
|
|
58
60
|
|
|
59
61
|
/** The plan a session applies: its work rows carry the candidate's source version as `work_id`. */
|
|
60
62
|
export interface PgReplacementPlan {
|
|
@@ -240,7 +242,7 @@ export class PgOntologyReplacementPlanStorage implements OntologyReplacementPlan
|
|
|
240
242
|
input: StreamReplacementPlanStateInput
|
|
241
243
|
): AsyncIterable<ReplacementPlanStatePage> {
|
|
242
244
|
assertPageRows(input.pageRows)
|
|
243
|
-
await runPgTransaction(this.sql, async (sql) => {
|
|
245
|
+
const versionId = await runPgTransaction(this.sql, async (sql) => {
|
|
244
246
|
const { candidate } = await requirePlan(sql, input)
|
|
245
247
|
if (input.entityKind === "object" && candidate.projection_kind !== "object") {
|
|
246
248
|
throw new MaterializationConflictError(
|
|
@@ -258,6 +260,7 @@ export class PgOntologyReplacementPlanStorage implements OntologyReplacementPlan
|
|
|
258
260
|
const expanded = await expandLinks(sql, candidate.version_id, input.projectId, "pending")
|
|
259
261
|
if (expanded >= ANALYZE_ROWS && !isWithinPgTransaction(this.sql)) await analyzePlans(sql)
|
|
260
262
|
}
|
|
263
|
+
return candidate.version_id
|
|
261
264
|
})
|
|
262
265
|
let after = ""
|
|
263
266
|
let streamed = 0
|
|
@@ -296,8 +299,8 @@ export class PgOntologyReplacementPlanStorage implements OntologyReplacementPlan
|
|
|
296
299
|
if (!page) {
|
|
297
300
|
// Every page is staged by now: later lookups and the commit join this work. Never in the
|
|
298
301
|
// commit transaction, where ANALYZE would hold a lock other commits queue behind.
|
|
299
|
-
if (streamed
|
|
300
|
-
await
|
|
302
|
+
if (streamed > 0 && !isWithinPgTransaction(this.sql)) {
|
|
303
|
+
await analyzeStagedWork(this.sql, versionId)
|
|
301
304
|
}
|
|
302
305
|
return
|
|
303
306
|
}
|
|
@@ -994,6 +997,21 @@ async function analyzePlans(sql: SQLClient): Promise<void> {
|
|
|
994
997
|
await sql`ANALYZE ${sql(IDENTITIES)}, ${sql(PG_PLAN_WORK_TABLE)}`
|
|
995
998
|
}
|
|
996
999
|
|
|
1000
|
+
/**
|
|
1001
|
+
* Analyzes the plan tables once the plan holds `ANALYZE_ROWS` rows of work. Counted in work rows,
|
|
1002
|
+
* not identities: an object stages its classification, its existence, its change and its event.
|
|
1003
|
+
*/
|
|
1004
|
+
async function analyzeStagedWork(sql: SQLClient, versionId: string): Promise<void> {
|
|
1005
|
+
const [staged] = await sql<{ readonly rows: number | string }[]>`
|
|
1006
|
+
SELECT COUNT(*) AS rows FROM (
|
|
1007
|
+
SELECT 1 FROM ${sql(PG_PLAN_WORK_TABLE)} WHERE work_id = ${versionId} LIMIT ${ANALYZE_ROWS}
|
|
1008
|
+
) AS work
|
|
1009
|
+
`
|
|
1010
|
+
|
|
1011
|
+
if (Number(staged?.rows ?? 0) < ANALYZE_ROWS) return
|
|
1012
|
+
await analyzePlans(sql)
|
|
1013
|
+
}
|
|
1014
|
+
|
|
997
1015
|
/**
|
|
998
1016
|
* Deletes the work of the identities of `kind` left to plan: an identity to plan holds none. The
|
|
999
1017
|
* keys come first, so the work is sought by key whatever the plan tables' statistics.
|
|
@@ -19,6 +19,7 @@ import type {
|
|
|
19
19
|
AbandonRunSourceMaterializationInput,
|
|
20
20
|
AbandonSourceMaterializationCandidateInput,
|
|
21
21
|
AbandonSourceMaterializationInput,
|
|
22
|
+
ActiveOntologySource,
|
|
22
23
|
AdoptedSourceMaterialization,
|
|
23
24
|
AdoptSourceMaterializationInput,
|
|
24
25
|
AssertSourceMaterializationExecutionInput,
|
|
@@ -26,6 +27,7 @@ import type {
|
|
|
26
27
|
CleanupTerminalSourceMaterializationsInput,
|
|
27
28
|
CleanupTerminalSourceMaterializationsResult,
|
|
28
29
|
GetActiveOntologySourceInput,
|
|
30
|
+
ListActiveOntologySourcesInput,
|
|
29
31
|
MarkSourceMaterializationReadyInput,
|
|
30
32
|
OntologySourceRecord,
|
|
31
33
|
OntologySourceStorage,
|
|
@@ -237,6 +239,32 @@ export class PgOntologySourceStorage implements OntologySourceStorage {
|
|
|
237
239
|
})
|
|
238
240
|
}
|
|
239
241
|
|
|
242
|
+
async listActive(
|
|
243
|
+
input: ListActiveOntologySourcesInput
|
|
244
|
+
): Promise<readonly ActiveOntologySource[]> {
|
|
245
|
+
return this.runRootOperation(async (sql) => {
|
|
246
|
+
assertNonblank(input.projectId, "Source project id")
|
|
247
|
+
|
|
248
|
+
// Served by the partial index on active sources.
|
|
249
|
+
const rows = await sql<ActiveSourceRow[]>`
|
|
250
|
+
SELECT source_id, dataset_id, dataset_version_id, dataset_version_created_at,
|
|
251
|
+
projection_revision
|
|
252
|
+
FROM ontology_sources
|
|
253
|
+
WHERE project_id = ${input.projectId} AND status = 'active'
|
|
254
|
+
`
|
|
255
|
+
|
|
256
|
+
return rows.map((row) => ({
|
|
257
|
+
source: { projectionId: row.source_id },
|
|
258
|
+
datasetVersion: {
|
|
259
|
+
datasetId: row.dataset_id,
|
|
260
|
+
versionId: row.dataset_version_id,
|
|
261
|
+
createdAt: toIsoString(row.dataset_version_created_at),
|
|
262
|
+
},
|
|
263
|
+
projectionRevision: row.projection_revision,
|
|
264
|
+
}))
|
|
265
|
+
})
|
|
266
|
+
}
|
|
267
|
+
|
|
240
268
|
async adopt(
|
|
241
269
|
input: AdoptSourceMaterializationInput
|
|
242
270
|
): Promise<AdoptedSourceMaterialization | null> {
|
|
@@ -620,3 +648,12 @@ export class PgOntologySourceStorage implements OntologySourceStorage {
|
|
|
620
648
|
function numberOrNull(value: number | string | null): number | null {
|
|
621
649
|
return value === null ? null : Number(value)
|
|
622
650
|
}
|
|
651
|
+
|
|
652
|
+
type ActiveSourceRow = Pick<
|
|
653
|
+
PgOntologySourceRow,
|
|
654
|
+
| "source_id"
|
|
655
|
+
| "dataset_id"
|
|
656
|
+
| "dataset_version_id"
|
|
657
|
+
| "dataset_version_created_at"
|
|
658
|
+
| "projection_revision"
|
|
659
|
+
>
|
|
@@ -136,6 +136,29 @@ export class PgActionRunStorage implements ActionRunStorage {
|
|
|
136
136
|
return row ? rowToActionRunRecord(row) : null
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
async getByExecutionIds(params: {
|
|
140
|
+
readonly projectId: string
|
|
141
|
+
readonly executionIds: readonly string[]
|
|
142
|
+
}): Promise<readonly ActionRunRecord[]> {
|
|
143
|
+
const executionIds = [...new Set(params.executionIds)]
|
|
144
|
+
if (executionIds.length === 0) {
|
|
145
|
+
return []
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const rows = await this.sql<DatabaseRow[]>`
|
|
149
|
+
SELECT *
|
|
150
|
+
FROM action_runs
|
|
151
|
+
WHERE project_id = ${params.projectId}
|
|
152
|
+
AND execution_id = ANY(${this.sql.array(executionIds)}::text[])
|
|
153
|
+
`
|
|
154
|
+
|
|
155
|
+
const byExecution = new Map(rows.map((row) => [row.execution_id, row]))
|
|
156
|
+
return executionIds.flatMap((executionId) => {
|
|
157
|
+
const row = byExecution.get(executionId)
|
|
158
|
+
return row ? [rowToActionRunRecord(row)] : []
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
|
|
139
162
|
async list(input: ListActionRunsInput): Promise<ListActionRunsResult> {
|
|
140
163
|
if ((input.statuses && input.statuses.length === 0) || input.actionIds?.length === 0) {
|
|
141
164
|
return {
|
|
@@ -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
|
+
}
|