add support heartbeat to couchdb-changes-stream
This commit is contained in:
Generated
-8509
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
dist
|
||||
@@ -0,0 +1,144 @@
|
||||
|
||||
# CouchDBChangesStream Documentation
|
||||
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
`CouchDBChangesStream` is a TypeScript class for handling real-time changes in a CouchDB database through the `_changes` API. It supports `normal`, `longpoll`, `continuous`, and `eventsource` modes, with filtering via `selector` and automatic reconnection in `live` mode.
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
Install the package via npm:
|
||||
|
||||
```bash
|
||||
npm install @hereconnect/couchdb-changes-stream
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
### Example
|
||||
|
||||
```typescript
|
||||
import { CouchDBChangesStream } from "couchdb-changes-stream";
|
||||
|
||||
const changesStream = new CouchDBChangesStream(
|
||||
"http://localhost:5984/mydb",
|
||||
{
|
||||
feed: "continuous",
|
||||
include_docs: true,
|
||||
since: "now",
|
||||
heartbeat: 10000,
|
||||
selector: {
|
||||
type: "message",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
for await (const change of changesStream) {
|
||||
console.log("Change:", change);
|
||||
|
||||
// Process data
|
||||
if (change.doc) {
|
||||
console.log("Document:", change.doc);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error in changes stream:", error);
|
||||
}
|
||||
})();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Parameters
|
||||
|
||||
### Constructor
|
||||
|
||||
```typescript
|
||||
new CouchDBChangesStream<T>(
|
||||
dbUrl: string,
|
||||
options: CouchDBChangesOptions | Readonly<CouchDBChangesOptions>,
|
||||
)
|
||||
```
|
||||
|
||||
### Constructor Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|--------------------|----------------------------------------------|-----------------------------------------------------------------------------|
|
||||
| `dbUrl` | `string` | The CouchDB database URL (e.g., `http://localhost:5984/mydb`). |
|
||||
| `options` | `CouchDBChangesOptions` | The query options. Supports the same parameters as CouchDB `_changes` API. |
|
||||
|
||||
### Fields in `CouchDBChangesOptions`
|
||||
|
||||
| Field | Type | Description |
|
||||
|--------------------|----------------------------------------------|-----------------------------------------------------------------------------|
|
||||
| `since` | `string \| number` | The sequence to start from (`now` or a number). |
|
||||
| `filter` | `string` | Name of the filter (e.g., `_selector`). |
|
||||
| `doc_ids` | `string[] \| readonly string[]` | List of document IDs to filter. |
|
||||
| `selector` | `Record<string, unknown>` | A JSON object for selecting documents (works only with `POST`). |
|
||||
| `feed` | `"normal" \| "longpoll" \| "continuous" \| "eventsource"` | Feed mode. |
|
||||
| `include_docs` | `boolean` | Include document body in changes. |
|
||||
| `heartbeat` | `boolean \| number` | Frequency of keep-alive messages in milliseconds. |
|
||||
| `live` | `boolean` | Enable live mode with automatic reconnection. |
|
||||
| `timeout` | `number` | Timeout for waiting for changes in milliseconds. |
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- **Support for all modes:** `normal`, `longpoll`, `continuous`, `eventsource`.
|
||||
- **Filtering via `selector`:** Uses the `POST` method when `selector` is present.
|
||||
- **Automatic reconnection:** Enabled in `live` mode.
|
||||
- **Heartbeat support:** Keeps connections alive.
|
||||
- **Scalable:** Asynchronous stream for handling large data volumes.
|
||||
|
||||
---
|
||||
|
||||
## Methods
|
||||
|
||||
### `stop`
|
||||
|
||||
```typescript
|
||||
public stop(): void
|
||||
```
|
||||
|
||||
Stops the current changes stream.
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
changesStream.stop();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Errors
|
||||
|
||||
- `HTTP error: 400`: Invalid request parameters (e.g., incorrect `selector`).
|
||||
- `HTTP error: 401`: Authentication error.
|
||||
- `Error parsing change`: Failed to parse data from the stream.
|
||||
|
||||
---
|
||||
|
||||
## Usage Tips
|
||||
|
||||
1. **Use `heartbeat`** to keep connections alive.
|
||||
2. **Add exception handling** to properly manage network errors.
|
||||
3. **Prefer `selector` over `filter`** for more flexible document selection.
|
||||
4. **Call `stop`** when done to terminate the stream gracefully.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
This project is distributed under the [MIT License](LICENSE).
|
||||
|
||||
---
|
||||
|
||||
For questions or issues, feel free to reach out! 🚀
|
||||
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
preset: "ts-jest/presets/default-esm",
|
||||
testEnvironment: "node",
|
||||
testMatch: ["**/__tests__/**/*.test.ts", "**/?(*.)+(spec|test).ts"],
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "@hereconnect/couchdb-changes-stream",
|
||||
"version": "1.0.1",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Ti-webdev/couchdb-changes-stream",
|
||||
"bugs": "https://github.com/Ti-webdev/couchdb-changes-stream/issues",
|
||||
"description": "Stream CouchDB changes feed with support for pause, resume, and reconnection in TypeScript.",
|
||||
"main": "./dist/CouchDBChangesStream.js",
|
||||
"types": "./dist/CouchDBChangesStream.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/CouchDBChangesStream.js",
|
||||
"default": "./dist/CouchDBChangesStream.js"
|
||||
}
|
||||
},
|
||||
"browser": "./dist/CouchDBChangesStream.js",
|
||||
"files": [
|
||||
"dist/**/*"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"prepublishOnly": "npm run build",
|
||||
"prepare": "npm run build"
|
||||
},
|
||||
"keywords": [
|
||||
"couchdb",
|
||||
"changes feed",
|
||||
"typescript",
|
||||
"stream",
|
||||
"reconnection"
|
||||
],
|
||||
"author": "Your Name",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@jest/types": "^29.6.3",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^20.17.9",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"fast-check": "^3.23.1",
|
||||
"jest": "^29.7.0",
|
||||
"prettier": "^3.4.1",
|
||||
"ts-jest": "^29.2.5",
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^0.34.6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
export type CouchDBChange<T> = {
|
||||
seq: string | number;
|
||||
id: string;
|
||||
changes: { rev: string }[];
|
||||
deleted?: boolean;
|
||||
doc?: T;
|
||||
};
|
||||
|
||||
export interface CouchDBChangesOptions {
|
||||
since?: string | number;
|
||||
filter?: string;
|
||||
doc_ids?: string[] | readonly string[];
|
||||
selector?: Record<string, unknown>;
|
||||
conflicts?: boolean;
|
||||
descending?: boolean;
|
||||
heartbeat?: boolean | number;
|
||||
include_docs?: boolean;
|
||||
attachments?: boolean;
|
||||
att_encoding_info?: boolean;
|
||||
limit?: number;
|
||||
style?: "main_only" | "all_docs";
|
||||
timeout?: number;
|
||||
seq_interval?: number;
|
||||
feed?: "normal" | "longpoll" | "continuous" | "eventsource"; // Добавлено поле feed
|
||||
live?: boolean;
|
||||
abortController?: AbortController;
|
||||
fetch?: typeof globalThis.fetch;
|
||||
}
|
||||
|
||||
export class CouchDBChangesStream<T> {
|
||||
private readonly options:
|
||||
| CouchDBChangesOptions
|
||||
| Readonly<CouchDBChangesOptions>;
|
||||
private readonly abortController: AbortController;
|
||||
private readonly fetch: typeof globalThis.fetch;
|
||||
private activeSeq?: string | number;
|
||||
|
||||
constructor(
|
||||
private readonly dbUrl: string,
|
||||
{
|
||||
abortController,
|
||||
fetch,
|
||||
...options
|
||||
}: CouchDBChangesOptions | Readonly<CouchDBChangesOptions> = {},
|
||||
) {
|
||||
this.options = options;
|
||||
this.abortController = abortController || new AbortController();
|
||||
this.fetch = fetch || globalThis.fetch;
|
||||
}
|
||||
|
||||
private buildUrl(): URL {
|
||||
const url = new URL(`${this.dbUrl}/_changes`);
|
||||
Object.entries(this.options).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== "") {
|
||||
if (key !== "doc_ids" && key !== "selector" && key !== "live") {
|
||||
url.searchParams.append(key, value.toString());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Add `since` parameter if `activeSeq` is set
|
||||
if (this.activeSeq !== undefined) {
|
||||
url.searchParams.set("since", this.activeSeq.toString());
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
private async *fetchChanges(): AsyncGenerator<
|
||||
CouchDBChange<T>,
|
||||
void,
|
||||
unknown
|
||||
> {
|
||||
const headers = { Accept: "application/json" };
|
||||
|
||||
const signal = this.abortController.signal;
|
||||
|
||||
const isNormal = this.options.feed === "normal" || !this.options.feed;
|
||||
const isLongpoll = this.options.feed === "longpoll";
|
||||
const isContinuous = this.options.feed === "continuous";
|
||||
const isEventsource = this.options.feed === "eventsource";
|
||||
|
||||
// Heartbeat configuration
|
||||
const usesHeartbeat =
|
||||
(isContinuous || isEventsource) && this.options.heartbeat !== 0;
|
||||
let heartbeatTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let heartbeatPromise: Promise<never> = new Promise<never>(() => {});
|
||||
|
||||
signal.addEventListener("abort", () => {
|
||||
if (heartbeatTimeout) {
|
||||
clearTimeout(heartbeatTimeout);
|
||||
heartbeatTimeout = null;
|
||||
}
|
||||
});
|
||||
|
||||
const resetHeartbeat = () => {
|
||||
if (!usesHeartbeat) return;
|
||||
|
||||
if (heartbeatTimeout) {
|
||||
clearTimeout(heartbeatTimeout);
|
||||
}
|
||||
|
||||
const heartbeatInterval =
|
||||
typeof this.options.heartbeat === "number"
|
||||
? this.options.heartbeat
|
||||
: 60000; // Default 60 seconds
|
||||
const timeoutBuffer = Math.max(heartbeatInterval * 0.1, 1000); // 10% buffer or at least 1s
|
||||
const adjustedTimeout = heartbeatInterval + timeoutBuffer;
|
||||
heartbeatPromise = new Promise<never>((resolve, reject) => {
|
||||
heartbeatTimeout = setTimeout(() => {
|
||||
reject(
|
||||
new Error(
|
||||
`Heartbeat timeout: no data received from server after ${adjustedTimeout} ms`,
|
||||
),
|
||||
);
|
||||
}, adjustedTimeout);
|
||||
|
||||
if (typeof heartbeatTimeout === "object" && heartbeatTimeout.unref) {
|
||||
heartbeatTimeout.unref();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
let retryCount = 0;
|
||||
|
||||
while (!signal.aborted) {
|
||||
try {
|
||||
resetHeartbeat();
|
||||
|
||||
const url = this.buildUrl(); // Dynamic URL update with new `since`
|
||||
|
||||
const hasDocIds =
|
||||
Array.isArray(this.options.doc_ids) &&
|
||||
this.options.doc_ids.length > 0;
|
||||
|
||||
const hasSelector =
|
||||
this.options.selector && typeof this.options.selector === "object";
|
||||
|
||||
const requestBody = hasDocIds
|
||||
? { doc_ids: this.options.doc_ids }
|
||||
: hasSelector
|
||||
? { selector: this.options.selector }
|
||||
: undefined;
|
||||
|
||||
const body = requestBody ? JSON.stringify(requestBody) : undefined;
|
||||
|
||||
const method = body ? "POST" : "GET";
|
||||
|
||||
const response = await Promise.race([
|
||||
this.fetch(`${url.origin}${url.pathname}${url.search}`, {
|
||||
method,
|
||||
headers: {
|
||||
...headers,
|
||||
...(method === "POST"
|
||||
? { "Content-Type": "application/json" }
|
||||
: {}),
|
||||
...(url.username && url.password
|
||||
? {
|
||||
Authorization: `Basic ${btoa(`${url.username}:${url.password}`)}`,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
body,
|
||||
signal,
|
||||
}),
|
||||
heartbeatPromise,
|
||||
]);
|
||||
|
||||
if (!response.ok) {
|
||||
// console.error(`[fetchChanges] HTTP error: ${response.status}`);
|
||||
const textError = await Promise.race([
|
||||
response.text(),
|
||||
heartbeatPromise,
|
||||
]);
|
||||
throw new Error(`HTTP error: ${response.status}\n${textError}`);
|
||||
}
|
||||
|
||||
if (isNormal || isLongpoll) {
|
||||
const data = await Promise.race([response.json(), heartbeatPromise]);
|
||||
for (const change of data.results) {
|
||||
this.activeSeq = change.seq;
|
||||
yield change;
|
||||
}
|
||||
if (data.last_seq) {
|
||||
this.activeSeq = data.last_seq;
|
||||
}
|
||||
|
||||
// If `longpoll`, restart the loop after processing changes
|
||||
if (isLongpoll && !signal.aborted) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return; // Exit for `normal` after processing all changes
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error("Unable to read response body");
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (!signal.aborted) {
|
||||
const { value, done } = await Promise.race([
|
||||
reader.read(),
|
||||
heartbeatPromise,
|
||||
]);
|
||||
resetHeartbeat();
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
let newlineIndex;
|
||||
while ((newlineIndex = buffer.indexOf("\n")) >= 0) {
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
let line = buffer.slice(0, newlineIndex).trim();
|
||||
buffer = buffer.slice(newlineIndex + 1);
|
||||
|
||||
if (isEventsource) {
|
||||
if (line.startsWith("event:")) {
|
||||
continue; // Игнорируем строки event:
|
||||
}
|
||||
if (line.startsWith("data:")) {
|
||||
line = line.slice(5);
|
||||
}
|
||||
}
|
||||
|
||||
if (line.trim() === "") {
|
||||
if (done) {
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const change: CouchDBChange<T> = JSON.parse(line);
|
||||
this.activeSeq = change.seq;
|
||||
yield change;
|
||||
} catch (error) {
|
||||
if (!signal.aborted && buffer.indexOf("\n", newlineIndex)) {
|
||||
throw new Error(
|
||||
`[fetchChanges] Error parsing change: ${line} ${error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (done || signal.aborted) {
|
||||
return; // No more data to read
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (signal.aborted) break;
|
||||
|
||||
if (this.options.live) {
|
||||
retryCount++;
|
||||
const backoff = Math.min(2 ** retryCount * 100, 30000); // Exponential backoff with max 30 seconds
|
||||
const jitter = Math.random() * 100; // Add jitter
|
||||
const delay = backoff + jitter;
|
||||
|
||||
console.debug(
|
||||
`[fetchChanges] Error fetching changes: ${
|
||||
error instanceof Error ? error.message : error
|
||||
}. Retrying in ${(delay / 1000).toFixed(2)} seconds...`,
|
||||
);
|
||||
|
||||
if (heartbeatTimeout) {
|
||||
clearTimeout(heartbeatTimeout);
|
||||
heartbeatTimeout = null;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async *[Symbol.asyncIterator](): AsyncIterableIterator<
|
||||
CouchDBChange<T>
|
||||
> {
|
||||
try {
|
||||
yield* this.fetchChanges();
|
||||
} finally {
|
||||
this.abortController.abort(); // Ensure we abort if the iterator is closed
|
||||
}
|
||||
}
|
||||
|
||||
public stop() {
|
||||
this.abortController.abort();
|
||||
}
|
||||
}
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
import {
|
||||
CouchDBChangesStream,
|
||||
CouchDBChange,
|
||||
} from "../src/CouchDBChangesStream";
|
||||
|
||||
describe("CouchDBChangesStream - Automatic Reconnection", () => {
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should automatically reconnect and continue from the last seq without duplicates", async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
// Simulate changes before and after connection drop
|
||||
const initialChanges: CouchDBChange<any>[] = [
|
||||
{ seq: 1, id: "doc1", changes: [{ rev: "1-abc" }] },
|
||||
{ seq: 2, id: "doc2", changes: [{ rev: "1-def" }] },
|
||||
// Connection will drop after this change
|
||||
];
|
||||
|
||||
const subsequentChanges: CouchDBChange<any>[] = [
|
||||
{ seq: 3, id: "doc3", changes: [{ rev: "1-ghi" }] },
|
||||
{ seq: 4, id: "doc4", changes: [{ rev: "1-jkl" }] },
|
||||
];
|
||||
|
||||
const mockFetch = jest.fn();
|
||||
let fetchCallCount = 0;
|
||||
|
||||
mockFetch.mockImplementation(async () => {
|
||||
fetchCallCount++;
|
||||
|
||||
if (fetchCallCount === 1) {
|
||||
// First fetch returns initial changes and simulates a connection drop
|
||||
const responseStream = initialChanges
|
||||
.map((change) => JSON.stringify(change) + "\n")
|
||||
.join("");
|
||||
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
// Enqueue initial changes
|
||||
controller.enqueue(new TextEncoder().encode(responseStream));
|
||||
|
||||
// Simulate connection drop after a short delay
|
||||
setTimeout(() => {
|
||||
controller.error(new Error("Connection dropped"));
|
||||
}, 100); // 100ms задержка перед разрывом соединения
|
||||
},
|
||||
});
|
||||
|
||||
const mockResponse: Partial<Response> = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body,
|
||||
};
|
||||
|
||||
return mockResponse as Response;
|
||||
} else {
|
||||
// Subsequent fetch returns remaining changes
|
||||
const responseStream = subsequentChanges
|
||||
.map((change) => JSON.stringify(change) + "\n")
|
||||
.join("");
|
||||
|
||||
const mockResponse: Partial<Response> = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(responseStream));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
return mockResponse as Response;
|
||||
}
|
||||
});
|
||||
|
||||
// Suppress console errors for clean test output
|
||||
const consoleErrorSpy = jest
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const stream = new CouchDBChangesStream<any>("http://mock-couchdb", {
|
||||
live: true,
|
||||
feed: "continuous",
|
||||
fetch: mockFetch,
|
||||
});
|
||||
|
||||
const result: CouchDBChange<any>[] = [];
|
||||
const iterator = stream[Symbol.asyncIterator]();
|
||||
|
||||
// Process changes asynchronously
|
||||
const processChanges = async () => {
|
||||
try {
|
||||
for await (const change of iterator) {
|
||||
result.push(change);
|
||||
// Simulate processing time
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
if (result.length === 4) {
|
||||
stream.stop();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Should not reach here
|
||||
fail("Unexpected error during processing: " + error);
|
||||
}
|
||||
};
|
||||
|
||||
const processingPromise = processChanges();
|
||||
|
||||
// Advance timers to simulate passage of time
|
||||
await jest.advanceTimersByTimeAsync(650);
|
||||
|
||||
// At this point, connection should have dropped and reconnection should occur
|
||||
expect(fetchCallCount).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Finish processing
|
||||
await processingPromise;
|
||||
|
||||
// Verify that no duplicates are present
|
||||
const seqs = result.map((change) => change.seq);
|
||||
const uniqueSeqs = Array.from(new Set(seqs));
|
||||
expect(seqs).toEqual(uniqueSeqs);
|
||||
|
||||
// Verify that all changes are received
|
||||
const expectedChanges = [...initialChanges, ...subsequentChanges];
|
||||
expect(result).toEqual(expectedChanges);
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
import {
|
||||
CouchDBChangesStream,
|
||||
CouchDBChange,
|
||||
} from "../src/CouchDBChangesStream";
|
||||
|
||||
describe("CouchDBChangesStream Error Handling", () => {
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("retries fetch on connection drop and doesn't hang", async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
// Mock fetch to simulate connection drop
|
||||
const mockFetch = jest.fn();
|
||||
|
||||
// First call to fetch will reject with a connection error
|
||||
const connectionError = new Error("Connection dropped");
|
||||
|
||||
// Keep track of how many times fetch is called
|
||||
let fetchCallCount = 0;
|
||||
|
||||
mockFetch.mockImplementation(async () => {
|
||||
fetchCallCount++;
|
||||
|
||||
if (fetchCallCount === 1) {
|
||||
throw connectionError;
|
||||
} else {
|
||||
// After the first failure, return a valid response
|
||||
const changes: CouchDBChange<any>[] = [
|
||||
{ seq: 1, id: "doc1", changes: [{ rev: "1-abc" }], deleted: false },
|
||||
];
|
||||
|
||||
const responseStream = changes
|
||||
.map((change) => JSON.stringify(change) + "\n")
|
||||
.join("");
|
||||
|
||||
const mockResponse: Partial<Response> = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(responseStream));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
return mockResponse as Response;
|
||||
}
|
||||
});
|
||||
|
||||
// Mock Math.random to return 0, so jitter is 0
|
||||
jest.spyOn(Math, "random").mockReturnValue(0);
|
||||
|
||||
const stream = new CouchDBChangesStream<any>("http://mock-couchdb", {
|
||||
live: true,
|
||||
feed: "continuous",
|
||||
fetch: mockFetch,
|
||||
});
|
||||
|
||||
const result: CouchDBChange<any>[] = [];
|
||||
|
||||
const iterator = stream[Symbol.asyncIterator]();
|
||||
|
||||
// Start fetching the first change
|
||||
const nextPromise = iterator.next();
|
||||
|
||||
// Advance timers to allow the first fetch attempt
|
||||
await Promise.resolve(); // Ensure any pending promises are resolved
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
expect(fetchCallCount).toBe(1);
|
||||
|
||||
// Since the first fetch fails, it should schedule a retry with backoff
|
||||
// The backoff delay for retryCount = 1 is 2^1 * 100 = 200ms
|
||||
// Jitter is 0
|
||||
|
||||
await Promise.resolve(); // Allow promises to resolve
|
||||
|
||||
// Advance timers by 200ms to trigger the retry
|
||||
jest.advanceTimersByTime(200);
|
||||
|
||||
// Wait for the retry to occur
|
||||
await Promise.resolve(); // Allow promises to resolve
|
||||
//
|
||||
// expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
// expect(fetchCallCount).toBe(2);
|
||||
|
||||
// Now the fetch should succeed, and we can get the next change
|
||||
const next = await nextPromise;
|
||||
|
||||
expect(next.done).toBe(false);
|
||||
result.push(next.value);
|
||||
|
||||
// Stop the stream
|
||||
stream.stop();
|
||||
|
||||
const final = await iterator.next();
|
||||
expect(final.done).toBe(true);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].seq).toBe(1);
|
||||
|
||||
jest.useRealTimers();
|
||||
jest.spyOn(Math, "random").mockRestore();
|
||||
});
|
||||
|
||||
it("throws an exception when there is a malformed JSON in response", async () => {
|
||||
// Mock fetch to return a response with malformed JSON
|
||||
const changesResponse = `
|
||||
{"seq":1,"id":"doc1","changes":[{"rev":"1-abc"}]}
|
||||
MALFORMED_JSON
|
||||
{"seq":2,"id":"doc2","changes":[{"rev":"1-def"}]}
|
||||
`;
|
||||
|
||||
const mockResponse: Partial<Response> = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(changesResponse));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const mockFetch = jest.fn<
|
||||
Promise<Response>,
|
||||
[RequestInfo | URL, RequestInit?]
|
||||
>(async () => {
|
||||
return mockResponse as Response;
|
||||
}) as jest.MockedFunction<typeof fetch>;
|
||||
|
||||
const stream = new CouchDBChangesStream<any>("http://mock-couchdb", {
|
||||
live: false,
|
||||
feed: "continuous",
|
||||
fetch: mockFetch,
|
||||
});
|
||||
|
||||
const result: CouchDBChange<any>[] = [];
|
||||
let caughtError: Error | null = null;
|
||||
|
||||
try {
|
||||
for await (const change of stream) {
|
||||
result.push(change);
|
||||
}
|
||||
} catch (error) {
|
||||
caughtError = error as Error;
|
||||
}
|
||||
|
||||
// Check that an error was thrown
|
||||
expect(caughtError).not.toBeNull();
|
||||
expect(caughtError?.message).toContain(
|
||||
"[fetchChanges] Error parsing change:",
|
||||
);
|
||||
|
||||
// Ensure only valid changes were processed before the error
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].seq).toBe(1);
|
||||
});
|
||||
|
||||
it("stops after processing all changes when live is false", async () => {
|
||||
// Mock fetch to return a finite response
|
||||
const changesResponse = `
|
||||
{"seq":1,"id":"doc1","changes":[{"rev":"1-abc"}]}
|
||||
{"seq":2,"id":"doc2","changes":[{"rev":"1-def"}]}
|
||||
{"seq":3,"id":"doc3","changes":[{"rev":"1-ghi"}]}
|
||||
`;
|
||||
|
||||
const mockResponse: Partial<Response> = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(changesResponse));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const mockFetch = jest.fn<
|
||||
Promise<Response>,
|
||||
[RequestInfo | URL, RequestInit?]
|
||||
>(async (input, init) => {
|
||||
return mockResponse as Response;
|
||||
}) as jest.MockedFunction<typeof fetch>;
|
||||
|
||||
const stream = new CouchDBChangesStream<any>("http://mock-couchdb", {
|
||||
live: false,
|
||||
feed: "continuous",
|
||||
fetch: mockFetch,
|
||||
});
|
||||
|
||||
const result: CouchDBChange<any>[] = [];
|
||||
|
||||
for await (const change of stream) {
|
||||
result.push(change);
|
||||
}
|
||||
|
||||
// The stream should complete after processing all changes
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0].seq).toBe(1);
|
||||
expect(result[1].seq).toBe(2);
|
||||
expect(result[2].seq).toBe(3);
|
||||
|
||||
// Verify fetch was called exactly once since live mode is disabled
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Access the first call arguments
|
||||
const calledArgs = mockFetch.mock.calls[0];
|
||||
|
||||
// Ensure that calledArgs is defined and has at least one element
|
||||
expect(calledArgs).toBeDefined();
|
||||
expect(calledArgs.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const [calledUrl, calledOptions] = calledArgs;
|
||||
|
||||
// Validate that the URL is correct
|
||||
expect(calledUrl).toBe("http://mock-couchdb/_changes?feed=continuous");
|
||||
|
||||
// Validate that options exist and contain the expected method
|
||||
if (calledOptions) {
|
||||
expect(calledOptions.method).toBe("GET");
|
||||
} else {
|
||||
fail("Expected calledOptions to be defined");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
CouchDBChangesStream,
|
||||
CouchDBChange,
|
||||
} from "../src/CouchDBChangesStream";
|
||||
|
||||
describe("CouchDBChangesStream - EventSource Format", () => {
|
||||
it("should parse changes from text/event-stream format correctly", async () => {
|
||||
const eventSourceData = `
|
||||
event: change
|
||||
data: {"seq":1,"id":"doc1","changes":[{"rev":"1-abc"}]}
|
||||
|
||||
event: change
|
||||
data: {"seq":2,"id":"doc2","changes":[{"rev":"2-def"}]}
|
||||
|
||||
event: change
|
||||
data: {"seq":3,"id":"doc3","changes":[{"rev":"3-ghi"}]}
|
||||
`;
|
||||
|
||||
const mockResponse: Partial<Response> = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers({ "Content-Type": "text/event-stream" }),
|
||||
body: new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(eventSourceData));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const mockFetch = jest.fn(async () => mockResponse as Response);
|
||||
|
||||
const stream = new CouchDBChangesStream<any>("http://mock-couchdb", {
|
||||
feed: "eventsource",
|
||||
fetch: mockFetch,
|
||||
});
|
||||
|
||||
const result: CouchDBChange<any>[] = [];
|
||||
for await (const change of stream) {
|
||||
result.push(change);
|
||||
}
|
||||
|
||||
// Ожидаемый результат
|
||||
const expectedChanges: CouchDBChange<any>[] = [
|
||||
{ seq: 1, id: "doc1", changes: [{ rev: "1-abc" }] },
|
||||
{ seq: 2, id: "doc2", changes: [{ rev: "2-def" }] },
|
||||
{ seq: 3, id: "doc3", changes: [{ rev: "3-ghi" }] },
|
||||
];
|
||||
|
||||
// Проверяем, что результат соответствует ожидаемым изменениям
|
||||
expect(result).toEqual(expectedChanges);
|
||||
|
||||
// Проверяем, что fetch был вызван
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import {
|
||||
CouchDBChangesStream,
|
||||
CouchDBChange,
|
||||
} from "../src/CouchDBChangesStream";
|
||||
|
||||
describe("CouchDBChangesStream - Heartbeat", () => {
|
||||
let mockFetch: jest.Mock;
|
||||
const dbUrl = "http://localhost:5984/mydb";
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch = jest.fn();
|
||||
jest.useFakeTimers(); // Подключаем fake timers
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers(); // Отключаем fake timers после каждого теста
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should reset heartbeat timer on receiving data", async () => {
|
||||
const mockResponseData = {
|
||||
seq: "1",
|
||||
id: "doc1",
|
||||
changes: [{ rev: "1-abc" }],
|
||||
};
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
body: new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode(JSON.stringify(mockResponseData) + "\n"),
|
||||
);
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const stream = new CouchDBChangesStream(dbUrl, {
|
||||
feed: "continuous",
|
||||
heartbeat: 30_000, // 30 секунд
|
||||
live: true,
|
||||
fetch: mockFetch,
|
||||
});
|
||||
|
||||
const iterator = stream[Symbol.asyncIterator]();
|
||||
|
||||
const heartbeatSpy = jest.spyOn(global, "setTimeout");
|
||||
|
||||
const firstChange = await iterator.next();
|
||||
expect(firstChange.done).toBe(false);
|
||||
expect(firstChange.value.seq).toBe("1");
|
||||
// Таймер heartbeat должен был быть установлен
|
||||
expect(heartbeatSpy).toHaveBeenCalledWith(expect.any(Function), 33_000); // 5000 + 10% буфер,
|
||||
});
|
||||
|
||||
it("should throw an error if no data is received within heartbeat + buffer", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
body: new ReadableStream({
|
||||
start(controller) {
|
||||
// do not send any data to test heartbeat timeout
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const stream = new CouchDBChangesStream(dbUrl, {
|
||||
feed: "continuous",
|
||||
heartbeat: 20_000,
|
||||
fetch: mockFetch,
|
||||
});
|
||||
|
||||
const iterator = stream[Symbol.asyncIterator]();
|
||||
|
||||
const fetchPromise = iterator.next();
|
||||
|
||||
// Прокручиваем время до истечения heartbeat таймера (20_000 + 10%)
|
||||
jest.advanceTimersByTime(22_000);
|
||||
|
||||
await expect(fetchPromise).rejects.toThrow(
|
||||
"Heartbeat timeout: no data received from server after 22000 ms",
|
||||
);
|
||||
});
|
||||
|
||||
it("should continue working if data is received before heartbeat timeout", async () => {
|
||||
const mockResponseData = {
|
||||
seq: "1",
|
||||
id: "doc1",
|
||||
changes: [{ rev: "1-abc" }],
|
||||
};
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
body: new ReadableStream({
|
||||
start(controller) {
|
||||
// Отправляем данные перед истечением таймера
|
||||
setTimeout(() => {
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode(JSON.stringify(mockResponseData) + "\n"),
|
||||
);
|
||||
controller.close();
|
||||
}, 2000); // Data arrives before heartbeat timeout (3000)
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const stream = new CouchDBChangesStream(dbUrl, {
|
||||
feed: "continuous",
|
||||
heartbeat: 3_000,
|
||||
fetch: mockFetch,
|
||||
live: true,
|
||||
});
|
||||
|
||||
const iterator = stream[Symbol.asyncIterator]();
|
||||
const nextPromise = iterator.next();
|
||||
|
||||
await Promise.resolve();
|
||||
jest.advanceTimersByTime(2_999);
|
||||
await Promise.resolve();
|
||||
|
||||
const firstChange = await nextPromise;
|
||||
expect(firstChange.done).toBe(false);
|
||||
expect(firstChange.value.seq).toBe("1");
|
||||
|
||||
// Let's make sure that a timeout error does not occur
|
||||
await Promise.resolve();
|
||||
jest.advanceTimersByTime(2_999);
|
||||
await Promise.resolve();
|
||||
|
||||
const secondFetch = iterator.next();
|
||||
await expect(secondFetch).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import {
|
||||
CouchDBChangesStream,
|
||||
CouchDBChange,
|
||||
} from "../src/CouchDBChangesStream";
|
||||
|
||||
describe("CouchDBChangesStream - Limit Parameter", () => {
|
||||
it("should return only the specified number of changes when limit is set", async () => {
|
||||
const changes: CouchDBChange<any>[] = Array.from(
|
||||
{ length: 10 },
|
||||
(_, i) => ({
|
||||
seq: i + 1,
|
||||
id: `doc${i + 1}`,
|
||||
changes: [{ rev: `${i + 1}-abc` }],
|
||||
deleted: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const mockResponse: Partial<Response> = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: new ReadableStream({
|
||||
start(controller) {
|
||||
const changeStream = changes
|
||||
.map((change) => JSON.stringify(change) + "\n")
|
||||
.join("");
|
||||
controller.enqueue(new TextEncoder().encode(changeStream));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const mockFetch = jest.fn(async (url) => {
|
||||
const urlObj = new URL(url);
|
||||
const limitParam = urlObj.searchParams.get("limit");
|
||||
const limit = limitParam ? parseInt(limitParam, 10) : changes.length;
|
||||
|
||||
const responseStream = changes
|
||||
.slice(0, limit) // Применяем limit
|
||||
.map((change) => JSON.stringify(change) + "\n")
|
||||
.join("");
|
||||
|
||||
const mockResponse: Partial<Response> = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(responseStream));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
return mockResponse as Response;
|
||||
});
|
||||
|
||||
for (const limit of [1, 5, 10]) {
|
||||
const stream = new CouchDBChangesStream<any>("http://mock-couchdb", {
|
||||
limit,
|
||||
feed: "continuous",
|
||||
fetch: mockFetch,
|
||||
});
|
||||
|
||||
const result: CouchDBChange<any>[] = [];
|
||||
for await (const change of stream) {
|
||||
result.push(change);
|
||||
}
|
||||
|
||||
// Проверяем, что количество возвращенных изменений соответствует лимиту
|
||||
expect(result).toHaveLength(limit);
|
||||
|
||||
// Проверяем, что полученные изменения соответствуют первым `limit` изменениям
|
||||
expect(result.map((r) => r.seq)).toEqual(
|
||||
changes.slice(0, limit).map((c) => c.seq),
|
||||
);
|
||||
}
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(3); // Проверяем, что fetch вызывался для каждого limit
|
||||
});
|
||||
|
||||
it("should return all changes if limit is not set", async () => {
|
||||
const changes: CouchDBChange<any>[] = Array.from(
|
||||
{ length: 10 },
|
||||
(_, i) => ({
|
||||
seq: i + 1,
|
||||
id: `doc${i + 1}`,
|
||||
changes: [{ rev: `${i + 1}-abc` }],
|
||||
deleted: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const mockResponse: Partial<Response> = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: new ReadableStream({
|
||||
start(controller) {
|
||||
const changeStream = changes
|
||||
.map((change) => JSON.stringify(change) + "\n")
|
||||
.join("");
|
||||
controller.enqueue(new TextEncoder().encode(changeStream));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const mockFetch = jest.fn(async () => mockResponse as Response);
|
||||
|
||||
const stream = new CouchDBChangesStream<any>("http://mock-couchdb", {
|
||||
feed: "continuous",
|
||||
fetch: mockFetch,
|
||||
});
|
||||
|
||||
const result: CouchDBChange<any>[] = [];
|
||||
for await (const change of stream) {
|
||||
result.push(change);
|
||||
}
|
||||
|
||||
// Проверяем, что возвращены все изменения
|
||||
expect(result).toHaveLength(changes.length);
|
||||
|
||||
// Проверяем, что полученные изменения соответствуют всем изменениям
|
||||
expect(result.map((r) => r.seq)).toEqual(changes.map((c) => c.seq));
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
CouchDBChangesStream,
|
||||
CouchDBChange,
|
||||
} from "../src/CouchDBChangesStream";
|
||||
|
||||
if (typeof global.gc !== "function") {
|
||||
console.warn(
|
||||
"Skipping tests: Run Node.js with --expose-gc to enable garbage collection.",
|
||||
);
|
||||
test.skip("Garbage collection is not enabled.", () => {});
|
||||
} else {
|
||||
describe("CouchDBChangesStream Memory Leak Test", () => {
|
||||
it("should not cause memory leaks after being stopped", async () => {
|
||||
// Mock fetch response
|
||||
const changes: CouchDBChange<any>[] = Array.from(
|
||||
{ length: 1000 },
|
||||
(_, i) => ({
|
||||
seq: i + 1,
|
||||
id: `doc${i + 1}`,
|
||||
changes: [{ rev: `${i + 1}-abc` }],
|
||||
deleted: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const mockResponse: Partial<Response> = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: new ReadableStream({
|
||||
start(controller) {
|
||||
changes.forEach((change) => {
|
||||
const changeString = JSON.stringify(change) + "\n";
|
||||
controller.enqueue(new TextEncoder().encode(changeString));
|
||||
});
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const mockFetch = jest.fn(async () => mockResponse as Response);
|
||||
|
||||
// Measure memory before creating the stream
|
||||
if (global.gc) global.gc(); // Force garbage collection
|
||||
const memoryBefore = process.memoryUsage().heapUsed;
|
||||
|
||||
// Create and consume the stream
|
||||
const stream = new CouchDBChangesStream<any>("http://mock-couchdb", {
|
||||
feed: "continuous",
|
||||
fetch: mockFetch,
|
||||
});
|
||||
|
||||
const result: CouchDBChange<any>[] = [];
|
||||
for await (const change of stream) {
|
||||
result.push(change);
|
||||
if (result.length === changes.length) {
|
||||
stream.stop();
|
||||
}
|
||||
}
|
||||
|
||||
// Stop the stream and allow garbage collection
|
||||
stream.stop();
|
||||
if (global.gc) global.gc();
|
||||
|
||||
// Measure memory after the stream is stopped
|
||||
const memoryAfter = process.memoryUsage().heapUsed;
|
||||
|
||||
// Log memory usage for analysis
|
||||
console.log(`Memory Before: ${memoryBefore}`);
|
||||
console.log(`Memory After: ${memoryAfter}`);
|
||||
console.log(`Memory Difference: ${memoryAfter - memoryBefore}`);
|
||||
|
||||
// Allow for slightly larger tolerances in memory usage differences
|
||||
const memoryLeakThreshold = 1024 * 100; // 100 KB
|
||||
|
||||
expect(memoryAfter - memoryBefore).toBeLessThan(memoryLeakThreshold);
|
||||
});
|
||||
});
|
||||
}
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
import {
|
||||
CouchDBChangesStream,
|
||||
CouchDBChange,
|
||||
} from "../src/CouchDBChangesStream";
|
||||
|
||||
describe("CouchDBChangesStream Performance and Scalability", () => {
|
||||
it("processes a large number of changes correctly and completely", async () => {
|
||||
const changeCount = 1000;
|
||||
const changes: CouchDBChange<any>[] = Array.from(
|
||||
{ length: changeCount },
|
||||
(_, i) => ({
|
||||
seq: i + 1,
|
||||
id: `doc${i + 1}`,
|
||||
changes: [{ rev: `${i + 1}-abc` }],
|
||||
deleted: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const mockResponse: Partial<Response> = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: new ReadableStream({
|
||||
start(controller) {
|
||||
const changeStream = changes
|
||||
.map((change) => JSON.stringify(change) + "\n")
|
||||
.join("");
|
||||
|
||||
controller.enqueue(new TextEncoder().encode(changeStream));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const mockFetch = jest.fn(async () => {
|
||||
return mockResponse as Response;
|
||||
});
|
||||
|
||||
const stream = new CouchDBChangesStream<any>("http://mock-couchdb", {
|
||||
feed: "continuous",
|
||||
fetch: mockFetch,
|
||||
});
|
||||
|
||||
const result: CouchDBChange<any>[] = [];
|
||||
|
||||
for await (const change of stream) {
|
||||
result.push(change);
|
||||
if (result.length === changeCount) {
|
||||
stream.stop();
|
||||
}
|
||||
}
|
||||
|
||||
expect(result).toHaveLength(changeCount);
|
||||
expect(result.map((r) => r.seq)).toEqual(changes.map((c) => c.seq));
|
||||
});
|
||||
|
||||
it("maintains stability with increasing data volume", async () => {
|
||||
const largeVolumes = [100, 1000, 5000];
|
||||
const performanceResults: Record<number, number> = {};
|
||||
|
||||
for (const volume of largeVolumes) {
|
||||
const changes: CouchDBChange<any>[] = Array.from(
|
||||
{ length: volume },
|
||||
(_, i) => ({
|
||||
seq: i + 1,
|
||||
id: `doc${i + 1}`,
|
||||
changes: [{ rev: `${i + 1}-abc` }],
|
||||
deleted: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const mockResponse: Partial<Response> = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: new ReadableStream({
|
||||
start(controller) {
|
||||
const changeStream = changes
|
||||
.map((change) => JSON.stringify(change) + "\n")
|
||||
.join("");
|
||||
|
||||
controller.enqueue(new TextEncoder().encode(changeStream));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const mockFetch = jest.fn(async () => {
|
||||
return mockResponse as Response;
|
||||
});
|
||||
|
||||
const stream = new CouchDBChangesStream<any>("http://mock-couchdb", {
|
||||
feed: "continuous",
|
||||
fetch: mockFetch,
|
||||
});
|
||||
|
||||
const result: CouchDBChange<any>[] = [];
|
||||
const startTime = performance.now();
|
||||
|
||||
for await (const change of stream) {
|
||||
result.push(change);
|
||||
if (result.length === volume) {
|
||||
stream.stop();
|
||||
}
|
||||
}
|
||||
|
||||
const endTime = performance.now();
|
||||
const elapsedTime = endTime - startTime;
|
||||
performanceResults[volume] = elapsedTime;
|
||||
|
||||
expect(result).toHaveLength(volume);
|
||||
expect(result.map((r) => r.seq)).toEqual(changes.map((c) => c.seq));
|
||||
}
|
||||
|
||||
console.log("Performance Results (ms):", performanceResults);
|
||||
});
|
||||
|
||||
it("handles high throughput with multiple chunks of data", async () => {
|
||||
const chunkSize = 500;
|
||||
const chunkCount = 5;
|
||||
const changes: CouchDBChange<any>[] = Array.from(
|
||||
{ length: chunkSize * chunkCount },
|
||||
(_, i) => ({
|
||||
seq: i + 1,
|
||||
id: `doc${i + 1}`,
|
||||
changes: [{ rev: `${i + 1}-abc` }],
|
||||
deleted: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const mockResponse: Partial<Response> = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: new ReadableStream({
|
||||
start(controller) {
|
||||
changes.forEach((change, i) => {
|
||||
const changeString = JSON.stringify(change) + "\n";
|
||||
controller.enqueue(new TextEncoder().encode(changeString));
|
||||
if ((i + 1) % chunkSize === 0) {
|
||||
// Simulate chunked data
|
||||
controller.enqueue(new TextEncoder().encode("\n"));
|
||||
}
|
||||
});
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const mockFetch = jest.fn(async () => {
|
||||
return mockResponse as Response;
|
||||
});
|
||||
|
||||
const stream = new CouchDBChangesStream<any>("http://mock-couchdb", {
|
||||
feed: "continuous",
|
||||
fetch: mockFetch,
|
||||
});
|
||||
|
||||
const result: CouchDBChange<any>[] = [];
|
||||
|
||||
for await (const change of stream) {
|
||||
result.push(change);
|
||||
if (result.length === changes.length) {
|
||||
stream.stop();
|
||||
}
|
||||
}
|
||||
|
||||
expect(result).toHaveLength(changes.length);
|
||||
expect(result.map((r) => r.seq)).toEqual(changes.map((c) => c.seq));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,491 @@
|
||||
import {
|
||||
CouchDBChangesStream,
|
||||
CouchDBChange,
|
||||
CouchDBChangesOptions,
|
||||
} from "../src/CouchDBChangesStream";
|
||||
|
||||
import * as fc from "fast-check";
|
||||
|
||||
type NestedDocument = {
|
||||
_id: string;
|
||||
_rev: string;
|
||||
type: string;
|
||||
data: {
|
||||
nested: {
|
||||
field: string;
|
||||
value: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type SimpleDocument = {
|
||||
_id: string;
|
||||
_rev: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
function createMockFetch<T>(
|
||||
changes: CouchDBChange<T>[],
|
||||
): jest.Mock<Promise<Response>> {
|
||||
return jest.fn(async () => {
|
||||
const responseStream = changes
|
||||
.map((change) => JSON.stringify(change) + "\n")
|
||||
.join("");
|
||||
|
||||
const mockResponse: Partial<Response> = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(responseStream));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
return mockResponse as Response;
|
||||
});
|
||||
}
|
||||
|
||||
describe("CouchDBChangesStream", () => {
|
||||
it("returns changes in the correct order and type", async () => {
|
||||
const changes: CouchDBChange<any>[] = [
|
||||
{ seq: 1, id: "doc1", changes: [{ rev: "1-abc" }], deleted: false },
|
||||
{ seq: 2, id: "doc2", changes: [{ rev: "1-def" }], deleted: false },
|
||||
];
|
||||
|
||||
const mockFetch = createMockFetch(changes);
|
||||
|
||||
const stream = new CouchDBChangesStream<any>("http://mock-couchdb", {
|
||||
feed: "continuous",
|
||||
fetch: mockFetch,
|
||||
});
|
||||
|
||||
const result: CouchDBChange<any>[] = [];
|
||||
for await (const change of stream) {
|
||||
result.push(change);
|
||||
}
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
expect(result).toHaveLength(changes.length);
|
||||
expect(result.map((r) => r.seq)).toEqual(changes.map((c) => c.seq));
|
||||
});
|
||||
|
||||
it("ensures pause between processing changes", async () => {
|
||||
const changes: CouchDBChange<any>[] = [
|
||||
{ seq: 1, id: "doc1", changes: [{ rev: "1-abc" }], deleted: false },
|
||||
{ seq: 2, id: "doc2", changes: [{ rev: "1-def" }], deleted: false },
|
||||
];
|
||||
|
||||
const mockFetch = createMockFetch(changes);
|
||||
|
||||
const stream = new CouchDBChangesStream<any>("http://mock-couchdb", {
|
||||
feed: "continuous",
|
||||
fetch: mockFetch,
|
||||
});
|
||||
|
||||
const result: CouchDBChange<any>[] = [];
|
||||
let processing = false;
|
||||
|
||||
for await (const change of stream) {
|
||||
expect(processing).toBe(false);
|
||||
processing = true;
|
||||
|
||||
// Simulate long processing time
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
result.push(change);
|
||||
processing = false;
|
||||
|
||||
if (result.length === changes.length) {
|
||||
stream.stop();
|
||||
}
|
||||
}
|
||||
|
||||
expect(result).toHaveLength(changes.length);
|
||||
});
|
||||
|
||||
it("correctly stops when stop() or AbortController is called", async () => {
|
||||
const changes: CouchDBChange<any>[] = [
|
||||
{ seq: 1, id: "doc1", changes: [{ rev: "1-abc" }], deleted: false },
|
||||
{ seq: 2, id: "doc2", changes: [{ rev: "1-def" }], deleted: false },
|
||||
];
|
||||
|
||||
const mockFetch = createMockFetch(changes);
|
||||
|
||||
const stream = new CouchDBChangesStream<any>("http://mock-couchdb", {
|
||||
feed: "continuous",
|
||||
fetch: mockFetch,
|
||||
});
|
||||
|
||||
const result: CouchDBChange<any>[] = [];
|
||||
const iterator = stream[Symbol.asyncIterator]();
|
||||
|
||||
// Read first change
|
||||
const next1 = await iterator.next();
|
||||
expect(next1.done).toBe(false);
|
||||
result.push(next1.value);
|
||||
|
||||
// Call stop()
|
||||
stream.stop();
|
||||
|
||||
// Attempt to read the next change
|
||||
const next2 = await iterator.next();
|
||||
expect(next2.done).toBe(true);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("stops the stream when AbortController is aborted", async () => {
|
||||
const changes: CouchDBChange<any>[] = [
|
||||
{ seq: 1, id: "doc1", changes: [{ rev: "1-abc" }], deleted: false },
|
||||
{ seq: 2, id: "doc2", changes: [{ rev: "1-def" }], deleted: false },
|
||||
];
|
||||
|
||||
const abortController = new AbortController();
|
||||
const mockFetch = createMockFetch(changes);
|
||||
|
||||
const stream = new CouchDBChangesStream<any>("http://mock-couchdb", {
|
||||
feed: "continuous",
|
||||
fetch: mockFetch,
|
||||
abortController,
|
||||
});
|
||||
|
||||
const result: CouchDBChange<any>[] = [];
|
||||
const iterator = stream[Symbol.asyncIterator]();
|
||||
|
||||
// Read first change
|
||||
const next1 = await iterator.next();
|
||||
expect(next1.done).toBe(false);
|
||||
result.push(next1.value);
|
||||
|
||||
// Abort the signal
|
||||
abortController.abort();
|
||||
|
||||
// Attempt to read the next change
|
||||
const next2 = await iterator.next();
|
||||
expect(next2.done).toBe(true);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("isolated test for filter parameter", async () => {
|
||||
const mockFetch = createMockFetch([]);
|
||||
const options = {
|
||||
filter: "",
|
||||
doc_ids: [],
|
||||
feed: "continuous",
|
||||
fetch: mockFetch,
|
||||
} as const;
|
||||
|
||||
const stream = new CouchDBChangesStream<any>(
|
||||
"http://mock-couchdb",
|
||||
options,
|
||||
);
|
||||
|
||||
const iterator = stream[Symbol.asyncIterator]();
|
||||
await iterator.next();
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
const fetchCallArgs = mockFetch.mock.calls[0];
|
||||
const fetchUrl = fetchCallArgs[0] as string;
|
||||
const url = new URL(fetchUrl);
|
||||
const params = url.searchParams;
|
||||
|
||||
expect(params.has("filter")).toBe(false);
|
||||
});
|
||||
|
||||
it("correctly reflects passed parameters in the HTTP request", async () => {
|
||||
const mockFetch = createMockFetch([]);
|
||||
|
||||
const options = {
|
||||
since: "now",
|
||||
filter: "test_filter",
|
||||
doc_ids: ["doc1", "doc2"],
|
||||
feed: "continuous",
|
||||
conflicts: true,
|
||||
descending: false,
|
||||
include_docs: true,
|
||||
limit: 5,
|
||||
fetch: mockFetch,
|
||||
} as const;
|
||||
|
||||
const stream = new CouchDBChangesStream<any>(
|
||||
"http://mock-couchdb",
|
||||
options,
|
||||
);
|
||||
|
||||
const iterator = stream[Symbol.asyncIterator]();
|
||||
await iterator.next();
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
const fetchCallArgs = mockFetch.mock.calls[0];
|
||||
const fetchUrl = fetchCallArgs[0] as string;
|
||||
const fetchOptions = fetchCallArgs[1] as RequestInit;
|
||||
|
||||
if (!fetchOptions) {
|
||||
throw new Error("fetchOptions is undefined");
|
||||
}
|
||||
|
||||
const url = new URL(fetchUrl);
|
||||
const params = url.searchParams;
|
||||
|
||||
expect(params.get("since")).toBe("now");
|
||||
expect(params.get("filter")).toBe("test_filter");
|
||||
expect(params.get("include_docs")).toBe("true");
|
||||
expect(params.get("limit")).toBe("5");
|
||||
|
||||
expect(fetchOptions.method).toBe("POST");
|
||||
expect(fetchOptions.body).toEqual(
|
||||
JSON.stringify({ doc_ids: ["doc1", "doc2"] }),
|
||||
);
|
||||
});
|
||||
|
||||
it("correctly maps changes to the specified document type", async () => {
|
||||
const nestedChanges: CouchDBChange<NestedDocument>[] = [
|
||||
{
|
||||
seq: 1,
|
||||
id: "nested_doc1",
|
||||
changes: [{ rev: "1-nested" }],
|
||||
doc: {
|
||||
_id: "nested_doc1",
|
||||
_rev: "1-nested",
|
||||
type: "nested",
|
||||
data: { nested: { field: "example", value: 42 } },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const simpleChanges: CouchDBChange<SimpleDocument>[] = [
|
||||
{
|
||||
seq: 2,
|
||||
id: "simple_doc1",
|
||||
changes: [{ rev: "1-simple" }],
|
||||
doc: {
|
||||
_id: "simple_doc1",
|
||||
_rev: "1-simple",
|
||||
type: "simple",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const mockFetchNested = createMockFetch(nestedChanges);
|
||||
const mockFetchSimple = createMockFetch(simpleChanges);
|
||||
|
||||
const nestedStream = new CouchDBChangesStream<NestedDocument>(
|
||||
"http://mock-couchdb",
|
||||
{ include_docs: true, feed: "continuous", fetch: mockFetchNested },
|
||||
);
|
||||
|
||||
const simpleStream = new CouchDBChangesStream<SimpleDocument>(
|
||||
"http://mock-couchdb",
|
||||
{ include_docs: true, feed: "continuous", fetch: mockFetchSimple },
|
||||
);
|
||||
|
||||
const nestedResult: CouchDBChange<NestedDocument>[] = [];
|
||||
for await (const change of nestedStream) {
|
||||
nestedResult.push(change);
|
||||
}
|
||||
|
||||
const simpleResult: CouchDBChange<SimpleDocument>[] = [];
|
||||
for await (const change of simpleStream) {
|
||||
simpleResult.push(change);
|
||||
}
|
||||
|
||||
// Validate nested document results
|
||||
expect(nestedResult).toHaveLength(nestedChanges.length);
|
||||
expect(nestedResult[0].doc).toEqual(nestedChanges[0].doc);
|
||||
|
||||
// Validate simple document results
|
||||
expect(simpleResult).toHaveLength(simpleChanges.length);
|
||||
expect(simpleResult[0].doc).toEqual(simpleChanges[0].doc);
|
||||
});
|
||||
|
||||
it("excludes 'live' from URL parameters but affects behavior", async () => {
|
||||
const mockFetch = createMockFetch([]);
|
||||
const options: CouchDBChangesOptions = {
|
||||
since: "now",
|
||||
feed: "continuous",
|
||||
filter: "test_filter",
|
||||
live: true, // This is the key parameter we're testing
|
||||
include_docs: true,
|
||||
fetch: mockFetch,
|
||||
};
|
||||
|
||||
const stream = new CouchDBChangesStream<any>(
|
||||
"http://mock-couchdb",
|
||||
options,
|
||||
);
|
||||
|
||||
const iterator = stream[Symbol.asyncIterator]();
|
||||
await iterator.next();
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
const fetchCallArgs = mockFetch.mock.calls[0];
|
||||
const fetchUrl = fetchCallArgs[0] as string;
|
||||
const fetchOptions = fetchCallArgs[1] as RequestInit;
|
||||
|
||||
if (!fetchOptions) {
|
||||
throw new Error("fetchOptions is undefined");
|
||||
}
|
||||
|
||||
const url = new URL(fetchUrl);
|
||||
const params = url.searchParams;
|
||||
|
||||
// Ensure 'live' does not appear as a query parameter
|
||||
expect(params.get("live")).toBeNull();
|
||||
|
||||
// Check that other parameters are still included
|
||||
expect(params.get("since")).toBe("now");
|
||||
expect(params.get("filter")).toBe("test_filter");
|
||||
expect(params.get("include_docs")).toBe("true");
|
||||
|
||||
// Validate the fetch method and body
|
||||
expect(fetchOptions.method).toBe("GET");
|
||||
expect(fetchOptions.body).toBeUndefined();
|
||||
|
||||
// Further behavior checks can be added for live mode if necessary
|
||||
});
|
||||
|
||||
it("adds Basic Auth header when credentials are included in URL", async () => {
|
||||
const mockFetch = jest.fn(
|
||||
async (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> => {
|
||||
return new Response(JSON.stringify({ results: [], last_seq: "0" }));
|
||||
},
|
||||
) as jest.MockedFunction<typeof fetch>;
|
||||
const options = {
|
||||
since: "now",
|
||||
include_docs: true,
|
||||
fetch: mockFetch,
|
||||
};
|
||||
|
||||
const stream = new CouchDBChangesStream<any>(
|
||||
"https://admin:password@mock-couchdb",
|
||||
options,
|
||||
);
|
||||
|
||||
const iterator = stream[Symbol.asyncIterator]();
|
||||
await iterator.next();
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
const fetchCallArgs = mockFetch.mock.calls[0];
|
||||
const fetchOptions = fetchCallArgs[1] as RequestInit;
|
||||
|
||||
expect(fetchOptions?.headers).toBeDefined();
|
||||
expect(
|
||||
"Authorization" in fetchOptions?.headers! &&
|
||||
fetchOptions?.headers!["Authorization"],
|
||||
).toBe("Basic " + btoa("admin:password"));
|
||||
});
|
||||
|
||||
it("processes all changes and stops in 'normal' mode", async () => {
|
||||
// Mock fetch to return a finite response
|
||||
const changesResponse = {
|
||||
results: [
|
||||
{ seq: 1, id: "doc1", changes: [{ rev: "1-abc" }], deleted: false },
|
||||
{ seq: 2, id: "doc2", changes: [{ rev: "1-def" }], deleted: false },
|
||||
],
|
||||
last_seq: "2",
|
||||
};
|
||||
|
||||
const mockFetch = jest.fn(
|
||||
async (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> => {
|
||||
return new Response(JSON.stringify(changesResponse), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const stream = new CouchDBChangesStream<any>("http://mock-couchdb", {
|
||||
feed: "normal",
|
||||
include_docs: true,
|
||||
fetch: mockFetch,
|
||||
});
|
||||
|
||||
const result: CouchDBChange<any>[] = [];
|
||||
|
||||
for await (const change of stream) {
|
||||
result.push(change);
|
||||
}
|
||||
|
||||
// Check results
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].seq).toBe(1);
|
||||
expect(result[1].seq).toBe(2);
|
||||
|
||||
// Check fetch call
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
const [url] = mockFetch.mock.calls[0];
|
||||
expect(url).toContain("_changes");
|
||||
});
|
||||
|
||||
it("processes changes in 'longpoll' mode and continues waiting for updates", async () => {
|
||||
// Mock fetch to return different responses on each call
|
||||
const firstResponse = {
|
||||
results: [
|
||||
{ seq: 1, id: "doc1", changes: [{ rev: "1-abc" }], deleted: false },
|
||||
],
|
||||
last_seq: "1",
|
||||
};
|
||||
|
||||
const secondResponse = {
|
||||
results: [
|
||||
{ seq: 2, id: "doc2", changes: [{ rev: "1-def" }], deleted: false },
|
||||
],
|
||||
last_seq: "2",
|
||||
};
|
||||
|
||||
const mockFetch = jest
|
||||
.fn()
|
||||
.mockImplementationOnce(async () => {
|
||||
return new Response(JSON.stringify(firstResponse), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
})
|
||||
.mockImplementationOnce(async () => {
|
||||
return new Response(JSON.stringify(secondResponse), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}) as jest.MockedFunction<typeof fetch>;
|
||||
|
||||
const stream = new CouchDBChangesStream<any>("http://mock-couchdb", {
|
||||
feed: "longpoll",
|
||||
include_docs: true,
|
||||
live: true,
|
||||
fetch: mockFetch,
|
||||
});
|
||||
|
||||
const result: CouchDBChange<any>[] = [];
|
||||
const iterator = stream[Symbol.asyncIterator]();
|
||||
|
||||
// Read first set of changes
|
||||
result.push((await iterator.next()).value);
|
||||
|
||||
// Read second set of changes
|
||||
result.push((await iterator.next()).value);
|
||||
|
||||
// Stop the stream
|
||||
stream.stop();
|
||||
|
||||
// Check results
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].seq).toBe(1);
|
||||
expect(result[1].seq).toBe(2);
|
||||
|
||||
// Check fetch calls
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"lib": ["DOM", "ES2020"],
|
||||
"outDir": "dist",
|
||||
"strict": true,
|
||||
"moduleResolution": "node",
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"sourceMap": true,
|
||||
"declaration": true,
|
||||
"types": ["node","jest"] // Добавляем поддержку Jest
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "tests"]
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { BaseDocument } from "./BaseDocument";
|
||||
import type { PushSubscription } from "web-push";
|
||||
|
||||
export interface WebPushSubscriptionDocument extends BaseDocument {
|
||||
_id: string; // Формат: "web_push_subscription:<user_uuid>:<browser_uuid>"
|
||||
type: "web_push_subscription";
|
||||
user_uuid: string; // UUID пользователя, связанного с подпиской
|
||||
browser_uuid: string; // UUID браузера, связанного с подпиской
|
||||
subscription: PushSubscriptionJSON;
|
||||
subscription: PushSubscription;
|
||||
created_at: string; // Дата создания подписки в ISO формате
|
||||
updated_at?: string; // Дата последнего обновления подписки
|
||||
}
|
||||
|
||||
@@ -6,5 +6,11 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"typescript": "~5.6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/web-push": "^3.6.4"
|
||||
}
|
||||
}
|
||||
Generated
+6946
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
||||
packages:
|
||||
- "apps/*"
|
||||
- "packages/*"
|
||||
- "services/*"
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
*.log
|
||||
@@ -0,0 +1 @@
|
||||
dist
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "@hereconnect/message-delivery-method-web-push",
|
||||
"name": "@hereconnect/service-message-delivery-method-web-push",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "src/server.ts",
|
||||
@@ -24,16 +24,25 @@
|
||||
},
|
||||
"contributors": [],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"generate-keys": "node ./src/generate-keys.js",
|
||||
"test-send-notification": "node ./src/test-send-notification.js",
|
||||
"test-send-notification-cancel": "node ./src/test-send-notification-cancel.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hereconnect/couchdb-changes-stream": "workspace:^",
|
||||
"@hereconnect/types": "^1.0.0",
|
||||
"couchdb-changes-stream": "^1.0.1",
|
||||
"pouchdb-adapter-http": "^9.0.0",
|
||||
"pouchdb-core": "^9.0.0",
|
||||
"pouchdb-find": "^9.0.0",
|
||||
"typescript": "~5.6.3",
|
||||
"web-push": "^3.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.17.6",
|
||||
"@types/pouchdb-adapter-http": "^6.1.6",
|
||||
"@types/pouchdb-core": "^7.0.15",
|
||||
"@types/pouchdb-find": "^7.3.3",
|
||||
"@types/web-push": "^3.6.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import http from "http";
|
||||
|
||||
/**
|
||||
* Запускает HTTP-сервер для проверки здоровья.
|
||||
* @param controller - AbortController для управления состоянием здоровья.
|
||||
* @param defaultPort - Порт для сервера по умолчанию (если не указан в ENV HEALTH_PORT).
|
||||
* @param defaultHostName - Имя хоста для сервера по умолчанию (если не указан в ENV HEALTH_HOST).
|
||||
*/
|
||||
export function startHealthServer(
|
||||
controller: AbortController,
|
||||
defaultPort: number = 8000,
|
||||
defaultHostName: string = "127.0.0.1",
|
||||
): void {
|
||||
const port = parseInt(process.env.HEALTH_PORT || `${defaultPort}`, 10);
|
||||
const hostname = process.env.HEALTH_HOST || defaultHostName;
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.url === "/health") {
|
||||
const signal = controller.signal;
|
||||
|
||||
// Если сигнал отмены активирован, сервер помечается как "unhealthy"
|
||||
if (signal.aborted) {
|
||||
res.writeHead(500, { "Content-Type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
status: "unhealthy",
|
||||
reason: "Service aborted",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Возвращаем стандартный статус "ok", если всё нормально
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
status: "ok",
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
res.writeHead(404, { "Content-Type": "text/plain" });
|
||||
res.end("Not Found");
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(port, hostname, () => {
|
||||
console.log(
|
||||
`Healthcheck server running on http://${hostname}:${port}/health`,
|
||||
);
|
||||
});
|
||||
|
||||
// Закрываем сервер при активации AbortController
|
||||
controller.signal.addEventListener("abort", () => {
|
||||
console.log("Healthcheck server shutting down due to abort signal.");
|
||||
server.close();
|
||||
});
|
||||
}
|
||||
@@ -1,21 +1,27 @@
|
||||
import webpush from "web-push";
|
||||
|
||||
import { CouchDBChangesStream } from "couchdb-changes-stream";
|
||||
import { CouchDBChangesStream } from "@hereconnect/couchdb-changes-stream";
|
||||
import { qrDb, messagesDbUrl, webPushSubscriptionsDb } from "./dbs";
|
||||
import { setupVapidDetails } from "./setupVapidDetails";
|
||||
import type { MessageDocument } from "@hereconnect/types";
|
||||
import { ServiceSeqTracker } from "./serviceSeqTracker";
|
||||
import { startHealthServer } from "./health-server";
|
||||
|
||||
const serviceSeqTracker = new ServiceSeqTracker();
|
||||
const abortController = new AbortController();
|
||||
|
||||
startHealthServer(abortController);
|
||||
|
||||
const start = async () => {
|
||||
await setupVapidDetails();
|
||||
const since = await serviceSeqTracker.getSince();
|
||||
const serviceSeqTracker = await ServiceSeqTracker.Init(
|
||||
"_local/service:message-delivery-method-web-push",
|
||||
);
|
||||
|
||||
const changesStream = new CouchDBChangesStream<MessageDocument>(
|
||||
messagesDbUrl,
|
||||
{
|
||||
since,
|
||||
abortController,
|
||||
since: serviceSeqTracker.lastSeq,
|
||||
feed: "continuous",
|
||||
include_docs: true,
|
||||
heartbeat: 1000,
|
||||
@@ -28,12 +34,12 @@ const start = async () => {
|
||||
|
||||
// Gracefully handle SIGINT
|
||||
process.on("SIGINT", async () => {
|
||||
changesStream.stop();
|
||||
changesStream?.stop();
|
||||
process.stdout.write("\n");
|
||||
process.exit(2);
|
||||
});
|
||||
|
||||
console.info("Service started from sequence", since);
|
||||
console.info("Service started from sequence", serviceSeqTracker.lastSeq);
|
||||
|
||||
for await (const change of changesStream) {
|
||||
const { seq, doc: msgDoc } = change;
|
||||
@@ -68,7 +74,7 @@ const start = async () => {
|
||||
|
||||
for (const row of rows) {
|
||||
try {
|
||||
const subscription = row.doc.subscription;
|
||||
const subscription = row.doc!.subscription;
|
||||
const topic = `chat-${msgDoc.qr_code_uri}-${msgDoc.chat}`;
|
||||
const tag = `${topic}-${(new Date(msgDoc.created_at).getTime() - new Date(qrDoc.created_at).getTime()).toString(36)}`;
|
||||
|
||||
|
||||
@@ -1,28 +1,32 @@
|
||||
import { serviceDb, type ServiceDoc } from "./dbs";
|
||||
|
||||
const SERVICE_DOC_ID = "_local/service:message-delivery-method-web-push";
|
||||
|
||||
export class ServiceSeqTracker {
|
||||
private serviceDoc: ServiceDoc;
|
||||
|
||||
async getSince() {
|
||||
this.serviceDoc = await serviceDb.get(SERVICE_DOC_ID).catch((error) => {
|
||||
/**
|
||||
* @param _id
|
||||
* @constructor
|
||||
*/
|
||||
static async Init(_id: string) {
|
||||
const serviceDoc = await serviceDb.get(_id).catch((error) => {
|
||||
if (error.name === "not_found") {
|
||||
return {
|
||||
_id: SERVICE_DOC_ID,
|
||||
_id,
|
||||
lastSeq: "0",
|
||||
};
|
||||
}
|
||||
throw error; // Re-throw other errors
|
||||
});
|
||||
return new ServiceSeqTracker(serviceDoc);
|
||||
}
|
||||
|
||||
// this.serviceDoc.lastSeq =
|
||||
// "126-g1AAAACLeJzLYWBgYMpgTmHgzcvPy09JdcjLz8gvLskBCScyJNX___8_K4M50SUXKMCebGlpnmxpiq4Yh_Y8FiDJ0ACk_kNNsQKbYpSakphkYYKuJwsAb2YrPw";
|
||||
private constructor(private serviceDoc: ServiceDoc) {}
|
||||
|
||||
get lastSeq() {
|
||||
return this.serviceDoc.lastSeq;
|
||||
}
|
||||
|
||||
checkNextSeq(seq: number | string) {
|
||||
if (!this.serviceDoc) {
|
||||
}
|
||||
const next = parseInt(seq as string, 10);
|
||||
const current = parseInt(this.serviceDoc.lastSeq as string, 10);
|
||||
if (next < current) {
|
||||
|
||||
@@ -3,10 +3,10 @@ import { serverSettingsDb } from "./dbs";
|
||||
import { ServerSettingsDocument } from "@hereconnect/types";
|
||||
|
||||
function isType<N extends ServerSettingsDocument["name"]>(
|
||||
doc: ServerSettingsDocument,
|
||||
doc: ServerSettingsDocument | null | undefined,
|
||||
name: N,
|
||||
): doc is ServerSettingsDocument & { name: N } {
|
||||
return doc.name === name;
|
||||
return !!(doc && doc.name === name);
|
||||
}
|
||||
|
||||
export const setupVapidDetails = async () => {
|
||||
@@ -53,9 +53,10 @@ export const setupVapidDetails = async () => {
|
||||
);
|
||||
|
||||
changes.on("change", (change) => {
|
||||
if (!("doc" in change)) {
|
||||
if (!change.doc) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isType(change.doc, "message_delivery_method_web_push_vapid")) {
|
||||
console.log("update vapid details");
|
||||
webpush.setVapidDetails(
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"moduleResolution": "node",
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user