| 156 | 156 | | |
| 157 | 157 | | const now = () => new Date().toISOString(); |
| 158 | 158 | | |
| 159 | + | /** |
| 160 | + | * Whether this isolate has seen the descriptions sweep done (see |
| 161 | + | * `sweepDescriptions`), so a listing reads its row once, not every time. |
| 162 | + | */ |
| 163 | + | let descriptionsSwept = false; |
| 164 | + | |
| 159 | 165 | | function toProject(row: Row): Project { |
| 160 | 166 | | const setting = { kind: kindSetting(row.kind_setting), runs: runsSetting(row.runs_setting) }; |
| 161 | 167 | | const facts: Omit<KindFacts, "setting"> = { |
| ⋯ |
| 323 | 329 | | async list(a: { workspace: string; viewer: Viewer }): Promise<Result<Project[]>> { |
| 324 | 330 | | const workspace = a.workspace.toLowerCase(); |
| 325 | 331 | | await this.backfill(workspace); |
| 332 | + | if (!descriptionsSwept) this.defer(this.sweepDescriptions()); |
| 326 | 333 | | const rows = await this.db |
| 327 | 334 | | .prepare("SELECT * FROM projects WHERE workspace = ? AND repo_deleted_at IS NULL ORDER BY name COLLATE NOCASE") |
| 328 | 335 | | .bind(workspace) |
| ⋯ |
| 337 | 344 | | async get(a: { workspace: string; slug: string; viewer: Viewer }): Promise<Result<Project>> { |
| 338 | 345 | | const workspace = a.workspace.toLowerCase(); |
| 339 | 346 | | await this.backfill(workspace); |
| 347 | + | if (!descriptionsSwept) this.defer(this.sweepDescriptions()); |
| 340 | 348 | | const row = await this.db |
| 341 | 349 | | .prepare("SELECT * FROM projects WHERE workspace = ? AND slug = ? AND repo_deleted_at IS NULL") |
| 342 | 350 | | .bind(workspace, a.slug.toLowerCase()) |
| ⋯ |
| 401 | 409 | | private async updated(row: Row, actor: User): Promise<void> { |
| 402 | 410 | | if (!this.env.EVENTS) return; |
| 403 | 411 | | await eventsClient(this.env.EVENTS) |
| 404 | | − | .publish([ |
| 405 | | − | { |
| 406 | | − | type: "project.updated", |
| 407 | | − | source: "projects", |
| 408 | | − | repoId: row.repo_id, |
| 409 | | − | actor: actor.username, |
| 410 | | − | data: { |
| 411 | | − | projectId: row.id, |
| 412 | | − | workspace: row.workspace, |
| 413 | | − | slug: row.slug, |
| 414 | | − | repoId: row.repo_id, |
| 415 | | − | primary: row.is_primary === 1, |
| 416 | | − | description: row.description, |
| 417 | | − | }, |
| 418 | | − | }, |
| 419 | | − | ]) |
| 412 | + | .publish([projectUpdated(row, actor.username)]) |
| 420 | 413 | | .catch((error: unknown) => console.error("project.updated not published", row.id, String(error))); |
| 421 | 414 | | } |
| 422 | 415 | | |
| 423 | 416 | | /** |
| 417 | + | * Once for all of g1t: tells search every repository's own project's |
| 418 | + | * description. Search kept repositories' descriptions until #45, so a |
| 419 | + | * project that already had its own showed the repository's old one there. |
| 420 | + | * Claimed by its row in `sweeps` (migrations/0016), so it runs once. |
| 421 | + | */ |
| 422 | + | private async sweepDescriptions(): Promise<void> { |
| 423 | + | if (!this.env.EVENTS) return; |
| 424 | + | const done = await this.db.prepare("SELECT 1 FROM sweeps WHERE key = 'descriptions'").first(); |
| 425 | + | if (done) { |
| 426 | + | descriptionsSwept = true; |
| 427 | + | return; |
| 428 | + | } |
| 429 | + | const claimed = await this.db.prepare("INSERT OR IGNORE INTO sweeps (key, at) VALUES ('descriptions', ?)").bind(now()).run(); |
| 430 | + | descriptionsSwept = true; |
| 431 | + | if (!claimed.meta.changes) return; |
| 432 | + | const rows = await this.db |
| 433 | + | .prepare("SELECT * FROM projects WHERE is_primary = 1 AND repo_deleted_at IS NULL AND description IS NOT NULL") |
| 434 | + | .all<Row>(); |
| 435 | + | const events = rows.results.map((row) => projectUpdated(row, null)); |
| 436 | + | for (let at = 0; at < events.length; at += 50) { |
| 437 | + | await eventsClient(this.env.EVENTS) |
| 438 | + | .publish(events.slice(at, at + 50)) |
| 439 | + | .catch((error: unknown) => console.error("descriptions sweep: some not published", String(error))); |
| 440 | + | } |
| 441 | + | console.log("descriptions sweep: published", events.length); |
| 442 | + | } |
| 443 | + | |
| 444 | + | /** |
| 424 | 445 | | * Sets the description of a repository's own project, making the project |
| 425 | 446 | | * first if it is not there yet: for whatever makes a repository with a |
| 426 | 447 | | * description, such as creating one through the API or importing one. |
| ⋯ |
| 1165 | 1186 | | } |
| 1166 | 1187 | | } |
| 1167 | 1188 | | |
| 1189 | + | /** `project.updated` for a project as it is now. */ |
| 1190 | + | function projectUpdated(row: Row, actor: string | null) { |
| 1191 | + | return { |
| 1192 | + | type: "project.updated" as const, |
| 1193 | + | source: "projects", |
| 1194 | + | repoId: row.repo_id, |
| 1195 | + | actor, |
| 1196 | + | data: { |
| 1197 | + | projectId: row.id, |
| 1198 | + | workspace: row.workspace, |
| 1199 | + | slug: row.slug, |
| 1200 | + | repoId: row.repo_id, |
| 1201 | + | primary: row.is_primary === 1, |
| 1202 | + | description: row.description, |
| 1203 | + | }, |
| 1204 | + | }; |
| 1205 | + | } |
| 1206 | + | |
| 1168 | 1207 | | export default { |
| 1169 | 1208 | | async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> { |
| 1170 | 1209 | | const match = new URL(request.url).pathname.match(/^\/rpc\/([a-z_]+)$/); |