feat: Document insight rollups (#12086)

* First pass

* Remove popularity changes

* Address review feedback

- Compute retention cutoff in UTC from the database rather than worker-local TZ
- Push partition predicate into rollup source CTEs to avoid full-table scans per partition

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Anchor insight rollups to UTC and include today

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Tom Moor
2026-04-18 08:11:15 -04:00
committed by GitHub
parent 6d7d8b056c
commit 600108bc43
13 changed files with 880 additions and 21 deletions
@@ -0,0 +1,96 @@
"use strict";
module.exports = {
async up(queryInterface, Sequelize) {
return queryInterface.sequelize.transaction(async (transaction) => {
await queryInterface.createTable(
"document_insights",
{
id: {
type: Sequelize.UUID,
allowNull: false,
defaultValue: Sequelize.UUIDV4,
primaryKey: true,
},
documentId: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: "documents",
key: "id",
},
onDelete: "CASCADE",
},
teamId: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: "teams",
key: "id",
},
onDelete: "CASCADE",
},
date: {
type: Sequelize.DATEONLY,
allowNull: false,
},
viewCount: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0,
},
viewerCount: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0,
},
commentCount: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0,
},
reactionCount: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0,
},
revisionCount: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0,
},
editorCount: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0,
},
createdAt: {
type: Sequelize.DATE,
allowNull: false,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false,
},
},
{ transaction }
);
await queryInterface.addIndex(
"document_insights",
["documentId", "date"],
{ unique: true, transaction }
);
await queryInterface.addIndex("document_insights", ["teamId", "date"], {
transaction,
});
});
},
async down(queryInterface) {
return queryInterface.sequelize.transaction(async (transaction) => {
await queryInterface.dropTable("document_insights", { transaction });
});
},
};
+72
View File
@@ -0,0 +1,72 @@
import type { InferAttributes, InferCreationAttributes } from "sequelize";
import {
BelongsTo,
Column,
DataType,
Default,
ForeignKey,
Table,
} from "sequelize-typescript";
import Document from "./Document";
import Team from "./Team";
import IdModel from "./base/IdModel";
import { SkipChangeset } from "./decorators/Changeset";
import Fix from "./decorators/Fix";
@Table({ tableName: "document_insights", modelName: "documentInsight" })
@Fix
class DocumentInsight extends IdModel<
InferAttributes<DocumentInsight>,
Partial<InferCreationAttributes<DocumentInsight>>
> {
@Column(DataType.DATEONLY)
date: string;
@Default(0)
@Column(DataType.INTEGER)
@SkipChangeset
viewCount: number;
@Default(0)
@Column(DataType.INTEGER)
@SkipChangeset
viewerCount: number;
@Default(0)
@Column(DataType.INTEGER)
@SkipChangeset
commentCount: number;
@Default(0)
@Column(DataType.INTEGER)
@SkipChangeset
reactionCount: number;
@Default(0)
@Column(DataType.INTEGER)
@SkipChangeset
revisionCount: number;
@Default(0)
@Column(DataType.INTEGER)
@SkipChangeset
editorCount: number;
// associations
@BelongsTo(() => Document, "documentId")
document: Document;
@ForeignKey(() => Document)
@Column(DataType.UUID)
documentId: string;
@BelongsTo(() => Team, "teamId")
team: Team;
@ForeignKey(() => Team)
@Column(DataType.UUID)
teamId: string;
}
export default DocumentInsight;
+2
View File
@@ -16,6 +16,8 @@ export { default as Comment } from "./Comment";
export { default as Document } from "./Document";
export { default as DocumentInsight } from "./DocumentInsight";
export { default as Event } from "./Event";
export { default as ExternalGroup } from "./ExternalGroup";
+13
View File
@@ -0,0 +1,13 @@
import type { DocumentInsight } from "@server/models";
export default function presentDocumentInsight(insight: DocumentInsight) {
return {
date: insight.date,
viewCount: insight.viewCount,
viewerCount: insight.viewerCount,
commentCount: insight.commentCount,
reactionCount: insight.reactionCount,
revisionCount: insight.revisionCount,
editorCount: insight.editorCount,
};
}
+2
View File
@@ -5,6 +5,7 @@ import presentAvailableTeam from "./availableTeam";
import presentCollection from "./collection";
import presentComment from "./comment";
import presentDocument, { presentDocuments } from "./document";
import presentDocumentInsight from "./documentInsight";
import presentEvent from "./event";
import presentExternalGroup from "./externalGroup";
import presentFileOperation from "./fileOperation";
@@ -42,6 +43,7 @@ export {
presentComment,
presentDocument,
presentDocuments,
presentDocumentInsight,
presentEvent,
presentExternalGroup,
presentFileOperation,
@@ -0,0 +1,42 @@
import { format, subDays } from "date-fns";
import { DocumentInsight } from "@server/models";
import { buildDocument, buildTeam } from "@server/test/factories";
import CleanupExpiredDocumentInsightsTask from "./CleanupExpiredDocumentInsightsTask";
const daysAgo = (n: number) => subDays(new Date(), n);
const dayStr = (d: Date) => format(d, "yyyy-MM-dd");
describe("CleanupExpiredDocumentInsightsTask", () => {
it("deletes rows older than the retention window", async () => {
const team = await buildTeam();
const document = await buildDocument({
teamId: team.id,
publishedAt: new Date(),
});
await DocumentInsight.create({
documentId: document.id,
teamId: team.id,
date: dayStr(daysAgo(400)),
viewCount: 1,
});
await DocumentInsight.create({
documentId: document.id,
teamId: team.id,
date: dayStr(daysAgo(5)),
viewCount: 1,
});
await new CleanupExpiredDocumentInsightsTask().perform();
const dates = (
await DocumentInsight.findAll({
where: { documentId: document.id },
order: [["date", "ASC"]],
})
).map((i) => i.date);
expect(dates).not.toContain(dayStr(daysAgo(400)));
expect(dates).toContain(dayStr(daysAgo(5)));
});
});
@@ -0,0 +1,42 @@
import { Op, literal } from "sequelize";
import { Minute } from "@shared/utils/time";
import Logger from "@server/logging/Logger";
import { DocumentInsight } from "@server/models";
import { TaskPriority } from "./base/BaseTask";
import { CronTask, TaskInterval } from "./base/CronTask";
/**
* Number of days of rollup history to retain.
*/
const RETENTION_DAYS = 365;
export default class CleanupExpiredDocumentInsightsTask extends CronTask {
public async perform() {
// Derive the cutoff in UTC from the database so retention isn't affected
// by the worker's local timezone. `date` is stored as a UTC DATE.
const cutoff = literal(
`(NOW() AT TIME ZONE 'UTC')::date - INTERVAL '${RETENTION_DAYS} days'`
);
const deleted = await DocumentInsight.destroy({
where: { date: { [Op.lt]: cutoff } },
});
if (deleted > 0) {
Logger.info("task", `Deleted ${deleted} expired document_insights rows`);
}
}
public get cron() {
return {
interval: TaskInterval.Day,
partitionWindow: 30 * Minute.ms,
};
}
public get options() {
return {
attempts: 1,
priority: TaskPriority.Background,
};
}
}
@@ -0,0 +1,247 @@
import { format, subDays } from "date-fns";
import { DocumentInsight, Event, Reaction, Revision } from "@server/models";
import {
buildComment,
buildDocument,
buildTeam,
buildUser,
} from "@server/test/factories";
import RollupDocumentInsightsTask from "./RollupDocumentInsightsTask";
const props = {
limit: 10000,
partition: {
partitionIndex: 0,
partitionCount: 1,
},
};
const daysAgo = (n: number) => subDays(new Date(), n);
const dayStr = (d: Date) => format(d, "yyyy-MM-dd");
describe("RollupDocumentInsightsTask", () => {
let task: RollupDocumentInsightsTask;
beforeAll(() => {
jest.setTimeout(30000);
});
beforeEach(() => {
task = new RollupDocumentInsightsTask();
});
it("writes nothing when no source activity exists", async () => {
const team = await buildTeam();
await buildDocument({ teamId: team.id });
await task.perform(props);
const count = await DocumentInsight.count({ where: { teamId: team.id } });
expect(count).toBe(0);
});
it("rolls up view events into viewCount and viewerCount", async () => {
const team = await buildTeam();
const userA = await buildUser({ teamId: team.id });
const userB = await buildUser({ teamId: team.id });
const document = await buildDocument({
teamId: team.id,
publishedAt: new Date(),
});
const yesterday = daysAgo(1);
await Event.create({
name: "views.create",
teamId: team.id,
userId: userA.id,
documentId: document.id,
createdAt: yesterday,
});
await Event.create({
name: "views.create",
teamId: team.id,
userId: userA.id,
documentId: document.id,
createdAt: yesterday,
});
await Event.create({
name: "views.create",
teamId: team.id,
userId: userB.id,
documentId: document.id,
createdAt: yesterday,
});
await task.perform(props);
const insight = await DocumentInsight.findOne({
where: { documentId: document.id, date: dayStr(yesterday) },
});
expect(insight).toBeTruthy();
expect(insight!.viewCount).toBe(3);
expect(insight!.viewerCount).toBe(2);
});
it("rolls up comments and reactions without double-counting", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const document = await buildDocument({
teamId: team.id,
publishedAt: new Date(),
});
const yesterday = daysAgo(1);
// Older comment that only receives a reaction yesterday.
const olderComment = await buildComment({
documentId: document.id,
userId: user.id,
createdAt: daysAgo(10),
});
// New comment yesterday.
await buildComment({
documentId: document.id,
userId: user.id,
createdAt: yesterday,
});
// Reaction on the older comment, but created yesterday.
await Reaction.create(
{
emoji: "🎉",
commentId: olderComment.id,
userId: user.id,
createdAt: yesterday,
},
{ hooks: false }
);
await task.perform(props);
const insight = await DocumentInsight.findOne({
where: { documentId: document.id, date: dayStr(yesterday) },
});
expect(insight).toBeTruthy();
expect(insight!.commentCount).toBe(1);
expect(insight!.reactionCount).toBe(1);
});
it("rolls up revisions into revisionCount and editorCount", async () => {
const team = await buildTeam();
const userA = await buildUser({ teamId: team.id });
const userB = await buildUser({ teamId: team.id });
const document = await buildDocument({
teamId: team.id,
publishedAt: new Date(),
});
const yesterday = daysAgo(1);
await Revision.create({
documentId: document.id,
userId: userA.id,
title: document.title,
text: "A",
createdAt: yesterday,
});
await Revision.create({
documentId: document.id,
userId: userA.id,
title: document.title,
text: "B",
createdAt: yesterday,
});
await Revision.create({
documentId: document.id,
userId: userB.id,
title: document.title,
text: "C",
createdAt: yesterday,
});
await task.perform(props);
const insight = await DocumentInsight.findOne({
where: { documentId: document.id, date: dayStr(yesterday) },
});
expect(insight).toBeTruthy();
expect(insight!.revisionCount).toBe(3);
expect(insight!.editorCount).toBe(2);
});
it("is idempotent when re-run for the same day", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const document = await buildDocument({
teamId: team.id,
publishedAt: new Date(),
});
const yesterday = daysAgo(1);
await Event.create({
name: "views.create",
teamId: team.id,
userId: user.id,
documentId: document.id,
createdAt: yesterday,
});
await task.perform(props);
await task.perform(props);
const rows = await DocumentInsight.findAll({
where: { documentId: document.id, date: dayStr(yesterday) },
});
expect(rows).toHaveLength(1);
expect(rows[0].viewCount).toBe(1);
});
it("excludes deleted documents", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const document = await buildDocument({
teamId: team.id,
publishedAt: new Date(),
deletedAt: new Date(),
});
await Event.create({
name: "views.create",
teamId: team.id,
userId: user.id,
documentId: document.id,
createdAt: daysAgo(1),
});
await task.perform(props);
const count = await DocumentInsight.count({
where: { documentId: document.id },
});
expect(count).toBe(0);
});
it("includes unpublished documents", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const draft = await buildDocument({
teamId: team.id,
publishedAt: undefined,
});
await Revision.create({
documentId: draft.id,
userId: user.id,
title: draft.title,
text: "x",
createdAt: daysAgo(1),
});
await task.perform(props);
const insight = await DocumentInsight.findOne({
where: { documentId: draft.id, date: dayStr(daysAgo(1)) },
});
expect(insight).toBeTruthy();
expect(insight!.revisionCount).toBe(1);
});
});
@@ -0,0 +1,151 @@
import { QueryTypes } from "sequelize";
import { Day, Minute } from "@shared/utils/time";
import Logger from "@server/logging/Logger";
import { sequelize } from "@server/storage/database";
import { TaskPriority } from "./base/BaseTask";
import type { Props } from "./base/CronTask";
import { CronTask, TaskInterval } from "./base/CronTask";
/**
* Number of recent days to (re)compute on each run, in addition to the current
* day. Reprocessing the most recent days lets late-arriving writes (slow
* workers, out-of-order event emission) settle into the rollup. The upsert is
* idempotent.
*/
const RECOMPUTE_DAYS = 2;
export default class RollupDocumentInsightsTask extends CronTask {
public async perform({ partition }: Props) {
const [startUuid, endUuid] = this.getPartitionBounds(partition);
for (let offset = RECOMPUTE_DAYS; offset >= 0; offset--) {
const date = new Date(Date.now() - offset * Day.ms)
.toISOString()
.slice(0, 10);
await this.rollupDay(date, startUuid, endUuid);
}
}
private async rollupDay(
date: string,
startUuid: string,
endUuid: string
): Promise<void> {
const [{ upserted }] = await sequelize.query<{ upserted: string }>(
`
WITH partitioned_documents AS (
SELECT id, "teamId"
FROM documents
WHERE "deletedAt" IS NULL
AND id >= :startUuid::uuid
AND id <= :endUuid::uuid
),
view_counts AS (
SELECT
e."documentId",
COUNT(*) AS view_count,
COUNT(DISTINCT e."userId") AS viewer_count
FROM events e
INNER JOIN partitioned_documents pd ON pd.id = e."documentId"
WHERE e.name = 'views.create'
AND e."createdAt" >= :dayStart::timestamp AT TIME ZONE 'UTC'
AND e."createdAt" < (:dayStart::timestamp + INTERVAL '1 day') AT TIME ZONE 'UTC'
GROUP BY e."documentId"
),
comment_counts AS (
SELECT c."documentId", COUNT(*) AS comment_count
FROM comments c
INNER JOIN partitioned_documents pd ON pd.id = c."documentId"
WHERE c."createdAt" >= :dayStart::timestamp AT TIME ZONE 'UTC'
AND c."createdAt" < (:dayStart::timestamp + INTERVAL '1 day') AT TIME ZONE 'UTC'
GROUP BY c."documentId"
),
reaction_counts AS (
SELECT c."documentId", COUNT(rx.id) AS reaction_count
FROM reactions rx
INNER JOIN comments c ON c.id = rx."commentId"
INNER JOIN partitioned_documents pd ON pd.id = c."documentId"
WHERE rx."createdAt" >= :dayStart::timestamp AT TIME ZONE 'UTC'
AND rx."createdAt" < (:dayStart::timestamp + INTERVAL '1 day') AT TIME ZONE 'UTC'
GROUP BY c."documentId"
),
revision_counts AS (
SELECT
r."documentId",
COUNT(*) AS revision_count,
COUNT(DISTINCT r."userId") AS editor_count
FROM revisions r
INNER JOIN partitioned_documents pd ON pd.id = r."documentId"
WHERE r."createdAt" >= :dayStart::timestamp AT TIME ZONE 'UTC'
AND r."createdAt" < (:dayStart::timestamp + INTERVAL '1 day') AT TIME ZONE 'UTC'
GROUP BY r."documentId"
),
active AS (
SELECT "documentId" FROM view_counts
UNION SELECT "documentId" FROM comment_counts
UNION SELECT "documentId" FROM reaction_counts
UNION SELECT "documentId" FROM revision_counts
),
inserted AS (
INSERT INTO document_insights (
id, "documentId", "teamId", date,
"viewCount", "viewerCount",
"commentCount", "reactionCount",
"revisionCount", "editorCount",
"createdAt", "updatedAt"
)
SELECT
uuid_generate_v4(),
pd.id,
pd."teamId",
:dayStart::date,
COALESCE(v.view_count, 0),
COALESCE(v.viewer_count, 0),
COALESCE(c.comment_count, 0),
COALESCE(rx.reaction_count, 0),
COALESCE(r.revision_count, 0),
COALESCE(r.editor_count, 0),
NOW(), NOW()
FROM active a
INNER JOIN partitioned_documents pd ON pd.id = a."documentId"
LEFT JOIN view_counts v ON v."documentId" = pd.id
LEFT JOIN comment_counts c ON c."documentId" = pd.id
LEFT JOIN reaction_counts rx ON rx."documentId" = pd.id
LEFT JOIN revision_counts r ON r."documentId" = pd.id
ON CONFLICT ("documentId", date) DO UPDATE SET
"viewCount" = EXCLUDED."viewCount",
"viewerCount" = EXCLUDED."viewerCount",
"commentCount" = EXCLUDED."commentCount",
"reactionCount" = EXCLUDED."reactionCount",
"revisionCount" = EXCLUDED."revisionCount",
"editorCount" = EXCLUDED."editorCount",
"updatedAt" = NOW()
RETURNING 1
)
SELECT COUNT(*)::text AS upserted FROM inserted
`,
{
replacements: { dayStart: date, startUuid, endUuid },
type: QueryTypes.SELECT,
}
);
Logger.info("task", `Rolled up document insights for ${date}`, {
upserted: parseInt(upserted, 10),
});
}
public get cron() {
return {
interval: TaskInterval.Day,
partitionWindow: 30 * Minute.ms,
};
}
public get options() {
return {
attempts: 1,
priority: TaskPriority.Background,
};
}
}
+38
View File
@@ -24,6 +24,7 @@ import {
} from "@shared/types";
import { subtractDate } from "@shared/utils/date";
import slugify from "@shared/utils/slugify";
import { Day } from "@shared/utils/time";
import documentCreator from "@server/commands/documentCreator";
import documentDuplicator from "@server/commands/documentDuplicator";
import documentLoader from "@server/commands/documentLoader";
@@ -49,6 +50,7 @@ import {
Relationship,
Collection,
Document,
DocumentInsight,
Event,
Revision,
SearchQuery,
@@ -69,6 +71,7 @@ import { TextHelper } from "@server/models/helpers/TextHelper";
import { authorize, cannot } from "@server/policies";
import {
presentDocument,
presentDocumentInsight,
presentDocuments,
presentPolicies,
presentTemplate,
@@ -621,6 +624,41 @@ router.post(
}
);
router.post(
"documents.insights",
auth(),
validate(T.DocumentsInsightsSchema),
async (ctx: APIContext<T.DocumentsInsightsReq>) => {
const { id, startDate, endDate } = ctx.input.body;
const { user } = ctx.state.auth;
const document = await Document.findByPk(id, { userId: user.id });
authorize(user, "listViews", document);
if (!document.insightsEnabled) {
throw ValidationError("Insights are not enabled for this document");
}
const end = endDate ?? new Date();
const start = startDate ?? new Date(end.getTime() - 30 * Day.ms);
const insights = await DocumentInsight.findAll({
where: {
documentId: document.id,
date: {
[Op.gte]: start.toISOString().slice(0, 10),
[Op.lte]: end.toISOString().slice(0, 10),
},
},
order: [["date", "ASC"]],
});
ctx.body = {
data: insights.map(presentDocumentInsight),
};
}
);
router.post(
"documents.users",
auth(),
+19
View File
@@ -154,6 +154,25 @@ export const DocumentsInfoSchema = BaseSchema.extend({
export type DocumentsInfoReq = z.infer<typeof DocumentsInfoSchema>;
export const DocumentsInsightsSchema = BaseSchema.extend({
body: BaseIdSchema.extend({
/** Start of the insights window (inclusive). Defaults to 30 days ago. */
startDate: z.coerce.date().optional(),
/** End of the insights window (inclusive). Defaults to today. */
endDate: z.coerce.date().optional(),
}),
}).refine(
(req) =>
!req.body.startDate ||
!req.body.endDate ||
req.body.startDate <= req.body.endDate,
{
message: "startDate must be on or before endDate",
}
);
export type DocumentsInsightsReq = z.infer<typeof DocumentsInsightsSchema>;
export const DocumentsExportSchema = BaseSchema.extend({
body: BaseIdSchema.extend({
signedUrls: z.number().optional(),
@@ -0,0 +1,129 @@
import "./bootstrap";
import { QueryTypes } from "sequelize";
import { Day } from "@shared/utils/time";
import { sequelize } from "@server/storage/database";
const DEFAULT_DAYS = 14;
const days = parseInt(process.argv[2], 10);
const backfillDays = Number.isNaN(days) ? DEFAULT_DAYS : days;
/**
* Populates document_insights with one row per (document, day) for each day
* within the backfill window that has source activity. Safe to re-run — the
* upsert keys on (documentId, date). Source ranges are half-open
* [dayStart, dayStart + 1) in UTC so events land in exactly one day.
*/
async function backfillDay(date: string): Promise<number> {
const [{ upserted }] = await sequelize.query<{ upserted: string }>(
`
WITH view_counts AS (
SELECT
"documentId",
COUNT(*) AS view_count,
COUNT(DISTINCT "userId") AS viewer_count
FROM events
WHERE name = 'views.create'
AND "createdAt" >= :dayStart::timestamp AT TIME ZONE 'UTC'
AND "createdAt" < (:dayStart::timestamp + INTERVAL '1 day') AT TIME ZONE 'UTC'
GROUP BY "documentId"
),
comment_counts AS (
SELECT "documentId", COUNT(*) AS comment_count
FROM comments
WHERE "createdAt" >= :dayStart::timestamp AT TIME ZONE 'UTC'
AND "createdAt" < (:dayStart::timestamp + INTERVAL '1 day') AT TIME ZONE 'UTC'
GROUP BY "documentId"
),
reaction_counts AS (
SELECT c."documentId", COUNT(rx.id) AS reaction_count
FROM reactions rx
INNER JOIN comments c ON c.id = rx."commentId"
WHERE rx."createdAt" >= :dayStart::timestamp AT TIME ZONE 'UTC'
AND rx."createdAt" < (:dayStart::timestamp + INTERVAL '1 day') AT TIME ZONE 'UTC'
GROUP BY c."documentId"
),
revision_counts AS (
SELECT
"documentId",
COUNT(*) AS revision_count,
COUNT(DISTINCT "userId") AS editor_count
FROM revisions
WHERE "createdAt" >= :dayStart::timestamp AT TIME ZONE 'UTC'
AND "createdAt" < (:dayStart::timestamp + INTERVAL '1 day') AT TIME ZONE 'UTC'
GROUP BY "documentId"
),
active AS (
SELECT "documentId" FROM view_counts
UNION SELECT "documentId" FROM comment_counts
UNION SELECT "documentId" FROM reaction_counts
UNION SELECT "documentId" FROM revision_counts
),
inserted AS (
INSERT INTO document_insights (
id, "documentId", "teamId", date,
"viewCount", "viewerCount",
"commentCount", "reactionCount",
"revisionCount", "editorCount",
"createdAt", "updatedAt"
)
SELECT
uuid_generate_v4(),
d.id,
d."teamId",
:dayStart::date,
COALESCE(v.view_count, 0),
COALESCE(v.viewer_count, 0),
COALESCE(c.comment_count, 0),
COALESCE(rx.reaction_count, 0),
COALESCE(r.revision_count, 0),
COALESCE(r.editor_count, 0),
NOW(), NOW()
FROM active a
INNER JOIN documents d ON d.id = a."documentId"
LEFT JOIN view_counts v ON v."documentId" = d.id
LEFT JOIN comment_counts c ON c."documentId" = d.id
LEFT JOIN reaction_counts rx ON rx."documentId" = d.id
LEFT JOIN revision_counts r ON r."documentId" = d.id
WHERE d."deletedAt" IS NULL
ON CONFLICT ("documentId", date) DO UPDATE SET
"viewCount" = EXCLUDED."viewCount",
"viewerCount" = EXCLUDED."viewerCount",
"commentCount" = EXCLUDED."commentCount",
"reactionCount" = EXCLUDED."reactionCount",
"revisionCount" = EXCLUDED."revisionCount",
"editorCount" = EXCLUDED."editorCount",
"updatedAt" = NOW()
RETURNING 1
)
SELECT COUNT(*)::text AS upserted FROM inserted
`,
{
replacements: { dayStart: date },
type: QueryTypes.SELECT,
}
);
return parseInt(upserted, 10);
}
async function main() {
console.log(`Backfilling ${backfillDays} days of document insights…`);
for (let offset = backfillDays; offset >= 1; offset--) {
const date = new Date(Date.now() - offset * Day.ms)
.toISOString()
.slice(0, 10);
const upserted = await backfillDay(date);
console.log(` ${date}: ${upserted} rows`);
}
console.log("Backfill complete");
process.exit(0);
}
if (process.env.NODE_ENV !== "test") {
void main();
}
export default main;
+27 -21
View File
@@ -484,29 +484,35 @@ export async function buildComment(overrides: {
parentCommentId?: string;
resolvedById?: string;
reactions?: ReactionSummary[];
createdAt?: Date;
}) {
const comment = await Comment.create({
resolvedById: overrides.resolvedById,
parentCommentId: overrides.parentCommentId,
documentId: overrides.documentId,
data: {
type: "doc",
content: [
{
type: "paragraph",
content: [
{
content: [],
type: "text",
text: "test",
},
],
},
],
const comment = await Comment.create(
{
resolvedById: overrides.resolvedById,
parentCommentId: overrides.parentCommentId,
documentId: overrides.documentId,
data: {
type: "doc",
content: [
{
type: "paragraph",
content: [
{
content: [],
type: "text",
text: "test",
},
],
},
],
},
createdById: overrides.userId,
reactions: overrides.reactions,
createdAt: overrides.createdAt,
updatedAt: overrides.createdAt,
},
createdById: overrides.userId,
reactions: overrides.reactions,
});
{ silent: overrides.createdAt ? true : false }
);
return comment;
}