@gotcos/glasses-server 6.51.0 → 6.52.0

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.
@@ -1,4 +1,4 @@
1
- import { Router, type Response } from 'express'
1
+ import { Router, type Request, type Response } from 'express'
2
2
  import { durableQueryJobsEnabled } from '../lib/query-job-feature.js'
3
3
  import {
4
4
  QueryJobCoordinator,
@@ -16,6 +16,7 @@ import {
16
16
  QueryJobPersistenceError,
17
17
  QueryJobProviderOrphanFenceError,
18
18
  QueryJobStoreError,
19
+ type QueryJobTrailFrame,
19
20
  } from '../lib/query-job-store.js'
20
21
  import {
21
22
  isTerminalQueryJobStatus,
@@ -89,7 +90,25 @@ function writeSse(res: Response, type: string, event: Record<string, unknown>):
89
90
  res.write(`event: ${type}\ndata: ${JSON.stringify(event)}\n\n`)
90
91
  }
91
92
 
92
- function snapshotEvent(job: QueryJobSnapshot, reason: string): Record<string, unknown> {
93
+ /**
94
+ * THE TRAIL IS OPT-IN (6.52.0 QA round 1). A client that does not send `?trail=1` gets
95
+ * exactly the 6.51.0 bytes: no `trail` frames on the stream, and no `trail` field on any
96
+ * snapshot, in any response. A client that does gets `trail` frames (their own dense
97
+ * `trailSeq`, no `id:` line, so Last-Event-ID stays the job eventSeq) and, on subscribe or
98
+ * reconnect, every entry after its `trailAfter` from `snapshot.trail`, or one
99
+ * `trail_snapshot` frame to rebuild from when entries it missed are no longer held.
100
+ */
101
+ function wantsTrail(req: Request): boolean {
102
+ return req.query.trail === '1'
103
+ }
104
+
105
+ function forClient(job: QueryJobSnapshot, trail: boolean): QueryJobSnapshot {
106
+ if (trail || !('trail' in job)) return job
107
+ const { trail: _trail, ...rest } = job
108
+ return rest as QueryJobSnapshot
109
+ }
110
+
111
+ function snapshotEvent(job: QueryJobSnapshot, reason: string, trail: boolean): Record<string, unknown> {
93
112
  return {
94
113
  type: 'snapshot',
95
114
  eventSeq: job.eventSeq,
@@ -98,7 +117,7 @@ function snapshotEvent(job: QueryJobSnapshot, reason: string): Record<string, un
98
117
  generation: job.generation,
99
118
  status: job.status,
100
119
  at: job.updatedAt,
101
- data: { reason, job },
120
+ data: { reason, job: forClient(job, trail) },
102
121
  }
103
122
  }
104
123
 
@@ -122,7 +141,7 @@ export function createQueryJobsRouter(
122
141
  try {
123
142
  const prepared = options.prepareAdmission ? await options.prepareAdmission(req.body) : req.body
124
143
  const admission = await coordinator.submit(prepared)
125
- return res.status(202).json({ job: admission.job })
144
+ return res.status(202).json({ job: forClient(admission.job, wantsTrail(req)) })
126
145
  } catch (error) {
127
146
  const wire = wireError(error)
128
147
  return res.status(wire.status).json(wire.body)
@@ -138,7 +157,7 @@ export function createQueryJobsRouter(
138
157
  const generation = requiredGeneration(req.query.generation)
139
158
  const job = await coordinator.getByClientGeneration(clientJobId, generation)
140
159
  if (!job) throw new QueryJobNotFoundError(`${clientJobId}:${generation}`)
141
- return res.json({ job })
160
+ return res.json({ job: forClient(job, wantsTrail(req)) })
142
161
  } catch (error) {
143
162
  const wire = wireError(error)
144
163
  return res.status(wire.status).json(wire.body)
@@ -150,7 +169,7 @@ export function createQueryJobsRouter(
150
169
  const jobId = validJobId(req.params.jobId)
151
170
  const generation = requiredGeneration(req.query.generation)
152
171
  const job = await coordinator.getSnapshot(jobId, generation)
153
- return res.json({ job })
172
+ return res.json({ job: forClient(job, wantsTrail(req)) })
154
173
  } catch (error) {
155
174
  const wire = wireError(error)
156
175
  return res.status(wire.status).json(wire.body)
@@ -163,7 +182,15 @@ export function createQueryJobsRouter(
163
182
  let closed = false
164
183
  let streamReady = false
165
184
  let sentSeq = -1
185
+ let sentTrailSeq = 0
166
186
  const pendingLive: QueryJobEvent[] = []
187
+ const pendingTrail: QueryJobTrailFrame[] = []
188
+ const trail = wantsTrail(req)
189
+ const writeTrail = (frame: QueryJobTrailFrame) => {
190
+ if (closed || frame.trailSeq <= sentTrailSeq) return
191
+ sentTrailSeq = frame.trailSeq
192
+ writeSse(res, 'trail', frame as unknown as Record<string, unknown>)
193
+ }
167
194
  const cleanup = () => {
168
195
  if (closed) return
169
196
  closed = true
@@ -184,8 +211,22 @@ export function createQueryJobsRouter(
184
211
  const generation = requiredGeneration(req.query.generation)
185
212
  const after = cursor(req.query.after)
186
213
  sentSeq = after
214
+ const trailAfter = trail ? cursor(req.query.trailAfter) : 0
215
+ sentTrailSeq = trailAfter
216
+ const trailSubscription = trail
217
+ ? {
218
+ after: trailAfter,
219
+ listener: (frame: QueryJobTrailFrame) => {
220
+ if (!streamReady) {
221
+ pendingTrail.push(frame)
222
+ return
223
+ }
224
+ writeTrail(frame)
225
+ },
226
+ }
227
+ : undefined
187
228
 
188
- const subscription = await coordinator.subscribe(jobId, generation, after, (event: QueryJobEvent) => {
229
+ const onEvent = (event: QueryJobEvent) => {
189
230
  // subscribe() registers its live listener before returning the replay
190
231
  // snapshot so no append can fall into a gap. A very fast provider may
191
232
  // therefore publish while this route is still installing SSE headers.
@@ -199,7 +240,11 @@ export function createQueryJobsRouter(
199
240
  sentSeq = event.eventSeq
200
241
  writeSse(res, event.type, event as unknown as Record<string, unknown>)
201
242
  if (isTerminalQueryJobStatus(event.status)) finish()
202
- })
243
+ }
244
+ // Without `?trail=1` the call is exactly the 6.51.0 one.
245
+ const subscription = trailSubscription
246
+ ? await coordinator.subscribe(jobId, generation, after, onEvent, trailSubscription)
247
+ : await coordinator.subscribe(jobId, generation, after, onEvent)
203
248
  unsubscribe = subscription.unsubscribe
204
249
  if (closed) {
205
250
  unsubscribe()
@@ -216,9 +261,33 @@ export function createQueryJobsRouter(
216
261
  res.flushHeaders()
217
262
  res.write(': keepalive\n\n')
218
263
 
219
- const { replay } = subscription
264
+ const { replay, trailReplay } = subscription
265
+ // The trail first: every entry precedes the terminal event that may end this stream.
266
+ if (trail && trailReplay) {
267
+ if (trailReplay.gap) {
268
+ writeSse(res, 'trail_snapshot', {
269
+ type: 'trail_snapshot',
270
+ reason: trailReplay.reason ?? 'trail_gap',
271
+ jobId: replay.snapshot.jobId,
272
+ clientJobId: replay.snapshot.clientJobId,
273
+ generation: replay.snapshot.generation,
274
+ oldestTrailSeq: trailReplay.oldestTrailSeq,
275
+ latestTrailSeq: trailReplay.latestTrailSeq,
276
+ trail: trailReplay.entries,
277
+ })
278
+ sentTrailSeq = trailReplay.latestTrailSeq
279
+ } else {
280
+ for (const entry of trailReplay.entries) {
281
+ const { trailSeq, at, ...data } = entry
282
+ writeTrail({
283
+ type: 'trail', trailSeq, at, data,
284
+ jobId: replay.snapshot.jobId, clientJobId: replay.snapshot.clientJobId, generation: replay.snapshot.generation,
285
+ } as QueryJobTrailFrame)
286
+ }
287
+ }
288
+ }
220
289
  if (replay.gap) {
221
- writeSse(res, 'snapshot', snapshotEvent(replay.snapshot, replay.reason ?? 'replay_gap'))
290
+ writeSse(res, 'snapshot', snapshotEvent(replay.snapshot, replay.reason ?? 'replay_gap', trail))
222
291
  sentSeq = replay.snapshot.eventSeq
223
292
  } else {
224
293
  for (const event of replay.events) {
@@ -230,7 +299,7 @@ export function createQueryJobsRouter(
230
299
 
231
300
  const replayTerminal = replay.events.some(event => isTerminalQueryJobStatus(event.status))
232
301
  if (isTerminalQueryJobStatus(replay.snapshot.status) && !replayTerminal && !replay.gap) {
233
- writeSse(res, 'snapshot', snapshotEvent(replay.snapshot, 'terminal_snapshot'))
302
+ writeSse(res, 'snapshot', snapshotEvent(replay.snapshot, 'terminal_snapshot', trail))
234
303
  sentSeq = replay.snapshot.eventSeq
235
304
  }
236
305
  if (isTerminalQueryJobStatus(replay.snapshot.status)) {
@@ -239,6 +308,8 @@ export function createQueryJobsRouter(
239
308
  }
240
309
 
241
310
  streamReady = true
311
+ for (const frame of pendingTrail) writeTrail(frame)
312
+ pendingTrail.length = 0
242
313
  for (const event of pendingLive) {
243
314
  if (closed || event.eventSeq <= sentSeq) continue
244
315
  sentSeq = event.eventSeq
@@ -271,7 +342,7 @@ export function createQueryJobsRouter(
271
342
  const jobId = validJobId(req.params.jobId)
272
343
  const generation = requiredGeneration(req.body?.generation)
273
344
  const { job } = await coordinator.cancel(jobId, generation)
274
- return res.json({ job })
345
+ return res.json({ job: forClient(job, wantsTrail(req)) })
275
346
  } catch (error) {
276
347
  const wire = wireError(error)
277
348
  return res.status(wire.status).json(wire.body)
@@ -283,7 +354,7 @@ export function createQueryJobsRouter(
283
354
  const jobId = validJobId(req.params.jobId)
284
355
  const generation = requiredGeneration(req.body?.generation)
285
356
  const job = await coordinator.acknowledge(jobId, generation)
286
- return res.json({ job })
357
+ return res.json({ job: forClient(job, wantsTrail(req)) })
287
358
  } catch (error) {
288
359
  const wire = wireError(error)
289
360
  return res.status(wire.status).json(wire.body)