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 { BaseDocument } from "./BaseDocument";
|
||||||
|
import type { PushSubscription } from "web-push";
|
||||||
|
|
||||||
export interface WebPushSubscriptionDocument extends BaseDocument {
|
export interface WebPushSubscriptionDocument extends BaseDocument {
|
||||||
_id: string; // Формат: "web_push_subscription:<user_uuid>:<browser_uuid>"
|
_id: string; // Формат: "web_push_subscription:<user_uuid>:<browser_uuid>"
|
||||||
type: "web_push_subscription";
|
type: "web_push_subscription";
|
||||||
user_uuid: string; // UUID пользователя, связанного с подпиской
|
user_uuid: string; // UUID пользователя, связанного с подпиской
|
||||||
browser_uuid: string; // UUID браузера, связанного с подпиской
|
browser_uuid: string; // UUID браузера, связанного с подпиской
|
||||||
subscription: PushSubscriptionJSON;
|
subscription: PushSubscription;
|
||||||
created_at: string; // Дата создания подписки в ISO формате
|
created_at: string; // Дата создания подписки в ISO формате
|
||||||
updated_at?: string; // Дата последнего обновления подписки
|
updated_at?: string; // Дата последнего обновления подписки
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,5 +6,11 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc"
|
"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",
|
"version": "1.0.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "src/server.ts",
|
"main": "src/server.ts",
|
||||||
@@ -24,16 +24,25 @@
|
|||||||
},
|
},
|
||||||
"contributors": [],
|
"contributors": [],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
"generate-keys": "node ./src/generate-keys.js",
|
"generate-keys": "node ./src/generate-keys.js",
|
||||||
"test-send-notification": "node ./src/test-send-notification.js",
|
"test-send-notification": "node ./src/test-send-notification.js",
|
||||||
"test-send-notification-cancel": "node ./src/test-send-notification-cancel.js"
|
"test-send-notification-cancel": "node ./src/test-send-notification-cancel.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@hereconnect/couchdb-changes-stream": "workspace:^",
|
||||||
"@hereconnect/types": "^1.0.0",
|
"@hereconnect/types": "^1.0.0",
|
||||||
"couchdb-changes-stream": "^1.0.1",
|
|
||||||
"pouchdb-adapter-http": "^9.0.0",
|
"pouchdb-adapter-http": "^9.0.0",
|
||||||
"pouchdb-core": "^9.0.0",
|
"pouchdb-core": "^9.0.0",
|
||||||
"pouchdb-find": "^9.0.0",
|
"pouchdb-find": "^9.0.0",
|
||||||
|
"typescript": "~5.6.3",
|
||||||
"web-push": "^3.6.7"
|
"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 webpush from "web-push";
|
||||||
|
|
||||||
import { CouchDBChangesStream } from "couchdb-changes-stream";
|
import { CouchDBChangesStream } from "@hereconnect/couchdb-changes-stream";
|
||||||
import { qrDb, messagesDbUrl, webPushSubscriptionsDb } from "./dbs";
|
import { qrDb, messagesDbUrl, webPushSubscriptionsDb } from "./dbs";
|
||||||
import { setupVapidDetails } from "./setupVapidDetails";
|
import { setupVapidDetails } from "./setupVapidDetails";
|
||||||
import type { MessageDocument } from "@hereconnect/types";
|
import type { MessageDocument } from "@hereconnect/types";
|
||||||
import { ServiceSeqTracker } from "./serviceSeqTracker";
|
import { ServiceSeqTracker } from "./serviceSeqTracker";
|
||||||
|
import { startHealthServer } from "./health-server";
|
||||||
|
|
||||||
const serviceSeqTracker = new ServiceSeqTracker();
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
startHealthServer(abortController);
|
||||||
|
|
||||||
const start = async () => {
|
const start = async () => {
|
||||||
await setupVapidDetails();
|
await setupVapidDetails();
|
||||||
const since = await serviceSeqTracker.getSince();
|
const serviceSeqTracker = await ServiceSeqTracker.Init(
|
||||||
|
"_local/service:message-delivery-method-web-push",
|
||||||
|
);
|
||||||
|
|
||||||
const changesStream = new CouchDBChangesStream<MessageDocument>(
|
const changesStream = new CouchDBChangesStream<MessageDocument>(
|
||||||
messagesDbUrl,
|
messagesDbUrl,
|
||||||
{
|
{
|
||||||
since,
|
abortController,
|
||||||
|
since: serviceSeqTracker.lastSeq,
|
||||||
feed: "continuous",
|
feed: "continuous",
|
||||||
include_docs: true,
|
include_docs: true,
|
||||||
heartbeat: 1000,
|
heartbeat: 1000,
|
||||||
@@ -28,12 +34,12 @@ const start = async () => {
|
|||||||
|
|
||||||
// Gracefully handle SIGINT
|
// Gracefully handle SIGINT
|
||||||
process.on("SIGINT", async () => {
|
process.on("SIGINT", async () => {
|
||||||
changesStream.stop();
|
changesStream?.stop();
|
||||||
process.stdout.write("\n");
|
process.stdout.write("\n");
|
||||||
process.exit(2);
|
process.exit(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
console.info("Service started from sequence", since);
|
console.info("Service started from sequence", serviceSeqTracker.lastSeq);
|
||||||
|
|
||||||
for await (const change of changesStream) {
|
for await (const change of changesStream) {
|
||||||
const { seq, doc: msgDoc } = change;
|
const { seq, doc: msgDoc } = change;
|
||||||
@@ -68,7 +74,7 @@ const start = async () => {
|
|||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
try {
|
try {
|
||||||
const subscription = row.doc.subscription;
|
const subscription = row.doc!.subscription;
|
||||||
const topic = `chat-${msgDoc.qr_code_uri}-${msgDoc.chat}`;
|
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)}`;
|
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";
|
import { serviceDb, type ServiceDoc } from "./dbs";
|
||||||
|
|
||||||
const SERVICE_DOC_ID = "_local/service:message-delivery-method-web-push";
|
|
||||||
|
|
||||||
export class ServiceSeqTracker {
|
export class ServiceSeqTracker {
|
||||||
private serviceDoc: ServiceDoc;
|
/**
|
||||||
|
* @param _id
|
||||||
async getSince() {
|
* @constructor
|
||||||
this.serviceDoc = await serviceDb.get(SERVICE_DOC_ID).catch((error) => {
|
*/
|
||||||
|
static async Init(_id: string) {
|
||||||
|
const serviceDoc = await serviceDb.get(_id).catch((error) => {
|
||||||
if (error.name === "not_found") {
|
if (error.name === "not_found") {
|
||||||
return {
|
return {
|
||||||
_id: SERVICE_DOC_ID,
|
_id,
|
||||||
lastSeq: "0",
|
lastSeq: "0",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
throw error; // Re-throw other errors
|
throw error; // Re-throw other errors
|
||||||
});
|
});
|
||||||
|
return new ServiceSeqTracker(serviceDoc);
|
||||||
|
}
|
||||||
|
|
||||||
// this.serviceDoc.lastSeq =
|
private constructor(private serviceDoc: ServiceDoc) {}
|
||||||
// "126-g1AAAACLeJzLYWBgYMpgTmHgzcvPy09JdcjLz8gvLskBCScyJNX___8_K4M50SUXKMCebGlpnmxpiq4Yh_Y8FiDJ0ACk_kNNsQKbYpSakphkYYKuJwsAb2YrPw";
|
|
||||||
|
|
||||||
|
get lastSeq() {
|
||||||
return this.serviceDoc.lastSeq;
|
return this.serviceDoc.lastSeq;
|
||||||
}
|
}
|
||||||
|
|
||||||
checkNextSeq(seq: number | string) {
|
checkNextSeq(seq: number | string) {
|
||||||
|
if (!this.serviceDoc) {
|
||||||
|
}
|
||||||
const next = parseInt(seq as string, 10);
|
const next = parseInt(seq as string, 10);
|
||||||
const current = parseInt(this.serviceDoc.lastSeq as string, 10);
|
const current = parseInt(this.serviceDoc.lastSeq as string, 10);
|
||||||
if (next < current) {
|
if (next < current) {
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import { serverSettingsDb } from "./dbs";
|
|||||||
import { ServerSettingsDocument } from "@hereconnect/types";
|
import { ServerSettingsDocument } from "@hereconnect/types";
|
||||||
|
|
||||||
function isType<N extends ServerSettingsDocument["name"]>(
|
function isType<N extends ServerSettingsDocument["name"]>(
|
||||||
doc: ServerSettingsDocument,
|
doc: ServerSettingsDocument | null | undefined,
|
||||||
name: N,
|
name: N,
|
||||||
): doc is ServerSettingsDocument & { name: N } {
|
): doc is ServerSettingsDocument & { name: N } {
|
||||||
return doc.name === name;
|
return !!(doc && doc.name === name);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const setupVapidDetails = async () => {
|
export const setupVapidDetails = async () => {
|
||||||
@@ -53,9 +53,10 @@ export const setupVapidDetails = async () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
changes.on("change", (change) => {
|
changes.on("change", (change) => {
|
||||||
if (!("doc" in change)) {
|
if (!change.doc) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isType(change.doc, "message_delivery_method_web_push_vapid")) {
|
if (isType(change.doc, "message_delivery_method_web_push_vapid")) {
|
||||||
console.log("update vapid details");
|
console.log("update vapid details");
|
||||||
webpush.setVapidDetails(
|
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