nuxt-ai-ready 1.7.5 → 1.7.7

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.
Files changed (42) hide show
  1. package/dist/chunks/agent-skills.mjs +1 -1
  2. package/dist/chunks/prerender.mjs +3 -9
  3. package/dist/module.d.mts +2 -10
  4. package/dist/module.json +1 -1
  5. package/dist/module.mjs +1 -1
  6. package/dist/runtime/llms-txt-format.d.ts +2 -2
  7. package/dist/runtime/llms-txt-format.js +8 -4
  8. package/dist/runtime/llms-txt-utils.js +6 -4
  9. package/dist/runtime/server/db/drizzle/index.d.ts +1 -1
  10. package/dist/runtime/server/db/drizzle/index.js +2 -0
  11. package/dist/runtime/server/db/drizzle/queries.d.ts +11 -1
  12. package/dist/runtime/server/db/drizzle/queries.js +129 -42
  13. package/dist/runtime/server/db/drizzle/raw.d.ts +8 -0
  14. package/dist/runtime/server/db/drizzle/raw.js +49 -0
  15. package/dist/runtime/server/db/queries.d.ts +26 -3
  16. package/dist/runtime/server/db/queries.js +121 -33
  17. package/dist/runtime/server/db/schema/postgres.d.ts +34 -0
  18. package/dist/runtime/server/db/schema/postgres.js +2 -1
  19. package/dist/runtime/server/db/schema/sqlite.d.ts +38 -0
  20. package/dist/runtime/server/db/schema/sqlite.js +2 -1
  21. package/dist/runtime/server/db/schema-sql.d.ts +1 -1
  22. package/dist/runtime/server/db/schema-sql.js +11 -3
  23. package/dist/runtime/server/db/shared.d.ts +8 -5
  24. package/dist/runtime/server/db/shared.js +61 -10
  25. package/dist/runtime/server/middleware/markdown.js +25 -21
  26. package/dist/runtime/server/plugins/sitemap-seeder.js +4 -8
  27. package/dist/runtime/server/routes/__ai-ready/status.get.js +2 -1
  28. package/dist/runtime/server/utils/cron-plan.d.ts +15 -0
  29. package/dist/runtime/server/utils/cron-plan.js +13 -0
  30. package/dist/runtime/server/utils/i18n.d.ts +6 -38
  31. package/dist/runtime/server/utils/i18n.js +13 -29
  32. package/dist/runtime/server/utils/indexPage.js +5 -6
  33. package/dist/runtime/server/utils/link-header.d.ts +2 -2
  34. package/dist/runtime/server/utils/link-header.js +9 -5
  35. package/dist/runtime/server/utils/runCron.d.ts +1 -0
  36. package/dist/runtime/server/utils/runCron.js +52 -22
  37. package/dist/runtime/server/utils/sitemap-crawl-state.d.ts +19 -0
  38. package/dist/runtime/server/utils/sitemap-crawl-state.js +51 -0
  39. package/dist/runtime/server/utils/sitemap.d.ts +34 -0
  40. package/dist/runtime/server/utils/sitemap.js +148 -15
  41. package/dist/shared/{nuxt-ai-ready.CDEc2X7T.mjs → nuxt-ai-ready.DJimiOHi.mjs} +67 -16
  42. package/package.json +17 -17
@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto';
2
2
  import { realpath, readFile } from 'node:fs/promises';
3
3
  import { resolve, isAbsolute, relative, sep } from 'node:path';
4
4
  import { parseDocument } from 'yaml';
5
- import { A as AGENT_SKILLS_SCHEMA } from '../shared/nuxt-ai-ready.CDEc2X7T.mjs';
5
+ import { A as AGENT_SKILLS_SCHEMA } from '../shared/nuxt-ai-ready.DJimiOHi.mjs';
6
6
  import 'node:module';
7
7
  import '@nuxt/kit';
8
8
  import 'defu';
@@ -2,9 +2,10 @@ import { mkdir, writeFile, appendFile, stat, readdir } from 'node:fs/promises';
2
2
  import { join, dirname, relative, resolve } from 'node:path';
3
3
  import { useNuxt, hasNuxtModule, resolveFiles } from '@nuxt/kit';
4
4
  import { colorize } from 'consola/utils';
5
+ import { resolveLocaleFromRoute } from 'nuxtseo-shared/i18n-runtime';
5
6
  import { collectSitemap } from 'sitemapd/parse';
6
7
  import { withLeadingSlash, withBase, joinURL } from 'ufo';
7
- import { l as logger, M as MARKDOWN_LINK_AVAILABILITY_FILE } from '../shared/nuxt-ai-ready.CDEc2X7T.mjs';
8
+ import { l as logger, M as MARKDOWN_LINK_AVAILABILITY_FILE } from '../shared/nuxt-ai-ready.DJimiOHi.mjs';
8
9
  import { normalizePagePath, toMarkdownPath } from '../../dist/runtime/markdown-path.js';
9
10
  import { toLogicalRoute, toDeployedRoute } from '../../dist/runtime/route-path.js';
