@gotcos/glasses-server 6.8.0 → 6.10.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.
@@ -0,0 +1,294 @@
1
+ import { Router, type Response } from 'express'
2
+ import { durableQueryJobsEnabled } from '../lib/query-job-feature.js'
3
+ import {
4
+ QueryJobCoordinator,
5
+ QueryJobCoordinatorError,
6
+ queryJobErrorCode,
7
+ } from '../lib/query-job-coordinator.js'
8
+ import {
9
+ QueryJobAnswerCommittingError,
10
+ QueryJobActiveGenerationError,
11
+ QueryJobGenerationMismatchError,
12
+ QueryJobGenerationOrderError,
13
+ QueryJobIdentityConflictError,
14
+ QueryJobNotFoundError,
15
+ QueryJobNotTerminalError,
16
+ QueryJobPersistenceError,
17
+ QueryJobProviderOrphanFenceError,
18
+ QueryJobStoreError,
19
+ } from '../lib/query-job-store.js'
20
+ import {
21
+ isTerminalQueryJobStatus,
22
+ normalizeQueryJobError,
23
+ parsePositiveInteger,
24
+ QueryJobValidationError,
25
+ type QueryJobEvent,
26
+ type QueryJobSnapshot,
27
+ } from '../lib/query-job-types.js'
28
+
29
+ const JOB_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
30
+
31
+ export interface QueryJobsRouterOptions {
32
+ enabled?: () => boolean
33
+ heartbeatMs?: number
34
+ prepareAdmission?: (raw: unknown) => unknown | Promise<unknown>
35
+ }
36
+
37
+ interface WireError {
38
+ error: { code: string; message: string; retryable?: boolean; retryAfterMs?: number }
39
+ }
40
+
41
+ function wireError(error: unknown): { status: number; body: WireError } {
42
+ let status = 500
43
+ const explicitStatus = Number((error as { status?: unknown })?.status)
44
+ if (Number.isInteger(explicitStatus) && explicitStatus >= 400 && explicitStatus <= 599) status = explicitStatus
45
+ else if (error instanceof QueryJobValidationError) status = 400
46
+ else if (error instanceof QueryJobNotFoundError) status = 404
47
+ else if (error instanceof QueryJobGenerationMismatchError
48
+ || error instanceof QueryJobIdentityConflictError
49
+ || error instanceof QueryJobActiveGenerationError
50
+ || error instanceof QueryJobGenerationOrderError
51
+ || error instanceof QueryJobProviderOrphanFenceError
52
+ || error instanceof QueryJobAnswerCommittingError
53
+ || error instanceof QueryJobNotTerminalError) status = 409
54
+ else if (error instanceof QueryJobPersistenceError
55
+ || error instanceof QueryJobCoordinatorError) status = 503
56
+ else if (error instanceof QueryJobStoreError) status = 409
57
+ const normalized = normalizeQueryJobError(error, queryJobErrorCode(error))
58
+ return { status, body: { error: normalized } }
59
+ }
60
+
61
+ function validJobId(raw: unknown): string {
62
+ if (typeof raw !== 'string' || !JOB_ID_RE.test(raw)) throw new QueryJobValidationError('invalid_job_id')
63
+ return raw.toLowerCase()
64
+ }
65
+
66
+ function validClientJobId(raw: unknown): string {
67
+ if (typeof raw !== 'string' || !JOB_ID_RE.test(raw)) {
68
+ throw new QueryJobValidationError('invalid_client_job_id')
69
+ }
70
+ return raw.toLowerCase()
71
+ }
72
+
73
+ function requiredGeneration(raw: unknown): number {
74
+ const value = parsePositiveInteger(raw)
75
+ if (value == null || value < 1) throw new QueryJobValidationError('invalid_generation')
76
+ return value
77
+ }
78
+
79
+ function cursor(raw: unknown): number {
80
+ if (raw == null || raw === '') return 0
81
+ const value = parsePositiveInteger(raw)
82
+ if (value == null) throw new QueryJobValidationError('invalid_event_cursor')
83
+ return value
84
+ }
85
+
86
+ function writeSse(res: Response, type: string, event: Record<string, unknown>): void {
87
+ const sequence = typeof event.eventSeq === 'number' ? event.eventSeq : undefined
88
+ if (sequence != null) res.write(`id: ${sequence}\n`)
89
+ res.write(`event: ${type}\ndata: ${JSON.stringify(event)}\n\n`)
90
+ }
91
+
92
+ function snapshotEvent(job: QueryJobSnapshot, reason: string): Record<string, unknown> {
93
+ return {
94
+ type: 'snapshot',
95
+ eventSeq: job.eventSeq,
96
+ jobId: job.jobId,
97
+ clientJobId: job.clientJobId,
98
+ generation: job.generation,
99
+ status: job.status,
100
+ at: job.updatedAt,
101
+ data: { reason, job },
102
+ }
103
+ }
104
+
105
+ export function createQueryJobsRouter(
106
+ coordinator: QueryJobCoordinator,
107
+ options: QueryJobsRouterOptions = {},
108
+ ): Router {
109
+ const router = Router()
110
+ const enabled = options.enabled ?? durableQueryJobsEnabled
111
+ const heartbeatMs = Math.max(1_000, options.heartbeatMs ?? 15_000)
112
+
113
+ router.post('/query-jobs', async (req, res) => {
114
+ if (!enabled()) {
115
+ return res.status(404).json({
116
+ error: {
117
+ code: 'durable_query_jobs_disabled',
118
+ message: 'Durable query jobs are not enabled on this server.',
119
+ },
120
+ } satisfies WireError)
121
+ }
122
+ try {
123
+ const prepared = options.prepareAdmission ? await options.prepareAdmission(req.body) : req.body
124
+ const admission = await coordinator.submit(prepared)
125
+ return res.status(202).json({ job: admission.job })
126
+ } catch (error) {
127
+ const wire = wireError(error)
128
+ return res.status(wire.status).json(wire.body)
129
+ }
130
+ })
131
+
132
+ // Lost-202 recovery is keyed by the immutable client identity. Keep this
133
+ // route explicit and ahead of the dynamic job lookup for future router
134
+ // changes that may broaden the latter's matcher.
135
+ router.get('/query-jobs/by-client/:clientJobId', async (req, res) => {
136
+ try {
137
+ const clientJobId = validClientJobId(req.params.clientJobId)
138
+ const generation = requiredGeneration(req.query.generation)
139
+ const job = await coordinator.getByClientGeneration(clientJobId, generation)
140
+ if (!job) throw new QueryJobNotFoundError(`${clientJobId}:${generation}`)
141
+ return res.json({ job })
142
+ } catch (error) {
143
+ const wire = wireError(error)
144
+ return res.status(wire.status).json(wire.body)
145
+ }
146
+ })
147
+
148
+ router.get('/query-jobs/:jobId', async (req, res) => {
149
+ try {
150
+ const jobId = validJobId(req.params.jobId)
151
+ const generation = requiredGeneration(req.query.generation)
152
+ const job = await coordinator.getSnapshot(jobId, generation)
153
+ return res.json({ job })
154
+ } catch (error) {
155
+ const wire = wireError(error)
156
+ return res.status(wire.status).json(wire.body)
157
+ }
158
+ })
159
+
160
+ router.get('/query-jobs/:jobId/events', async (req, res) => {
161
+ let unsubscribe: (() => void) | undefined
162
+ let heartbeat: ReturnType<typeof setInterval> | undefined
163
+ let closed = false
164
+ let streamReady = false
165
+ let sentSeq = -1
166
+ const pendingLive: QueryJobEvent[] = []
167
+ const cleanup = () => {
168
+ if (closed) return
169
+ closed = true
170
+ if (heartbeat) clearInterval(heartbeat)
171
+ unsubscribe?.()
172
+ }
173
+ const finish = () => {
174
+ cleanup()
175
+ if (!res.writableEnded) res.end()
176
+ }
177
+ // Register before subscribe(): a client can background/close while the
178
+ // store is still preparing replay. The returned live listener must then
179
+ // be released as soon as its delayed subscription arrives.
180
+ res.on('close', cleanup)
181
+
182
+ try {
183
+ const jobId = validJobId(req.params.jobId)
184
+ const generation = requiredGeneration(req.query.generation)
185
+ const after = cursor(req.query.after)
186
+ sentSeq = after
187
+
188
+ const subscription = await coordinator.subscribe(jobId, generation, after, (event: QueryJobEvent) => {
189
+ // subscribe() registers its live listener before returning the replay
190
+ // snapshot so no append can fall into a gap. A very fast provider may
191
+ // therefore publish while this route is still installing SSE headers.
192
+ // Buffer that narrow window instead of allowing res.write() to commit
193
+ // implicit non-SSE headers before writeHead() below.
194
+ if (!streamReady) {
195
+ pendingLive.push(event)
196
+ return
197
+ }
198
+ if (closed || event.eventSeq <= sentSeq) return
199
+ sentSeq = event.eventSeq
200
+ writeSse(res, event.type, event as unknown as Record<string, unknown>)
201
+ if (isTerminalQueryJobStatus(event.status)) finish()
202
+ })
203
+ unsubscribe = subscription.unsubscribe
204
+ if (closed) {
205
+ unsubscribe()
206
+ return
207
+ }
208
+
209
+ res.writeHead(200, {
210
+ 'Content-Type': 'text/event-stream',
211
+ 'Cache-Control': 'no-cache, no-transform',
212
+ 'Connection': 'keep-alive',
213
+ 'X-Accel-Buffering': 'no',
214
+ 'Access-Control-Allow-Origin': '*',
215
+ })
216
+ res.flushHeaders()
217
+ res.write(': keepalive\n\n')
218
+
219
+ const { replay } = subscription
220
+ if (replay.gap) {
221
+ writeSse(res, 'snapshot', snapshotEvent(replay.snapshot, replay.reason ?? 'replay_gap'))
222
+ sentSeq = replay.snapshot.eventSeq
223
+ } else {
224
+ for (const event of replay.events) {
225
+ if (event.eventSeq <= sentSeq) continue
226
+ sentSeq = event.eventSeq
227
+ writeSse(res, event.type, event as unknown as Record<string, unknown>)
228
+ }
229
+ }
230
+
231
+ const replayTerminal = replay.events.some(event => isTerminalQueryJobStatus(event.status))
232
+ if (isTerminalQueryJobStatus(replay.snapshot.status) && !replayTerminal && !replay.gap) {
233
+ writeSse(res, 'snapshot', snapshotEvent(replay.snapshot, 'terminal_snapshot'))
234
+ sentSeq = replay.snapshot.eventSeq
235
+ }
236
+ if (isTerminalQueryJobStatus(replay.snapshot.status)) {
237
+ finish()
238
+ return
239
+ }
240
+
241
+ streamReady = true
242
+ for (const event of pendingLive) {
243
+ if (closed || event.eventSeq <= sentSeq) continue
244
+ sentSeq = event.eventSeq
245
+ writeSse(res, event.type, event as unknown as Record<string, unknown>)
246
+ if (isTerminalQueryJobStatus(event.status)) {
247
+ finish()
248
+ break
249
+ }
250
+ }
251
+ pendingLive.length = 0
252
+ if (closed) return
253
+
254
+ heartbeat = setInterval(() => {
255
+ if (!closed && !res.writableEnded) res.write(': keepalive\n\n')
256
+ }, heartbeatMs)
257
+ heartbeat.unref?.()
258
+ } catch (error) {
259
+ cleanup()
260
+ if (res.headersSent) {
261
+ if (!res.writableEnded) res.end()
262
+ return
263
+ }
264
+ const wire = wireError(error)
265
+ res.status(wire.status).json(wire.body)
266
+ }
267
+ })
268
+
269
+ router.post('/query-jobs/:jobId/cancel', async (req, res) => {
270
+ try {
271
+ const jobId = validJobId(req.params.jobId)
272
+ const generation = requiredGeneration(req.body?.generation)
273
+ const job = await coordinator.cancel(jobId, generation)
274
+ return res.json({ job })
275
+ } catch (error) {
276
+ const wire = wireError(error)
277
+ return res.status(wire.status).json(wire.body)
278
+ }
279
+ })
280
+
281
+ router.post('/query-jobs/:jobId/ack', async (req, res) => {
282
+ try {
283
+ const jobId = validJobId(req.params.jobId)
284
+ const generation = requiredGeneration(req.body?.generation)
285
+ const job = await coordinator.acknowledge(jobId, generation)
286
+ return res.json({ job })
287
+ } catch (error) {
288
+ const wire = wireError(error)
289
+ return res.status(wire.status).json(wire.body)
290
+ }
291
+ })
292
+
293
+ return router
294
+ }