doc2vec 2.5.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -36,6 +36,10 @@ The primary goal is to prepare documentation content for Retrieval-Augmented Gen
36
36
  * **Vector Storage:** Supports storing chunks, metadata, and embeddings in:
37
37
  * **SQLite:** Using `better-sqlite3` and the `sqlite-vec` extension for efficient vector search.
38
38
  * **Qdrant:** A dedicated vector database, using the `@qdrant/js-client-rest`.
39
+ * **[Postgres Markdown Store](MARKDOWN_STORE.md):** Optionally stores the generated markdown for each crawled URL in a Postgres table. Useful for maintaining a searchable, raw-text copy of all documentation pages alongside the vector embeddings.
40
+ * **Automatic population:** On the first sync, all pages are force-processed (bypassing lastmod/ETag caching) to fully populate the store. Subsequent syncs only update rows when a change is detected.
41
+ * **404 cleanup:** Pages that return 404 are automatically removed from the store.
42
+ * **Shared table:** A single table (configurable name, default `markdown_pages`) is shared across all sources, with a `product_name` column to distinguish them.
39
43
  * **Multi-Layer Change Detection:** Four layers of change detection minimize unnecessary re-processing:
40
44
  1. **Sitemap `lastmod`:** When available, compares the sitemap's `<lastmod>` date against the stored value — skips without any HTTP request. Child URLs inherit `lastmod` from their most specific parent directory.
41
45
  2. **ETag via HEAD request:** For URLs without `lastmod`, sends a lightweight HEAD request and compares the ETag header against the stored value. Adaptive backoff prevents rate limiting (starts at 0ms delay, increases on 429 responses, decays on success).
@@ -43,6 +47,8 @@ The primary goal is to prepare documentation content for Retrieval-Augmented Gen
43
47
  4. **Embedding:** Only re-embeds chunks when content has actually changed.
44
48
 
45
49
  ETag and lastmod values are only stored when chunking and embedding succeed, ensuring failed pages are retried on the next run.
50
+
51
+ A `sync_complete` metadata flag tracks whether a full sync has ever completed successfully. If a sync is interrupted (process killed), the next run force-processes all pages regardless of lastmod/ETag values, ensuring no pages are permanently skipped.
46
52
  * **Incremental Updates:** For GitHub and Zendesk sources, tracks the last run date to only fetch new or updated issues/tickets.
47
53
  * **Cleanup:** Removes obsolete chunks from the database corresponding to pages or files that are no longer found during processing.
48
54
  * **Configuration:** Driven by a YAML configuration file (`config.yaml`) specifying sites, repositories, local directories, Zendesk instances, database types, metadata, and other parameters.
@@ -175,6 +181,7 @@ Configuration is managed through two files:
175
181
  For websites (`type: 'website'`):
176
182
  * `url`: The starting URL for crawling the documentation site.
177
183
  * `sitemap_url`: (Optional) URL to the site's XML sitemap for discovering additional pages not linked in navigation.
184
+ * `markdown_store`: (Optional) Set to `true` to store generated markdown in the Postgres markdown store (requires top-level `markdown_store` config). Defaults to `false`.
178
185
 
179
186
  For GitHub repositories (`type: 'github'`):
180
187
  * `repo`: Repository name in the format `'owner/repo'` (e.g., `'istio/istio'`).
@@ -230,6 +237,17 @@ Configuration is managed through two files:
230
237
  * `embedding.provider`: Provider for embeddings (`openai` or `azure`).
231
238
  * `embedding.dimension`: Embedding vector size. Defaults to `3072` when not set.
232
239
 
