@durable-streams/server 0.1.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.
- package/dist/index.d.ts +494 -0
- package/dist/index.js +1592 -0
- package/dist/index.js.map +1 -0
- package/package.json +41 -0
- package/src/cursor.ts +156 -0
- package/src/file-manager.ts +89 -0
- package/src/file-store.ts +863 -0
- package/src/index.ts +27 -0
- package/src/path-encoding.ts +61 -0
- package/src/registry-hook.ts +118 -0
- package/src/server.ts +946 -0
- package/src/store.ts +433 -0
- package/src/types.ts +183 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/server.ts","../src/store.ts"],"sourcesContent":["/**\n * HTTP server for durable streams testing.\n */\n\nimport { createServer } from \"node:http\"\nimport { StreamStore } from \"./store\"\nimport type { IncomingMessage, Server, ServerResponse } from \"node:http\"\nimport type { TestServerOptions } from \"./types\"\n\n// Protocol headers\nconst STREAM_OFFSET_HEADER = `stream-offset`\nconst STREAM_CURSOR_HEADER = `stream-cursor`\nconst STREAM_UP_TO_DATE_HEADER = `stream-up-to-date`\nconst STREAM_SEQ_HEADER = `stream-seq`\nconst STREAM_TTL_HEADER = `stream-ttl`\nconst STREAM_EXPIRES_AT_HEADER = `stream-expires-at`\n\n// Query params\nconst OFFSET_QUERY_PARAM = `offset`\nconst LIVE_QUERY_PARAM = `live`\nconst CURSOR_QUERY_PARAM = `cursor`\n\n/**\n * In-memory HTTP server for testing durable streams.\n */\nexport class DurableStreamTestServer {\n readonly store: StreamStore\n private server: Server | null = null\n private options: Required<TestServerOptions>\n private _url: string | null = null\n\n constructor(options: TestServerOptions = {}) {\n this.store = new StreamStore()\n this.options = {\n port: options.port ?? 0,\n host: options.host ?? `127.0.0.1`,\n longPollTimeout: options.longPollTimeout ?? 30_000,\n }\n }\n\n /**\n * Start the server.\n */\n async start(): Promise<string> {\n if (this.server) {\n throw new Error(`Server already started`)\n }\n\n return new Promise((resolve, reject) => {\n this.server = createServer((req, res) => {\n this.handleRequest(req, res).catch((err) => {\n console.error(`Request error:`, err)\n if (!res.headersSent) {\n res.writeHead(500, { \"content-type\": `text/plain` })\n res.end(`Internal server error`)\n }\n })\n })\n\n this.server.on(`error`, reject)\n\n this.server.listen(this.options.port, this.options.host, () => {\n const addr = this.server!.address()\n if (typeof addr === `string`) {\n this._url = addr\n } else if (addr) {\n this._url = `http://${this.options.host}:${addr.port}`\n }\n resolve(this._url!)\n })\n })\n }\n\n /**\n * Stop the server.\n */\n async stop(): Promise<void> {\n if (!this.server) {\n return\n }\n\n return new Promise((resolve, reject) => {\n this.server!.close((err) => {\n if (err) {\n reject(err)\n } else {\n this.server = null\n this._url = null\n resolve()\n }\n })\n })\n }\n\n /**\n * Get the server URL.\n */\n get url(): string {\n if (!this._url) {\n throw new Error(`Server not started`)\n }\n return this._url\n }\n\n /**\n * Clear all streams.\n */\n clear(): void {\n this.store.clear()\n }\n\n // ============================================================================\n // Request handling\n // ============================================================================\n\n private async handleRequest(\n req: IncomingMessage,\n res: ServerResponse\n ): Promise<void> {\n const url = new URL(req.url ?? `/`, `http://${req.headers.host}`)\n const path = url.pathname\n const method = req.method?.toUpperCase()\n\n // CORS headers for browser testing\n res.setHeader(`access-control-allow-origin`, `*`)\n res.setHeader(\n `access-control-allow-methods`,\n `GET, POST, PUT, DELETE, HEAD, OPTIONS`\n )\n res.setHeader(\n `access-control-allow-headers`,\n `content-type, authorization, ${STREAM_SEQ_HEADER}, ${STREAM_TTL_HEADER}, ${STREAM_EXPIRES_AT_HEADER}`\n )\n res.setHeader(\n `access-control-expose-headers`,\n `${STREAM_OFFSET_HEADER}, ${STREAM_CURSOR_HEADER}, ${STREAM_UP_TO_DATE_HEADER}, etag, content-type`\n )\n\n // Handle CORS preflight\n if (method === `OPTIONS`) {\n res.writeHead(204)\n res.end()\n return\n }\n\n try {\n switch (method) {\n case `PUT`:\n await this.handleCreate(path, req, res)\n break\n case `HEAD`:\n this.handleHead(path, res)\n break\n case `GET`:\n await this.handleRead(path, url, res)\n break\n case `POST`:\n await this.handleAppend(path, req, res)\n break\n case `DELETE`:\n this.handleDelete(path, res)\n break\n default:\n res.writeHead(405, { \"content-type\": `text/plain` })\n res.end(`Method not allowed`)\n }\n } catch (err) {\n if (err instanceof Error) {\n if (err.message.includes(`not found`)) {\n res.writeHead(404, { \"content-type\": `text/plain` })\n res.end(`Stream not found`)\n } else if (err.message.includes(`already exists`)) {\n res.writeHead(409, { \"content-type\": `text/plain` })\n res.end(`Stream already exists`)\n } else if (err.message.includes(`Sequence conflict`)) {\n res.writeHead(409, { \"content-type\": `text/plain` })\n res.end(`Sequence conflict`)\n } else if (err.message.includes(`Content-type mismatch`)) {\n res.writeHead(400, { \"content-type\": `text/plain` })\n res.end(`Content-type mismatch`)\n } else {\n throw err\n }\n } else {\n throw err\n }\n }\n }\n\n /**\n * Handle PUT - create stream\n */\n private async handleCreate(\n path: string,\n req: IncomingMessage,\n res: ServerResponse\n ): Promise<void> {\n const contentType = req.headers[`content-type`]\n const ttlHeader = req.headers[STREAM_TTL_HEADER] as string | undefined\n const expiresAtHeader = req.headers[STREAM_EXPIRES_AT_HEADER] as\n | string\n | undefined\n\n // Read body if present\n const body = await this.readBody(req)\n\n this.store.create(path, {\n contentType,\n ttlSeconds: ttlHeader ? parseInt(ttlHeader, 10) : undefined,\n expiresAt: expiresAtHeader,\n initialData: body.length > 0 ? body : undefined,\n })\n\n const stream = this.store.get(path)!\n\n res.writeHead(201, {\n \"content-type\": contentType ?? `application/octet-stream`,\n [STREAM_OFFSET_HEADER]: stream.currentOffset,\n })\n res.end()\n }\n\n /**\n * Handle HEAD - get metadata\n */\n private handleHead(path: string, res: ServerResponse): void {\n const stream = this.store.get(path)\n if (!stream) {\n res.writeHead(404, { \"content-type\": `text/plain` })\n res.end()\n return\n }\n\n const headers: Record<string, string> = {\n [STREAM_OFFSET_HEADER]: stream.currentOffset,\n }\n\n if (stream.contentType) {\n headers[`content-type`] = stream.contentType\n }\n\n // Generate ETag: {path}:{offset}\n headers[`etag`] =\n `\"${Buffer.from(path).toString(`base64`)}:${stream.currentOffset}\"`\n\n res.writeHead(200, headers)\n res.end()\n }\n\n /**\n * Handle GET - read data\n */\n private async handleRead(\n path: string,\n url: URL,\n res: ServerResponse\n ): Promise<void> {\n const stream = this.store.get(path)\n if (!stream) {\n res.writeHead(404, { \"content-type\": `text/plain` })\n res.end(`Stream not found`)\n return\n }\n\n const offset = url.searchParams.get(OFFSET_QUERY_PARAM) ?? undefined\n const live = url.searchParams.get(LIVE_QUERY_PARAM)\n const cursor = url.searchParams.get(CURSOR_QUERY_PARAM) ?? undefined\n\n // Read current messages\n let { messages, upToDate } = this.store.read(path, offset)\n\n // Only wait in long-poll if:\n // 1. long-poll mode is enabled\n // 2. Client provided an offset (not first request)\n // 3. Client's offset matches current offset (already caught up)\n // 4. No new messages\n const clientIsCaughtUp = offset && offset === stream.currentOffset\n if (live === `long-poll` && clientIsCaughtUp && messages.length === 0) {\n const result = await this.store.waitForMessages(\n path,\n offset,\n this.options.longPollTimeout\n )\n\n if (result.timedOut) {\n // Return 204 No Content on timeout\n res.writeHead(204, {\n [STREAM_OFFSET_HEADER]: offset,\n })\n res.end()\n return\n }\n\n messages = result.messages\n upToDate = true\n }\n\n // Build response\n const headers: Record<string, string> = {}\n\n if (stream.contentType) {\n headers[`content-type`] = stream.contentType\n }\n\n // Set offset header to the last message's offset, or current if no messages\n const lastMessage = messages[messages.length - 1]\n headers[STREAM_OFFSET_HEADER] = lastMessage?.offset ?? stream.currentOffset\n\n // Echo cursor if provided\n if (cursor) {\n headers[STREAM_CURSOR_HEADER] = cursor\n }\n\n // Set up-to-date header\n if (upToDate) {\n headers[STREAM_UP_TO_DATE_HEADER] = `true`\n }\n\n // Concatenate all message data\n const totalSize = messages.reduce((sum, m) => sum + m.data.length, 0)\n const responseData = new Uint8Array(totalSize)\n let offset2 = 0\n for (const msg of messages) {\n responseData.set(msg.data, offset2)\n offset2 += msg.data.length\n }\n\n res.writeHead(200, headers)\n res.end(Buffer.from(responseData))\n }\n\n /**\n * Handle POST - append data\n */\n private async handleAppend(\n path: string,\n req: IncomingMessage,\n res: ServerResponse\n ): Promise<void> {\n const contentType = req.headers[`content-type`]\n const seq = req.headers[STREAM_SEQ_HEADER] as string | undefined\n\n const body = await this.readBody(req)\n\n if (body.length === 0) {\n res.writeHead(400, { \"content-type\": `text/plain` })\n res.end(`Empty body`)\n return\n }\n\n const message = this.store.append(path, body, { seq, contentType })\n\n res.writeHead(200, {\n [STREAM_OFFSET_HEADER]: message.offset,\n })\n res.end()\n }\n\n /**\n * Handle DELETE - delete stream\n */\n private handleDelete(path: string, res: ServerResponse): void {\n if (!this.store.has(path)) {\n res.writeHead(404, { \"content-type\": `text/plain` })\n res.end(`Stream not found`)\n return\n }\n\n this.store.delete(path)\n res.writeHead(204)\n res.end()\n }\n\n // ============================================================================\n // Helpers\n // ============================================================================\n\n private readBody(req: IncomingMessage): Promise<Uint8Array> {\n return new Promise((resolve, reject) => {\n const chunks: Array<Buffer> = []\n\n req.on(`data`, (chunk: Buffer) => {\n chunks.push(chunk)\n })\n\n req.on(`end`, () => {\n const body = Buffer.concat(chunks)\n resolve(new Uint8Array(body))\n })\n\n req.on(`error`, reject)\n })\n }\n}\n","/**\n * In-memory stream storage.\n */\n\nimport type { PendingLongPoll, Stream, StreamMessage } from \"./types\"\n\n/**\n * In-memory store for durable streams.\n */\nexport class StreamStore {\n private streams = new Map<string, Stream>()\n private pendingLongPolls: Array<PendingLongPoll> = []\n\n /**\n * Create a new stream.\n * @throws Error if stream already exists\n */\n create(\n path: string,\n options: {\n contentType?: string\n ttlSeconds?: number\n expiresAt?: string\n initialData?: Uint8Array\n } = {}\n ): Stream {\n if (this.streams.has(path)) {\n throw new Error(`Stream already exists: ${path}`)\n }\n\n const stream: Stream = {\n path,\n contentType: options.contentType,\n messages: [],\n currentOffset: `0_0`,\n ttlSeconds: options.ttlSeconds,\n expiresAt: options.expiresAt,\n createdAt: Date.now(),\n }\n\n // If initial data is provided, append it\n if (options.initialData && options.initialData.length > 0) {\n this.appendToStream(stream, options.initialData)\n }\n\n this.streams.set(path, stream)\n return stream\n }\n\n /**\n * Get a stream by path.\n */\n get(path: string): Stream | undefined {\n return this.streams.get(path)\n }\n\n /**\n * Check if a stream exists.\n */\n has(path: string): boolean {\n return this.streams.has(path)\n }\n\n /**\n * Delete a stream.\n */\n delete(path: string): boolean {\n // Cancel any pending long-polls for this stream\n this.cancelLongPollsForStream(path)\n return this.streams.delete(path)\n }\n\n /**\n * Append data to a stream.\n * @throws Error if stream doesn't exist\n * @throws Error if seq is lower than lastSeq\n */\n append(\n path: string,\n data: Uint8Array,\n options: { seq?: string; contentType?: string } = {}\n ): StreamMessage {\n const stream = this.streams.get(path)\n if (!stream) {\n throw new Error(`Stream not found: ${path}`)\n }\n\n // Check content type match\n if (\n options.contentType &&\n stream.contentType &&\n options.contentType !== stream.contentType\n ) {\n throw new Error(\n `Content-type mismatch: expected ${stream.contentType}, got ${options.contentType}`\n )\n }\n\n // Check sequence for writer coordination\n if (options.seq !== undefined) {\n if (stream.lastSeq !== undefined && options.seq <= stream.lastSeq) {\n throw new Error(\n `Sequence conflict: ${options.seq} <= ${stream.lastSeq}`\n )\n }\n stream.lastSeq = options.seq\n }\n\n const message = this.appendToStream(stream, data)\n\n // Notify any pending long-polls\n this.notifyLongPolls(path)\n\n return message\n }\n\n /**\n * Read messages from a stream starting at the given offset.\n */\n read(\n path: string,\n offset?: string\n ): { messages: Array<StreamMessage>; upToDate: boolean } {\n const stream = this.streams.get(path)\n if (!stream) {\n throw new Error(`Stream not found: ${path}`)\n }\n\n if (!offset || offset === `0_0`) {\n // Read from beginning\n return {\n messages: [...stream.messages],\n upToDate: true,\n }\n }\n\n // Find messages after the given offset\n const offsetIndex = this.findOffsetIndex(stream, offset)\n if (offsetIndex === -1) {\n // Offset is at or past the end\n return {\n messages: [],\n upToDate: true,\n }\n }\n\n return {\n messages: stream.messages.slice(offsetIndex),\n upToDate: true,\n }\n }\n\n /**\n * Wait for new messages (long-poll).\n */\n async waitForMessages(\n path: string,\n offset: string,\n timeoutMs: number\n ): Promise<{ messages: Array<StreamMessage>; timedOut: boolean }> {\n const stream = this.streams.get(path)\n if (!stream) {\n throw new Error(`Stream not found: ${path}`)\n }\n\n // Check if there are already new messages\n const { messages } = this.read(path, offset)\n if (messages.length > 0) {\n return { messages, timedOut: false }\n }\n\n // Wait for new messages\n return new Promise((resolve) => {\n const timeoutId = setTimeout(() => {\n // Remove from pending\n this.removePendingLongPoll(pending)\n resolve({ messages: [], timedOut: true })\n }, timeoutMs)\n\n const pending: PendingLongPoll = {\n path,\n offset,\n resolve: (msgs) => {\n clearTimeout(timeoutId)\n this.removePendingLongPoll(pending)\n resolve({ messages: msgs, timedOut: false })\n },\n timeoutId,\n }\n\n this.pendingLongPolls.push(pending)\n })\n }\n\n /**\n * Get the current offset for a stream.\n */\n getCurrentOffset(path: string): string | undefined {\n return this.streams.get(path)?.currentOffset\n }\n\n /**\n * Clear all streams.\n */\n clear(): void {\n // Cancel all pending long-polls\n for (const pending of this.pendingLongPolls) {\n clearTimeout(pending.timeoutId)\n }\n this.pendingLongPolls = []\n this.streams.clear()\n }\n\n /**\n * Get all stream paths.\n */\n list(): Array<string> {\n return Array.from(this.streams.keys())\n }\n\n // ============================================================================\n // Private helpers\n // ============================================================================\n\n private appendToStream(stream: Stream, data: Uint8Array): StreamMessage {\n // Parse current offset\n const parts = stream.currentOffset.split(`_`).map(Number)\n const readSeq = parts[0]!\n const byteOffset = parts[1]!\n\n // Calculate new offset\n const newByteOffset = byteOffset + data.length\n const newOffset = `${readSeq}_${newByteOffset}`\n\n const message: StreamMessage = {\n data,\n offset: newOffset,\n timestamp: Date.now(),\n }\n\n stream.messages.push(message)\n stream.currentOffset = newOffset\n\n return message\n }\n\n private findOffsetIndex(stream: Stream, offset: string): number {\n // Find the first message with an offset greater than the given offset\n for (let i = 0; i < stream.messages.length; i++) {\n if (this.compareOffsets(stream.messages[i]!.offset, offset) > 0) {\n return i\n }\n }\n return -1 // No messages after the offset\n }\n\n private compareOffsets(a: string, b: string): number {\n const aParts = a.split(`_`).map(Number)\n const bParts = b.split(`_`).map(Number)\n const aSeq = aParts[0]!\n const aOffset = aParts[1]!\n const bSeq = bParts[0]!\n const bOffset = bParts[1]!\n\n if (aSeq !== bSeq) {\n return aSeq - bSeq\n }\n return aOffset - bOffset\n }\n\n private notifyLongPolls(path: string): void {\n const toNotify = this.pendingLongPolls.filter((p) => p.path === path)\n\n for (const pending of toNotify) {\n const { messages } = this.read(path, pending.offset)\n if (messages.length > 0) {\n pending.resolve(messages)\n }\n }\n }\n\n private cancelLongPollsForStream(path: string): void {\n const toCancel = this.pendingLongPolls.filter((p) => p.path === path)\n for (const pending of toCancel) {\n clearTimeout(pending.timeoutId)\n pending.resolve([])\n }\n this.pendingLongPolls = this.pendingLongPolls.filter((p) => p.path !== path)\n }\n\n private removePendingLongPoll(pending: PendingLongPoll): void {\n const index = this.pendingLongPolls.indexOf(pending)\n if (index !== -1) {\n this.pendingLongPolls.splice(index, 1)\n }\n }\n}\n"],"mappings":"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","names":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@durable-streams/server",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Node.js reference server implementation for Durable Streams",
|
|
5
|
+
"author": "Durable Stream contributors",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "./dist/index.js",
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"import": "./dist/index.js",
|
|
13
|
+
"types": "./dist/index.d.ts"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "tsdown",
|
|
18
|
+
"dev": "tsdown --watch",
|
|
19
|
+
"typecheck": "tsc --noEmit"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@durable-streams/client": "workspace:*",
|
|
23
|
+
"@durable-streams/state": "workspace:*",
|
|
24
|
+
"@neophi/sieve-cache": "^1.0.0",
|
|
25
|
+
"lmdb": "^3.3.0"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@durable-streams/server-conformance-tests": "workspace:*",
|
|
29
|
+
"@types/node": "^22.0.0",
|
|
30
|
+
"tsdown": "^0.9.0",
|
|
31
|
+
"typescript": "^5.0.0",
|
|
32
|
+
"vitest": "^3.2.4"
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"dist",
|
|
36
|
+
"src"
|
|
37
|
+
],
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=18.0.0"
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/cursor.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stream cursor calculation for CDN cache collapsing.
|
|
3
|
+
*
|
|
4
|
+
* This module implements interval-based cursor generation to prevent
|
|
5
|
+
* infinite CDN cache loops while enabling request collapsing.
|
|
6
|
+
*
|
|
7
|
+
* The mechanism works by:
|
|
8
|
+
* 1. Dividing time into fixed intervals (default 20 seconds)
|
|
9
|
+
* 2. Computing interval number from an epoch (October 9, 2024)
|
|
10
|
+
* 3. Returning cursor values that change at interval boundaries
|
|
11
|
+
* 4. Ensuring monotonic cursor progression (never going backwards)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Default epoch for cursor calculation: October 9, 2024 00:00:00 UTC.
|
|
16
|
+
* This is the reference point from which intervals are counted.
|
|
17
|
+
* Using a past date ensures cursors are always positive.
|
|
18
|
+
*/
|
|
19
|
+
export const DEFAULT_CURSOR_EPOCH: Date = new Date(`2024-10-09T00:00:00.000Z`)
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Default interval duration in seconds.
|
|
23
|
+
*/
|
|
24
|
+
export const DEFAULT_CURSOR_INTERVAL_SECONDS = 20
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Maximum jitter in seconds to add on collision.
|
|
28
|
+
* Per protocol spec: random value between 1-3600 seconds.
|
|
29
|
+
*/
|
|
30
|
+
const MAX_JITTER_SECONDS = 3600
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Minimum jitter in seconds.
|
|
34
|
+
*/
|
|
35
|
+
const MIN_JITTER_SECONDS = 1
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Configuration options for cursor calculation.
|
|
39
|
+
*/
|
|
40
|
+
export interface CursorOptions {
|
|
41
|
+
/**
|
|
42
|
+
* Interval duration in seconds.
|
|
43
|
+
* Default: 20 seconds.
|
|
44
|
+
*/
|
|
45
|
+
intervalSeconds?: number
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Epoch timestamp for interval calculation.
|
|
49
|
+
* Default: October 9, 2024 00:00:00 UTC.
|
|
50
|
+
*/
|
|
51
|
+
epoch?: Date
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Calculate the current cursor value based on time intervals.
|
|
56
|
+
*
|
|
57
|
+
* @param options - Configuration for cursor calculation
|
|
58
|
+
* @returns The current cursor value as a string
|
|
59
|
+
*/
|
|
60
|
+
export function calculateCursor(options: CursorOptions = {}): string {
|
|
61
|
+
const intervalSeconds =
|
|
62
|
+
options.intervalSeconds ?? DEFAULT_CURSOR_INTERVAL_SECONDS
|
|
63
|
+
const epoch = options.epoch ?? DEFAULT_CURSOR_EPOCH
|
|
64
|
+
|
|
65
|
+
const now = Date.now()
|
|
66
|
+
const epochMs = epoch.getTime()
|
|
67
|
+
const intervalMs = intervalSeconds * 1000
|
|
68
|
+
|
|
69
|
+
// Calculate interval number since epoch
|
|
70
|
+
const intervalNumber = Math.floor((now - epochMs) / intervalMs)
|
|
71
|
+
|
|
72
|
+
return String(intervalNumber)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Generate a random jitter value in intervals.
|
|
77
|
+
*
|
|
78
|
+
* @param intervalSeconds - The interval duration in seconds
|
|
79
|
+
* @returns Number of intervals to add as jitter
|
|
80
|
+
*/
|
|
81
|
+
function generateJitterIntervals(intervalSeconds: number): number {
|
|
82
|
+
// Add random jitter: 1-3600 seconds
|
|
83
|
+
const jitterSeconds =
|
|
84
|
+
MIN_JITTER_SECONDS +
|
|
85
|
+
Math.floor(Math.random() * (MAX_JITTER_SECONDS - MIN_JITTER_SECONDS + 1))
|
|
86
|
+
|
|
87
|
+
// Calculate how many intervals the jitter represents (at least 1)
|
|
88
|
+
return Math.max(1, Math.ceil(jitterSeconds / intervalSeconds))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Generate a cursor for a response, ensuring monotonic progression.
|
|
93
|
+
*
|
|
94
|
+
* This function ensures the returned cursor is always greater than or equal
|
|
95
|
+
* to the current time interval, and strictly greater than any client-provided
|
|
96
|
+
* cursor. This prevents cache loops where a client could cycle between
|
|
97
|
+
* cursor values.
|
|
98
|
+
*
|
|
99
|
+
* Algorithm:
|
|
100
|
+
* - If no client cursor: return current interval
|
|
101
|
+
* - If client cursor < current interval: return current interval
|
|
102
|
+
* - If client cursor >= current interval: return client cursor + jitter
|
|
103
|
+
*
|
|
104
|
+
* This guarantees monotonic cursor progression and prevents A→B→A cycles.
|
|
105
|
+
*
|
|
106
|
+
* @param clientCursor - The cursor provided by the client (if any)
|
|
107
|
+
* @param options - Configuration for cursor calculation
|
|
108
|
+
* @returns The cursor value to include in the response
|
|
109
|
+
*/
|
|
110
|
+
export function generateResponseCursor(
|
|
111
|
+
clientCursor: string | undefined,
|
|
112
|
+
options: CursorOptions = {}
|
|
113
|
+
): string {
|
|
114
|
+
const intervalSeconds =
|
|
115
|
+
options.intervalSeconds ?? DEFAULT_CURSOR_INTERVAL_SECONDS
|
|
116
|
+
const currentCursor = calculateCursor(options)
|
|
117
|
+
const currentInterval = parseInt(currentCursor, 10)
|
|
118
|
+
|
|
119
|
+
// No client cursor - return current interval
|
|
120
|
+
if (!clientCursor) {
|
|
121
|
+
return currentCursor
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Parse client cursor
|
|
125
|
+
const clientInterval = parseInt(clientCursor, 10)
|
|
126
|
+
|
|
127
|
+
// If client cursor is invalid or behind current time, return current interval
|
|
128
|
+
if (isNaN(clientInterval) || clientInterval < currentInterval) {
|
|
129
|
+
return currentCursor
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Client cursor is at or ahead of current interval - add jitter to advance
|
|
133
|
+
// This ensures we never return a cursor <= what the client sent
|
|
134
|
+
const jitterIntervals = generateJitterIntervals(intervalSeconds)
|
|
135
|
+
return String(clientInterval + jitterIntervals)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Handle cursor collision by adding random jitter.
|
|
140
|
+
*
|
|
141
|
+
* @deprecated Use generateResponseCursor instead, which handles all cases
|
|
142
|
+
* including monotonicity guarantees.
|
|
143
|
+
*
|
|
144
|
+
* @param currentCursor - The newly calculated cursor value
|
|
145
|
+
* @param previousCursor - The cursor provided by the client (if any)
|
|
146
|
+
* @param options - Configuration for cursor calculation
|
|
147
|
+
* @returns The cursor value to return, with jitter applied if there's a collision
|
|
148
|
+
*/
|
|
149
|
+
export function handleCursorCollision(
|
|
150
|
+
currentCursor: string,
|
|
151
|
+
previousCursor: string | undefined,
|
|
152
|
+
options: CursorOptions = {}
|
|
153
|
+
): string {
|
|
154
|
+
// Delegate to the new implementation for backwards compatibility
|
|
155
|
+
return generateResponseCursor(previousCursor, options)
|
|
156
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File manager for stream storage operations.
|
|
3
|
+
* Handles directory creation, deletion, and listing for stream data files.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import * as fs from "node:fs/promises"
|
|
7
|
+
import * as path from "node:path"
|
|
8
|
+
import { decodeStreamPath, encodeStreamPath } from "./path-encoding"
|
|
9
|
+
|
|
10
|
+
export class StreamFileManager {
|
|
11
|
+
constructor(private streamsDir: string) {}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Create a directory for a new stream and initialize the first segment file.
|
|
15
|
+
* Returns the absolute path to the stream directory.
|
|
16
|
+
*/
|
|
17
|
+
async createStreamDirectory(streamPath: string): Promise<string> {
|
|
18
|
+
const encoded = encodeStreamPath(streamPath)
|
|
19
|
+
const dir = path.join(this.streamsDir, encoded)
|
|
20
|
+
await fs.mkdir(dir, { recursive: true })
|
|
21
|
+
|
|
22
|
+
// Create initial segment file (empty)
|
|
23
|
+
const segmentPath = path.join(dir, `segment_00000.log`)
|
|
24
|
+
await fs.writeFile(segmentPath, ``)
|
|
25
|
+
|
|
26
|
+
return dir
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Delete a stream directory and all its contents.
|
|
31
|
+
*/
|
|
32
|
+
async deleteStreamDirectory(streamPath: string): Promise<void> {
|
|
33
|
+
const encoded = encodeStreamPath(streamPath)
|
|
34
|
+
const dir = path.join(this.streamsDir, encoded)
|
|
35
|
+
await fs.rm(dir, { recursive: true, force: true })
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Delete a directory by its exact name (used for unique directory names).
|
|
40
|
+
*/
|
|
41
|
+
async deleteDirectoryByName(directoryName: string): Promise<void> {
|
|
42
|
+
const dir = path.join(this.streamsDir, directoryName)
|
|
43
|
+
await fs.rm(dir, { recursive: true, force: true })
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Get the absolute path to a stream's directory.
|
|
48
|
+
* Returns null if the directory doesn't exist.
|
|
49
|
+
*/
|
|
50
|
+
async getStreamDirectory(streamPath: string): Promise<string | null> {
|
|
51
|
+
const encoded = encodeStreamPath(streamPath)
|
|
52
|
+
const dir = path.join(this.streamsDir, encoded)
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
await fs.access(dir)
|
|
56
|
+
return dir
|
|
57
|
+
} catch {
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* List all stream paths by scanning the streams directory.
|
|
64
|
+
*/
|
|
65
|
+
async listStreamPaths(): Promise<Array<string>> {
|
|
66
|
+
try {
|
|
67
|
+
const entries = await fs.readdir(this.streamsDir, {
|
|
68
|
+
withFileTypes: true,
|
|
69
|
+
})
|
|
70
|
+
return entries
|
|
71
|
+
.filter((e) => e.isDirectory())
|
|
72
|
+
.map((e) => decodeStreamPath(e.name))
|
|
73
|
+
} catch {
|
|
74
|
+
// Directory doesn't exist yet - return empty array
|
|
75
|
+
return []
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Get the path to a segment file within a stream directory.
|
|
81
|
+
*
|
|
82
|
+
* @param streamDir - Absolute path to the stream directory
|
|
83
|
+
* @param index - Segment index (0-based)
|
|
84
|
+
*/
|
|
85
|
+
getSegmentPath(streamDir: string, index: number): string {
|
|
86
|
+
const paddedIndex = String(index).padStart(5, `0`)
|
|
87
|
+
return path.join(streamDir, `segment_${paddedIndex}.log`)
|
|
88
|
+
}
|
|
89
|
+
}
|