10
11
  import { initSchema, computeContentHash, insertPage, queryAllPages, exportDbDump } from '../../dist/runtime/server/db/shared.js';
@@ -151,14 +152,7 @@ function flattenHeadings(headings) {
151
152
  return (headings || []).map((h) => Object.entries(h).map(([tag, text]) => `${tag}:${text}`).join("")).join("|");
152
153
  }
153
154
  function resolveRouteLocale(route, i18n) {
154
- if (!i18n)
155
- return "";
156
- if (i18n.strategy === "no_prefix")
157
- return i18n.defaultLocale;
158
- const segments = route.split("/").filter(Boolean);
159
- const first = segments[0];
160
- const matched = first ? i18n.locales.find((l) => l.code === first) : void 0;
161
- return matched ? matched.code : i18n.defaultLocale;
155
+ return i18n ? resolveLocaleFromRoute(route, i18n).locale : "";
162
156
  }
163
157
  async function processMarkdownRoute(state, nuxt, route, parsed, lastmod, options) {
164
158
  route = normalizePagePath(route);
package/dist/module.d.mts CHANGED
@@ -1,6 +1,7 @@
1
1
  import * as _nuxt_schema from '@nuxt/schema';
2
2
  import { LlmsTxtConfig, ContentNegotiationPolicy, ModuleOptions } from '../dist/runtime/types.js';
3
3
  export { AgentSkillConfig, AgentSkillsConfig, AgentSkillsIndex, AgentSkillsIndexEntry, ApiCatalogConfig, ApiCatalogEntry, ApiCatalogLinkTarget, ApiCatalogLinks, ExternalAgentSkillConfig, LocalAgentSkillConfig, McpServerCardConfig, ModuleOptions } from '../dist/runtime/types.js';
4
+ import { RuntimeI18nConfig } from 'nuxtseo-shared/i18n-runtime';
4
5
  import { ResolvedWebMcpToolsConfig } from '../dist/runtime/site-tool-config.js';
5
6
  export { GetPageMarkdownToolOptions, ListPagesToolOptions, McpSiteToolAttachmentOptions, SearchPagesToolOptions, SiteToolOptions, SiteToolsConfig, WebMcpSiteToolAttachmentOptions } from '../dist/runtime/site-tool-config.js';
6
7
  import { AiCatalog } from '../dist/runtime/server/utils/discovery-response.js';
@@ -86,16 +87,7 @@ interface ModulePublicRuntimeConfig {
86
87
  runtimeSyncSecret?: string;
87
88
  indexNow?: string;
88
89
  sitemapPrerendered: boolean;
89
- i18n?: {
90
- defaultLocale: string;
91
- strategy: 'no_prefix' | 'prefix_except_default' | 'prefix' | 'prefix_and_default';
92
- locales: Array<{
93
- code: string;
94
- hreflang: string;
95
- name?: string;
96
- nativeName?: string;
97
- }>;
98
- } | null;
90
+ i18n?: RuntimeI18nConfig | null;
99
91
  ftsTokenizer?: string;
100
92
  aiCatalog?: {
101
93
  cacheMaxAge: number;
package/dist/module.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "nuxt": ">=4.0.0"
5
5
  },
6
6
  "configKey": "aiReady",
7
- "version": "1.7.5",
7
+ "version": "1.7.7",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "1.0.3",
10
10
  "unbuild": "3.6.1"
package/dist/module.mjs CHANGED
@@ -7,7 +7,7 @@ import 'defu';
7
7
  import 'nuxt-site-config/kit';
8
8
  import 'nuxtseo-shared/kit';
9
9
  import 'pkg-types';
10
- export { m as default } from './shared/nuxt-ai-ready.CDEc2X7T.mjs';
10
+ export { m as default } from './shared/nuxt-ai-ready.DJimiOHi.mjs';
11
11
  import '../dist/runtime/server/utils/discovery-response.js';
12
12
  import 'node:url';
13
13
  import 'pathe';
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Pure formatting functions for llms.txt - no runtime dependencies
3
3
  */
4
- import type { RuntimeI18nConfig } from './server/utils/i18n.js';
4
+ import type { RuntimeI18nConfig, RuntimeRouteContext } from './server/utils/i18n.js';
5
5
  import type { LlmsTxtConfig } from './types.js';
6
- export declare function formatAvailableLanguagesSection(i18n: RuntimeI18nConfig, pageCounts: Map<string, number>, resolveHref?: (pathname: string) => string): string[];
6
+ export declare function formatAvailableLanguagesSection(i18n: RuntimeI18nConfig, pageCounts: Map<string, number>, resolveHref?: (pathname: string) => string, routeContext?: RuntimeRouteContext): string[];
7
7
  interface LlmsTxtPageLink {
8
8
  pathname: string;
9
9
  href?: string;
@@ -1,4 +1,4 @@
1
- import { localePath } from "./server/utils/i18n.js";
1
+ import { computeLocaleAlternates, localePath, resolveLocaleAlternateUrl } from "./server/utils/i18n.js";
2
2
  const RE_INLINE_WHITESPACE = /\s+/g;
3
3
  const RE_LINK_TITLE_BRACKET = /[[\]]/g;
4
4
  const RE_LINK_HREF_UNSAFE = /[\s()]/g;
@@ -62,17 +62,21 @@ function normalizeOptionalSections(sections) {
62
62
  function normalizeRequiredSections(sections) {
63
63
  return sections.filter((section) => section.links?.length).map(normalizeSection);
64
64
  }
65
- export function formatAvailableLanguagesSection(i18n, pageCounts, resolveHref = (pathname) => pathname) {
65
+ export function formatAvailableLanguagesSection(i18n, pageCounts, resolveHref = (pathname) => pathname, routeContext = {}) {
66
66
  const lines = ["## Available Languages on Website", ""];
67
+ const rootAlternates = new Map(computeLocaleAlternates("/", i18n, routeContext).map((alternate) => [alternate.code, alternate]));
67
68
  for (const locale of i18n.locales) {
68
69
  const isDefault = locale.code === i18n.defaultLocale;
69
- const prefix = localePath("/", locale.code, i18n);
70
+ const alternate = rootAlternates.get(locale.code);
71
+ const prefix = alternate?.path ?? localePath("/", locale.code, i18n, routeContext);
72
+ const resolvedPath = resolveHref(prefix);
73
+ const href = alternate ? resolveLocaleAlternateUrl({ ...alternate, path: resolvedPath }, (candidate) => candidate) : resolvedPath;
70
74
  const count = pageCounts.get(locale.code) ?? 0;
71
75
  const display = locale.nativeName ? `${locale.nativeName} (${locale.code})` : locale.name ? `${locale.name} (${locale.code})` : locale.code;
72
76
  const suffix = isDefault ? "content included below" : "visit this language for content";
73
77
  lines.push(normalizeLink({
74
78
  title: display,
75
- href: resolveHref(prefix),
79
+ href,
76
80
  description: `${count} pages; ${suffix}.`
77
81
  }));
78
82
  }
@@ -1,4 +1,5 @@
1
1
  import { decodePath } from "ufo";
2
+ import { getRequestURL } from "#nuxtseo/h3";
2
3
  import { useRuntimeConfig } from "#nuxtseo/nitro";
3
4
  import { getSiteConfig } from "#site-config/server/composables/getSiteConfig";
4
5
  import { withSiteTrailingSlash, withSiteUrl } from "#site-config/server/composables/utils";
@@ -122,6 +123,7 @@ export async function buildLlmsTxt(event) {
122
123
  const siteConfig = getSiteConfig(event);
123
124
  const llmsTxtConfig = aiReadyConfig.llmsTxt;
124
125
  const i18n = aiReadyConfig.i18n;
126
+ const i18nContext = { host: getRequestURL(event).host };
125
127
  const baseURL = runtimeConfig.app.baseURL;
126
128
  const resolvePath = (path) => withSiteTrailingSlash(event, toDeployedRoute(path, baseURL));
127
129
  const resolveUrl = (path) => withSiteUrl(event, toDeployedRoute(path, baseURL));
@@ -192,7 +194,7 @@ Canonical Origin: ${canonicalSiteUrl}`);
192
194
  const other = [];
193
195
  for (const pathname of sitemapPaths) {
194
196
  if (!seenPaths.has(pathname) && !errorSet.has(pathname)) {
195
- const locale = i18n ? resolveLocaleFromRoute(pathname, i18n).locale : void 0;
197
+ const locale = i18n ? resolveLocaleFromRoute(pathname, i18n, i18nContext).locale : void 0;
196
198
  other.push({ pathname, locale });
197
199
  seenPaths.add(pathname);
198
200
  }
@@ -215,16 +217,16 @@ Canonical Origin: ${canonicalSiteUrl}`);
215
217
  const pageCounts = /* @__PURE__ */ new Map();
216
218
  for (const locale of i18n.locales) pageCounts.set(locale.code, 0);
217
219
  for (const p of [...prerendered, ...other]) {
218
- const code = p.locale || resolveLocaleFromRoute(p.pathname, i18n).locale;
220
+ const code = p.locale || resolveLocaleFromRoute(p.pathname, i18n, i18nContext).locale;
219
221
  pageCounts.set(code, (pageCounts.get(code) ?? 0) + 1);
220
222
  }
221
- parts.push(...formatAvailableLanguagesSection(i18n, pageCounts, resolvePageHref));
223
+ parts.push(...formatAvailableLanguagesSection(i18n, pageCounts, resolvePageHref, i18nContext));
222
224
  parts.push("");
223
225
  }
224
226
  const isDefaultLocale = (item) => {
225
227
  if (!i18n)
226
228
  return true;
227
- const code = item.locale || resolveLocaleFromRoute(item.pathname, i18n).locale;
229
+ const code = item.locale || resolveLocaleFromRoute(item.pathname, i18n, i18nContext).locale;
228
230
  return code === i18n.defaultLocale;
229
231
  };
230
232
  const filteredPrerendered = i18n ? prerendered.filter(isDefaultLocale) : prerendered;
@@ -4,7 +4,7 @@
4
4
  export * from '#ai-ready-virtual/db-schema.mjs';
5
5
  export { closeDrizzle, useDrizzle } from './client.js';
6
6
  export type { DatabaseDialect, DrizzleDatabase } from './client.js';
7
- export { completeCronRun, countPages, countPagesNeedingIndexNowSync, deleteInfoValue, deletePage, getAllPages, getContentHashes, getInfoValue, getNextSitemapToCrawl, getPageByRoute, getPageLastmods, getPagesNeedingIndexNowSync, getPendingPages, getRecentCronRuns, getSitemapStatus, initSchema, markIndexNowSynced, markPageIndexed, markRoutesPending, markSitemapCrawled, markSitemapError, resetSitemapErrors, searchPages, seedRoutes, setInfoValue, startCronRun, syncSitemaps, upsertPage, } from './queries.js';
7
+ export { completeCronRun, countPages, countPagesNeedingIndexNowSync, deleteInfoValue, deletePage, getAllPages, getContentHashes, getInfoValue, getNextSitemapToCrawl, getPageByRoute, getPageLastmods, getPagesNeedingIndexNowSync, getPendingPages, getRecentCronRuns, getSitemapStatus, initSchema, markIndexNowSynced, markPageIndexed, markRoutesPending, markSitemapCrawled, markSitemapCrawlPartial, markSitemapError, markSitemapSeeded, resetSitemapErrors, searchPages, seedRoutes, setInfoValue, startCronRun, syncSitemaps, upsertPage, } from './queries.js';
8
8
  export type { CronRunOutput, PageInput, PageMetaOutput, PageOutput, SitemapOutput, } from './queries.js';
9
9
  export { useRawDb } from './raw.js';
10
10
  export type { RawExecutor } from './raw.js';
@@ -21,7 +21,9 @@ export {
21
21
  markPageIndexed,
22
22
  markRoutesPending,
23
23
  markSitemapCrawled,
24
+ markSitemapCrawlPartial,
24
25
  markSitemapError,
26
+ markSitemapSeeded,
25
27
  resetSitemapErrors,
26
28
  searchPages,
27
29
  seedRoutes,
@@ -1,4 +1,5 @@
1
1
  import type { H3Event } from '#nuxtseo/h3';
2
+ import type { SitemapCrawlState } from '../../utils/sitemap-crawl-state.js';
2
3
  export interface PageInput {
3
4
  route: string;
4
5
  title: string;
@@ -155,6 +156,10 @@ export interface SitemapOutput {
155
156
  urlCount: number;
156
157
  errorCount: number;
157
158
  lastError: string | null;
159
+ continuing: boolean;
160
+ }
161
+ interface SitemapEntry extends Omit<SitemapOutput, 'continuing'> {
162
+ crawlState: SitemapCrawlState | null;
158
163
  }
159
164
  /**
160
165
  * Sync sitemaps from config
@@ -169,11 +174,15 @@ export declare function syncSitemaps(event: H3Event | undefined, sitemapList: Ar
169
174
  /**
170
175
  * Get next sitemap to crawl
171
176
  */
172
- export declare function getNextSitemapToCrawl(event: H3Event | undefined, minIntervalMinutes?: number): Promise<SitemapOutput | null>;
177
+ export declare function getNextSitemapToCrawl(event: H3Event | undefined, minIntervalMinutes?: number): Promise<SitemapEntry | null>;
173
178
  /**
174
179
  * Mark sitemap as crawled
175
180
  */
176
181
  export declare function markSitemapCrawled(event: H3Event | undefined, name: string, urlCount: number): Promise<void>;
182
+ /** Record a deferred sitemap hook seed only when no crawl is in progress. */
183
+ export declare function markSitemapSeeded(event: H3Event | undefined, name: string, urlCount: number, expectedLastCrawledAt: number | null): Promise<void>;
184
+ /** Persist a resumable sitemap crawl without incrementing its error budget. */
185
+ export declare function markSitemapCrawlPartial(event: H3Event | undefined, name: string, state: SitemapCrawlState): Promise<void>;
177
186
  /**
178
187
  * Mark sitemap error
179
188
  */
@@ -190,3 +199,4 @@ export declare function resetSitemapErrors(event: H3Event | undefined): Promise<
190
199
  * Seed routes from sitemap
191
200
  */
192
201
  export declare function seedRoutes(event: H3Event | undefined, routes: string[]): Promise<number>;
202
+ export {};
@@ -1,8 +1,10 @@
1
- import { and, count, desc, eq, gt, isNull, like, lt, or, sql } from "drizzle-orm";
1
+ import { and, count, desc, eq, gt, isNotNull, isNull, like, lt, or, sql } from "drizzle-orm";
2
2
  import { cronRuns, info, pages, sitemaps } from "#ai-ready-virtual/db-schema.mjs";
3
3
  import { useRuntimeConfig } from "#nuxtseo/nitro";
4
+ import { parseSitemapCrawlState, serializeSitemapCrawlState } from "../../utils/sitemap-crawl-state.js";
4
5
  import { resolveFtsTokenizer as validateFtsTokenizer } from "../schema-sql.js";
5
6
  import { useDrizzle } from "./client.js";
7
+ import { useRawDb } from "./raw.js";
6
8
  function resolveFtsTokenizer(event) {
7
9
  const cfg = useRuntimeConfig(event);
8
10
  return validateFtsTokenizer(cfg["nuxt-ai-ready"]?.ftsTokenizer);
@@ -76,12 +78,22 @@ export async function upsertPage(event, page) {
76
78
  }
77
79
  export async function getAllPages(event, options) {
78
80
  const client = await useDrizzle(event);
79
- let query = client.db.select().from(pages);
81
+ const cols = options?.excludeMarkdown ? {
82
+ route: pages.route,
83
+ title: pages.title,
84
+ description: pages.description,
85
+ headings: pages.headings,
86
+ keywords: pages.keywords,
87
+ contentHash: pages.contentHash,
88
+ updatedAt: pages.updatedAt,
89
+ isError: pages.isError
90
+ } : void 0;
91
+ let query = cols ? client.db.select(cols).from(pages) : client.db.select().from(pages);
80
92
  if (!options?.includeErrors) {
81
93
  query = query.where(eq(pages.isError, 0));
82
94
  }
83
95
  const rows = await query;
84
- return rows.map((row) => options?.excludeMarkdown ? rowToMeta(row) : rowToPage(row));
96
+ return rows.map((row) => cols ? rowToMeta(row) : rowToPage(row));
85
97
  }
86
98
  export async function getPageByRoute(event, route) {
87
99
  const client = await useDrizzle(event);
@@ -178,8 +190,18 @@ export async function markPageIndexed(event, route) {
178
190
  export async function markRoutesPending(event, routes) {
179
191
  if (routes.length === 0)
180
192
  return;
181
- const client = await useDrizzle(event);
182
- await client.db.update(pages).set({ indexed: 0 }).where(sql`${pages.route} IN (${sql.join(routes.map((r) => sql`${r}`), sql`, `)})`);
193
+ const db = await useRawDb(event);
194
+ const stmts = [];
195
+ const D1_MAX_IN_ROUTES = 99;
196
+ for (let i = 0; i < routes.length; i += D1_MAX_IN_ROUTES) {
197
+ const batch = routes.slice(i, i + D1_MAX_IN_ROUTES);
198
+ const placeholders = batch.map(() => "?").join(",");
199
+ stmts.push({
200
+ sql: `UPDATE ai_ready_pages SET indexed = 0 WHERE route IN (${placeholders})`,
201
+ params: batch
202
+ });
203
+ }
204
+ await db.batch(stmts);
183
205
  }
184
206
  export async function getContentHashes(event) {
185
207
  const client = await useDrizzle(event);
@@ -202,7 +224,7 @@ export async function deleteInfoValue(event, key) {
202
224
  const client = await useDrizzle(event);
203
225
  await client.db.delete(info).where(eq(info.id, key));
204
226
  }
205
- const SCHEMA_VERSION = "v2.1.0-drizzle";
227
+ const SCHEMA_VERSION = "v2.2.0-drizzle";
206
228
  export async function initSchema(event) {
207
229
  const client = await useDrizzle(event);
208
230
  const tokenizer = resolveFtsTokenizer(event);
@@ -331,7 +353,8 @@ async function createSQLiteTables(client, ftsTokenizer) {
331
353
  last_crawled_at INTEGER,
332
354
  url_count INTEGER DEFAULT 0,
333
355
  error_count INTEGER DEFAULT 0,
334
- last_error TEXT
356
+ last_error TEXT,
357
+ crawl_state TEXT
335
358
  )`,
336
359
  // Indexes
337
360
  sql`CREATE INDEX IF NOT EXISTS idx_ai_ready_pages_route ON ai_ready_pages(route)`,
@@ -345,7 +368,9 @@ async function createSQLiteTables(client, ftsTokenizer) {
345
368
  route, title, description, markdown, headings, keywords,
346
369
  content=ai_ready_pages, content_rowid=id, tokenize='${ftsTokenizer}'
347
370
  )`),
348
- // FTS triggers
371
+ // FTS triggers. The UPDATE trigger only fires when a searchable column
372
+ // actually changed; updates to bookkeeping columns (indexed, indexed_at,
373
+ // indexnow_synced_at, last_seen_at…) skip the delete+reinsert FTS churn.
349
374
  sql`CREATE TRIGGER IF NOT EXISTS ai_ready_pages_ai AFTER INSERT ON ai_ready_pages BEGIN
350
375
  INSERT INTO ai_ready_pages_fts(rowid, route, title, description, markdown, headings, keywords)
351
376
  VALUES (new.id, new.route, new.title, new.description, new.markdown, new.headings, new.keywords);
@@ -354,7 +379,14 @@ async function createSQLiteTables(client, ftsTokenizer) {
354
379
  INSERT INTO ai_ready_pages_fts(ai_ready_pages_fts, rowid, route, title, description, markdown, headings, keywords)
355
380
  VALUES('delete', old.id, old.route, old.title, old.description, old.markdown, old.headings, old.keywords);
356
381
  END`,
357
- sql`CREATE TRIGGER IF NOT EXISTS ai_ready_pages_au AFTER UPDATE ON ai_ready_pages BEGIN
382
+ sql`CREATE TRIGGER IF NOT EXISTS ai_ready_pages_au AFTER UPDATE ON ai_ready_pages
383
+ WHEN old.route IS NOT new.route
384
+ OR old.title IS NOT new.title
385
+ OR old.description IS NOT new.description
386
+ OR old.markdown IS NOT new.markdown
387
+ OR old.headings IS NOT new.headings
388
+ OR old.keywords IS NOT new.keywords
389
+ BEGIN
358
390
  INSERT INTO ai_ready_pages_fts(ai_ready_pages_fts, rowid, route, title, description, markdown, headings, keywords)
359
391
  VALUES('delete', old.id, old.route, old.title, old.description, old.markdown, old.headings, old.keywords);
360
392
  INSERT INTO ai_ready_pages_fts(rowid, route, title, description, markdown, headings, keywords)
@@ -418,7 +450,8 @@ async function createPostgresTables(client) {
418
450
  last_crawled_at INTEGER,
419
451
  url_count INTEGER DEFAULT 0,
420
452
  error_count INTEGER DEFAULT 0,
421
- last_error TEXT
453
+ last_error TEXT,
454
+ crawl_state TEXT
422
455
  )`,
423
456
  // Indexes
424
457
  sql`CREATE INDEX IF NOT EXISTS idx_ai_ready_pages_route ON ai_ready_pages(route)`,
@@ -462,17 +495,19 @@ export async function countPagesNeedingIndexNowSync(event) {
462
495
  export async function markIndexNowSynced(event, routes) {
463
496
  if (routes.length === 0)
464
497
  return;
465
- const client = await useDrizzle(event);
466
498
  const now = Date.now();
499
+ const db = await useRawDb(event);
500
+ const stmts = [];
467
501
  const D1_MAX_IN_ROUTES = 99;
468
502
  for (let i = 0; i < routes.length; i += D1_MAX_IN_ROUTES) {
469
503
  const batch = routes.slice(i, i + D1_MAX_IN_ROUTES);
470
504
  const placeholders = batch.map(() => "?").join(",");
471
- await client.db.run(
472
- sql.raw(`UPDATE ai_ready_pages SET indexnow_synced_at = ? WHERE route IN (${placeholders})`),
473
- [now, ...batch]
474
- );
505
+ stmts.push({
506
+ sql: `UPDATE ai_ready_pages SET indexnow_synced_at = ? WHERE route IN (${placeholders})`,
507
+ params: [now, ...batch]
508
+ });
475
509
  }
510
+ await db.batch(stmts);
476
511
  }
477
512
  function rowToCronRun(row) {
478
513
  return {
@@ -517,19 +552,24 @@ export async function getRecentCronRuns(event, limit = 10) {
517
552
  return rows.map(rowToCronRun);
518
553
  }
519
554
  function rowToSitemap(row) {
555
+ const parsedState = parseSitemapCrawlState(row.crawlState ?? null);
556
+ if (parsedState._tag === "error")
557
+ throw new Error(`Invalid crawl state for sitemap ${row.name}: ${parsedState.error}`);
520
558
  return {
521
559
  name: row.name,
522
560
  route: row.route,
523
561
  lastCrawledAt: row.lastCrawledAt,
524
562
  urlCount: row.urlCount || 0,
525
563
  errorCount: row.errorCount || 0,
526
- lastError: row.lastError
564
+ lastError: row.lastError,
565
+ crawlState: parsedState.state
527
566
  };
528
567
  }
529
568
  export async function syncSitemaps(event, sitemapList) {
530
569
  const client = await useDrizzle(event);
531
- const existing = await client.db.select({ name: sitemaps.name }).from(sitemaps);
570
+ const existing = await client.db.select({ name: sitemaps.name, route: sitemaps.route }).from(sitemaps);
532
571
  const existingNames = new Set(existing.map((r) => r.name));
572
+ const existingRoutes = new Map(existing.map((row) => [row.name, row.route]));
533
573
  const configNames = new Set(sitemapList.map((s) => s.name));
534
574
  let added = 0;
535
575
  let removed = 0;
@@ -537,6 +577,15 @@ export async function syncSitemaps(event, sitemapList) {
537
577
  if (!existingNames.has(sitemap.name)) {
538
578
  await client.db.insert(sitemaps).values({ name: sitemap.name, route: sitemap.route });
539
579
  added++;
580
+ } else if (existingRoutes.get(sitemap.name) !== sitemap.route) {
581
+ await client.db.update(sitemaps).set({
582
+ route: sitemap.route,
583
+ lastCrawledAt: null,
584
+ urlCount: 0,
585
+ errorCount: 0,
586
+ lastError: null,
587
+ crawlState: null
588
+ }).where(eq(sitemaps.name, sitemap.name));
540
589
  }
541
590
  }
542
591
  for (const name of existingNames) {
@@ -550,6 +599,12 @@ export async function syncSitemaps(event, sitemapList) {
550
599
  export async function getNextSitemapToCrawl(event, minIntervalMinutes = 5) {
551
600
  const client = await useDrizzle(event);
552
601
  const threshold = Date.now() - minIntervalMinutes * 60 * 1e3;
602
+ const continuationRow = await client.db.select().from(sitemaps).where(and(
603
+ isNotNull(sitemaps.crawlState),
604
+ eq(sitemaps.errorCount, 0)
605
+ )).orderBy(sitemaps.lastCrawledAt).limit(1);
606
+ if (continuationRow.length)
607
+ return rowToSitemap(continuationRow[0]);
553
608
  const errorRow = await client.db.select().from(sitemaps).where(
554
609
  and(
555
610
  gt(sitemaps.errorCount, 0),
@@ -574,12 +629,37 @@ export async function getNextSitemapToCrawl(event, minIntervalMinutes = 5) {
574
629
  return row.length ? rowToSitemap(row[0]) : null;
575
630
  }
576
631
  export async function markSitemapCrawled(event, name, urlCount) {
632
+ const client = await useDrizzle(event);
633
+ await client.db.update(sitemaps).set({
634
+ lastCrawledAt: Date.now(),
635
+ urlCount,
636
+ errorCount: 0,
637
+ lastError: null,
638
+ crawlState: null
639
+ }).where(eq(sitemaps.name, name));
640
+ }
641
+ export async function markSitemapSeeded(event, name, urlCount, expectedLastCrawledAt) {
577
642
  const client = await useDrizzle(event);
578
643
  await client.db.update(sitemaps).set({
579
644
  lastCrawledAt: Date.now(),
580
645
  urlCount,
581
646
  errorCount: 0,
582
647
  lastError: null
648
+ }).where(and(
649
+ eq(sitemaps.name, name),
650
+ isNull(sitemaps.crawlState),
651
+ eq(sitemaps.errorCount, 0),
652
+ expectedLastCrawledAt === null ? isNull(sitemaps.lastCrawledAt) : eq(sitemaps.lastCrawledAt, expectedLastCrawledAt)
653
+ ));
654
+ }
655
+ export async function markSitemapCrawlPartial(event, name, state) {
656
+ const client = await useDrizzle(event);
657
+ await client.db.update(sitemaps).set({
658
+ lastCrawledAt: Date.now(),
659
+ urlCount: state.urlsObserved,
660
+ errorCount: 0,
661
+ lastError: null,
662
+ crawlState: serializeSitemapCrawlState(state)
583
663
  }).where(eq(sitemaps.name, name));
584
664
  }
585
665
  export async function markSitemapError(event, name, error) {
@@ -587,23 +667,31 @@ export async function markSitemapError(event, name, error) {
587
667
  await client.db.update(sitemaps).set({
588
668
  lastCrawledAt: Date.now(),
589
669
  errorCount: sql`${sitemaps.errorCount} + 1`,
590
- lastError: error
670
+ lastError: error,
671
+ crawlState: null
591
672
  }).where(eq(sitemaps.name, name));
592
673
  }
593
674
  export async function getSitemapStatus(event) {
594
675
  const client = await useDrizzle(event);
595
676
  const rows = await client.db.select().from(sitemaps).orderBy(sitemaps.name);
596
- return rows.map(rowToSitemap);
677
+ return rows.map((row) => {
678
+ const { crawlState, ...entry } = rowToSitemap(row);
679
+ return { ...entry, continuing: crawlState !== null };
680
+ });
597
681
  }
598
682
  export async function resetSitemapErrors(event) {
599
683
  const client = await useDrizzle(event);
600
- const countResult = await client.db.select({ count: count() }).from(sitemaps).where(gt(sitemaps.errorCount, 0));
684
+ const countResult = await client.db.select({ count: count() }).from(sitemaps).where(or(
685
+ gt(sitemaps.errorCount, 0),
686
+ sql`${sitemaps.crawlState} IS NOT NULL`
687
+ ));
601
688
  const errorCount = countResult[0]?.count || 0;
602
689
  if (errorCount > 0) {
603
690
  await client.db.update(sitemaps).set({
604
691
  errorCount: 0,
605
692
  lastError: null,
606
- lastCrawledAt: null
693
+ lastCrawledAt: null,
694
+ crawlState: null
607
695
  });
608
696
  }
609
697
  return errorCount;
@@ -611,29 +699,28 @@ export async function resetSitemapErrors(event) {
611
699
  export async function seedRoutes(event, routes) {
612
700
  if (routes.length === 0)
613
701
  return 0;
614
- const client = await useDrizzle(event);
702
+ const byRoute = /* @__PURE__ */ new Map();
703
+ for (const route of routes)
704
+ byRoute.set(route, normalizeRouteKey(route));
705
+ const ROWS_PER_INSERT = 20;
706
+ const db = await useRawDb(event);
615
707
  const now = (/* @__PURE__ */ new Date()).toISOString();
616
708
  const nowMs = Date.now();
617
- for (const route of routes) {
618
- const values = {
619
- route,
620
- routeKey: normalizeRouteKey(route),
621
- title: "",
622
- description: "",
623
- markdown: "",
624
- headings: "[]",
625
- keywords: "[]",
626
- updatedAt: now,
627
- indexedAt: 0,
628
- isError: 0,
629
- indexed: 0,
630
- source: "runtime",
631
- lastSeenAt: nowMs
632
- };
633
- await client.db.insert(pages).values(values).onConflictDoUpdate({
634
- target: pages.route,
635
- set: { lastSeenAt: nowMs }
709
+ const entries = [...byRoute.entries()];
710
+ const stmts = [];
711
+ for (let i = 0; i < entries.length; i += ROWS_PER_INSERT) {
712
+ const batch = entries.slice(i, i + ROWS_PER_INSERT);
713
+ const valuesSql = batch.map(() => `(?, ?, '', '', '', '[]', '[]', ?, 0, 0, 0, 'runtime', ?)`).join(", ");
714
+ const params = batch.flatMap(([route, routeKey]) => [route, routeKey, now, nowMs]);
715
+ stmts.push({
716
+ sql: `
717
+ INSERT INTO ai_ready_pages (route, route_key, title, description, markdown, headings, keywords, updated_at, indexed_at, is_error, indexed, source, last_seen_at)
718
+ VALUES ${valuesSql}
719
+ ON CONFLICT(route) DO UPDATE SET last_seen_at = excluded.last_seen_at
720
+ `,
721
+ params
636
722
  });
637
723
  }
638
- return routes.length;
724
+ await db.batch(stmts);
725
+ return byRoute.size;
639
726
  }
@@ -16,6 +16,14 @@ export declare function getRawExecutor(client: DrizzleDatabase): {
16
16
  all<T = Record<string, unknown>>(query: string, params?: unknown[]): Promise<T[]>;
17
17
  first<T = Record<string, unknown>>(query: string, params?: unknown[]): Promise<T | undefined>;
18
18
  exec(query: string, params?: unknown[]): Promise<void>;
19
+ /**
20
+ * Execute many statements in as few round-trips as the driver allows.
21
+ * Statements run in order; on a remote driver each chunk is one request.
22
+ */
23
+ batch(queries: {
24
+ sql: string;
25
+ params?: unknown[];
26
+ }[]): Promise<void>;
19
27
  };
20
28
  export type RawExecutor = ReturnType<typeof getRawExecutor>;
21
29
  /**
@@ -4,6 +4,7 @@ export function registerDriver(db, type, driver) {
4
4
  driverCache.set(db, { type, driver });
5
5
  }
6
6
  const RE_PARAM_PLACEHOLDER = /\?/g;
7
+ const MAX_BATCH_STATEMENTS = 100;
7
8
  export function getRawExecutor(client) {
8
9
  const cached = driverCache.get(client.db);
9
10
  if (!cached) {
@@ -66,6 +67,54 @@ export function getRawExecutor(client) {
66
67
  break;
67
68
  }
68
69
  }
70
+ },
71
+ /**
72
+ * Execute many statements in as few round-trips as the driver allows.
73
+ * Statements run in order; on a remote driver each chunk is one request.
74
+ */
75
+ async batch(queries) {
76
+ if (queries.length === 0)
77
+ return;
78
+ switch (type) {
79
+ case "better-sqlite3": {
80
+ const sqlite = driver;
81
+ const tx = sqlite.transaction(() => {
82
+ for (const q of queries)
83
+ sqlite.prepare(q.sql).run(...q.params || []);
84
+ });
85
+ tx();
86
+ break;
87
+ }
88
+ case "libsql": {
89
+ const client2 = driver;
90
+ for (let i = 0; i < queries.length; i += MAX_BATCH_STATEMENTS) {
91
+ const chunk = queries.slice(i, i + MAX_BATCH_STATEMENTS);
92
+ await client2.batch(chunk.map((q) => ({ sql: q.sql, args: q.params || [] })));
93
+ }
94
+ break;
95
+ }
96
+ case "d1": {
97
+ const db = driver;
98
+ for (let i = 0; i < queries.length; i += MAX_BATCH_STATEMENTS) {
99
+ const chunk = queries.slice(i, i + MAX_BATCH_STATEMENTS);
100
+ await db.batch(chunk.map((q) => db.prepare(q.sql).bind(...q.params || [])));
101
+ }
102
+ break;
103
+ }
104
+ case "neon": {
105
+ const sqlFn = driver;
106
+ for (let i = 0; i < queries.length; i += MAX_BATCH_STATEMENTS) {
107
+ const chunk = queries.slice(i, i + MAX_BATCH_STATEMENTS);
108
+ const pgQueries = chunk.map((q) => {
109
+ let idx = 0;
110
+ const pgQuery = q.sql.replace(RE_PARAM_PLACEHOLDER, () => `$${++idx}`);
111
+ return sqlFn.query(pgQuery, q.params || []);
112
+ });
113
+ await sqlFn.transaction(pgQueries);
114
+ }
115
+ break;
116
+ }
117
+ }
69
118
  }
70
119
  };
71
120
  }