feed.ts 6.3 KB

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