240
+ Optional Postgres markdown store (top-level):
241
+ * `markdown_store.connection_string`: (Optional) Full Postgres connection string (e.g., `'postgres://user:pass@host:5432/db'`). Takes priority over individual fields.
242
+ * `markdown_store.host`: (Optional) Postgres host.
243
+ * `markdown_store.port`: (Optional) Postgres port.
244
+ * `markdown_store.database`: (Optional) Postgres database name.
245
+ * `markdown_store.user`: (Optional) Postgres user.
246
+ * `markdown_store.password`: (Optional) Postgres password. Supports `${PG_PASSWORD}` env var substitution.
247
+ * `markdown_store.table_name`: (Optional) Table name. Defaults to `'markdown_pages'`.
248
+
249
+ When configured, website sources with `markdown_store: true` will store the generated markdown for each URL in this Postgres table. On the first sync, all pages are force-processed (bypassing lastmod/ETag skip logic) to populate the table. On subsequent syncs, only pages with detected changes get their rows updated.
250
+
233
251
  **Example (`config.yaml`):**
234
252
  ```yaml
235
253
  # Optional: Configure embedding provider
@@ -248,13 +266,25 @@ Configuration is managed through two files:
248
266
  # deployment_name: 'text-embedding-3-large'
249
267
  # api_version: '2024-10-21' # Optional
250
268
 
269
+ # Optional: Store generated markdown in Postgres
270
+ # markdown_store:
271
+ # connection_string: 'postgres://user:pass@host:5432/db'
272
+ # # OR use individual fields:
273
+ # # host: 'localhost'
274
+ # # port: 5432
275
+ # # database: 'doc2vec'
276
+ # # user: 'myuser'
277
+ # # password: '${PG_PASSWORD}'
278
+ # # table_name: 'markdown_pages' # Optional, defaults to 'markdown_pages'
279
+
251
280
  sources:
252
- # Website source example
281
+ # Website source example (with markdown store enabled)
253
282
  - type: 'website'
254
283
  product_name: 'argo'
255
284
  version: 'stable'
256
285
  url: 'https://argo-cd.readthedocs.io/en/stable/'
257
286
  sitemap_url: 'https://argo-cd.readthedocs.io/en/stable/sitemap.xml'
287
+ markdown_store: true # Store generated markdown in Postgres
258
288
  max_size: 1048576
259
289
  database_config:
260
290
  type: 'sqlite'
@@ -548,6 +578,20 @@ If you don't specify a config path, it will look for config.yaml in the current
548
578
 
549
579
  ## Recent Changes
550
580
 
581
+ ### Postgres Markdown Store
582
+ - **New feature:** Optionally store the generated markdown for each crawled website URL in a Postgres table (`url`, `product_name`, `markdown`, `updated_at`)
583
+ - **Top-level configuration:** Configure Postgres connection once at the top level via `connection_string` or individual `host`/`port`/`database`/`user`/`password` fields, with environment variable substitution support
584
+ - **Per-source opt-in:** Enable per website source with `markdown_store: true` (disabled by default)
585
+ - **First-sync force-processing:** When the markdown store is enabled, pages that aren't yet in the Postgres table bypass lastmod and ETag skip logic, ensuring all pages are stored on the first sync
586
+ - **Change-only updates:** On subsequent syncs, only pages with detected content changes (via lastmod/ETag) have their Postgres rows updated
587
+ - **404 cleanup:** Pages that return 404 during HEAD checks are automatically removed from the Postgres store
588
+
589
+ ### Incomplete Sync Recovery
590
+ - **New feature:** Tracks whether a full sync has ever completed successfully for each website source via a `sync_complete:<url_prefix>` metadata key
591
+ - **Interrupted sync handling:** If a sync is killed mid-crawl (process terminated, crash, etc.), the stored ETags/lastmods from the partial run would otherwise cause remaining pages to be skipped permanently. The `sync_complete` flag prevents this — when absent, all pages are force-processed regardless of caching signals
592
+ - **Gated on clean completion:** The flag is only set when the crawl completes without network errors (DNS failures, connection refused, timeouts). If the site is unreachable, the next run will force a full sync again
593
+ - **Scoped per source:** Each website source has its own `sync_complete` key based on its URL prefix. Changing the source URL naturally triggers a new full sync
594
+
551
595
  ### Multi-Layer Change Detection for Websites
552
596
  - **Sitemap `lastmod` support:** When a sitemap includes `<lastmod>` dates, pages are skipped entirely if the date hasn't changed — no HEAD request, no Puppeteer load, no chunking. One sitemap fetch replaces hundreds of individual HEAD requests.
553
597
  - **`lastmod` inheritance:** Child URLs without their own `<lastmod>` inherit from the most specific parent directory URL in the sitemap (e.g., `/docs/2.10.x/reference/cli/` inherits from `/docs/2.10.x/`).
@@ -218,6 +218,7 @@ class ContentProcessor {
218
218
  const queue = [baseUrl];
219
219
  const brokenLinks = [];
220
220
  const brokenLinkKeys = new Set();
221
+ const notFoundUrls = new Set();
221
222
  const referrers = new Map();
222
223
  const addReferrer = (targetUrl, sourceUrl) => {
223
224
  const existing = referrers.get(targetUrl);
@@ -236,7 +237,7 @@ class ContentProcessor {
236
237
  brokenLinks.push({ source: sourceUrl, target: targetUrl });
237
238
  };
238
239
  addReferrer(baseUrl, baseUrl);
239
- const { knownUrls, etagStore, lastmodStore } = options ?? {};
240
+ const { knownUrls, etagStore, lastmodStore, markdownStoreUrls, forceFullSync } = options ?? {};
240
241
  // Pre-seed queue with previously-known URLs so link discovery isn't lost
241
242
  // when pages are skipped via ETag/lastmod matching on re-runs
242
243
  if (knownUrls && knownUrls.size > 0) {
@@ -422,14 +423,26 @@ class ContentProcessor {
422
423
  // this URL and it matches the stored value, skip entirely. This avoids
423
424
  // both HEAD requests and full page loads — the sitemap is fetched once
424
425
  // for the entire crawl.
426
+ // Bypass this skip when:
427
+ // - forceFullSync is true (previous sync never completed)
428
+ // - markdownStoreUrls is set and the URL is not in it (markdown store gap)
425
429
  const urlLastmod = sitemapLastmod.get(url);
430
+ const urlInMarkdownStore = !markdownStoreUrls || markdownStoreUrls.has(url);
426
431
  if (urlLastmod && lastmodStore) {
427
432
  const storedLastmod = await lastmodStore.get(url);
428
433
  if (storedLastmod && storedLastmod === urlLastmod) {
429
- logger.info(`Lastmod unchanged (${urlLastmod}), skipping: ${url}`);
430
- visitedUrls.add(url);
431
- lastmodSkippedCount++;
432
- continue;
434
+ if (forceFullSync) {
435
+ logger.info(`Lastmod unchanged but full sync not yet completed, forcing processing: ${url}`);
436
+ }
437
+ else if (!urlInMarkdownStore) {
438
+ logger.info(`Lastmod unchanged but URL not in markdown store, forcing processing: ${url}`);
439
+ }
440
+ else {
441
+ logger.info(`Lastmod unchanged (${urlLastmod}), skipping: ${url}`);
442
+ visitedUrls.add(url);
443
+ lastmodSkippedCount++;
444
+ continue;
445
+ }
433
446
  }
434
447
  else if (storedLastmod) {
435
448
  logger.debug(`Lastmod changed (stored: ${storedLastmod}, sitemap: ${urlLastmod}), processing: ${url}`);
@@ -494,10 +507,21 @@ class ContentProcessor {
494
507
  decayBackoff();
495
508
  const result = await checkEtag(headResponse.headers['etag']);
496
509
  if (result === 'skip') {
497
- logger.info(`ETag unchanged, skipping: ${url}`);
498
- visitedUrls.add(url);
499
- etagSkippedCount++;
500
- continue;
510
+ // Bypass ETag skip when:
511
+ // - forceFullSync is true (previous sync never completed)
512
+ // - markdownStoreUrls is set and URL is not in it
513
+ if (forceFullSync) {
514
+ logger.info(`ETag unchanged but full sync not yet completed, forcing processing: ${url}`);
515
+ }
516
+ else if (!urlInMarkdownStore) {
517
+ logger.info(`ETag unchanged but URL not in markdown store, forcing processing: ${url}`);
518
+ }
519
+ else {
520
+ logger.info(`ETag unchanged, skipping: ${url}`);
521
+ visitedUrls.add(url);
522
+ etagSkippedCount++;
523
+ continue;
524
+ }
501
525
  }
502
526
  else if (result === 'no-etag') {
503
527
  consecutiveMissingEtag++;
@@ -524,10 +548,18 @@ class ContentProcessor {
524
548
  decayBackoff();
525
549
  const result = await checkEtag(retryResponse.headers['etag']);
526
550
  if (result === 'skip') {
527
- logger.info(`ETag unchanged, skipping: ${url}`);
528
- visitedUrls.add(url);
529
- etagSkippedCount++;
530
- continue;
551
+ if (forceFullSync) {
552
+ logger.info(`ETag unchanged but full sync not yet completed, forcing processing: ${url}`);
553
+ }
554
+ else if (!urlInMarkdownStore) {
555
+ logger.info(`ETag unchanged but URL not in markdown store, forcing processing: ${url}`);
556
+ }
557
+ else {
558
+ logger.info(`ETag unchanged, skipping: ${url}`);
559
+ visitedUrls.add(url);
560
+ etagSkippedCount++;
561
+ continue;
562
+ }
531
563
  }
532
564
  // 'process' or 'no-etag' — fall through to full processing
533
565
  }
@@ -542,6 +574,9 @@ class ContentProcessor {
542
574
  // 405 (Method Not Allowed) is excluded because the server
543
575
  // may not support HEAD but the page may still exist via GET.
544
576
  logger.debug(`HEAD returned ${headStatus} for ${url}, skipping`);
577
+ if (headStatus === 404) {
578
+ notFoundUrls.add(url);
579
+ }
545
580
  skippedCount++;
546
581
  continue;
547
582
  }
@@ -707,7 +742,7 @@ class ContentProcessor {
707
742
  if (hasNetworkErrors) {
708
743
  logger.warn('Network errors were encountered during crawling. Cleanup may be skipped to avoid removing valid chunks.');
709
744
  }
710
- return { hasNetworkErrors, brokenLinks };
745
+ return { hasNetworkErrors, brokenLinks, notFoundUrls };
711
746
  }
712
747
  /**
713
748
  * Detects Chrome DevTools Protocol-level errors that indicate the browser
package/dist/doc2vec.js CHANGED
@@ -53,6 +53,7 @@ const logger_1 = require("./logger");
53
53
  const utils_1 = require("./utils");
54
54
  const database_1 = require("./database");
55
55
  const content_processor_1 = require("./content-processor");
56
+ const markdown_store_1 = require("./markdown-store");
56
57
  const GITHUB_TOKEN = process.env.GITHUB_PERSONAL_ACCESS_TOKEN;
57
58
  const execAsync = (0, util_1.promisify)(child_process_1.exec);
58
59
  dotenv.config();
@@ -103,6 +104,10 @@ class Doc2Vec {
103
104
  this.logger.info(`Using OpenAI with model: ${openaiModel} (${this.embeddingDimension} dimensions)`);
104
105
  }
105
106
  this.contentProcessor = new content_processor_1.ContentProcessor(this.logger);
107
+ // Initialize Postgres markdown store if configured
108
+ if (this.config.markdown_store) {
109
+ this.markdownStore = new markdown_store_1.MarkdownStore(this.config.markdown_store, this.logger);
110
+ }
106
111
  }
107
112
  loadConfig(configPath) {
108
113
  try {
@@ -161,6 +166,10 @@ class Doc2Vec {
161
166
  return parsedValue;
162
167
  }
163
168
  async run() {
169
+ // Initialize Postgres markdown store table if configured
170
+ if (this.markdownStore) {
171
+ await this.markdownStore.init();
172
+ }
164
173
  this.logger.section('PROCESSING SOURCES');
165
174
  for (const sourceConfig of this.config.sources) {
166
175
  const sourceLogger = this.logger.child(`source:${sourceConfig.product_name}`);
@@ -184,6 +193,10 @@ class Doc2Vec {
184
193
  sourceLogger.error(`Unknown source type: ${sourceConfig.type}`);
185
194
  }
186
195
  }
196
+ // Close the Postgres markdown store connection pool
197
+ if (this.markdownStore) {
198
+ await this.markdownStore.close();
199
+ }
187
200
  this.logger.section('PROCESSING COMPLETE');
188
201
  }
189
202
  async fetchAndProcessGitHubIssues(repo, sourceConfig, dbConnection, logger) {
@@ -447,6 +460,28 @@ class Doc2Vec {
447
460
  await database_1.DatabaseManager.setMetadataValue(dbConnection, `lastmod:${url}`, lastmod, logger, this.embeddingDimension);
448
461
  },
449
462
  };
463
+ // If Postgres markdown store is enabled for this source, load URLs that
464
+ // already have markdown stored. URLs NOT in this set will bypass
465
+ // lastmod/ETag skip logic so that the store is fully populated on the
466
+ // first sync.
467
+ const useMarkdownStore = config.markdown_store === true && this.markdownStore != null;
468
+ let markdownStoreUrls;
469
+ if (useMarkdownStore) {
470
+ markdownStoreUrls = await this.markdownStore.getUrlsWithMarkdown(urlPrefix);
471
+ logger.info(`Markdown store: ${markdownStoreUrls.size} URLs already stored for prefix ${urlPrefix}`);
472
+ }
473
+ // Check whether a full sync has ever completed successfully for this
474
+ // source. If not, bypass all lastmod/ETag skip logic so that every
475
+ // page is processed at least once. This handles the case where a
476
+ // previous sync was interrupted (killed mid-crawl) — the stored
477
+ // ETags/lastmods from the partial run would otherwise cause the
478
+ // remaining pages to be skipped indefinitely.
479
+ const syncCompleteKey = `sync_complete:${urlPrefix}`;
480
+ const syncCompleteValue = await database_1.DatabaseManager.getMetadataValue(dbConnection, syncCompleteKey, undefined, logger);
481
+ const forceFullSync = syncCompleteValue !== 'true';
482
+ if (forceFullSync) {
483
+ logger.info('Full sync has not yet completed for this source — forcing processing of all pages (bypassing lastmod/ETag skip)');
484
+ }
450
485
  const crawlResult = await this.contentProcessor.crawlWebsite(config.url, config, async (url, content) => {
451
486
  visitedUrls.add(url);
452
487
  logger.info(`Processing content from ${url} (${content.length} chars markdown)`);
@@ -457,21 +492,50 @@ class Doc2Vec {
457
492
  validChunkIds.add(chunk.metadata.chunk_id);
458
493
  }
459
494
  await this.processChunksForUrl(chunks, url, dbConnection, logger);
495
+ // Store the generated markdown in Postgres
496
+ if (useMarkdownStore) {
497
+ try {
498
+ await this.markdownStore.upsertMarkdown(url, config.product_name, content);
499
+ }
500
+ catch (pgError) {
501
+ logger.error(`Failed to store markdown in Postgres for ${url}:`, pgError);
502
+ }
503
+ }
460
504
  return true;
461
505
  }
462
506
  catch (error) {
463
507
  logger.error(`Error during chunking or embedding for ${url}:`, error);
464
508
  return false;
465
509
  }
466
- }, logger, visitedUrls, { knownUrls, etagStore, lastmodStore });
510
+ }, logger, visitedUrls, { knownUrls, etagStore, lastmodStore, markdownStoreUrls, forceFullSync });
467
511
  this.recordBrokenLinks(config.url, crawlResult.brokenLinks);
468
512
  this.writeBrokenLinksReport();
469
513
  logger.info(`Found ${validChunkIds.size} valid chunks across processed pages for ${config.url}`);
470
514
  logger.section('CLEANUP');
515
+ // Remove 404 URLs from the Postgres markdown store
516
+ if (useMarkdownStore && crawlResult.notFoundUrls && crawlResult.notFoundUrls.size > 0) {
517
+ logger.info(`Removing ${crawlResult.notFoundUrls.size} not-found URLs from markdown store`);
518
+ for (const url of crawlResult.notFoundUrls) {
519
+ try {
520
+ await this.markdownStore.deleteMarkdown(url);
521
+ }
522
+ catch (pgError) {
523
+ logger.error(`Failed to delete markdown from Postgres for ${url}:`, pgError);
524
+ }
525
+ }
526
+ }
471
527
  if (crawlResult.hasNetworkErrors) {
472
528
  logger.warn('Skipping cleanup due to network errors encountered during crawling. This prevents removal of valid chunks when the site is temporarily unreachable.');
473
529
  }
474
530
  else {
531
+ // Mark this source as having completed a full sync. On subsequent
532
+ // runs, lastmod/ETag skip logic will function normally. If the
533
+ // process is killed before reaching this point, the flag stays
534
+ // unset and the next run will force-process all pages again.
535
+ if (forceFullSync) {
536
+ await database_1.DatabaseManager.setMetadataValue(dbConnection, syncCompleteKey, 'true', logger, this.embeddingDimension);
537
+ logger.info('Full sync completed successfully — subsequent runs will use normal caching');
538
+ }
475
539
  if (dbConnection.type === 'sqlite') {
476
540
  logger.info(`Running SQLite cleanup for ${urlPrefix}`);
477
541
  database_1.DatabaseManager.removeObsoleteChunksSQLite(dbConnection.db, visitedUrls, urlPrefix, logger);
@@ -0,0 +1,105 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MarkdownStore = void 0;
4
+ const pg_1 = require("pg");
5
+ /**
6
+ * Stores generated markdown for website pages in a Postgres table.
7
+ *
8
+ * Table schema (single shared table, default name `markdown_pages`):
9
+ * url TEXT PRIMARY KEY
10
+ * product_name TEXT NOT NULL
11
+ * markdown TEXT NOT NULL
12
+ * updated_at TIMESTAMPTZ DEFAULT NOW()
13
+ *
14
+ * When a website source has `markdown_store: true`, the crawl loop uses this
15
+ * store to decide whether to force-process a page that would otherwise be
16
+ * skipped by lastmod / ETag caching. If the URL is not yet in Postgres the
17
+ * page is processed regardless of cache signals, ensuring the table is fully
18
+ * populated after the first sync. On subsequent syncs only pages with
19
+ * detected changes are updated.
20
+ */
21
+ class MarkdownStore {
22
+ constructor(config, logger) {
23
+ this.logger = logger.child('markdown-store');
24
+ this.tableName = config.table_name ?? 'markdown_pages';
25
+ const poolConfig = {};
26
+ if (config.connection_string) {
27
+ poolConfig.connectionString = config.connection_string;
28
+ }
29
+ else {
30
+ if (config.host)
31
+ poolConfig.host = config.host;
32
+ if (config.port)
33
+ poolConfig.port = config.port;
34
+ if (config.database)
35
+ poolConfig.database = config.database;
36
+ if (config.user)
37
+ poolConfig.user = config.user;
38
+ if (config.password)
39
+ poolConfig.password = config.password;
40
+ }
41
+ this.pool = new pg_1.Pool(poolConfig);
42
+ }
43
+ /**
44
+ * Create the markdown table if it doesn't already exist.
45
+ */
46
+ async init() {
47
+ const query = `
48
+ CREATE TABLE IF NOT EXISTS ${this.escapeIdentifier(this.tableName)} (
49
+ url TEXT PRIMARY KEY,
50
+ product_name TEXT NOT NULL,
51
+ markdown TEXT NOT NULL,
52
+ updated_at TIMESTAMPTZ DEFAULT NOW()
53
+ );
54
+ `;
55
+ await this.pool.query(query);
56
+ this.logger.info(`Initialized Postgres markdown store (table: ${this.tableName})`);
57
+ }
58
+ /**
59
+ * Return the set of URLs that already have markdown stored for a given URL
60
+ * prefix (e.g., "https://istio.io/latest/docs/"). This is called once
61
+ * before the crawl starts so the crawler can decide which pages need
62
+ * force-processing.
63
+ */
64
+ async getUrlsWithMarkdown(urlPrefix) {
65
+ const result = await this.pool.query(`SELECT url FROM ${this.escapeIdentifier(this.tableName)} WHERE url LIKE $1`, [urlPrefix + '%']);
66
+ return new Set(result.rows.map((row) => row.url));
67
+ }
68
+ /**
69
+ * Insert or update the markdown for a URL. Called after a page is
70
+ * successfully processed (fetched + converted to markdown).
71
+ */
72
+ async upsertMarkdown(url, productName, markdown) {
73
+ const query = `
74
+ INSERT INTO ${this.escapeIdentifier(this.tableName)} (url, product_name, markdown, updated_at)
75
+ VALUES ($1, $2, $3, NOW())
76
+ ON CONFLICT (url) DO UPDATE SET
77
+ product_name = EXCLUDED.product_name,
78
+ markdown = EXCLUDED.markdown,
79
+ updated_at = NOW();
80
+ `;
81
+ await this.pool.query(query, [url, productName, markdown]);
82
+ }
83
+ /**
84
+ * Remove a URL from the store (e.g., when a HEAD request returns 404).
85
+ */
86
+ async deleteMarkdown(url) {
87
+ await this.pool.query(`DELETE FROM ${this.escapeIdentifier(this.tableName)} WHERE url = $1`, [url]);
88
+ }
89
+ /**
90
+ * Close the connection pool. Should be called once after all sources have
91
+ * been processed.
92
+ */
93
+ async close() {
94
+ await this.pool.end();
95
+ this.logger.info('Postgres markdown store connection pool closed');
96
+ }
97
+ /**
98
+ * Escape a SQL identifier (table name) to prevent injection.
99
+ * Uses double-quoting per the SQL standard.
100
+ */
101
+ escapeIdentifier(identifier) {
102
+ return '"' + identifier.replace(/"/g, '""') + '"';
103
+ }
104
+ }
105
+ exports.MarkdownStore = MarkdownStore;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doc2vec",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "type": "commonjs",
5
5
  "description": "",
6
6
  "main": "dist/doc2vec.js",
@@ -43,6 +43,7 @@
43
43
  "mammoth": "^1.11.0",
44
44
  "openai": "^4.20.1",
45
45
  "pdfjs-dist": "^5.3.31",
46
+ "pg": "^8.18.0",
46
47
  "puppeteer": "^24.1.1",
47
48
  "sanitize-html": "^2.11.0",
48
49
  "sqlite-vec": "0.1.7-alpha.2",
@@ -56,6 +57,7 @@
56
57
  "@types/js-yaml": "^4.0.9",
57
58
  "@types/jsdom": "^21.1.7",
58
59
  "@types/node": "^20.10.0",
60
+ "@types/pg": "^8.16.0",
59
61
  "@types/sanitize-html": "^2.9.5",
60
62
  "@types/turndown": "^5.0.4",
61
63
  "@vitest/coverage-v8": "^4.0.18",