feed.ts 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  1. /** Reconnect-safe Workspace baseline and increment producer. */
  2. import type { Context } from '@deepseek-ai/cordis'
  3. import { Deque } from '@deepseek-ai/dsh-deque'
  4. import type { DomainChanged } from '@deepseek-ai/dsh-storage-domain'
  5. import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace'
  6. import {
  7. workspaceDomainState,
  8. workspaceRecord,
  9. WorkspaceId,
  10. } from '@deepseek-ai/dsh-workspace'
  11. import type {
  12. WorkspaceBaseline,
  13. WorkspaceFollowFrame,
  14. WorkspaceView,
  15. } from './types.ts'
  16. /**
  17. * Project one authoritative Workspace entity into its Remote value.
  18. * @param workspace - authoritative registry entity.
  19. * @returns detached Workspace projection for Remote consumers.
  20. */
  21. export function workspaceView(workspace: Workspace): WorkspaceView {
  22. return {
  23. workspaceId: workspace.id,
  24. path: workspace.path,
  25. title: workspace.title,
  26. sessionIds: [...workspace.sessionIds],
  27. createdAt: workspace.createdAt,
  28. updatedAt: workspace.updatedAt,
  29. }
  30. }
  31. function changedWorkspaceView(workspaceId: string, value: unknown): WorkspaceView {
  32. const record: WorkspaceRecord = workspaceRecord.parse(value)
  33. return {
  34. workspaceId: WorkspaceId(workspaceId),
  35. path: record.path,
  36. title: record.title,
  37. sessionIds: [...record.sessionIds],
  38. createdAt: record.createdAt,
  39. updatedAt: record.updatedAt,
  40. }
  41. }
  42. /** Owns Workspace domain observation and all active follow generations. */
  43. export class WorkspaceFeed {
  44. private readonly followers = new Set<WorkspaceFollower>()
  45. private knownIds: Set<string>
  46. private order: readonly string[]
  47. private archived: readonly string[]
  48. /** @param ctx - Host context containing the authoritative Workspace registry. */
  49. constructor(private readonly ctx: Context) {
  50. const baseline = ctx.workspaceRegistry.list()
  51. this.knownIds = new Set(baseline.map(workspace => String(workspace.id)))
  52. this.order = baseline.map(workspace => String(workspace.id))
  53. this.archived = ctx.workspaceRegistry.archivedSessionIds.map(String)
  54. ctx.on('domain/changed', (change: DomainChanged) => { this.changed(change) })
  55. ctx.effect(() => () => {
  56. for (const follower of this.followers) follower.close()
  57. this.followers.clear()
  58. }, 'workspace-controller.feed')
  59. }
  60. /**
  61. * Read the complete current projection synchronously.
  62. * @returns all active Workspaces and archived Session identities.
  63. */
  64. baseline(): WorkspaceBaseline {
  65. return {
  66. items: this.ctx.workspaceRegistry.list().map(workspaceView),
  67. archivedSessionIds: [...this.ctx.workspaceRegistry.archivedSessionIds],
  68. }
  69. }
  70. /**
  71. * Open one generation beginning with a complete baseline.
  72. * @param signal - generation cancellation.
  73. * @returns baseline followed by ordered Workspace increments.
  74. */
  75. async *follow(signal: AbortSignal): AsyncIterable<WorkspaceFollowFrame> {
  76. signal.throwIfAborted()
  77. const follower = new WorkspaceFollower()
  78. this.followers.add(follower)
  79. try {
  80. yield { type: 'baseline', value: this.baseline() }
  81. yield* follower.read(signal)
  82. } finally {
  83. this.followers.delete(follower)
  84. follower.close()
  85. }
  86. }
  87. private changed(change: DomainChanged): void {
  88. if (change.domain !== 'workspace') return
  89. if (change.table === '') {
  90. if (change.operation !== 'put') return
  91. const state = workspaceDomainState.parse(change.value)
  92. const nextOrder = state.workspaceIds.map(String)
  93. const orderChanged = !sameStrings(this.order, nextOrder)
  94. for (const id of state.workspaceIds) {
  95. if (this.knownIds.has(id)) continue
  96. const workspace = this.ctx.workspaceRegistry.get(id)
  97. if (workspace === undefined) {
  98. throw new Error(`committed Workspace registry references missing Workspace "${id}"`)
  99. }
  100. this.knownIds.add(id)
  101. this.publish({ type: 'upsert', workspace: workspaceView(workspace) })
  102. }
  103. this.order = nextOrder
  104. if (orderChanged) this.publish({ type: 'order', workspaceIds: [...state.workspaceIds] })
  105. const nextArchived = state.archivedSessionIds.map(String)
  106. if (!sameStrings(this.archived, nextArchived)) {
  107. this.archived = nextArchived
  108. this.publish({ type: 'archived', archivedSessionIds: [...state.archivedSessionIds] })
  109. }
  110. return
  111. }
  112. if (change.table !== 'workspaces') return
  113. if (change.operation === 'deleted') {
  114. if (!this.knownIds.delete(change.key)) return
  115. this.publish({ type: 'remove', workspaceId: WorkspaceId(change.key) })
  116. return
  117. }
  118. if (!this.knownIds.has(change.key)) return
  119. this.publish({
  120. type: 'upsert',
  121. workspace: changedWorkspaceView(change.key, change.value),
  122. })
  123. }
  124. private publish(frame: Exclude<WorkspaceFollowFrame, { readonly type: 'baseline' }>): void {
  125. for (const follower of this.followers) follower.push(frame)
  126. }
  127. }
  128. function sameStrings(left: readonly string[], right: readonly string[]): boolean {
  129. return left.length === right.length && left.every((value, index) => value === right[index])
  130. }
  131. class WorkspaceFollower {
  132. private readonly frames = new Deque<WorkspaceFollowFrame>()
  133. private waiting: (() => void) | undefined
  134. private closed = false
  135. push(frame: WorkspaceFollowFrame): void {
  136. /* v8 ignore next -- closed followers are removed before later publication can reach them. */
  137. if (this.closed) return
  138. this.frames.pushBack(frame)
  139. this.waiting?.()
  140. }
  141. close(): void {
  142. if (this.closed) return
  143. this.closed = true
  144. this.waiting?.()
  145. }
  146. async *read(signal: AbortSignal): AsyncIterable<WorkspaceFollowFrame> {
  147. while (!this.closed && !signal.aborted) {
  148. const frame = this.frames.popFront()
  149. if (frame !== undefined) {
  150. yield frame
  151. continue
  152. }
  153. await this.wait(signal)
  154. }
  155. }
  156. private wait(signal: AbortSignal): Promise<void> {
  157. return new Promise((resolve) => {
  158. const finish = (): void => {
  159. signal.removeEventListener('abort', finish)
  160. /* v8 ignore next -- one read owns the sole installed wait callback. */
  161. if (this.waiting === finish) this.waiting = undefined
  162. resolve()
  163. }
  164. this.waiting = finish
  165. signal.addEventListener('abort', finish, { once: true })
  166. /* v8 ignore next -- native signals and the private queue cannot change during this synchronous setup. */
  167. if (signal.aborted || this.closed || this.frames.size > 0) finish()
  168. })
  169. }
  170. }