@mastra/factory 0.12.0-alpha.0 → 0.12.0-alpha.2
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/CHANGELOG.md +45 -0
- package/dist/factory.d.ts.map +1 -1
- package/dist/factory.js +6 -1
- package/dist/factory.js.map +1 -1
- package/dist/feed-events.d.ts +13 -0
- package/dist/feed-events.d.ts.map +1 -0
- package/dist/feed-events.js +25 -0
- package/dist/feed-events.js.map +1 -0
- package/dist/integrations/github/issue-reconciler.d.ts.map +1 -1
- package/dist/integrations/github/issue-reconciler.js +22 -14
- package/dist/integrations/github/issue-reconciler.js.map +1 -1
- package/dist/integrations/github/reconcile-worker.js +1 -1
- package/dist/integrations/github/rules.d.ts +6 -0
- package/dist/integrations/github/rules.d.ts.map +1 -1
- package/dist/integrations/github/rules.js +58 -12
- package/dist/integrations/github/rules.js.map +1 -1
- package/dist/integrations/issue-reconcile-worker.js +1 -1
- package/dist/integrations/platform/github/event-worker.js +1 -1
- package/dist/integrations/platform/linear/event-worker.js +1 -1
- package/dist/routes/attention-activity.d.ts +24 -0
- package/dist/routes/attention-activity.d.ts.map +1 -0
- package/dist/routes/attention-activity.js +143 -0
- package/dist/routes/attention-activity.js.map +1 -0
- package/dist/routes/attention-providers.d.ts +31 -1
- package/dist/routes/attention-providers.d.ts.map +1 -1
- package/dist/routes/attention-providers.js +4 -4
- package/dist/routes/attention-providers.js.map +1 -1
- package/dist/routes/attention.d.ts.map +1 -1
- package/dist/routes/attention.js +39 -11
- package/dist/routes/attention.js.map +1 -1
- package/dist/routes/surface.d.ts.map +1 -1
- package/dist/routes/surface.js +4 -3
- package/dist/routes/surface.js.map +1 -1
- package/dist/routes/work-items.d.ts.map +1 -1
- package/dist/routes/work-items.js +11 -1
- package/dist/routes/work-items.js.map +1 -1
- package/dist/rules/defaults.d.ts.map +1 -1
- package/dist/rules/defaults.js +20 -8
- package/dist/rules/defaults.js.map +1 -1
- package/dist/rules/dispatcher.d.ts.map +1 -1
- package/dist/rules/dispatcher.js +24 -10
- package/dist/rules/dispatcher.js.map +1 -1
- package/dist/rules/processor.d.ts.map +1 -1
- package/dist/rules/processor.js +2 -3
- package/dist/rules/processor.js.map +1 -1
- package/dist/rules/transition-service.d.ts +2 -2
- package/dist/rules/transition-service.d.ts.map +1 -1
- package/dist/rules/transition-service.js +33 -12
- package/dist/rules/transition-service.js.map +1 -1
- package/dist/rules/types.d.ts +27 -1
- package/dist/rules/types.d.ts.map +1 -1
- package/dist/rules/types.js +37 -1
- package/dist/rules/types.js.map +1 -1
- package/dist/rules/validation.d.ts.map +1 -1
- package/dist/rules/validation.js +3 -1
- package/dist/rules/validation.js.map +1 -1
- package/dist/storage/domains/comments/activity.d.ts +40 -0
- package/dist/storage/domains/comments/activity.d.ts.map +1 -0
- package/dist/storage/domains/comments/activity.js +94 -0
- package/dist/storage/domains/comments/activity.js.map +1 -0
- package/dist/storage/domains/comments/base.d.ts +13 -1
- package/dist/storage/domains/comments/base.d.ts.map +1 -1
- package/dist/storage/domains/comments/base.js +41 -3
- package/dist/storage/domains/comments/base.js.map +1 -1
- package/dist/storage/domains/comments/domain.d.ts +4 -1
- package/dist/storage/domains/comments/domain.d.ts.map +1 -1
- package/dist/storage/domains/comments/domain.js +17 -5
- package/dist/storage/domains/comments/domain.js.map +1 -1
- package/dist/storage/domains/comments/parse.d.ts +1 -0
- package/dist/storage/domains/comments/parse.d.ts.map +1 -1
- package/dist/storage/domains/comments/parse.js +2 -2
- package/dist/storage/domains/comments/parse.js.map +1 -1
- package/dist/storage/domains/comments/routes.d.ts +2 -0
- package/dist/storage/domains/comments/routes.d.ts.map +1 -1
- package/dist/storage/domains/comments/routes.js +49 -3
- package/dist/storage/domains/comments/routes.js.map +1 -1
- package/dist/storage/domains/comments/schema.d.ts +1 -0
- package/dist/storage/domains/comments/schema.d.ts.map +1 -1
- package/dist/storage/domains/comments/schema.js +45 -1
- package/dist/storage/domains/comments/schema.js.map +1 -1
- package/dist/storage/domains/work-items/base.d.ts +18 -3
- package/dist/storage/domains/work-items/base.d.ts.map +1 -1
- package/dist/storage/domains/work-items/base.js +95 -38
- package/dist/storage/domains/work-items/base.js.map +1 -1
- package/package.json +6 -6
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"base.js","names":["#insertWithMentions","#writeMentionRows","#listAround","#listMentionRows"],"sources":["../../../../src/storage/domains/comments/base.ts"],"sourcesContent":["/**\n * Work-item comments domain — one message list per work item, materialized\n * from every source. Local creates and platform mirrors (Slack, Linear,\n * GitHub) share one idempotency story: `external_source` json + derived\n * `source_key` under a partial unique index, a local retry token being just\n * another source (`local:comment:<uuid>`).\n *\n * Ordering is caller-settable `occurred_at`, never insert time — ingest\n * backdates to the platform timestamp and retries arrive out of order.\n * Deletes are soft: the tombstone holds the ordering and its kept\n * `source_key` stops a redelivery from resurrecting the row.\n */\n\nimport { FactoryStorageDomain, UniqueViolationError } from '@mastra/core/storage';\n\nimport type { ExternalWorkItemSource } from '../work-items/base.js';\nimport type { FactoryActorExternalIdentity, FactoryActorRef } from './actor.js';\nimport { WORK_ITEM_COMMENT_MENTIONS_SCHEMA, WORK_ITEM_COMMENTS_SCHEMA } from './schema.js';\n\nexport { WORK_ITEM_COMMENT_MENTIONS_SCHEMA, WORK_ITEM_COMMENTS_SCHEMA } from './schema.js';\n\nexport type WorkItemCommentKind = 'comment';\n\nexport interface FactoryMentionRef {\n kind: 'user';\n id: string;\n}\n\nexport interface WorkItemCommentReplyRef {\n commentId: string;\n quote?: string;\n authorId?: string;\n authorName?: string;\n}\n\nexport interface WorkItemCommentRow {\n id: string;\n orgId: string;\n factoryProjectId: string;\n workItemId: string;\n kind: WorkItemCommentKind;\n body: string;\n bodyFormat: string;\n author: FactoryActorRef;\n replyTo: WorkItemCommentReplyRef | null;\n mentions: FactoryMentionRef[];\n externalSource: ExternalWorkItemSource | null;\n sourceKey: string | null;\n occurredAt: Date;\n editedAt: Date | null;\n deletedAt: Date | null;\n deletedBy: string | null;\n revision: number;\n createdAt: Date;\n updatedAt: Date;\n}\n\nexport interface CreateWorkItemCommentInput {\n orgId: string;\n factoryProjectId: string;\n workItemId: string;\n author: FactoryActorRef;\n body: string;\n bodyFormat?: string;\n replyTo?: WorkItemCommentReplyRef;\n mentions?: FactoryMentionRef[];\n externalSource?: ExternalWorkItemSource;\n /** Local idempotent-retry token; mutually exclusive with `externalSource`. */\n clientToken?: string;\n occurredAt?: Date;\n}\n\nexport interface EditWorkItemCommentInput {\n orgId: string;\n commentId: string;\n body: string;\n mentions?: FactoryMentionRef[];\n /** The acting user; their own handle never becomes a mention row. */\n editorId?: string;\n /** When set, the edit only lands if the row is still at this revision. */\n expectedRevision?: number;\n now?: Date;\n}\n\nexport interface EditWorkItemCommentResult {\n comment: WorkItemCommentRow;\n addedMentions: FactoryMentionRef[];\n removedMentions: FactoryMentionRef[];\n}\n\nexport interface ListWorkItemCommentsInput {\n orgId: string;\n factoryProjectId: string;\n workItemId: string;\n before?: string;\n limit?: number;\n /**\n * Anchor the first page on this comment: it and every comment newer than it,\n * so a deep link opens on the target instead of paging back to find it.\n * Ignored when the comment is gone, or sits further back than one page holds.\n */\n around?: string;\n}\n\n/** The only two work-item columns the feed refresh reads or writes. */\ninterface WorkItemFeedColumns extends Record<string, unknown> {\n comment_count: number;\n feed_activity_at: Date | null;\n}\n\n/** A feed snapshot: how many comments the item had, and when it last moved. */\nexport interface FeedActivitySnapshot {\n commentCount: number;\n feedActivityAt: Date;\n}\n\n/**\n * The refresh reads its snapshot outside the write transaction, so two of them\n * can interleave and the loser can write last. This is the one rule that keeps\n * that from undoing a newer refresh: feed activity never moves backwards, and\n * on the same stamp the fuller count wins.\n *\n * Known ceiling: a soft delete sharing a millisecond with a create leaves the\n * count one high until the next feed mutation recounts. Closing that needs the\n * aggregate read inside the write transaction, which the ops layer cannot do\n * without checking out a second pool connection per open transaction.\n */\nexport function supersedesFeedActivity(next: FeedActivitySnapshot, stored: FeedActivitySnapshot): boolean {\n const drift = next.feedActivityAt.getTime() - stored.feedActivityAt.getTime();\n return drift > 0 || (drift === 0 && next.commentCount >= stored.commentCount);\n}\n\nexport interface WorkItemCommentPage {\n comments: WorkItemCommentRow[];\n nextCursor?: string;\n}\n\nexport interface WorkItemMentionRow {\n id: string;\n commentId: string;\n mentionedKind: 'user';\n mentionedId: string;\n authorId: string;\n orgId: string;\n factoryProjectId: string;\n workItemId: string;\n occurredAt: Date;\n}\n\nexport const MAX_COMMENT_BODY_LENGTH = 16_000;\nexport const MAX_COMMENT_QUOTE_LENGTH = 500;\nexport const MAX_COMMENT_MENTIONS = 20;\n\n/** The one body policy: HTTP parsing and the service both reject on it. */\nexport function commentBodyError(body: string): string | undefined {\n if (!body.trim()) return 'Comment body must not be empty.';\n if (body.length > MAX_COMMENT_BODY_LENGTH)\n return `Comment body must be at most ${MAX_COMMENT_BODY_LENGTH} characters.`;\n return undefined;\n}\n\nconst DEFAULT_PAGE_SIZE = 30;\nconst MAX_PAGE_SIZE = 100;\n\nexport function clampCommentLimit(limit: number | undefined): number {\n const normalized = typeof limit === 'number' && Number.isFinite(limit) ? Math.trunc(limit) : DEFAULT_PAGE_SIZE;\n return Math.min(Math.max(normalized, 1), MAX_PAGE_SIZE);\n}\n\nexport function encodeCommentCursor(row: WorkItemCommentRow): string {\n return `${row.occurredAt.toISOString()}_${row.id}`;\n}\n\nexport function decodeCommentCursor(cursor: string): { occurredAt: Date; id: string } | undefined {\n const sep = cursor.lastIndexOf('_');\n if (sep <= 0) return undefined;\n const occurredAt = new Date(cursor.slice(0, sep));\n const id = cursor.slice(sep + 1);\n if (Number.isNaN(occurredAt.getTime()) || !id) return undefined;\n return { occurredAt, id };\n}\n\nexport function commentSourceKey(input: {\n externalSource?: ExternalWorkItemSource;\n clientToken?: string;\n}): string | null {\n if (input.externalSource) {\n const source = input.externalSource;\n return `${source.integrationId}:${source.type}:${source.externalId}`;\n }\n if (input.clientToken) return `local:comment:${input.clientToken}`;\n return null;\n}\n\n/** A `clientToken` replay that resolved to a different work item or author. */\nexport class CommentTokenConflictError extends Error {\n constructor() {\n super('Client token already used by a different comment.');\n this.name = 'CommentTokenConflictError';\n }\n}\n\ninterface WorkItemCommentDbRow extends Record<string, unknown> {\n id: string;\n org_id: string;\n factory_project_id: string;\n work_item_id: string;\n kind: WorkItemCommentKind;\n body: string;\n body_format: string;\n author_kind: FactoryActorRef['kind'];\n author_id: string;\n author_display_name: string | null;\n author_avatar_url: string | null;\n author_external: FactoryActorExternalIdentity | null;\n reply_to_comment_id: string | null;\n reply_quote: string | null;\n reply_to_author_id: string | null;\n reply_to_author_name: string | null;\n mentions: FactoryMentionRef[];\n external_source: ExternalWorkItemSource | null;\n source_key: string | null;\n occurred_at: Date;\n edited_at: Date | null;\n deleted_at: Date | null;\n deleted_by: string | null;\n revision: number;\n created_at: Date;\n updated_at: Date;\n}\n\ninterface WorkItemMentionDbRow extends Record<string, unknown> {\n id: string;\n comment_id: string;\n mentioned_kind: 'user';\n mentioned_id: string;\n author_id: string;\n org_id: string;\n factory_project_id: string;\n work_item_id: string;\n occurred_at: Date;\n}\n\nfunction toComment(row: WorkItemCommentDbRow): WorkItemCommentRow {\n return {\n id: row.id,\n orgId: row.org_id,\n factoryProjectId: row.factory_project_id,\n workItemId: row.work_item_id,\n kind: row.kind,\n body: row.body,\n bodyFormat: row.body_format,\n author: {\n kind: row.author_kind,\n id: row.author_id,\n ...(row.author_display_name ? { displayName: row.author_display_name } : {}),\n ...(row.author_avatar_url ? { avatarUrl: row.author_avatar_url } : {}),\n ...(row.author_external ? { external: row.author_external } : {}),\n },\n replyTo: row.reply_to_comment_id\n ? {\n commentId: row.reply_to_comment_id,\n ...(row.reply_quote ? { quote: row.reply_quote } : {}),\n ...(row.reply_to_author_id ? { authorId: row.reply_to_author_id } : {}),\n ...(row.reply_to_author_name ? { authorName: row.reply_to_author_name } : {}),\n }\n : null,\n mentions: row.mentions,\n externalSource: row.external_source,\n sourceKey: row.source_key,\n occurredAt: row.occurred_at,\n editedAt: row.edited_at,\n deletedAt: row.deleted_at,\n deletedBy: row.deleted_by,\n revision: row.revision,\n createdAt: row.created_at,\n updatedAt: row.updated_at,\n };\n}\n\nfunction toMention(row: WorkItemMentionDbRow): WorkItemMentionRow {\n return {\n id: row.id,\n commentId: row.comment_id,\n mentionedKind: row.mentioned_kind,\n mentionedId: row.mentioned_id,\n authorId: row.author_id,\n orgId: row.org_id,\n factoryProjectId: row.factory_project_id,\n workItemId: row.work_item_id,\n occurredAt: row.occurred_at,\n };\n}\n\nfunction toMentionRef(row: WorkItemMentionRow): FactoryMentionRef {\n return { kind: row.mentionedKind, id: row.mentionedId };\n}\n\nfunction mentionKey(mention: { kind: string; id: string }): string {\n return `${mention.kind}\\0${mention.id}`;\n}\n\nfunction dedupeMentions(mentions: FactoryMentionRef[] | undefined): FactoryMentionRef[] {\n if (!mentions?.length) return [];\n return [...new Map(mentions.map(mention => [mentionKey(mention), mention])).values()];\n}\n\nexport class WorkItemCommentsStorage extends FactoryStorageDomain {\n constructor() {\n super('work-item-comments');\n }\n\n async init(): Promise<void> {\n await this.ensureCollections([WORK_ITEM_COMMENTS_SCHEMA, WORK_ITEM_COMMENT_MENTIONS_SCHEMA]);\n }\n\n async dangerouslyClearAll(): Promise<void> {\n await this.ops.deleteMany('work_item_comment_mentions', {});\n await this.ops.deleteMany('work_item_comments', {});\n }\n\n async create(input: CreateWorkItemCommentInput): Promise<WorkItemCommentRow> {\n const now = new Date();\n const occurredAt = input.occurredAt ?? now;\n const sourceKey = commentSourceKey(input);\n const author = input.author;\n const row: Partial<WorkItemCommentDbRow> = {\n org_id: input.orgId,\n factory_project_id: input.factoryProjectId,\n work_item_id: input.workItemId,\n kind: 'comment',\n body: input.body,\n body_format: input.bodyFormat ?? 'markdown',\n author_kind: author.kind,\n author_id: author.id,\n author_display_name: author.displayName ?? null,\n author_avatar_url: author.avatarUrl ?? null,\n author_external: author.external ?? null,\n reply_to_comment_id: input.replyTo?.commentId ?? null,\n reply_quote: input.replyTo?.quote ?? null,\n reply_to_author_id: input.replyTo?.authorId ?? null,\n reply_to_author_name: input.replyTo?.authorName ?? null,\n mentions: dedupeMentions(input.mentions),\n external_source: input.externalSource ?? null,\n source_key: sourceKey,\n occurred_at: occurredAt,\n edited_at: null,\n deleted_at: null,\n deleted_by: null,\n revision: 1,\n created_at: now,\n updated_at: now,\n };\n\n // Insert-or-recover, never upsert: a replayed create must return the\n // existing row untouched (an upsert would clobber a later edit's body).\n // A local token resolving to another item or author is a conflict, not a\n // recovery. External keys stay lenient: a redelivery after a thread\n // re-link maps to a new item id and must still no-op.\n if (sourceKey) {\n const sourceWhere = {\n org_id: input.orgId,\n factory_project_id: input.factoryProjectId,\n source_key: sourceKey,\n };\n const recover = (found: WorkItemCommentDbRow): WorkItemCommentRow => {\n if (!input.externalSource && (found.work_item_id !== input.workItemId || found.author_id !== author.id)) {\n throw new CommentTokenConflictError();\n }\n return toComment(found);\n };\n const existing = await this.ops.findOne<WorkItemCommentDbRow>('work_item_comments', sourceWhere);\n if (existing) return recover(existing);\n try {\n return await this.#insertWithMentions(row);\n } catch (error) {\n if (!(error instanceof UniqueViolationError)) throw error;\n const raced = await this.ops.findOne<WorkItemCommentDbRow>('work_item_comments', sourceWhere);\n if (!raced) throw error;\n return recover(raced);\n }\n }\n\n return this.#insertWithMentions(row);\n }\n\n async #insertWithMentions(row: Partial<WorkItemCommentDbRow>): Promise<WorkItemCommentRow> {\n const comment = toComment(await this.ops.insertOne<WorkItemCommentDbRow>('work_item_comments', row));\n await this.#writeMentionRows(comment, comment.mentions);\n return comment;\n }\n\n /**\n * Idempotent counter refresh on the parent work item: both columns are read\n * back off the comments (never incremented or stamped with the wall clock —\n * replays and races double an increment, and a replayed create would move a\n * work item in the feed without adding a comment; a read-back can't drift).\n * Read BEFORE `updateAtomic`: its mutator runs inside an open transaction\n * holding a pool connection, and a query in there checks out a second one —\n * concurrent posts would exhaust the pool. Reading outside means two\n * refreshes can interleave, so the write goes through\n * {@link supersedesFeedActivity} rather than landing whatever it read.\n * Touches ONLY the counter columns: `revision`/`updated_at` are the\n * stage-transition concurrency token.\n */\n async refreshWorkItemFeedActivity({\n orgId,\n factoryProjectId,\n workItemId,\n now = new Date(),\n }: {\n orgId: string;\n factoryProjectId: string;\n workItemId: string;\n now?: Date;\n }): Promise<void> {\n const commentCount = await this.countForWorkItem({ orgId, factoryProjectId, workItemId });\n // Every feed mutation stamps `updated_at`, edits and tombstones included,\n // so the newest one is when this feed last moved.\n const [latest] = await this.ops.findMany<WorkItemCommentDbRow>(\n 'work_item_comments',\n { org_id: orgId, factory_project_id: factoryProjectId, work_item_id: workItemId },\n { orderBy: [['updated_at', 'desc']], limit: 1 },\n );\n const feedActivityAt = latest?.updated_at ?? now;\n await this.ops.updateAtomic<WorkItemFeedColumns>(\n 'work_items',\n { id: workItemId, org_id: orgId, factory_project_id: factoryProjectId },\n row => {\n const next = { commentCount, feedActivityAt };\n const stored = row.feed_activity_at;\n if (stored && !supersedesFeedActivity(next, { commentCount: row.comment_count, feedActivityAt: stored })) {\n return null;\n }\n return { comment_count: commentCount, feed_activity_at: feedActivityAt };\n },\n );\n }\n\n async get({ orgId, commentId }: { orgId: string; commentId: string }): Promise<WorkItemCommentRow | null> {\n const row = await this.ops.findOne<WorkItemCommentDbRow>('work_item_comments', {\n id: commentId,\n org_id: orgId,\n });\n return row ? toComment(row) : null;\n }\n\n async listByIds({ orgId, ids }: { orgId: string; ids: string[] }): Promise<WorkItemCommentRow[]> {\n if (ids.length === 0) return [];\n const rows = await this.ops.findMany<WorkItemCommentDbRow>('work_item_comments', {\n org_id: orgId,\n id: { in: ids },\n });\n return rows.map(toComment);\n }\n\n async list(input: ListWorkItemCommentsInput): Promise<WorkItemCommentPage> {\n const anchored = input.around ? await this.#listAround(input, input.around) : undefined;\n if (anchored) return anchored;\n const limit = clampCommentLimit(input.limit);\n const cursor = input.before ? decodeCommentCursor(input.before) : undefined;\n const rows = await this.ops.findMany<WorkItemCommentDbRow>(\n 'work_item_comments',\n {\n org_id: input.orgId,\n factory_project_id: input.factoryProjectId,\n work_item_id: input.workItemId,\n },\n {\n orderBy: [\n ['occurred_at', 'desc'],\n ['id', 'desc'],\n ],\n limit: limit + 1,\n ...(cursor ? { cursor: { values: [cursor.occurredAt, cursor.id] } } : {}),\n },\n );\n const comments = rows.slice(0, limit).map(toComment);\n const hasMore = rows.length > limit;\n const last = comments[comments.length - 1];\n return {\n comments,\n ...(hasMore && last ? { nextCursor: encodeCommentCursor(last) } : {}),\n };\n }\n\n async #listAround(\n { orgId, factoryProjectId, workItemId }: ListWorkItemCommentsInput,\n commentId: string,\n ): Promise<WorkItemCommentPage | undefined> {\n const target = await this.get({ orgId, commentId });\n if (!target || target.factoryProjectId !== factoryProjectId || target.workItemId !== workItemId) return undefined;\n\n const newer = await this.ops.findMany<WorkItemCommentDbRow>(\n 'work_item_comments',\n { org_id: orgId, factory_project_id: factoryProjectId, work_item_id: workItemId },\n {\n orderBy: [\n ['occurred_at', 'asc'],\n ['id', 'asc'],\n ],\n limit: MAX_PAGE_SIZE + 1,\n cursor: { values: [target.occurredAt, target.id] },\n },\n );\n if (newer.length > MAX_PAGE_SIZE) return undefined;\n\n const before = encodeCommentCursor(target);\n const older = await this.list({ orgId, factoryProjectId, workItemId, before, limit: 1 });\n return {\n comments: [...newer.map(toComment).reverse(), target],\n ...(older.comments.length > 0 ? { nextCursor: before } : {}),\n };\n }\n\n /**\n * Newest non-deleted comments for run-context injection, newest-first (the\n * caller reverses for display order).\n */\n async listRecent({\n orgId,\n factoryProjectId,\n workItemId,\n limit,\n }: {\n orgId: string;\n factoryProjectId: string;\n workItemId: string;\n limit: number;\n }): Promise<WorkItemCommentRow[]> {\n const rows = await this.ops.findMany<WorkItemCommentDbRow>(\n 'work_item_comments',\n {\n org_id: orgId,\n factory_project_id: factoryProjectId,\n work_item_id: workItemId,\n deleted_at: null,\n },\n {\n orderBy: [\n ['occurred_at', 'desc'],\n ['id', 'desc'],\n ],\n limit,\n },\n );\n return rows.map(toComment);\n }\n\n async edit(input: EditWorkItemCommentInput): Promise<EditWorkItemCommentResult | 'conflict' | null> {\n const now = input.now ?? new Date();\n let rejection: 'deleted' | 'conflict' | undefined;\n const updated = await this.ops.updateAtomic<WorkItemCommentDbRow>(\n 'work_item_comments',\n { id: input.commentId, org_id: input.orgId },\n current => {\n if (current.deleted_at) {\n rejection = 'deleted';\n return null;\n }\n if (input.expectedRevision !== undefined && Number(current.revision) !== input.expectedRevision) {\n rejection = 'conflict';\n return null;\n }\n return {\n body: input.body,\n mentions: dedupeMentions(input.mentions ?? current.mentions),\n edited_at: now,\n revision: Number(current.revision) + 1,\n updated_at: now,\n };\n },\n );\n if (rejection === 'conflict') return 'conflict';\n if (!updated || rejection) return null;\n const comment = toComment(updated);\n\n const existing = await this.#listMentionRows(comment.id);\n const existingKeys = new Set(existing.map(mention => mentionKey(toMentionRef(mention))));\n const nextKeys = new Set(comment.mentions.map(mentionKey));\n // Self-mentions (author, or the acting editor) never become rows, so they\n // must not report as \"added\" either — they would re-report on every edit.\n const skippedIds = new Set([comment.author.id, ...(input.editorId ? [input.editorId] : [])]);\n const addedMentions = comment.mentions.filter(\n mention => !existingKeys.has(mentionKey(mention)) && !skippedIds.has(mention.id),\n );\n const removedMentions = existing.map(toMentionRef).filter(mention => !nextKeys.has(mentionKey(mention)));\n\n // Stamped with the edit time, not the comment's creation time: the inbox\n // is a keyset on `occurred_at`, and a backdated row lands buried under\n // everything the user already saw.\n await this.#writeMentionRows(comment, addedMentions, now);\n for (const mention of removedMentions) {\n await this.ops.deleteMany('work_item_comment_mentions', {\n comment_id: comment.id,\n mentioned_kind: mention.kind,\n mentioned_id: mention.id,\n });\n }\n return { comment, addedMentions, removedMentions };\n }\n\n async softDelete({\n orgId,\n commentId,\n deletedBy,\n now = new Date(),\n }: {\n orgId: string;\n commentId: string;\n deletedBy: string;\n now?: Date;\n }): Promise<WorkItemCommentRow | null> {\n let alreadyDeleted = false;\n const updated = await this.ops.updateAtomic<WorkItemCommentDbRow>(\n 'work_item_comments',\n { id: commentId, org_id: orgId },\n current => {\n if (current.deleted_at) {\n alreadyDeleted = true;\n return null;\n }\n return {\n body: '',\n mentions: [],\n deleted_at: now,\n deleted_by: deletedBy,\n updated_at: now,\n };\n },\n );\n if (!updated || alreadyDeleted) return null;\n await this.ops.deleteMany('work_item_comment_mentions', { comment_id: commentId });\n return toComment(updated);\n }\n\n /**\n * Provenance write-back after an outbound publish. First platform wins, and\n * an existing `source_key` is kept: replacing a `local:comment:<token>` one\n * would let a client retry duplicate the row. So a web-born comment is never\n * key-deduped against its own platform echo — the host's bot-sender check is\n * the only echo layer for those rows (COR-1174).\n */\n async attachExternalSource({\n orgId,\n commentId,\n source,\n }: {\n orgId: string;\n commentId: string;\n source: ExternalWorkItemSource;\n }): Promise<WorkItemCommentRow | null> {\n const updated = await this.ops.updateAtomic<WorkItemCommentDbRow>(\n 'work_item_comments',\n { id: commentId, org_id: orgId },\n current => {\n if (current.external_source) return null;\n return {\n external_source: source,\n source_key: current.source_key ?? commentSourceKey({ externalSource: source }),\n updated_at: new Date(),\n };\n },\n );\n return updated ? toComment(updated) : null;\n }\n\n async listMentionsForComment(commentId: string): Promise<WorkItemMentionRow[]> {\n return this.#listMentionRows(commentId);\n }\n\n /** Keyset inbox read for the mention attention provider, newest-first. */\n async listMentionsForUser({\n orgId,\n factoryProjectId,\n userId,\n before,\n limit,\n }: {\n orgId: string;\n factoryProjectId: string;\n userId: string;\n before?: { occurredAt: Date; id: string };\n limit: number;\n }): Promise<WorkItemMentionRow[]> {\n const rows = await this.ops.findMany<WorkItemMentionDbRow>(\n 'work_item_comment_mentions',\n {\n org_id: orgId,\n factory_project_id: factoryProjectId,\n mentioned_id: userId,\n },\n {\n orderBy: [\n ['occurred_at', 'desc'],\n ['id', 'desc'],\n ],\n limit,\n ...(before ? { cursor: { values: [before.occurredAt, before.id] } } : {}),\n },\n );\n return rows.map(toMention);\n }\n\n async countForWorkItem({\n orgId,\n factoryProjectId,\n workItemId,\n }: {\n orgId: string;\n factoryProjectId: string;\n workItemId: string;\n }): Promise<number> {\n if (!this.ops.count) {\n throw new Error('[WorkItemCommentsStorage] storage backend does not support collection counts.');\n }\n return this.ops.count('work_item_comments', {\n org_id: orgId,\n factory_project_id: factoryProjectId,\n work_item_id: workItemId,\n deleted_at: null,\n });\n }\n\n /** Recent distinct comment authors of a project, for the roster fallback. */\n async listRecentAuthors({\n orgId,\n factoryProjectId,\n limit = 200,\n }: {\n orgId: string;\n factoryProjectId: string;\n limit?: number;\n }): Promise<FactoryActorRef[]> {\n const rows = await this.ops.findMany<WorkItemCommentDbRow>(\n 'work_item_comments',\n { org_id: orgId, factory_project_id: factoryProjectId },\n {\n orderBy: [\n ['occurred_at', 'desc'],\n ['id', 'desc'],\n ],\n limit,\n },\n );\n const authors = new Map<string, FactoryActorRef>();\n for (const row of rows) {\n if (authors.has(row.author_id)) continue;\n authors.set(row.author_id, toComment(row).author);\n }\n return [...authors.values()];\n }\n\n async #listMentionRows(commentId: string): Promise<WorkItemMentionRow[]> {\n const rows = await this.ops.findMany<WorkItemMentionDbRow>('work_item_comment_mentions', {\n comment_id: commentId,\n });\n return rows.map(toMention);\n }\n\n /**\n * Self-mentions never get rows: the join exists solely for the attention\n * inbox, and `CollectionWhere` cannot express `author_id != userId` at read\n * time, so the filter happens at write time.\n */\n async #writeMentionRows(\n comment: WorkItemCommentRow,\n mentions: FactoryMentionRef[],\n occurredAt = comment.occurredAt,\n ): Promise<void> {\n for (const mention of mentions) {\n if (mention.id === comment.author.id) continue;\n await this.ops.upsertOne<WorkItemMentionDbRow>(\n 'work_item_comment_mentions',\n ['comment_id', 'mentioned_kind', 'mentioned_id'],\n {\n comment_id: comment.id,\n mentioned_kind: mention.kind,\n mentioned_id: mention.id,\n author_id: comment.author.id,\n org_id: comment.orgId,\n factory_project_id: comment.factoryProjectId,\n work_item_id: comment.workItemId,\n occurred_at: occurredAt,\n },\n );\n }\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"base.js","names":["#insertWithMentions","#writeMentionRows","#fanOutActivity","#listAround","#listMentionRows"],"sources":["../../../../src/storage/domains/comments/base.ts"],"sourcesContent":["/**\n * Work-item comments domain — one message list per work item, materialized\n * from every source. Local creates and platform mirrors (Slack, Linear,\n * GitHub) share one idempotency story: `external_source` json + derived\n * `source_key` under a partial unique index, a local retry token being just\n * another source (`local:comment:<uuid>`).\n *\n * Ordering is caller-settable `occurred_at`, never insert time — ingest\n * backdates to the platform timestamp and retries arrive out of order.\n * Deletes are soft: the tombstone holds the ordering and its kept\n * `source_key` stops a redelivery from resurrecting the row.\n */\n\nimport { FactoryStorageDomain, UniqueViolationError } from '@mastra/core/storage';\n\nimport type { ExternalWorkItemSource } from '../work-items/base.js';\nimport { fanOutWorkItemActivity, listActivityForUser, PARTICIPANT_SCAN_LIMIT } from './activity.js';\nimport type { WorkItemActivityRow } from './activity.js';\nimport type { FactoryActorExternalIdentity, FactoryActorRef } from './actor.js';\nimport { WORK_ITEM_ACTIVITY_SCHEMA, WORK_ITEM_COMMENT_MENTIONS_SCHEMA, WORK_ITEM_COMMENTS_SCHEMA } from './schema.js';\n\nexport type { WorkItemActivityRow } from './activity.js';\nexport { WORK_ITEM_ACTIVITY_SCHEMA, WORK_ITEM_COMMENT_MENTIONS_SCHEMA, WORK_ITEM_COMMENTS_SCHEMA } from './schema.js';\n\nexport type WorkItemCommentKind = 'comment';\n\nexport interface FactoryMentionRef {\n kind: 'user';\n id: string;\n}\n\nexport interface WorkItemCommentReplyRef {\n commentId: string;\n quote?: string;\n authorId?: string;\n authorName?: string;\n}\n\nexport interface WorkItemCommentRow {\n id: string;\n orgId: string;\n factoryProjectId: string;\n workItemId: string;\n kind: WorkItemCommentKind;\n body: string;\n bodyFormat: string;\n author: FactoryActorRef;\n replyTo: WorkItemCommentReplyRef | null;\n mentions: FactoryMentionRef[];\n externalSource: ExternalWorkItemSource | null;\n sourceKey: string | null;\n occurredAt: Date;\n editedAt: Date | null;\n deletedAt: Date | null;\n deletedBy: string | null;\n revision: number;\n createdAt: Date;\n updatedAt: Date;\n}\n\nexport interface CreateWorkItemCommentInput {\n orgId: string;\n factoryProjectId: string;\n workItemId: string;\n author: FactoryActorRef;\n body: string;\n bodyFormat?: string;\n replyTo?: WorkItemCommentReplyRef;\n mentions?: FactoryMentionRef[];\n externalSource?: ExternalWorkItemSource;\n /** Local idempotent-retry token; mutually exclusive with `externalSource`. */\n clientToken?: string;\n occurredAt?: Date;\n}\n\nexport interface EditWorkItemCommentInput {\n orgId: string;\n commentId: string;\n body: string;\n mentions?: FactoryMentionRef[];\n /** The acting user; their own handle never becomes a mention row. */\n editorId?: string;\n /** When set, the edit only lands if the row is still at this revision. */\n expectedRevision?: number;\n now?: Date;\n}\n\nexport interface EditWorkItemCommentResult {\n comment: WorkItemCommentRow;\n addedMentions: FactoryMentionRef[];\n removedMentions: FactoryMentionRef[];\n}\n\nexport interface ListWorkItemCommentsInput {\n orgId: string;\n factoryProjectId: string;\n workItemId: string;\n before?: string;\n limit?: number;\n /**\n * Anchor the first page on this comment: it and every comment newer than it,\n * so a deep link opens on the target instead of paging back to find it.\n * Ignored when the comment is gone, or sits further back than one page holds.\n */\n around?: string;\n}\n\n/** The only two work-item columns the feed refresh reads or writes. */\ninterface WorkItemFeedColumns extends Record<string, unknown> {\n comment_count: number;\n feed_activity_at: Date | null;\n}\n\n/** A feed snapshot: how many comments the item had, and when it last moved. */\nexport interface FeedActivitySnapshot {\n commentCount: number;\n feedActivityAt: Date;\n}\n\n/**\n * The refresh reads its snapshot outside the write transaction, so two of them\n * can interleave and the loser can write last. This is the one rule that keeps\n * that from undoing a newer refresh: feed activity never moves backwards, and\n * on the same stamp the fuller count wins.\n *\n * Known ceiling: a soft delete sharing a millisecond with a create leaves the\n * count one high until the next feed mutation recounts. Closing that needs the\n * aggregate read inside the write transaction, which the ops layer cannot do\n * without checking out a second pool connection per open transaction.\n */\nexport function supersedesFeedActivity(next: FeedActivitySnapshot, stored: FeedActivitySnapshot): boolean {\n const drift = next.feedActivityAt.getTime() - stored.feedActivityAt.getTime();\n return drift > 0 || (drift === 0 && next.commentCount >= stored.commentCount);\n}\n\nexport interface WorkItemCommentPage {\n comments: WorkItemCommentRow[];\n nextCursor?: string;\n}\n\nexport interface WorkItemMentionRow {\n id: string;\n commentId: string;\n mentionedKind: 'user';\n mentionedId: string;\n authorId: string;\n orgId: string;\n factoryProjectId: string;\n workItemId: string;\n occurredAt: Date;\n}\n\nexport const MAX_COMMENT_BODY_LENGTH = 16_000;\nexport const MAX_COMMENT_QUOTE_LENGTH = 500;\nexport const MAX_COMMENT_MENTIONS = 20;\n\n/** The one body policy: HTTP parsing and the service both reject on it. */\nexport function commentBodyError(body: string): string | undefined {\n if (!body.trim()) return 'Comment body must not be empty.';\n if (body.length > MAX_COMMENT_BODY_LENGTH)\n return `Comment body must be at most ${MAX_COMMENT_BODY_LENGTH} characters.`;\n return undefined;\n}\n\nconst DEFAULT_PAGE_SIZE = 30;\nconst MAX_PAGE_SIZE = 100;\n\nexport function clampCommentLimit(limit: number | undefined): number {\n const normalized = typeof limit === 'number' && Number.isFinite(limit) ? Math.trunc(limit) : DEFAULT_PAGE_SIZE;\n return Math.min(Math.max(normalized, 1), MAX_PAGE_SIZE);\n}\n\nexport function encodeCommentCursor(row: WorkItemCommentRow): string {\n return `${row.occurredAt.toISOString()}_${row.id}`;\n}\n\nexport function decodeCommentCursor(cursor: string): { occurredAt: Date; id: string } | undefined {\n const sep = cursor.lastIndexOf('_');\n if (sep <= 0) return undefined;\n const occurredAt = new Date(cursor.slice(0, sep));\n const id = cursor.slice(sep + 1);\n if (Number.isNaN(occurredAt.getTime()) || !id) return undefined;\n return { occurredAt, id };\n}\n\nexport function commentSourceKey(input: {\n externalSource?: ExternalWorkItemSource;\n clientToken?: string;\n}): string | null {\n if (input.externalSource) {\n const source = input.externalSource;\n return `${source.integrationId}:${source.type}:${source.externalId}`;\n }\n if (input.clientToken) return `local:comment:${input.clientToken}`;\n return null;\n}\n\n/** A `clientToken` replay that resolved to a different work item or author. */\nexport class CommentTokenConflictError extends Error {\n constructor() {\n super('Client token already used by a different comment.');\n this.name = 'CommentTokenConflictError';\n }\n}\n\ninterface WorkItemCommentDbRow extends Record<string, unknown> {\n id: string;\n org_id: string;\n factory_project_id: string;\n work_item_id: string;\n kind: WorkItemCommentKind;\n body: string;\n body_format: string;\n author_kind: FactoryActorRef['kind'];\n author_id: string;\n author_display_name: string | null;\n author_avatar_url: string | null;\n author_external: FactoryActorExternalIdentity | null;\n reply_to_comment_id: string | null;\n reply_quote: string | null;\n reply_to_author_id: string | null;\n reply_to_author_name: string | null;\n mentions: FactoryMentionRef[];\n external_source: ExternalWorkItemSource | null;\n source_key: string | null;\n occurred_at: Date;\n edited_at: Date | null;\n deleted_at: Date | null;\n deleted_by: string | null;\n revision: number;\n created_at: Date;\n updated_at: Date;\n}\n\ninterface WorkItemMentionDbRow extends Record<string, unknown> {\n id: string;\n comment_id: string;\n mentioned_kind: 'user';\n mentioned_id: string;\n author_id: string;\n org_id: string;\n factory_project_id: string;\n work_item_id: string;\n occurred_at: Date;\n}\n\nfunction toComment(row: WorkItemCommentDbRow): WorkItemCommentRow {\n return {\n id: row.id,\n orgId: row.org_id,\n factoryProjectId: row.factory_project_id,\n workItemId: row.work_item_id,\n kind: row.kind,\n body: row.body,\n bodyFormat: row.body_format,\n author: {\n kind: row.author_kind,\n id: row.author_id,\n ...(row.author_display_name ? { displayName: row.author_display_name } : {}),\n ...(row.author_avatar_url ? { avatarUrl: row.author_avatar_url } : {}),\n ...(row.author_external ? { external: row.author_external } : {}),\n },\n replyTo: row.reply_to_comment_id\n ? {\n commentId: row.reply_to_comment_id,\n ...(row.reply_quote ? { quote: row.reply_quote } : {}),\n ...(row.reply_to_author_id ? { authorId: row.reply_to_author_id } : {}),\n ...(row.reply_to_author_name ? { authorName: row.reply_to_author_name } : {}),\n }\n : null,\n mentions: row.mentions,\n externalSource: row.external_source,\n sourceKey: row.source_key,\n occurredAt: row.occurred_at,\n editedAt: row.edited_at,\n deletedAt: row.deleted_at,\n deletedBy: row.deleted_by,\n revision: row.revision,\n createdAt: row.created_at,\n updatedAt: row.updated_at,\n };\n}\n\nfunction toMention(row: WorkItemMentionDbRow): WorkItemMentionRow {\n return {\n id: row.id,\n commentId: row.comment_id,\n mentionedKind: row.mentioned_kind,\n mentionedId: row.mentioned_id,\n authorId: row.author_id,\n orgId: row.org_id,\n factoryProjectId: row.factory_project_id,\n workItemId: row.work_item_id,\n occurredAt: row.occurred_at,\n };\n}\n\nfunction toMentionRef(row: WorkItemMentionRow): FactoryMentionRef {\n return { kind: row.mentionedKind, id: row.mentionedId };\n}\n\nfunction mentionKey(mention: { kind: string; id: string }): string {\n return `${mention.kind}\\0${mention.id}`;\n}\n\nfunction dedupeMentions(mentions: FactoryMentionRef[] | undefined): FactoryMentionRef[] {\n if (!mentions?.length) return [];\n return [...new Map(mentions.map(mention => [mentionKey(mention), mention])).values()];\n}\n\nexport class WorkItemCommentsStorage extends FactoryStorageDomain {\n constructor() {\n super('work-item-comments');\n }\n\n async init(): Promise<void> {\n await this.ensureCollections([\n WORK_ITEM_COMMENTS_SCHEMA,\n WORK_ITEM_COMMENT_MENTIONS_SCHEMA,\n WORK_ITEM_ACTIVITY_SCHEMA,\n ]);\n }\n\n async dangerouslyClearAll(): Promise<void> {\n await this.ops.deleteMany('work_item_activity', {});\n await this.ops.deleteMany('work_item_comment_mentions', {});\n await this.ops.deleteMany('work_item_comments', {});\n }\n\n async create(input: CreateWorkItemCommentInput): Promise<WorkItemCommentRow> {\n const now = new Date();\n const occurredAt = input.occurredAt ?? now;\n const sourceKey = commentSourceKey(input);\n const author = input.author;\n const row: Partial<WorkItemCommentDbRow> = {\n org_id: input.orgId,\n factory_project_id: input.factoryProjectId,\n work_item_id: input.workItemId,\n kind: 'comment',\n body: input.body,\n body_format: input.bodyFormat ?? 'markdown',\n author_kind: author.kind,\n author_id: author.id,\n author_display_name: author.displayName ?? null,\n author_avatar_url: author.avatarUrl ?? null,\n author_external: author.external ?? null,\n reply_to_comment_id: input.replyTo?.commentId ?? null,\n reply_quote: input.replyTo?.quote ?? null,\n reply_to_author_id: input.replyTo?.authorId ?? null,\n reply_to_author_name: input.replyTo?.authorName ?? null,\n mentions: dedupeMentions(input.mentions),\n external_source: input.externalSource ?? null,\n source_key: sourceKey,\n occurred_at: occurredAt,\n edited_at: null,\n deleted_at: null,\n deleted_by: null,\n revision: 1,\n created_at: now,\n updated_at: now,\n };\n\n // Insert-or-recover, never upsert: a replayed create must return the\n // existing row untouched (an upsert would clobber a later edit's body).\n // A local token resolving to another item or author is a conflict, not a\n // recovery. External keys stay lenient: a redelivery after a thread\n // re-link maps to a new item id and must still no-op.\n if (sourceKey) {\n const sourceWhere = {\n org_id: input.orgId,\n factory_project_id: input.factoryProjectId,\n source_key: sourceKey,\n };\n const recover = (found: WorkItemCommentDbRow): WorkItemCommentRow => {\n if (!input.externalSource && (found.work_item_id !== input.workItemId || found.author_id !== author.id)) {\n throw new CommentTokenConflictError();\n }\n return toComment(found);\n };\n const existing = await this.ops.findOne<WorkItemCommentDbRow>('work_item_comments', sourceWhere);\n if (existing) return recover(existing);\n try {\n return await this.#insertWithMentions(row);\n } catch (error) {\n if (!(error instanceof UniqueViolationError)) throw error;\n const raced = await this.ops.findOne<WorkItemCommentDbRow>('work_item_comments', sourceWhere);\n if (!raced) throw error;\n return recover(raced);\n }\n }\n\n return this.#insertWithMentions(row);\n }\n\n /**\n * The one insert path a genuinely new comment takes — `create()`'s recover\n * branches never reach it, so a replay writes no mention or activity rows.\n */\n async #insertWithMentions(row: Partial<WorkItemCommentDbRow>): Promise<WorkItemCommentRow> {\n const comment = toComment(await this.ops.insertOne<WorkItemCommentDbRow>('work_item_comments', row));\n await this.#writeMentionRows(comment, comment.mentions);\n await this.#fanOutActivity(comment);\n return comment;\n }\n\n /**\n * Participants come off the item's recent authors, so the scan happens here\n * rather than in the fan-out, which must stay free of its own queries.\n */\n async #fanOutActivity(comment: WorkItemCommentRow): Promise<void> {\n const item = await this.ops.findOne<{ created_by: string } & Record<string, unknown>>('work_items', {\n id: comment.workItemId,\n org_id: comment.orgId,\n factory_project_id: comment.factoryProjectId,\n });\n if (!item) return;\n const recent = await this.listRecent({\n orgId: comment.orgId,\n factoryProjectId: comment.factoryProjectId,\n workItemId: comment.workItemId,\n limit: PARTICIPANT_SCAN_LIMIT,\n });\n await fanOutWorkItemActivity(this.ops, {\n comment,\n recentAuthors: recent.filter(row => row.author.kind === 'user').map(row => row.author.id),\n createdBy: item.created_by,\n mentions: comment.mentions,\n });\n }\n\n async listActivityForUser(args: {\n orgId: string;\n factoryProjectId: string;\n userId: string;\n before?: { occurredAt: Date; id: string };\n limit: number;\n }): Promise<WorkItemActivityRow[]> {\n return listActivityForUser(this.ops, args);\n }\n\n /**\n * Idempotent counter refresh on the parent work item: both columns are read\n * back off the comments (never incremented or stamped with the wall clock —\n * replays and races double an increment, and a replayed create would move a\n * work item in the feed without adding a comment; a read-back can't drift).\n * Read BEFORE `updateAtomic`: its mutator runs inside an open transaction\n * holding a pool connection, and a query in there checks out a second one —\n * concurrent posts would exhaust the pool. Reading outside means two\n * refreshes can interleave, so the write goes through\n * {@link supersedesFeedActivity} rather than landing whatever it read.\n * Touches ONLY the counter columns: `revision`/`updated_at` are the\n * stage-transition concurrency token.\n */\n async refreshWorkItemFeedActivity({\n orgId,\n factoryProjectId,\n workItemId,\n now = new Date(),\n }: {\n orgId: string;\n factoryProjectId: string;\n workItemId: string;\n now?: Date;\n }): Promise<void> {\n const commentCount = await this.countForWorkItem({ orgId, factoryProjectId, workItemId });\n // Every feed mutation stamps `updated_at`, edits and tombstones included,\n // so the newest one is when this feed last moved.\n const [latest] = await this.ops.findMany<WorkItemCommentDbRow>(\n 'work_item_comments',\n { org_id: orgId, factory_project_id: factoryProjectId, work_item_id: workItemId },\n { orderBy: [['updated_at', 'desc']], limit: 1 },\n );\n const feedActivityAt = latest?.updated_at ?? now;\n await this.ops.updateAtomic<WorkItemFeedColumns>(\n 'work_items',\n { id: workItemId, org_id: orgId, factory_project_id: factoryProjectId },\n row => {\n const next = { commentCount, feedActivityAt };\n const stored = row.feed_activity_at;\n if (stored && !supersedesFeedActivity(next, { commentCount: row.comment_count, feedActivityAt: stored })) {\n return null;\n }\n return { comment_count: commentCount, feed_activity_at: feedActivityAt };\n },\n );\n }\n\n async get({ orgId, commentId }: { orgId: string; commentId: string }): Promise<WorkItemCommentRow | null> {\n const row = await this.ops.findOne<WorkItemCommentDbRow>('work_item_comments', {\n id: commentId,\n org_id: orgId,\n });\n return row ? toComment(row) : null;\n }\n\n async listByIds({ orgId, ids }: { orgId: string; ids: string[] }): Promise<WorkItemCommentRow[]> {\n if (ids.length === 0) return [];\n const rows = await this.ops.findMany<WorkItemCommentDbRow>('work_item_comments', {\n org_id: orgId,\n id: { in: ids },\n });\n return rows.map(toComment);\n }\n\n async list(input: ListWorkItemCommentsInput): Promise<WorkItemCommentPage> {\n const anchored = input.around ? await this.#listAround(input, input.around) : undefined;\n if (anchored) return anchored;\n const limit = clampCommentLimit(input.limit);\n const cursor = input.before ? decodeCommentCursor(input.before) : undefined;\n const rows = await this.ops.findMany<WorkItemCommentDbRow>(\n 'work_item_comments',\n {\n org_id: input.orgId,\n factory_project_id: input.factoryProjectId,\n work_item_id: input.workItemId,\n },\n {\n orderBy: [\n ['occurred_at', 'desc'],\n ['id', 'desc'],\n ],\n limit: limit + 1,\n ...(cursor ? { cursor: { values: [cursor.occurredAt, cursor.id] } } : {}),\n },\n );\n const comments = rows.slice(0, limit).map(toComment);\n const hasMore = rows.length > limit;\n const last = comments[comments.length - 1];\n return {\n comments,\n ...(hasMore && last ? { nextCursor: encodeCommentCursor(last) } : {}),\n };\n }\n\n async #listAround(\n { orgId, factoryProjectId, workItemId }: ListWorkItemCommentsInput,\n commentId: string,\n ): Promise<WorkItemCommentPage | undefined> {\n const target = await this.get({ orgId, commentId });\n if (!target || target.factoryProjectId !== factoryProjectId || target.workItemId !== workItemId) return undefined;\n\n const newer = await this.ops.findMany<WorkItemCommentDbRow>(\n 'work_item_comments',\n { org_id: orgId, factory_project_id: factoryProjectId, work_item_id: workItemId },\n {\n orderBy: [\n ['occurred_at', 'asc'],\n ['id', 'asc'],\n ],\n limit: MAX_PAGE_SIZE + 1,\n cursor: { values: [target.occurredAt, target.id] },\n },\n );\n if (newer.length > MAX_PAGE_SIZE) return undefined;\n\n const before = encodeCommentCursor(target);\n const older = await this.list({ orgId, factoryProjectId, workItemId, before, limit: 1 });\n return {\n comments: [...newer.map(toComment).reverse(), target],\n ...(older.comments.length > 0 ? { nextCursor: before } : {}),\n };\n }\n\n /**\n * Newest non-deleted comments for run-context injection, newest-first (the\n * caller reverses for display order).\n */\n async listRecent({\n orgId,\n factoryProjectId,\n workItemId,\n limit,\n }: {\n orgId: string;\n factoryProjectId: string;\n workItemId: string;\n limit: number;\n }): Promise<WorkItemCommentRow[]> {\n const rows = await this.ops.findMany<WorkItemCommentDbRow>(\n 'work_item_comments',\n {\n org_id: orgId,\n factory_project_id: factoryProjectId,\n work_item_id: workItemId,\n deleted_at: null,\n },\n {\n orderBy: [\n ['occurred_at', 'desc'],\n ['id', 'desc'],\n ],\n limit,\n },\n );\n return rows.map(toComment);\n }\n\n async edit(input: EditWorkItemCommentInput): Promise<EditWorkItemCommentResult | 'conflict' | null> {\n const now = input.now ?? new Date();\n let rejection: 'deleted' | 'conflict' | undefined;\n const updated = await this.ops.updateAtomic<WorkItemCommentDbRow>(\n 'work_item_comments',\n { id: input.commentId, org_id: input.orgId },\n current => {\n if (current.deleted_at) {\n rejection = 'deleted';\n return null;\n }\n if (input.expectedRevision !== undefined && Number(current.revision) !== input.expectedRevision) {\n rejection = 'conflict';\n return null;\n }\n return {\n body: input.body,\n mentions: dedupeMentions(input.mentions ?? current.mentions),\n edited_at: now,\n revision: Number(current.revision) + 1,\n updated_at: now,\n };\n },\n );\n if (rejection === 'conflict') return 'conflict';\n if (!updated || rejection) return null;\n const comment = toComment(updated);\n\n const existing = await this.#listMentionRows(comment.id);\n const existingKeys = new Set(existing.map(mention => mentionKey(toMentionRef(mention))));\n const nextKeys = new Set(comment.mentions.map(mentionKey));\n // Self-mentions (author, or the acting editor) never become rows, so they\n // must not report as \"added\" either — they would re-report on every edit.\n const skippedIds = new Set([comment.author.id, ...(input.editorId ? [input.editorId] : [])]);\n const addedMentions = comment.mentions.filter(\n mention => !existingKeys.has(mentionKey(mention)) && !skippedIds.has(mention.id),\n );\n const removedMentions = existing.map(toMentionRef).filter(mention => !nextKeys.has(mentionKey(mention)));\n\n // Stamped with the edit time, not the comment's creation time: the inbox\n // is a keyset on `occurred_at`, and a backdated row lands buried under\n // everything the user already saw.\n await this.#writeMentionRows(comment, addedMentions, now);\n for (const mention of removedMentions) {\n await this.ops.deleteMany('work_item_comment_mentions', {\n comment_id: comment.id,\n mentioned_kind: mention.kind,\n mentioned_id: mention.id,\n });\n }\n return { comment, addedMentions, removedMentions };\n }\n\n async softDelete({\n orgId,\n commentId,\n deletedBy,\n now = new Date(),\n }: {\n orgId: string;\n commentId: string;\n deletedBy: string;\n now?: Date;\n }): Promise<WorkItemCommentRow | null> {\n let alreadyDeleted = false;\n const updated = await this.ops.updateAtomic<WorkItemCommentDbRow>(\n 'work_item_comments',\n { id: commentId, org_id: orgId },\n current => {\n if (current.deleted_at) {\n alreadyDeleted = true;\n return null;\n }\n return {\n body: '',\n mentions: [],\n deleted_at: now,\n deleted_by: deletedBy,\n updated_at: now,\n };\n },\n );\n if (!updated || alreadyDeleted) return null;\n await this.ops.deleteMany('work_item_comment_mentions', { comment_id: commentId });\n return toComment(updated);\n }\n\n /**\n * Provenance write-back after an outbound publish. First platform wins, and\n * an existing `source_key` is kept: replacing a `local:comment:<token>` one\n * would let a client retry duplicate the row. So a web-born comment is never\n * key-deduped against its own platform echo — the host's bot-sender check is\n * the only echo layer for those rows (COR-1174).\n */\n async attachExternalSource({\n orgId,\n commentId,\n source,\n }: {\n orgId: string;\n commentId: string;\n source: ExternalWorkItemSource;\n }): Promise<WorkItemCommentRow | null> {\n const updated = await this.ops.updateAtomic<WorkItemCommentDbRow>(\n 'work_item_comments',\n { id: commentId, org_id: orgId },\n current => {\n if (current.external_source) return null;\n return {\n external_source: source,\n source_key: current.source_key ?? commentSourceKey({ externalSource: source }),\n updated_at: new Date(),\n };\n },\n );\n return updated ? toComment(updated) : null;\n }\n\n async listMentionsForComment(commentId: string): Promise<WorkItemMentionRow[]> {\n return this.#listMentionRows(commentId);\n }\n\n /** Keyset inbox read for the mention attention provider, newest-first. */\n async listMentionsForUser({\n orgId,\n factoryProjectId,\n userId,\n before,\n limit,\n }: {\n orgId: string;\n factoryProjectId: string;\n userId: string;\n before?: { occurredAt: Date; id: string };\n limit: number;\n }): Promise<WorkItemMentionRow[]> {\n const rows = await this.ops.findMany<WorkItemMentionDbRow>(\n 'work_item_comment_mentions',\n {\n org_id: orgId,\n factory_project_id: factoryProjectId,\n mentioned_id: userId,\n },\n {\n orderBy: [\n ['occurred_at', 'desc'],\n ['id', 'desc'],\n ],\n limit,\n ...(before ? { cursor: { values: [before.occurredAt, before.id] } } : {}),\n },\n );\n return rows.map(toMention);\n }\n\n async countForWorkItem({\n orgId,\n factoryProjectId,\n workItemId,\n }: {\n orgId: string;\n factoryProjectId: string;\n workItemId: string;\n }): Promise<number> {\n if (!this.ops.count) {\n throw new Error('[WorkItemCommentsStorage] storage backend does not support collection counts.');\n }\n return this.ops.count('work_item_comments', {\n org_id: orgId,\n factory_project_id: factoryProjectId,\n work_item_id: workItemId,\n deleted_at: null,\n });\n }\n\n /** Recent distinct comment authors of a project, for the roster fallback. */\n async listRecentAuthors({\n orgId,\n factoryProjectId,\n limit = 200,\n }: {\n orgId: string;\n factoryProjectId: string;\n limit?: number;\n }): Promise<FactoryActorRef[]> {\n const rows = await this.ops.findMany<WorkItemCommentDbRow>(\n 'work_item_comments',\n { org_id: orgId, factory_project_id: factoryProjectId },\n {\n orderBy: [\n ['occurred_at', 'desc'],\n ['id', 'desc'],\n ],\n limit,\n },\n );\n const authors = new Map<string, FactoryActorRef>();\n for (const row of rows) {\n if (authors.has(row.author_id)) continue;\n authors.set(row.author_id, toComment(row).author);\n }\n return [...authors.values()];\n }\n\n async #listMentionRows(commentId: string): Promise<WorkItemMentionRow[]> {\n const rows = await this.ops.findMany<WorkItemMentionDbRow>('work_item_comment_mentions', {\n comment_id: commentId,\n });\n return rows.map(toMention);\n }\n\n /**\n * Self-mentions never get rows: the join exists solely for the attention\n * inbox, and `CollectionWhere` cannot express `author_id != userId` at read\n * time, so the filter happens at write time.\n */\n async #writeMentionRows(\n comment: WorkItemCommentRow,\n mentions: FactoryMentionRef[],\n occurredAt = comment.occurredAt,\n ): Promise<void> {\n for (const mention of mentions) {\n if (mention.id === comment.author.id) continue;\n await this.ops.upsertOne<WorkItemMentionDbRow>(\n 'work_item_comment_mentions',\n ['comment_id', 'mentioned_kind', 'mentioned_id'],\n {\n comment_id: comment.id,\n mentioned_kind: mention.kind,\n mentioned_id: mention.id,\n author_id: comment.author.id,\n org_id: comment.orgId,\n factory_project_id: comment.factoryProjectId,\n work_item_id: comment.workItemId,\n occurred_at: occurredAt,\n },\n );\n }\n }\n}\n"],"mappings":"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"}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* inside), so agent-authored comments later become a thin tool over
|
|
6
6
|
* `createComment` instead of a re-implementation.
|
|
7
7
|
*/
|
|
8
|
+
import type { PubSub } from '@mastra/core/events';
|
|
8
9
|
import type { ApiRoute } from '@mastra/core/server';
|
|
9
10
|
import type { RouteAuth } from '../../../routes/route.js';
|
|
10
11
|
import type { AuditEmitter } from '../audit/domain.js';
|
|
@@ -33,6 +34,8 @@ export interface CommentsDomainOptions {
|
|
|
33
34
|
audit?: AuditEmitter;
|
|
34
35
|
/** Outbound platform mirrors (COR-1174); empty until a platform wires one. */
|
|
35
36
|
publishers?: WorkItemFeedPublisher[];
|
|
37
|
+
/** Carries feed touches to every replica's open SSE streams. */
|
|
38
|
+
pubsub: PubSub;
|
|
36
39
|
}
|
|
37
40
|
export interface CreateCommentServiceInput {
|
|
38
41
|
orgId: string;
|
|
@@ -105,7 +108,7 @@ export type DeleteCommentServiceResult = {
|
|
|
105
108
|
};
|
|
106
109
|
export declare class CommentsDomain {
|
|
107
110
|
#private;
|
|
108
|
-
constructor({ auth, comments, workItems, projects, channelIdentity, members, audit, publishers, }: CommentsDomainOptions);
|
|
111
|
+
constructor({ auth, comments, workItems, projects, channelIdentity, members, audit, publishers, pubsub, }: CommentsDomainOptions);
|
|
109
112
|
createComment(input: CreateCommentServiceInput): Promise<CreateCommentServiceResult>;
|
|
110
113
|
editComment(input: EditCommentServiceInput): Promise<EditCommentServiceResult>;
|
|
111
114
|
deleteComment(input: DeleteCommentServiceInput): Promise<DeleteCommentServiceResult>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"domain.d.ts","sourceRoot":"","sources":["../../../../src/storage/domains/comments/domain.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAC;
|
|
1
|
+
{"version":3,"file":"domain.d.ts","sourceRoot":"","sources":["../../../../src/storage/domains/comments/domain.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { touchFeed } from "../../../feed-events.js";
|
|
1
2
|
import { factoryMentionAttentionIdentity } from "../work-items/base.js";
|
|
2
|
-
import { CommentTokenConflictError, commentBodyError } from "./base.js";
|
|
3
3
|
import { isMentionableActorId } from "./actor.js";
|
|
4
|
+
import { CommentTokenConflictError, commentBodyError } from "./base.js";
|
|
4
5
|
import { toWireComment } from "./wire.js";
|
|
5
6
|
import { buildCommentRoutes } from "./routes.js";
|
|
6
7
|
//#region src/storage/domains/comments/domain.ts
|
|
@@ -15,8 +16,9 @@ var CommentsDomain = class {
|
|
|
15
16
|
#members;
|
|
16
17
|
#audit;
|
|
17
18
|
#publishers;
|
|
19
|
+
#pubsub;
|
|
18
20
|
#rosterCache = /* @__PURE__ */ new Map();
|
|
19
|
-
constructor({ auth, comments, workItems, projects, channelIdentity, members, audit, publishers }) {
|
|
21
|
+
constructor({ auth, comments, workItems, projects, channelIdentity, members, audit, publishers, pubsub }) {
|
|
20
22
|
this.#auth = auth;
|
|
21
23
|
this.#comments = comments;
|
|
22
24
|
this.#workItems = workItems;
|
|
@@ -25,6 +27,15 @@ var CommentsDomain = class {
|
|
|
25
27
|
this.#members = members;
|
|
26
28
|
this.#audit = audit;
|
|
27
29
|
this.#publishers = publishers ?? [];
|
|
30
|
+
this.#pubsub = pubsub;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The one seam every feed mutation routes through — future
|
|
34
|
+
* `WorkItemFeedIngest` impls included.
|
|
35
|
+
*/
|
|
36
|
+
async #touchFeed(scope) {
|
|
37
|
+
await this.#comments.refreshWorkItemFeedActivity(scope);
|
|
38
|
+
touchFeed(this.#pubsub, scope, scope.workItemId);
|
|
28
39
|
}
|
|
29
40
|
async createComment(input) {
|
|
30
41
|
await this.#comments.ensureReady();
|
|
@@ -76,7 +87,7 @@ var CommentsDomain = class {
|
|
|
76
87
|
if (error instanceof CommentTokenConflictError) return { status: "token_conflict" };
|
|
77
88
|
throw error;
|
|
78
89
|
}
|
|
79
|
-
await this.#
|
|
90
|
+
await this.#touchFeed({
|
|
80
91
|
orgId: input.orgId,
|
|
81
92
|
factoryProjectId: workItem.factoryProjectId,
|
|
82
93
|
workItemId: input.workItemId
|
|
@@ -146,7 +157,7 @@ var CommentsDomain = class {
|
|
|
146
157
|
if (edited === "conflict") return { status: "conflict" };
|
|
147
158
|
if (!edited) return { status: "not_editable" };
|
|
148
159
|
await this.#cleanupMentionReceipts(existing, edited.removedMentions);
|
|
149
|
-
await this.#
|
|
160
|
+
await this.#touchFeed({
|
|
150
161
|
orgId: existing.orgId,
|
|
151
162
|
factoryProjectId: existing.factoryProjectId,
|
|
152
163
|
workItemId: existing.workItemId
|
|
@@ -178,7 +189,7 @@ var CommentsDomain = class {
|
|
|
178
189
|
factoryProjectId: existing.factoryProjectId,
|
|
179
190
|
identities: [factoryMentionAttentionIdentity(existing.id)]
|
|
180
191
|
});
|
|
181
|
-
await this.#
|
|
192
|
+
await this.#touchFeed({
|
|
182
193
|
orgId: existing.orgId,
|
|
183
194
|
factoryProjectId: existing.factoryProjectId,
|
|
184
195
|
workItemId: existing.workItemId
|
|
@@ -260,6 +271,7 @@ var CommentsDomain = class {
|
|
|
260
271
|
comments: this.#comments,
|
|
261
272
|
workItems: this.#workItems,
|
|
262
273
|
projects: this.#projects,
|
|
274
|
+
pubsub: this.#pubsub,
|
|
263
275
|
...this.#audit ? { audit: this.#audit } : {}
|
|
264
276
|
});
|
|
265
277
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"domain.js","names":["#auth","#comments","#workItems","#projects","#channelIdentity","#members","#audit","#publishers","#rosterCache","#mirrorComment","#cleanupMentionReceipts","#organizationRoster","#seenRoster"],"sources":["../../../../src/storage/domains/comments/domain.ts"],"sourcesContent":["/**\n * Comments domain — feed orchestration behind service methods; the HTTP layer\n * lives in `routes.ts` and only maps their result statuses. Service methods\n * take the author as a `FactoryActorRef` (never derived from the request\n * inside), so agent-authored comments later become a thin tool over\n * `createComment` instead of a re-implementation.\n */\n\nimport type { ApiRoute } from '@mastra/core/server';\n\nimport type { RouteAuth } from '../../../routes/route.js';\nimport type { AuditEmitter } from '../audit/domain.js';\nimport type { ChannelIdentityStorage } from '../channel-identity/base.js';\nimport type { FactoryProjectsStorage } from '../projects/base.js';\nimport type { WorkItemRow, WorkItemsStorage } from '../work-items/base.js';\nimport { factoryMentionAttentionIdentity } from '../work-items/base.js';\nimport type { FactoryActorRef } from './actor.js';\nimport { isMentionableActorId } from './actor.js';\nimport type {\n EditWorkItemCommentResult,\n FactoryMentionRef,\n WorkItemCommentReplyRef,\n WorkItemCommentRow,\n WorkItemCommentsStorage,\n} from './base.js';\nimport { CommentTokenConflictError, commentBodyError, MAX_COMMENT_MENTIONS, MAX_COMMENT_QUOTE_LENGTH } from './base.js';\nimport type { WorkItemFeedPublisher } from './feed-sync.js';\nimport { buildCommentRoutes } from './routes.js';\n\nexport interface FactoryRosterMember {\n id: string;\n name?: string;\n avatarUrl?: string;\n}\n\nexport interface OrganizationMembersProvider {\n listOrganizationMembers(orgId: string): Promise<FactoryRosterMember[]>;\n}\n\nexport interface CommentsDomainOptions {\n auth: RouteAuth;\n comments: WorkItemCommentsStorage;\n workItems: WorkItemsStorage;\n projects: FactoryProjectsStorage;\n channelIdentity?: ChannelIdentityStorage;\n /** Host-wired org membership listing for the mention roster (e.g. WorkOS). */\n members?: OrganizationMembersProvider;\n audit?: AuditEmitter;\n /** Outbound platform mirrors (COR-1174); empty until a platform wires one. */\n publishers?: WorkItemFeedPublisher[];\n}\n\nexport interface CreateCommentServiceInput {\n orgId: string;\n workItemId: string;\n author: FactoryActorRef;\n body: string;\n replyTo?: { commentId: string; quote?: string };\n mentions?: FactoryMentionRef[];\n clientToken?: string;\n}\n\nexport type CreateCommentServiceResult =\n | { status: 'created'; comment: WorkItemCommentRow; workItem: WorkItemRow }\n | { status: 'work_item_not_found' }\n | { status: 'token_conflict' }\n | { status: 'invalid'; message: string };\n\nexport interface CommentEditor {\n userId: string;\n /** Lazily checked, only when the caller is not the author. */\n canModerate: () => Promise<boolean>;\n}\n\nexport interface EditCommentServiceInput {\n orgId: string;\n workItemId: string;\n commentId: string;\n editor: CommentEditor;\n body: string;\n mentions?: FactoryMentionRef[];\n expectedRevision?: number;\n}\n\nexport type EditCommentServiceResult =\n | ({ status: 'edited'; previousBody: string } & EditWorkItemCommentResult)\n | { status: 'not_found' }\n | { status: 'forbidden' }\n | { status: 'not_editable' }\n | { status: 'conflict' }\n | { status: 'invalid'; message: string };\n\nexport interface DeleteCommentServiceInput {\n orgId: string;\n workItemId: string;\n commentId: string;\n editor: CommentEditor;\n}\n\nexport type DeleteCommentServiceResult =\n | { status: 'deleted'; comment: WorkItemCommentRow }\n | { status: 'not_found' }\n | { status: 'forbidden' }\n | { status: 'not_editable' };\n\nconst MAX_ROSTER_SIZE = 100;\nconst ROSTER_CACHE_TTL_MS = 60_000;\n\nexport class CommentsDomain {\n readonly #auth: RouteAuth;\n readonly #comments: WorkItemCommentsStorage;\n readonly #workItems: WorkItemsStorage;\n readonly #projects: FactoryProjectsStorage;\n readonly #channelIdentity: ChannelIdentityStorage | undefined;\n readonly #members: OrganizationMembersProvider | undefined;\n readonly #audit: AuditEmitter | undefined;\n readonly #publishers: WorkItemFeedPublisher[];\n readonly #rosterCache = new Map<string, { at: number; members: FactoryRosterMember[] }>();\n\n constructor({\n auth,\n comments,\n workItems,\n projects,\n channelIdentity,\n members,\n audit,\n publishers,\n }: CommentsDomainOptions) {\n this.#auth = auth;\n this.#comments = comments;\n this.#workItems = workItems;\n this.#projects = projects;\n this.#channelIdentity = channelIdentity;\n this.#members = members;\n this.#audit = audit;\n this.#publishers = publishers ?? [];\n }\n\n async createComment(input: CreateCommentServiceInput): Promise<CreateCommentServiceResult> {\n await this.#comments.ensureReady();\n await this.#workItems.ensureReady();\n\n const bodyError = commentBodyError(input.body);\n if (bodyError) return { status: 'invalid', message: bodyError };\n if ((input.mentions?.length ?? 0) > MAX_COMMENT_MENTIONS) {\n return { status: 'invalid', message: `At most ${MAX_COMMENT_MENTIONS} mentions per comment.` };\n }\n\n const workItem = await this.#workItems.get({ orgId: input.orgId, id: input.workItemId });\n if (!workItem) return { status: 'work_item_not_found' };\n\n let replyTo: WorkItemCommentReplyRef | undefined;\n if (input.replyTo) {\n const parent = await this.#comments.get({ orgId: input.orgId, commentId: input.replyTo.commentId });\n if (!parent || parent.workItemId !== input.workItemId) {\n return { status: 'invalid', message: 'Reply target is not a comment on this work item.' };\n }\n replyTo = {\n commentId: parent.id,\n ...(input.replyTo.quote ? { quote: input.replyTo.quote.slice(0, MAX_COMMENT_QUOTE_LENGTH) } : {}),\n authorId: parent.author.id,\n ...(parent.author.displayName ? { authorName: parent.author.displayName } : {}),\n };\n }\n\n let comment: WorkItemCommentRow;\n try {\n comment = await this.#comments.create({\n orgId: input.orgId,\n factoryProjectId: workItem.factoryProjectId,\n workItemId: input.workItemId,\n author: input.author,\n body: input.body,\n ...(replyTo ? { replyTo } : {}),\n ...(input.mentions ? { mentions: input.mentions } : {}),\n ...(input.clientToken ? { clientToken: input.clientToken } : {}),\n });\n } catch (error) {\n if (error instanceof CommentTokenConflictError) return { status: 'token_conflict' };\n throw error;\n }\n await this.#comments.refreshWorkItemFeedActivity({\n orgId: input.orgId,\n factoryProjectId: workItem.factoryProjectId,\n workItemId: input.workItemId,\n });\n await this.#mirrorComment(comment, workItem);\n return { status: 'created', comment, workItem };\n }\n\n /**\n * Best-effort: a failed publish never fails the create. The write-back is\n * the replay guard — a replayed create sees its own platform on the row and\n * skips it. Known ceiling, single publisher only: `external_source` is\n * single-valued, so with two publishers a replayed create re-publishes to\n * the one that is not recorded, and nothing persists a failed attempt for a\n * later pass to retry. Both need an outbox if a second publisher ever lands.\n */\n async #mirrorComment(comment: WorkItemCommentRow, workItem: WorkItemRow): Promise<void> {\n let current = comment;\n for (const publisher of this.#publishers) {\n if (current.externalSource?.integrationId === publisher.id) continue;\n try {\n const { source } = await publisher.publish(current, workItem);\n current =\n (await this.#comments.attachExternalSource({ orgId: current.orgId, commentId: current.id, source })) ??\n current;\n } catch (err) {\n console.warn('[Comments] Failed to mirror a comment to a platform', {\n publisherId: publisher.id,\n commentId: current.id,\n error: err instanceof Error ? err.message : String(err),\n });\n }\n }\n }\n\n async editComment(input: EditCommentServiceInput): Promise<EditCommentServiceResult> {\n await this.#comments.ensureReady();\n await this.#workItems.ensureReady();\n\n const bodyError = commentBodyError(input.body);\n if (bodyError) return { status: 'invalid', message: bodyError };\n if ((input.mentions?.length ?? 0) > MAX_COMMENT_MENTIONS) {\n return { status: 'invalid', message: `At most ${MAX_COMMENT_MENTIONS} mentions per comment.` };\n }\n\n const existing = await this.#comments.get({ orgId: input.orgId, commentId: input.commentId });\n if (!existing || existing.workItemId !== input.workItemId) return { status: 'not_found' };\n if (existing.deletedAt) return { status: 'not_editable' };\n if (existing.author.id !== input.editor.userId && !(await input.editor.canModerate())) {\n return { status: 'forbidden' };\n }\n\n const edited = await this.#comments.edit({\n orgId: input.orgId,\n commentId: input.commentId,\n body: input.body,\n editorId: input.editor.userId,\n ...(input.mentions ? { mentions: input.mentions } : {}),\n ...(input.expectedRevision !== undefined ? { expectedRevision: input.expectedRevision } : {}),\n });\n if (edited === 'conflict') return { status: 'conflict' };\n if (!edited) return { status: 'not_editable' };\n\n await this.#cleanupMentionReceipts(existing, edited.removedMentions);\n await this.#comments.refreshWorkItemFeedActivity({\n orgId: existing.orgId,\n factoryProjectId: existing.factoryProjectId,\n workItemId: existing.workItemId,\n });\n return { status: 'edited', previousBody: existing.body, ...edited };\n }\n\n async deleteComment(input: DeleteCommentServiceInput): Promise<DeleteCommentServiceResult> {\n await this.#comments.ensureReady();\n await this.#workItems.ensureReady();\n\n const existing = await this.#comments.get({ orgId: input.orgId, commentId: input.commentId });\n if (!existing || existing.workItemId !== input.workItemId) return { status: 'not_found' };\n if (existing.deletedAt) return { status: 'not_editable' };\n if (existing.author.id !== input.editor.userId && !(await input.editor.canModerate())) {\n return { status: 'forbidden' };\n }\n\n const deleted = await this.#comments.softDelete({\n orgId: input.orgId,\n commentId: input.commentId,\n deletedBy: input.editor.userId,\n });\n if (!deleted) return { status: 'not_editable' };\n\n await this.#workItems.deleteAttentionReceipts({\n orgId: existing.orgId,\n factoryProjectId: existing.factoryProjectId,\n identities: [factoryMentionAttentionIdentity(existing.id)],\n });\n await this.#comments.refreshWorkItemFeedActivity({\n orgId: existing.orgId,\n factoryProjectId: existing.factoryProjectId,\n workItemId: existing.workItemId,\n });\n return { status: 'deleted', comment: deleted };\n }\n\n /**\n * A removed mention deletes only that user's receipt: the receipt identity\n * is per-comment, and other still-mentioned users must keep their read state.\n */\n async #cleanupMentionReceipts(comment: WorkItemCommentRow, removed: FactoryMentionRef[]): Promise<void> {\n for (const mention of removed) {\n await this.#workItems.deleteAttentionReceipts({\n orgId: comment.orgId,\n factoryProjectId: comment.factoryProjectId,\n userId: mention.id,\n identities: [factoryMentionAttentionIdentity(comment.id)],\n });\n }\n }\n\n /**\n * Mentionable people: the host's org roster when it answers, otherwise the\n * people the project has actually seen (comment authors, linked channel\n * accounts). Cached per project so a dropdown session costs one read.\n */\n async mentionRoster({\n orgId,\n factoryProjectId,\n }: {\n orgId: string;\n factoryProjectId: string;\n }): Promise<FactoryRosterMember[]> {\n const cacheKey = `${orgId}\\0${factoryProjectId}`;\n const cached = this.#rosterCache.get(cacheKey);\n if (cached && Date.now() - cached.at < ROSTER_CACHE_TTL_MS) return cached.members;\n\n const members = await this.#organizationRoster(orgId);\n if (members.size === 0) await this.#seenRoster(orgId, factoryProjectId, members);\n\n const roster = [...members.values()].slice(0, MAX_ROSTER_SIZE);\n // Sweep on write: a long-lived server would otherwise hold one roster per\n // project it ever served.\n const cutoff = Date.now() - ROSTER_CACHE_TTL_MS;\n for (const [key, entry] of this.#rosterCache) {\n if (entry.at < cutoff) this.#rosterCache.delete(key);\n }\n this.#rosterCache.set(cacheKey, { at: Date.now(), members: roster });\n return roster;\n }\n\n /** Empty when no provider is wired, or when the host listing fails. */\n async #organizationRoster(orgId: string): Promise<Map<string, FactoryRosterMember>> {\n const members = new Map<string, FactoryRosterMember>();\n if (!this.#members) return members;\n try {\n for (const member of await this.#members.listOrganizationMembers(orgId)) {\n if (isMentionableActorId(member.id)) members.set(member.id, member);\n }\n } catch (err) {\n console.warn('[Comments] Failed to list organization members for the mention roster', {\n error: err instanceof Error ? err.message : String(err),\n });\n }\n return members;\n }\n\n async #seenRoster(orgId: string, factoryProjectId: string, members: Map<string, FactoryRosterMember>): Promise<void> {\n for (const author of await this.#comments.listRecentAuthors({ orgId, factoryProjectId })) {\n if (author.kind !== 'user' || !isMentionableActorId(author.id)) continue;\n members.set(author.id, {\n id: author.id,\n ...(author.displayName ? { name: author.displayName } : {}),\n ...(author.avatarUrl ? { avatarUrl: author.avatarUrl } : {}),\n });\n }\n if (!this.#channelIdentity) return;\n await this.#channelIdentity.ensureReady();\n for (const link of await this.#channelIdentity.listAccountLinksForOrg(orgId)) {\n if (members.has(link.userId)) continue;\n members.set(link.userId, {\n id: link.userId,\n ...(link.externalUserName ? { name: link.externalUserName } : {}),\n });\n }\n }\n\n routes(): ApiRoute[] {\n return buildCommentRoutes({\n domain: this,\n auth: this.#auth,\n comments: this.#comments,\n workItems: this.#workItems,\n projects: this.#projects,\n ...(this.#audit ? { audit: this.#audit } : {}),\n });\n }\n}\n\nexport { toWireComment } from './wire.js';\nexport type { WireComment, WireCommentPage } from './wire.js';\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"domain.js","names":["#auth","#comments","#workItems","#projects","#channelIdentity","#members","#audit","#publishers","#pubsub","#rosterCache","#touchFeed","#mirrorComment","#cleanupMentionReceipts","#organizationRoster","#seenRoster"],"sources":["../../../../src/storage/domains/comments/domain.ts"],"sourcesContent":["/**\n * Comments domain — feed orchestration behind service methods; the HTTP layer\n * lives in `routes.ts` and only maps their result statuses. Service methods\n * take the author as a `FactoryActorRef` (never derived from the request\n * inside), so agent-authored comments later become a thin tool over\n * `createComment` instead of a re-implementation.\n */\n\nimport type { PubSub } from '@mastra/core/events';\nimport type { ApiRoute } from '@mastra/core/server';\n\nimport { touchFeed } from '../../../feed-events.js';\nimport type { RouteAuth } from '../../../routes/route.js';\nimport type { AuditEmitter } from '../audit/domain.js';\nimport type { ChannelIdentityStorage } from '../channel-identity/base.js';\nimport type { FactoryProjectsStorage } from '../projects/base.js';\nimport type { WorkItemRow, WorkItemsStorage } from '../work-items/base.js';\nimport { factoryMentionAttentionIdentity } from '../work-items/base.js';\nimport type { FactoryActorRef } from './actor.js';\nimport { isMentionableActorId } from './actor.js';\nimport type {\n EditWorkItemCommentResult,\n FactoryMentionRef,\n WorkItemCommentReplyRef,\n WorkItemCommentRow,\n WorkItemCommentsStorage,\n} from './base.js';\nimport { CommentTokenConflictError, commentBodyError, MAX_COMMENT_MENTIONS, MAX_COMMENT_QUOTE_LENGTH } from './base.js';\nimport type { WorkItemFeedPublisher } from './feed-sync.js';\nimport { buildCommentRoutes } from './routes.js';\n\nexport interface FactoryRosterMember {\n id: string;\n name?: string;\n avatarUrl?: string;\n}\n\nexport interface OrganizationMembersProvider {\n listOrganizationMembers(orgId: string): Promise<FactoryRosterMember[]>;\n}\n\nexport interface CommentsDomainOptions {\n auth: RouteAuth;\n comments: WorkItemCommentsStorage;\n workItems: WorkItemsStorage;\n projects: FactoryProjectsStorage;\n channelIdentity?: ChannelIdentityStorage;\n /** Host-wired org membership listing for the mention roster (e.g. WorkOS). */\n members?: OrganizationMembersProvider;\n audit?: AuditEmitter;\n /** Outbound platform mirrors (COR-1174); empty until a platform wires one. */\n publishers?: WorkItemFeedPublisher[];\n /** Carries feed touches to every replica's open SSE streams. */\n pubsub: PubSub;\n}\n\nexport interface CreateCommentServiceInput {\n orgId: string;\n workItemId: string;\n author: FactoryActorRef;\n body: string;\n replyTo?: { commentId: string; quote?: string };\n mentions?: FactoryMentionRef[];\n clientToken?: string;\n}\n\nexport type CreateCommentServiceResult =\n | { status: 'created'; comment: WorkItemCommentRow; workItem: WorkItemRow }\n | { status: 'work_item_not_found' }\n | { status: 'token_conflict' }\n | { status: 'invalid'; message: string };\n\nexport interface CommentEditor {\n userId: string;\n /** Lazily checked, only when the caller is not the author. */\n canModerate: () => Promise<boolean>;\n}\n\nexport interface EditCommentServiceInput {\n orgId: string;\n workItemId: string;\n commentId: string;\n editor: CommentEditor;\n body: string;\n mentions?: FactoryMentionRef[];\n expectedRevision?: number;\n}\n\nexport type EditCommentServiceResult =\n | ({ status: 'edited'; previousBody: string } & EditWorkItemCommentResult)\n | { status: 'not_found' }\n | { status: 'forbidden' }\n | { status: 'not_editable' }\n | { status: 'conflict' }\n | { status: 'invalid'; message: string };\n\nexport interface DeleteCommentServiceInput {\n orgId: string;\n workItemId: string;\n commentId: string;\n editor: CommentEditor;\n}\n\nexport type DeleteCommentServiceResult =\n | { status: 'deleted'; comment: WorkItemCommentRow }\n | { status: 'not_found' }\n | { status: 'forbidden' }\n | { status: 'not_editable' };\n\nconst MAX_ROSTER_SIZE = 100;\nconst ROSTER_CACHE_TTL_MS = 60_000;\n\nexport class CommentsDomain {\n readonly #auth: RouteAuth;\n readonly #comments: WorkItemCommentsStorage;\n readonly #workItems: WorkItemsStorage;\n readonly #projects: FactoryProjectsStorage;\n readonly #channelIdentity: ChannelIdentityStorage | undefined;\n readonly #members: OrganizationMembersProvider | undefined;\n readonly #audit: AuditEmitter | undefined;\n readonly #publishers: WorkItemFeedPublisher[];\n readonly #pubsub: PubSub;\n readonly #rosterCache = new Map<string, { at: number; members: FactoryRosterMember[] }>();\n\n constructor({\n auth,\n comments,\n workItems,\n projects,\n channelIdentity,\n members,\n audit,\n publishers,\n pubsub,\n }: CommentsDomainOptions) {\n this.#auth = auth;\n this.#comments = comments;\n this.#workItems = workItems;\n this.#projects = projects;\n this.#channelIdentity = channelIdentity;\n this.#members = members;\n this.#audit = audit;\n this.#publishers = publishers ?? [];\n this.#pubsub = pubsub;\n }\n\n /**\n * The one seam every feed mutation routes through — future\n * `WorkItemFeedIngest` impls included.\n */\n async #touchFeed(scope: { orgId: string; factoryProjectId: string; workItemId: string }): Promise<void> {\n await this.#comments.refreshWorkItemFeedActivity(scope);\n touchFeed(this.#pubsub, scope, scope.workItemId);\n }\n\n async createComment(input: CreateCommentServiceInput): Promise<CreateCommentServiceResult> {\n await this.#comments.ensureReady();\n await this.#workItems.ensureReady();\n\n const bodyError = commentBodyError(input.body);\n if (bodyError) return { status: 'invalid', message: bodyError };\n if ((input.mentions?.length ?? 0) > MAX_COMMENT_MENTIONS) {\n return { status: 'invalid', message: `At most ${MAX_COMMENT_MENTIONS} mentions per comment.` };\n }\n\n const workItem = await this.#workItems.get({ orgId: input.orgId, id: input.workItemId });\n if (!workItem) return { status: 'work_item_not_found' };\n\n let replyTo: WorkItemCommentReplyRef | undefined;\n if (input.replyTo) {\n const parent = await this.#comments.get({ orgId: input.orgId, commentId: input.replyTo.commentId });\n if (!parent || parent.workItemId !== input.workItemId) {\n return { status: 'invalid', message: 'Reply target is not a comment on this work item.' };\n }\n replyTo = {\n commentId: parent.id,\n ...(input.replyTo.quote ? { quote: input.replyTo.quote.slice(0, MAX_COMMENT_QUOTE_LENGTH) } : {}),\n authorId: parent.author.id,\n ...(parent.author.displayName ? { authorName: parent.author.displayName } : {}),\n };\n }\n\n let comment: WorkItemCommentRow;\n try {\n comment = await this.#comments.create({\n orgId: input.orgId,\n factoryProjectId: workItem.factoryProjectId,\n workItemId: input.workItemId,\n author: input.author,\n body: input.body,\n ...(replyTo ? { replyTo } : {}),\n ...(input.mentions ? { mentions: input.mentions } : {}),\n ...(input.clientToken ? { clientToken: input.clientToken } : {}),\n });\n } catch (error) {\n if (error instanceof CommentTokenConflictError) return { status: 'token_conflict' };\n throw error;\n }\n await this.#touchFeed({\n orgId: input.orgId,\n factoryProjectId: workItem.factoryProjectId,\n workItemId: input.workItemId,\n });\n await this.#mirrorComment(comment, workItem);\n return { status: 'created', comment, workItem };\n }\n\n /**\n * Best-effort: a failed publish never fails the create. The write-back is\n * the replay guard — a replayed create sees its own platform on the row and\n * skips it. Known ceiling, single publisher only: `external_source` is\n * single-valued, so with two publishers a replayed create re-publishes to\n * the one that is not recorded, and nothing persists a failed attempt for a\n * later pass to retry. Both need an outbox if a second publisher ever lands.\n */\n async #mirrorComment(comment: WorkItemCommentRow, workItem: WorkItemRow): Promise<void> {\n let current = comment;\n for (const publisher of this.#publishers) {\n if (current.externalSource?.integrationId === publisher.id) continue;\n try {\n const { source } = await publisher.publish(current, workItem);\n current =\n (await this.#comments.attachExternalSource({ orgId: current.orgId, commentId: current.id, source })) ??\n current;\n } catch (err) {\n console.warn('[Comments] Failed to mirror a comment to a platform', {\n publisherId: publisher.id,\n commentId: current.id,\n error: err instanceof Error ? err.message : String(err),\n });\n }\n }\n }\n\n async editComment(input: EditCommentServiceInput): Promise<EditCommentServiceResult> {\n await this.#comments.ensureReady();\n await this.#workItems.ensureReady();\n\n const bodyError = commentBodyError(input.body);\n if (bodyError) return { status: 'invalid', message: bodyError };\n if ((input.mentions?.length ?? 0) > MAX_COMMENT_MENTIONS) {\n return { status: 'invalid', message: `At most ${MAX_COMMENT_MENTIONS} mentions per comment.` };\n }\n\n const existing = await this.#comments.get({ orgId: input.orgId, commentId: input.commentId });\n if (!existing || existing.workItemId !== input.workItemId) return { status: 'not_found' };\n if (existing.deletedAt) return { status: 'not_editable' };\n if (existing.author.id !== input.editor.userId && !(await input.editor.canModerate())) {\n return { status: 'forbidden' };\n }\n\n const edited = await this.#comments.edit({\n orgId: input.orgId,\n commentId: input.commentId,\n body: input.body,\n editorId: input.editor.userId,\n ...(input.mentions ? { mentions: input.mentions } : {}),\n ...(input.expectedRevision !== undefined ? { expectedRevision: input.expectedRevision } : {}),\n });\n if (edited === 'conflict') return { status: 'conflict' };\n if (!edited) return { status: 'not_editable' };\n\n await this.#cleanupMentionReceipts(existing, edited.removedMentions);\n await this.#touchFeed({\n orgId: existing.orgId,\n factoryProjectId: existing.factoryProjectId,\n workItemId: existing.workItemId,\n });\n return { status: 'edited', previousBody: existing.body, ...edited };\n }\n\n async deleteComment(input: DeleteCommentServiceInput): Promise<DeleteCommentServiceResult> {\n await this.#comments.ensureReady();\n await this.#workItems.ensureReady();\n\n const existing = await this.#comments.get({ orgId: input.orgId, commentId: input.commentId });\n if (!existing || existing.workItemId !== input.workItemId) return { status: 'not_found' };\n if (existing.deletedAt) return { status: 'not_editable' };\n if (existing.author.id !== input.editor.userId && !(await input.editor.canModerate())) {\n return { status: 'forbidden' };\n }\n\n const deleted = await this.#comments.softDelete({\n orgId: input.orgId,\n commentId: input.commentId,\n deletedBy: input.editor.userId,\n });\n if (!deleted) return { status: 'not_editable' };\n\n await this.#workItems.deleteAttentionReceipts({\n orgId: existing.orgId,\n factoryProjectId: existing.factoryProjectId,\n identities: [factoryMentionAttentionIdentity(existing.id)],\n });\n await this.#touchFeed({\n orgId: existing.orgId,\n factoryProjectId: existing.factoryProjectId,\n workItemId: existing.workItemId,\n });\n return { status: 'deleted', comment: deleted };\n }\n\n /**\n * A removed mention deletes only that user's receipt: the receipt identity\n * is per-comment, and other still-mentioned users must keep their read state.\n */\n async #cleanupMentionReceipts(comment: WorkItemCommentRow, removed: FactoryMentionRef[]): Promise<void> {\n for (const mention of removed) {\n await this.#workItems.deleteAttentionReceipts({\n orgId: comment.orgId,\n factoryProjectId: comment.factoryProjectId,\n userId: mention.id,\n identities: [factoryMentionAttentionIdentity(comment.id)],\n });\n }\n }\n\n /**\n * Mentionable people: the host's org roster when it answers, otherwise the\n * people the project has actually seen (comment authors, linked channel\n * accounts). Cached per project so a dropdown session costs one read.\n */\n async mentionRoster({\n orgId,\n factoryProjectId,\n }: {\n orgId: string;\n factoryProjectId: string;\n }): Promise<FactoryRosterMember[]> {\n const cacheKey = `${orgId}\\0${factoryProjectId}`;\n const cached = this.#rosterCache.get(cacheKey);\n if (cached && Date.now() - cached.at < ROSTER_CACHE_TTL_MS) return cached.members;\n\n const members = await this.#organizationRoster(orgId);\n if (members.size === 0) await this.#seenRoster(orgId, factoryProjectId, members);\n\n const roster = [...members.values()].slice(0, MAX_ROSTER_SIZE);\n // Sweep on write: a long-lived server would otherwise hold one roster per\n // project it ever served.\n const cutoff = Date.now() - ROSTER_CACHE_TTL_MS;\n for (const [key, entry] of this.#rosterCache) {\n if (entry.at < cutoff) this.#rosterCache.delete(key);\n }\n this.#rosterCache.set(cacheKey, { at: Date.now(), members: roster });\n return roster;\n }\n\n /** Empty when no provider is wired, or when the host listing fails. */\n async #organizationRoster(orgId: string): Promise<Map<string, FactoryRosterMember>> {\n const members = new Map<string, FactoryRosterMember>();\n if (!this.#members) return members;\n try {\n for (const member of await this.#members.listOrganizationMembers(orgId)) {\n if (isMentionableActorId(member.id)) members.set(member.id, member);\n }\n } catch (err) {\n console.warn('[Comments] Failed to list organization members for the mention roster', {\n error: err instanceof Error ? err.message : String(err),\n });\n }\n return members;\n }\n\n async #seenRoster(orgId: string, factoryProjectId: string, members: Map<string, FactoryRosterMember>): Promise<void> {\n for (const author of await this.#comments.listRecentAuthors({ orgId, factoryProjectId })) {\n if (author.kind !== 'user' || !isMentionableActorId(author.id)) continue;\n members.set(author.id, {\n id: author.id,\n ...(author.displayName ? { name: author.displayName } : {}),\n ...(author.avatarUrl ? { avatarUrl: author.avatarUrl } : {}),\n });\n }\n if (!this.#channelIdentity) return;\n await this.#channelIdentity.ensureReady();\n for (const link of await this.#channelIdentity.listAccountLinksForOrg(orgId)) {\n if (members.has(link.userId)) continue;\n members.set(link.userId, {\n id: link.userId,\n ...(link.externalUserName ? { name: link.externalUserName } : {}),\n });\n }\n }\n\n routes(): ApiRoute[] {\n return buildCommentRoutes({\n domain: this,\n auth: this.#auth,\n comments: this.#comments,\n workItems: this.#workItems,\n projects: this.#projects,\n pubsub: this.#pubsub,\n ...(this.#audit ? { audit: this.#audit } : {}),\n });\n }\n}\n\nexport { toWireComment } from './wire.js';\nexport type { WireComment, WireCommentPage } from './wire.js';\n"],"mappings":";;;;;;;AA6GA,MAAM,kBAAkB;AACxB,MAAM,sBAAsB;AAE5B,IAAa,iBAAb,MAA4B;CAC1B;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA,+BAAwB,IAAI,IAA4D;CAExF,YAAY,EACV,MACA,UACA,WACA,UACA,iBACA,SACA,OACA,YACA,UACwB;EACxB,KAAKA,QAAQ;EACb,KAAKC,YAAY;EACjB,KAAKC,aAAa;EAClB,KAAKC,YAAY;EACjB,KAAKC,mBAAmB;EACxB,KAAKC,WAAW;EAChB,KAAKC,SAAS;EACd,KAAKC,cAAc,cAAc,CAAC;EAClC,KAAKC,UAAU;CACjB;;;;;CAMA,MAAME,WAAW,OAAuF;EACtG,MAAM,KAAKT,UAAU,4BAA4B,KAAK;EACtD,UAAU,KAAKO,SAAS,OAAO,MAAM,UAAU;CACjD;CAEA,MAAM,cAAc,OAAuE;EACzF,MAAM,KAAKP,UAAU,YAAY;EACjC,MAAM,KAAKC,WAAW,YAAY;EAElC,MAAM,YAAY,iBAAiB,MAAM,IAAI;EAC7C,IAAI,WAAW,OAAO;GAAE,QAAQ;GAAW,SAAS;EAAU;EAC9D,KAAK,MAAM,UAAU,UAAU,KAAA,IAC7B,OAAO;GAAE,QAAQ;GAAW,SAAS;EAAwD;EAG/F,MAAM,WAAW,MAAM,KAAKA,WAAW,IAAI;GAAE,OAAO,MAAM;GAAO,IAAI,MAAM;EAAW,CAAC;EACvF,IAAI,CAAC,UAAU,OAAO,EAAE,QAAQ,sBAAsB;EAEtD,IAAI;EACJ,IAAI,MAAM,SAAS;GACjB,MAAM,SAAS,MAAM,KAAKD,UAAU,IAAI;IAAE,OAAO,MAAM;IAAO,WAAW,MAAM,QAAQ;GAAU,CAAC;GAClG,IAAI,CAAC,UAAU,OAAO,eAAe,MAAM,YACzC,OAAO;IAAE,QAAQ;IAAW,SAAS;GAAmD;GAE1F,UAAU;IACR,WAAW,OAAO;IAClB,GAAI,MAAM,QAAQ,QAAQ,EAAE,OAAO,MAAM,QAAQ,MAAM,MAAM,GAAA,GAA2B,EAAE,IAAI,CAAC;IAC/F,UAAU,OAAO,OAAO;IACxB,GAAI,OAAO,OAAO,cAAc,EAAE,YAAY,OAAO,OAAO,YAAY,IAAI,CAAC;GAC/E;EACF;EAEA,IAAI;EACJ,IAAI;GACF,UAAU,MAAM,KAAKA,UAAU,OAAO;IACpC,OAAO,MAAM;IACb,kBAAkB,SAAS;IAC3B,YAAY,MAAM;IAClB,QAAQ,MAAM;IACd,MAAM,MAAM;IACZ,GAAI,UAAU,EAAE,QAAQ,IAAI,CAAC;IAC7B,GAAI,MAAM,WAAW,EAAE,UAAU,MAAM,SAAS,IAAI,CAAC;IACrD,GAAI,MAAM,cAAc,EAAE,aAAa,MAAM,YAAY,IAAI,CAAC;GAChE,CAAC;EACH,SAAS,OAAO;GACd,IAAI,iBAAiB,2BAA2B,OAAO,EAAE,QAAQ,iBAAiB;GAClF,MAAM;EACR;EACA,MAAM,KAAKS,WAAW;GACpB,OAAO,MAAM;GACb,kBAAkB,SAAS;GAC3B,YAAY,MAAM;EACpB,CAAC;EACD,MAAM,KAAKC,eAAe,SAAS,QAAQ;EAC3C,OAAO;GAAE,QAAQ;GAAW;GAAS;EAAS;CAChD;;;;;;;;;CAUA,MAAMA,eAAe,SAA6B,UAAsC;EACtF,IAAI,UAAU;EACd,KAAK,MAAM,aAAa,KAAKJ,aAAa;GACxC,IAAI,QAAQ,gBAAgB,kBAAkB,UAAU,IAAI;GAC5D,IAAI;IACF,MAAM,EAAE,WAAW,MAAM,UAAU,QAAQ,SAAS,QAAQ;IAC5D,UACG,MAAM,KAAKN,UAAU,qBAAqB;KAAE,OAAO,QAAQ;KAAO,WAAW,QAAQ;KAAI;IAAO,CAAC,KAClG;GACJ,SAAS,KAAK;IACZ,QAAQ,KAAK,uDAAuD;KAClE,aAAa,UAAU;KACvB,WAAW,QAAQ;KACnB,OAAO,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;IACxD,CAAC;GACH;EACF;CACF;CAEA,MAAM,YAAY,OAAmE;EACnF,MAAM,KAAKA,UAAU,YAAY;EACjC,MAAM,KAAKC,WAAW,YAAY;EAElC,MAAM,YAAY,iBAAiB,MAAM,IAAI;EAC7C,IAAI,WAAW,OAAO;GAAE,QAAQ;GAAW,SAAS;EAAU;EAC9D,KAAK,MAAM,UAAU,UAAU,KAAA,IAC7B,OAAO;GAAE,QAAQ;GAAW,SAAS;EAAwD;EAG/F,MAAM,WAAW,MAAM,KAAKD,UAAU,IAAI;GAAE,OAAO,MAAM;GAAO,WAAW,MAAM;EAAU,CAAC;EAC5F,IAAI,CAAC,YAAY,SAAS,eAAe,MAAM,YAAY,OAAO,EAAE,QAAQ,YAAY;EACxF,IAAI,SAAS,WAAW,OAAO,EAAE,QAAQ,eAAe;EACxD,IAAI,SAAS,OAAO,OAAO,MAAM,OAAO,UAAU,CAAE,MAAM,MAAM,OAAO,YAAY,GACjF,OAAO,EAAE,QAAQ,YAAY;EAG/B,MAAM,SAAS,MAAM,KAAKA,UAAU,KAAK;GACvC,OAAO,MAAM;GACb,WAAW,MAAM;GACjB,MAAM,MAAM;GACZ,UAAU,MAAM,OAAO;GACvB,GAAI,MAAM,WAAW,EAAE,UAAU,MAAM,SAAS,IAAI,CAAC;GACrD,GAAI,MAAM,qBAAqB,KAAA,IAAY,EAAE,kBAAkB,MAAM,iBAAiB,IAAI,CAAC;EAC7F,CAAC;EACD,IAAI,WAAW,YAAY,OAAO,EAAE,QAAQ,WAAW;EACvD,IAAI,CAAC,QAAQ,OAAO,EAAE,QAAQ,eAAe;EAE7C,MAAM,KAAKW,wBAAwB,UAAU,OAAO,eAAe;EACnE,MAAM,KAAKF,WAAW;GACpB,OAAO,SAAS;GAChB,kBAAkB,SAAS;GAC3B,YAAY,SAAS;EACvB,CAAC;EACD,OAAO;GAAE,QAAQ;GAAU,cAAc,SAAS;GAAM,GAAG;EAAO;CACpE;CAEA,MAAM,cAAc,OAAuE;EACzF,MAAM,KAAKT,UAAU,YAAY;EACjC,MAAM,KAAKC,WAAW,YAAY;EAElC,MAAM,WAAW,MAAM,KAAKD,UAAU,IAAI;GAAE,OAAO,MAAM;GAAO,WAAW,MAAM;EAAU,CAAC;EAC5F,IAAI,CAAC,YAAY,SAAS,eAAe,MAAM,YAAY,OAAO,EAAE,QAAQ,YAAY;EACxF,IAAI,SAAS,WAAW,OAAO,EAAE,QAAQ,eAAe;EACxD,IAAI,SAAS,OAAO,OAAO,MAAM,OAAO,UAAU,CAAE,MAAM,MAAM,OAAO,YAAY,GACjF,OAAO,EAAE,QAAQ,YAAY;EAG/B,MAAM,UAAU,MAAM,KAAKA,UAAU,WAAW;GAC9C,OAAO,MAAM;GACb,WAAW,MAAM;GACjB,WAAW,MAAM,OAAO;EAC1B,CAAC;EACD,IAAI,CAAC,SAAS,OAAO,EAAE,QAAQ,eAAe;EAE9C,MAAM,KAAKC,WAAW,wBAAwB;GAC5C,OAAO,SAAS;GAChB,kBAAkB,SAAS;GAC3B,YAAY,CAAC,gCAAgC,SAAS,EAAE,CAAC;EAC3D,CAAC;EACD,MAAM,KAAKQ,WAAW;GACpB,OAAO,SAAS;GAChB,kBAAkB,SAAS;GAC3B,YAAY,SAAS;EACvB,CAAC;EACD,OAAO;GAAE,QAAQ;GAAW,SAAS;EAAQ;CAC/C;;;;;CAMA,MAAME,wBAAwB,SAA6B,SAA6C;EACtG,KAAK,MAAM,WAAW,SACpB,MAAM,KAAKV,WAAW,wBAAwB;GAC5C,OAAO,QAAQ;GACf,kBAAkB,QAAQ;GAC1B,QAAQ,QAAQ;GAChB,YAAY,CAAC,gCAAgC,QAAQ,EAAE,CAAC;EAC1D,CAAC;CAEL;;;;;;CAOA,MAAM,cAAc,EAClB,OACA,oBAIiC;EACjC,MAAM,WAAW,GAAG,MAAM,IAAI;EAC9B,MAAM,SAAS,KAAKO,aAAa,IAAI,QAAQ;EAC7C,IAAI,UAAU,KAAK,IAAI,IAAI,OAAO,KAAK,qBAAqB,OAAO,OAAO;EAE1E,MAAM,UAAU,MAAM,KAAKI,oBAAoB,KAAK;EACpD,IAAI,QAAQ,SAAS,GAAG,MAAM,KAAKC,YAAY,OAAO,kBAAkB,OAAO;EAE/E,MAAM,SAAS,CAAC,GAAG,QAAQ,OAAO,CAAC,CAAC,CAAC,MAAM,GAAG,eAAe;EAG7D,MAAM,SAAS,KAAK,IAAI,IAAI;EAC5B,KAAK,MAAM,CAAC,KAAK,UAAU,KAAKL,cAC9B,IAAI,MAAM,KAAK,QAAQ,KAAKA,aAAa,OAAO,GAAG;EAErD,KAAKA,aAAa,IAAI,UAAU;GAAE,IAAI,KAAK,IAAI;GAAG,SAAS;EAAO,CAAC;EACnE,OAAO;CACT;;CAGA,MAAMI,oBAAoB,OAA0D;EAClF,MAAM,0BAAU,IAAI,IAAiC;EACrD,IAAI,CAAC,KAAKR,UAAU,OAAO;EAC3B,IAAI;GACF,KAAK,MAAM,UAAU,MAAM,KAAKA,SAAS,wBAAwB,KAAK,GACpE,IAAI,qBAAqB,OAAO,EAAE,GAAG,QAAQ,IAAI,OAAO,IAAI,MAAM;EAEtE,SAAS,KAAK;GACZ,QAAQ,KAAK,yEAAyE,EACpF,OAAO,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG,EACxD,CAAC;EACH;EACA,OAAO;CACT;CAEA,MAAMS,YAAY,OAAe,kBAA0B,SAA0D;EACnH,KAAK,MAAM,UAAU,MAAM,KAAKb,UAAU,kBAAkB;GAAE;GAAO;EAAiB,CAAC,GAAG;GACxF,IAAI,OAAO,SAAS,UAAU,CAAC,qBAAqB,OAAO,EAAE,GAAG;GAChE,QAAQ,IAAI,OAAO,IAAI;IACrB,IAAI,OAAO;IACX,GAAI,OAAO,cAAc,EAAE,MAAM,OAAO,YAAY,IAAI,CAAC;IACzD,GAAI,OAAO,YAAY,EAAE,WAAW,OAAO,UAAU,IAAI,CAAC;GAC5D,CAAC;EACH;EACA,IAAI,CAAC,KAAKG,kBAAkB;EAC5B,MAAM,KAAKA,iBAAiB,YAAY;EACxC,KAAK,MAAM,QAAQ,MAAM,KAAKA,iBAAiB,uBAAuB,KAAK,GAAG;GAC5E,IAAI,QAAQ,IAAI,KAAK,MAAM,GAAG;GAC9B,QAAQ,IAAI,KAAK,QAAQ;IACvB,IAAI,KAAK;IACT,GAAI,KAAK,mBAAmB,EAAE,MAAM,KAAK,iBAAiB,IAAI,CAAC;GACjE,CAAC;EACH;CACF;CAEA,SAAqB;EACnB,OAAO,mBAAmB;GACxB,QAAQ;GACR,MAAM,KAAKJ;GACX,UAAU,KAAKC;GACf,WAAW,KAAKC;GAChB,UAAU,KAAKC;GACf,QAAQ,KAAKK;GACb,GAAI,KAAKF,SAAS,EAAE,OAAO,KAAKA,OAAO,IAAI,CAAC;EAC9C,CAAC;CACH;AACF"}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import type { Context } from 'hono';
|
|
3
3
|
import type { FactoryMentionRef } from './base.js';
|
|
4
4
|
export declare const UUID_RE: RegExp;
|
|
5
|
+
export declare function isRecord(value: unknown): value is Record<string, unknown>;
|
|
5
6
|
export declare function readJson(c: Context): Promise<unknown>;
|
|
6
7
|
export interface ParsedCreateComment {
|
|
7
8
|
body: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"parse.d.ts","sourceRoot":"","sources":["../../../../src/storage/domains/comments/parse.ts"],"names":[],"mappings":"AAAA,iGAAiG;AAEjG,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAC;AAGpC,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAC;AAGnD,eAAO,MAAM,OAAO,QAAoE,CAAC;
|
|
1
|
+
{"version":3,"file":"parse.d.ts","sourceRoot":"","sources":["../../../../src/storage/domains/comments/parse.ts"],"names":[],"mappings":"AAAA,iGAAiG;AAEjG,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAC;AAGpC,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAC;AAGnD,eAAO,MAAM,OAAO,QAAoE,CAAC;AAGzF,wBAAgB,QAAQ,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAEzE;AAED,wBAAsB,QAAQ,CAAC,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAM3D;AAyCD,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,OAAO,CAAC,EAAE;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAChD,QAAQ,CAAC,EAAE,iBAAiB,EAAE,CAAC;CAChC;AAED,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,OAAO,GAAG,mBAAmB,GAAG,IAAI,CAkB/E;AAED,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,CAAC,EAAE,iBAAiB,EAAE,CAAC;IAC/B,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,OAAO,GAAG,iBAAiB,GAAG,IAAI,CAe3E"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { commentBodyError } from "./base.js";
|
|
2
1
|
import { isMentionableActorId } from "./actor.js";
|
|
2
|
+
import { commentBodyError } from "./base.js";
|
|
3
3
|
//#region src/storage/domains/comments/parse.ts
|
|
4
4
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
5
5
|
const CLIENT_TOKEN_RE = /^[A-Za-z0-9-]{8,64}$/;
|
|
@@ -85,6 +85,6 @@ function parseEditCommentBody(raw) {
|
|
|
85
85
|
};
|
|
86
86
|
}
|
|
87
87
|
//#endregion
|
|
88
|
-
export { UUID_RE, parseCreateCommentBody, parseEditCommentBody, readJson };
|
|
88
|
+
export { UUID_RE, isRecord, parseCreateCommentBody, parseEditCommentBody, readJson };
|
|
89
89
|
|
|
90
90
|
//# sourceMappingURL=parse.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"parse.js","names":[],"sources":["../../../../src/storage/domains/comments/parse.ts"],"sourcesContent":["/** Request-body parsing for the comment routes: unknown JSON in, a typed input or `null` out. */\n\nimport type { Context } from 'hono';\n\nimport { isMentionableActorId } from './actor.js';\nimport type { FactoryMentionRef } from './base.js';\nimport { commentBodyError, MAX_COMMENT_MENTIONS, MAX_COMMENT_QUOTE_LENGTH } from './base.js';\n\nexport const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\nconst CLIENT_TOKEN_RE = /^[A-Za-z0-9-]{8,64}$/;\n\
|
|
1
|
+
{"version":3,"file":"parse.js","names":[],"sources":["../../../../src/storage/domains/comments/parse.ts"],"sourcesContent":["/** Request-body parsing for the comment routes: unknown JSON in, a typed input or `null` out. */\n\nimport type { Context } from 'hono';\n\nimport { isMentionableActorId } from './actor.js';\nimport type { FactoryMentionRef } from './base.js';\nimport { commentBodyError, MAX_COMMENT_MENTIONS, MAX_COMMENT_QUOTE_LENGTH } from './base.js';\n\nexport const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\nconst CLIENT_TOKEN_RE = /^[A-Za-z0-9-]{8,64}$/;\n\nexport function isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value);\n}\n\nexport async function readJson(c: Context): Promise<unknown> {\n try {\n return await c.req.json();\n } catch {\n return undefined;\n }\n}\n\n/** `undefined` when the field is absent, `null` when it is present but malformed. */\nfunction parseMentions(raw: unknown): FactoryMentionRef[] | null | undefined {\n if (raw === undefined) return undefined;\n if (!Array.isArray(raw) || raw.length > MAX_COMMENT_MENTIONS) return null;\n const mentions: FactoryMentionRef[] = [];\n for (const entry of raw) {\n if (!isRecord(entry)) return null;\n if (entry.kind !== 'user' || typeof entry.id !== 'string' || !isMentionableActorId(entry.id)) return null;\n mentions.push({ kind: 'user', id: entry.id });\n }\n return mentions;\n}\n\nfunction parseBody(raw: unknown): string | null {\n if (typeof raw !== 'string' || commentBodyError(raw)) return null;\n return raw;\n}\n\nfunction parseClientToken(raw: unknown): string | null | undefined {\n if (raw === undefined) return undefined;\n if (typeof raw !== 'string' || !CLIENT_TOKEN_RE.test(raw)) return null;\n return raw;\n}\n\nfunction parseExpectedRevision(raw: unknown): number | null | undefined {\n if (raw === undefined) return undefined;\n if (typeof raw !== 'number' || !Number.isInteger(raw)) return null;\n return raw;\n}\n\nfunction parseReplyTo(raw: unknown): { commentId: string; quote?: string } | null | undefined {\n if (raw === undefined) return undefined;\n if (!isRecord(raw)) return null;\n if (typeof raw.commentId !== 'string' || !UUID_RE.test(raw.commentId)) return null;\n if (raw.quote !== undefined && typeof raw.quote !== 'string') return null;\n const quote = typeof raw.quote === 'string' ? raw.quote.slice(0, MAX_COMMENT_QUOTE_LENGTH) : undefined;\n return { commentId: raw.commentId, ...(quote ? { quote } : {}) };\n}\n\nexport interface ParsedCreateComment {\n body: string;\n clientToken?: string;\n replyTo?: { commentId: string; quote?: string };\n mentions?: FactoryMentionRef[];\n}\n\nexport function parseCreateCommentBody(raw: unknown): ParsedCreateComment | null {\n if (!isRecord(raw)) return null;\n\n const body = parseBody(raw.body);\n if (body === null) return null;\n const mentions = parseMentions(raw.mentions);\n if (mentions === null) return null;\n const replyTo = parseReplyTo(raw.replyTo);\n if (replyTo === null) return null;\n const clientToken = parseClientToken(raw.clientToken);\n if (clientToken === null) return null;\n\n return {\n body,\n ...(clientToken ? { clientToken } : {}),\n ...(replyTo ? { replyTo } : {}),\n ...(mentions ? { mentions } : {}),\n };\n}\n\nexport interface ParsedEditComment {\n body: string;\n mentions?: FactoryMentionRef[];\n expectedRevision?: number;\n}\n\nexport function parseEditCommentBody(raw: unknown): ParsedEditComment | null {\n if (!isRecord(raw)) return null;\n\n const body = parseBody(raw.body);\n if (body === null) return null;\n const mentions = parseMentions(raw.mentions);\n if (mentions === null) return null;\n const expectedRevision = parseExpectedRevision(raw.expectedRevision);\n if (expectedRevision === null) return null;\n\n return {\n body,\n ...(mentions ? { mentions } : {}),\n ...(expectedRevision !== undefined ? { expectedRevision } : {}),\n };\n}\n"],"mappings":"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"}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* validate the path ids, delegate to a `CommentsDomain` service method, map its
|
|
4
4
|
* result status to a status code, then emit audit.
|
|
5
5
|
*/
|
|
6
|
+
import type { PubSub } from '@mastra/core/events';
|
|
6
7
|
import type { ApiRoute } from '@mastra/core/server';
|
|
7
8
|
import type { RouteAuth } from '../../../routes/route.js';
|
|
8
9
|
import type { AuditEmitter } from '../audit/domain.js';
|
|
@@ -16,6 +17,7 @@ export interface CommentRouteDependencies {
|
|
|
16
17
|
comments: WorkItemCommentsStorage;
|
|
17
18
|
workItems: WorkItemsStorage;
|
|
18
19
|
projects: FactoryProjectsStorage;
|
|
20
|
+
pubsub: PubSub;
|
|
19
21
|
audit?: AuditEmitter;
|
|
20
22
|
}
|
|
21
23
|
export declare function buildCommentRoutes(dependencies: CommentRouteDependencies): ApiRoute[];
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../../../../src/storage/domains/comments/routes.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAC;
|
|
1
|
+
{"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../../../../src/storage/domains/comments/routes.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAiB,MAAM,EAAE,MAAM,qBAAqB,CAAC;AACjE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AAOpD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,0BAA0B,CAAC;AAC1D,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AACvD,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,qBAAqB,CAAC;AAClE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAE9D,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,WAAW,CAAC;AAEzD,OAAO,KAAK,EAAiB,cAAc,EAAE,MAAM,aAAa,CAAC;AAWjE,MAAM,WAAW,wBAAwB;IACvC,MAAM,EAAE,cAAc,CAAC;IACvB,IAAI,EAAE,SAAS,CAAC;IAChB,QAAQ,EAAE,uBAAuB,CAAC;IAClC,SAAS,EAAE,gBAAgB,CAAC;IAC5B,QAAQ,EAAE,sBAAsB,CAAC;IACjC,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,YAAY,CAAC;CACtB;AAWD,wBAAgB,kBAAkB,CAAC,YAAY,EAAE,wBAAwB,GAAG,QAAQ,EAAE,CAyRrF"}
|
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
import { getFactoryAuthUser } from "../../../auth.js";
|
|
2
|
-
import {
|
|
2
|
+
import { feedTopic } from "../../../feed-events.js";
|
|
3
3
|
import { actorFromAuthUser } from "./actor.js";
|
|
4
|
-
import {
|
|
4
|
+
import { decodeCommentCursor } from "./base.js";
|
|
5
|
+
import { UUID_RE, isRecord, parseCreateCommentBody, parseEditCommentBody, readJson } from "./parse.js";
|
|
5
6
|
import { toWireComment } from "./wire.js";
|
|
6
7
|
import { registerApiRoute } from "@mastra/core/server";
|
|
8
|
+
import { streamSSE } from "hono/streaming";
|
|
7
9
|
//#region src/storage/domains/comments/routes.ts
|
|
8
10
|
const MAX_AUDIT_BODY_SNAPSHOT = 1024;
|
|
11
|
+
/** Comment frames only — proxies drop an idle stream, and `write` can't detect a dead peer. */
|
|
12
|
+
const FEED_KEEPALIVE_MS = 25e3;
|
|
9
13
|
function loose(c) {
|
|
10
14
|
return c;
|
|
11
15
|
}
|
|
12
16
|
function buildCommentRoutes(dependencies) {
|
|
13
|
-
const { domain, auth, comments, workItems, projects, audit } = dependencies;
|
|
17
|
+
const { domain, auth, comments, workItems, projects, pubsub, audit } = dependencies;
|
|
14
18
|
async function resolveTenant(c) {
|
|
15
19
|
await auth.ensureUser(c);
|
|
16
20
|
const tenant = auth.tenant(c);
|
|
@@ -239,6 +243,48 @@ function buildCommentRoutes(dependencies) {
|
|
|
239
243
|
const members = query ? roster.filter((member) => (member.name ?? member.id).toLowerCase().startsWith(query)) : roster;
|
|
240
244
|
return c.json({ members });
|
|
241
245
|
}
|
|
246
|
+
}),
|
|
247
|
+
registerApiRoute("/web/factory/projects/:id/feed-events", {
|
|
248
|
+
method: "GET",
|
|
249
|
+
handler: async (cc) => {
|
|
250
|
+
const c = loose(cc);
|
|
251
|
+
const tenant = await resolveTenant(c);
|
|
252
|
+
if ("response" in tenant) return tenant.response;
|
|
253
|
+
const projectId = c.req.param("id");
|
|
254
|
+
if (!projectId || !UUID_RE.test(projectId)) return c.json({ error: "Project not found" }, 404);
|
|
255
|
+
await projects.ensureReady();
|
|
256
|
+
if (!await projects.get({
|
|
257
|
+
orgId: tenant.orgId,
|
|
258
|
+
id: projectId
|
|
259
|
+
})) return c.json({ error: "Project not found" }, 404);
|
|
260
|
+
const topic = feedTopic(tenant.orgId, projectId);
|
|
261
|
+
return streamSSE(c, async (stream) => {
|
|
262
|
+
const onEvent = async (event) => {
|
|
263
|
+
if (stream.aborted) return;
|
|
264
|
+
const data = event.data;
|
|
265
|
+
const workItemId = isRecord(data) && typeof data.workItemId === "string" ? data.workItemId : void 0;
|
|
266
|
+
await stream.writeSSE({
|
|
267
|
+
event: "feed",
|
|
268
|
+
data: JSON.stringify(workItemId ? { workItemId } : {})
|
|
269
|
+
});
|
|
270
|
+
};
|
|
271
|
+
const closed = new Promise((resolve) => stream.onAbort(resolve));
|
|
272
|
+
let keepalive;
|
|
273
|
+
try {
|
|
274
|
+
await pubsub.subscribe(topic, onEvent, { startFrom: "latest" });
|
|
275
|
+
if (!stream.aborted) {
|
|
276
|
+
keepalive = setInterval(() => {
|
|
277
|
+
if (stream.aborted) return;
|
|
278
|
+
stream.write(": ping\n\n");
|
|
279
|
+
}, FEED_KEEPALIVE_MS);
|
|
280
|
+
await closed;
|
|
281
|
+
}
|
|
282
|
+
} finally {
|
|
283
|
+
clearInterval(keepalive);
|
|
284
|
+
await pubsub.unsubscribe(topic, onEvent);
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
}
|
|
242
288
|
})
|
|
243
289
|
];
|
|
244
290
|
}
|