nuxt-ai-ready 2.0.1 → 2.2.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.
Files changed (57) hide show
  1. package/dist/chunks/agent-skills.mjs +4 -1
  2. package/dist/chunks/prerender.mjs +10 -3
  3. package/dist/cli.mjs +67 -11
  4. package/dist/module.d.mts +3 -1
  5. package/dist/module.json +1 -1
  6. package/dist/module.mjs +4 -1
  7. package/dist/runtime/app/plugins/md-alternate.server.js +6 -2
  8. package/dist/runtime/llms-txt-utils.js +2 -2
  9. package/dist/runtime/markdown-path.d.ts +2 -0
  10. package/dist/runtime/markdown-path.js +4 -0
  11. package/dist/runtime/server/db/context.d.ts +2 -0
  12. package/dist/runtime/server/db/context.js +2 -0
  13. package/dist/runtime/server/db/drizzle/client.d.ts +6 -1
  14. package/dist/runtime/server/db/drizzle/client.js +66 -13
  15. package/dist/runtime/server/db/drizzle/index.d.ts +2 -2
  16. package/dist/runtime/server/db/drizzle/index.js +1 -2
  17. package/dist/runtime/server/db/drizzle/providers/dbPath.js +1 -1
  18. package/dist/runtime/server/db/drizzle/providers/postgres.d.ts +8 -0
  19. package/dist/runtime/server/db/drizzle/providers/postgres.js +17 -0
  20. package/dist/runtime/server/db/drizzle/queries.d.ts +1 -5
  21. package/dist/runtime/server/db/drizzle/queries.js +79 -38
  22. package/dist/runtime/server/db/drizzle/raw.d.ts +2 -2
  23. package/dist/runtime/server/db/drizzle/raw.js +32 -13
  24. package/dist/runtime/server/db/queries.d.ts +30 -27
  25. package/dist/runtime/server/db/queries.js +142 -91
  26. package/dist/runtime/server/db/schema/postgres.d.ts +20 -20
  27. package/dist/runtime/server/db/schema/postgres.js +6 -6
  28. package/dist/runtime/server/db/shared.d.ts +16 -0
  29. package/dist/runtime/server/db/shared.js +12 -0
  30. package/dist/runtime/server/middleware/markdown.js +10 -5
  31. package/dist/runtime/server/middleware/markdown.prerender.js +4 -1
  32. package/dist/runtime/server/plugins/db-lifecycle.js +5 -4
  33. package/dist/runtime/server/plugins/sitemap-seeder.js +9 -4
  34. package/dist/runtime/server/routes/__ai-ready/cron.get.js +10 -2
  35. package/dist/runtime/server/routes/__ai-ready/poll.post.js +30 -14
  36. package/dist/runtime/server/routes/__ai-ready/reindex.post.d.ts +2 -0
  37. package/dist/runtime/server/routes/__ai-ready/reindex.post.js +23 -0
  38. package/dist/runtime/server/routes/api-catalog.js +11 -2
  39. package/dist/runtime/server/routes/llms.txt.get.js +17 -12
  40. package/dist/runtime/server/routes/sitemap.md.get.d.ts +2 -0
  41. package/dist/runtime/server/routes/sitemap.md.get.js +51 -0
  42. package/dist/runtime/server/tasks/ai-ready-cron.js +3 -0
  43. package/dist/runtime/server/utils/i18n.d.ts +4 -0
  44. package/dist/runtime/server/utils/i18n.js +13 -0
  45. package/dist/runtime/server/utils/indexPage.d.ts +0 -1
  46. package/dist/runtime/server/utils/indexPage.js +10 -4
  47. package/dist/runtime/server/utils/link-header.d.ts +2 -0
  48. package/dist/runtime/server/utils/link-header.js +6 -0
  49. package/dist/runtime/server/utils/markdown-request.js +3 -1
  50. package/dist/runtime/server/utils/negotiation-decision.d.ts +1 -1
  51. package/dist/runtime/server/utils/runCron.d.ts +24 -0
  52. package/dist/runtime/server/utils/runCron.js +21 -4
  53. package/dist/runtime/server/utils/sitemap-md.d.ts +13 -0
  54. package/dist/runtime/server/utils/sitemap-md.js +65 -0
  55. package/dist/runtime/types.d.ts +21 -7
  56. package/dist/shared/{nuxt-ai-ready.D8qbnNrv.mjs → nuxt-ai-ready.D5URd0TD.mjs} +107 -39
  57. package/package.json +31 -26
@@ -1,18 +1,41 @@
1
+ import { randomUUID } from "uncrypto";
1
2
  import { getRequestURL } from "#nuxtseo/h3";
2
3
  import { useEvent, useRuntimeConfig } from "#nuxtseo/nitro";
3
- import { resolveLocaleFromRoute } from "../utils/i18n.js";
4
+ import { createUniversalContext } from "../utils/context.js";
5
+ import { hostMatchesLocaleDomain, resolveLocaleFromRoute } from "../utils/i18n.js";
4
6
  import { parseSitemapCrawlState, serializeSitemapCrawlState } from "../utils/sitemap-crawl-state.js";
5
7
  import { initSchema } from "./drizzle/queries.js";
6
8
  import { useRawDb } from "./drizzle/raw.js";
7
- import { normalizeRoute, normalizeRouteKey } from "./shared.js";
8
- function deriveLocale(event, route, explicit) {
9
+ import { LIKE_ESCAPE, likeSubstring, maxRowsPerInsert, normalizeRoute, normalizeRouteKey } from "./shared.js";
10
+ function hostFromUrl(url) {
11
+ if (!url)
12
+ return void 0;
13
+ try {
14
+ return new URL(url).host || void 0;
15
+ } catch {
16
+ return void 0;
17
+ }
18
+ }
19
+ function deriveLocale(event, route, explicit, pageUrl) {
9
20
  if (explicit !== void 0)
10
21
  return explicit;
11
22
  const cfg = useRuntimeConfig(event);
12
23
  const i18n = cfg["nuxt-ai-ready"]?.i18n;
13
24
  if (!i18n)
14
25
  return "";
15
- return resolveLocaleFromRoute(route, i18n, event ? { host: getRequestURL(event).host } : void 0).locale;
26
+ const entryHost = hostFromUrl(pageUrl);
27
+ if (entryHost)
28
+ return resolveLocaleFromRoute(route, i18n, { host: entryHost }).locale;
29
+ let requestHost;
30
+ if (event) {
31
+ try {
32
+ requestHost = getRequestURL(event).host;
33
+ } catch {
34
+ requestHost = void 0;
35
+ }
36
+ }
37
+ const host = requestHost && hostMatchesLocaleDomain(requestHost, i18n) ? requestHost : hostFromUrl(createUniversalContext(event).siteUrl);
38
+ return resolveLocaleFromRoute(route, i18n, host ? { host } : void 0).locale;
16
39
  }
17
40
  function getEventFromContext(providedEvent) {
18
41
  if (providedEvent)
@@ -24,7 +47,7 @@ function getEventFromContext(providedEvent) {
24
47
  }
25
48
  }
26
49
  let devWarningShown = false;
27
- let schemaInitialized = false;
50
+ let schemaInitializationState = { _tag: "Uninitialized" };
28
51
  const RE_FTS_CHARS = /[*:^"()]/g;
29
52
  const RE_WHITESPACE = /\s+/;
30
53
  async function getDb(event) {
@@ -43,10 +66,17 @@ async function getDb(event) {
43
66
  if (cfg["nuxt-ai-ready"]?.database?._tag === "Disabled")
44
67
  return null;
45
68
  const db = await useRawDb(resolvedEvent);
46
- if (!schemaInitialized) {
47
- await initSchema(resolvedEvent);
48
- schemaInitialized = true;
69
+ if (schemaInitializationState._tag === "Uninitialized") {
70
+ const promise = initSchema(resolvedEvent).then(() => {
71
+ schemaInitializationState = { _tag: "Initialized" };
72
+ }).catch((error) => {
73
+ schemaInitializationState = { _tag: "Uninitialized" };
74
+ throw error;
75
+ });
76
+ schemaInitializationState = { _tag: "Initializing", promise };
49
77
  }
78
+ if (schemaInitializationState._tag === "Initializing")
79
+ await schemaInitializationState.promise;
50
80
  return db;
51
81
  }
52
82
  async function getPrerenderDb() {
@@ -149,6 +179,12 @@ function safeJsonParse(json, fallback) {
149
179
  return fallback;
150
180
  }
151
181
  }
182
+ function toNumber(value, fallback = 0) {
183
+ return value === null || value === void 0 ? fallback : Number(value);
184
+ }
185
+ function toNullableNumber(value) {
186
+ return value === null || value === void 0 ? null : Number(value);
187
+ }
152
188
  function rowToEntry(row) {
153
189
  return {
154
190
  route: row.route,
@@ -195,7 +231,7 @@ export async function queryPages(event, options = {}) {
195
231
  return includeMarkdown ? rowToData(row) : rowToEntry(row);
196
232
  }
197
233
  const { sql: whereClause, params } = buildWhereClause(where);
198
- let sql = `SELECT ${cols} FROM ai_ready_pages ${whereClause}`;
234
+ let sql = `SELECT ${cols} FROM ai_ready_pages ${whereClause} ORDER BY route`;
199
235
  if (limit) {
200
236
  sql += ` LIMIT ?`;
201
237
  params.push(limit);
@@ -238,7 +274,7 @@ export async function countPages(event, options = {}) {
238
274
  `SELECT COUNT(*) as count FROM ai_ready_pages ${whereClause}`,
239
275
  params
240
276
  );
241
- return row?.count || 0;
277
+ return toNumber(row?.count);
242
278
  }
243
279
  export async function searchPages(event, query, options = {}) {
244
280
  if (import.meta.dev || import.meta.prerender)
@@ -250,6 +286,17 @@ export async function searchPages(event, query, options = {}) {
250
286
  const sanitized = query.replace(RE_FTS_CHARS, " ").trim();
251
287
  if (!sanitized)
252
288
  return [];
289
+ if (db.dialect === "postgres") {
290
+ const searchTerm = likeSubstring(sanitized);
291
+ return db.all(`
292
+ SELECT route, title, description, 0 AS score
293
+ FROM ai_ready_pages
294
+ WHERE is_error = 0
295
+ AND (title ILIKE ? ESCAPE ? OR description ILIKE ? ESCAPE ? OR markdown ILIKE ? ESCAPE ? OR headings ILIKE ? ESCAPE ?)
296
+ ORDER BY route
297
+ LIMIT ?
298
+ `, [searchTerm, LIKE_ESCAPE, searchTerm, LIKE_ESCAPE, searchTerm, LIKE_ESCAPE, searchTerm, LIKE_ESCAPE, limit]);
299
+ }
253
300
  const terms = sanitized.split(RE_WHITESPACE).map((t) => `${t}*`).join(" ");
254
301
  return db.all(`
255
302
  SELECT p.route, p.title, p.description, bm25(ai_ready_pages_fts, 5.0, 3.0, 1.0, 0.5, 2.0, 2.0) as score
@@ -271,9 +318,10 @@ export async function upsertPage(event, page) {
271
318
  const source = page.source || "runtime";
272
319
  const lastSeenAt = source === "runtime" ? indexedAt : null;
273
320
  const locale = deriveLocale(event, route, page.locale);
321
+ const localeTrusted = page.locale !== void 0;
274
322
  await db.exec(`
275
323
  INSERT INTO ai_ready_pages (route, route_key, title, description, markdown, headings, keywords, content_hash, updated_at, indexed_at, is_error, indexed, source, last_seen_at, locale)
276
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)
324
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
277
325
  ON CONFLICT(route) DO UPDATE SET
278
326
  title = excluded.title,
279
327
  description = excluded.description,
@@ -284,11 +332,11 @@ export async function upsertPage(event, page) {
284
332
  updated_at = excluded.updated_at,
285
333
  indexed_at = excluded.indexed_at,
286
334
  is_error = excluded.is_error,
287
- indexed = 1,
335
+ indexed = excluded.indexed,
288
336
  source = excluded.source,
289
- last_seen_at = excluded.last_seen_at,
290
- locale = excluded.locale
291
- `, [route, routeKey, page.title, page.description, page.markdown, page.headings, keywordsJson, page.contentHash || null, page.updatedAt, indexedAt, page.isError ? 1 : 0, source, lastSeenAt, locale]);
337
+ last_seen_at = excluded.last_seen_at${localeTrusted ? `,
338
+ locale = excluded.locale` : ""}
339
+ `, [route, routeKey, page.title, page.description, page.markdown, page.headings, keywordsJson, page.contentHash || null, page.updatedAt, indexedAt, page.isError ? 1 : 0, page.isError ? 0 : 1, source, lastSeenAt, locale]);
292
340
  }
293
341
  export async function isPageFresh(event, route, ttlSeconds) {
294
342
  if (ttlSeconds <= 0)
@@ -299,7 +347,7 @@ export async function isPageFresh(event, route, ttlSeconds) {
299
347
  const row = await db.first("SELECT indexed_at FROM ai_ready_pages WHERE route = ?", [route]);
300
348
  if (!row)
301
349
  return false;
302
- const age = (Date.now() - row.indexed_at) / 1e3;
350
+ const age = (Date.now() - toNumber(row.indexed_at)) / 1e3;
303
351
  return age < ttlSeconds;
304
352
  }
305
353
  export async function getPageIndexState(event, route) {
@@ -310,7 +358,7 @@ export async function getPageIndexState(event, route) {
310
358
  "SELECT indexed_at, content_hash FROM ai_ready_pages WHERE route = ?",
311
359
  [route]
312
360
  );
313
- return row ? { indexedAt: row.indexed_at, contentHash: row.content_hash } : void 0;
361
+ return row ? { indexedAt: toNumber(row.indexed_at), contentHash: row.content_hash } : void 0;
314
362
  }
315
363
  export async function getPageHash(event, route) {
316
364
  const db = await getDb(event);
@@ -335,22 +383,27 @@ export async function seedRoutes(event, routes) {
335
383
  for (const entry of routes) {
336
384
  const route = normalizeRoute(typeof entry === "string" ? entry : entry.route);
337
385
  const explicitLocale = typeof entry === "string" ? void 0 : entry.locale;
386
+ const entryUrl = typeof entry === "string" ? void 0 : entry.url;
338
387
  byRoute.set(route, {
339
388
  route,
340
389
  routeKey: normalizeRouteKey(route),
341
- locale: deriveLocale(event, route, explicitLocale)
390
+ locale: deriveLocale(event, route, explicitLocale, entryUrl)
342
391
  });
343
392
  }
344
- const ROWS_PER_INSERT = 20;
393
+ const rowsPerInsert = maxRowsPerInsert(5);
345
394
  const stmts = [];
346
- for (const batch of chunk([...byRoute.values()], ROWS_PER_INSERT)) {
395
+ for (const batch of chunk([...byRoute.values()], rowsPerInsert)) {
347
396
  const valuesSql = batch.map(() => `(?, ?, '', '', '', '[]', '[]', ?, 0, 0, 0, 'runtime', ?, ?)`).join(", ");
348
397
  const params = batch.flatMap((r) => [r.route, r.routeKey, now, nowMs, r.locale]);
349
398
  stmts.push({
350
399
  sql: `
351
400
  INSERT INTO ai_ready_pages (route, route_key, title, description, markdown, headings, keywords, updated_at, indexed_at, is_error, indexed, source, last_seen_at, locale)
352
401
  VALUES ${valuesSql}
353
- ON CONFLICT(route) DO UPDATE SET last_seen_at = excluded.last_seen_at, locale = excluded.locale
402
+ ON CONFLICT(route) DO UPDATE SET
403
+ last_seen_at = excluded.last_seen_at,
404
+ locale = excluded.locale,
405
+ is_error = 0,
406
+ indexed = CASE WHEN ai_ready_pages.is_error = 1 THEN 0 ELSE ai_ready_pages.indexed END
354
407
  `,
355
408
  params
356
409
  });
@@ -358,19 +411,6 @@ export async function seedRoutes(event, routes) {
358
411
  await db.batch(stmts);
359
412
  return byRoute.size;
360
413
  }
361
- export async function getSitemapSeededAt(event) {
362
- const db = await getDb(event);
363
- if (!db)
364
- return void 0;
365
- const row = await db.first("SELECT value FROM _ai_ready_info WHERE id = ?", ["sitemap_seeded_at"]);
366
- return row ? Number.parseInt(row.value, 10) : void 0;
367
- }
368
- export async function setSitemapSeededAt(event, timestamp) {
369
- const db = await getDb(event);
370
- if (!db)
371
- return;
372
- await db.exec("INSERT OR REPLACE INTO _ai_ready_info (id, value) VALUES (?, ?)", ["sitemap_seeded_at", String(timestamp)]);
373
- }
374
414
  export async function pruneStaleRoutes(event, staleThresholdSeconds, protectedSince) {
375
415
  const db = await getDb(event);
376
416
  if (!db)
@@ -381,7 +421,7 @@ export async function pruneStaleRoutes(event, staleThresholdSeconds, protectedSi
381
421
  "SELECT COUNT(*) as count FROM ai_ready_pages WHERE source = ? AND last_seen_at < ?",
382
422
  ["runtime", threshold]
383
423
  );
384
- const count = countRow?.count || 0;
424
+ const count = toNumber(countRow?.count);
385
425
  if (count > 0) {
386
426
  await db.exec("DELETE FROM ai_ready_pages WHERE source = ? AND last_seen_at < ?", ["runtime", threshold]);
387
427
  }
@@ -401,11 +441,11 @@ export async function getStaleRoutes(event, staleThresholdSeconds) {
401
441
  function rowToCronRun(row) {
402
442
  return {
403
443
  id: row.id,
404
- startedAt: row.started_at,
405
- finishedAt: row.finished_at,
406
- durationMs: row.duration_ms,
407
- pagesIndexed: row.pages_indexed,
408
- pagesRemaining: row.pages_remaining,
444
+ startedAt: toNumber(row.started_at),
445
+ finishedAt: toNullableNumber(row.finished_at),
446
+ durationMs: toNullableNumber(row.duration_ms),
447
+ pagesIndexed: toNumber(row.pages_indexed),
448
+ pagesRemaining: toNumber(row.pages_remaining),
409
449
  errors: safeJsonParse(row.errors, []),
410
450
  status: row.status
411
451
  };
@@ -415,12 +455,11 @@ export async function startCronRun(event) {
415
455
  if (!db)
416
456
  return null;
417
457
  const now = Date.now();
418
- await db.exec(
419
- "INSERT INTO ai_ready_cron_runs (started_at, status) VALUES (?, ?)",
458
+ const row = await db.first(
459
+ "INSERT INTO ai_ready_cron_runs (started_at, status) VALUES (?, ?) RETURNING id",
420
460
  [now, "running"]
421
461
  );
422
- const row = await db.first("SELECT last_insert_rowid() as id");
423
- return row?.id || null;
462
+ return row ? toNumber(row.id) : null;
424
463
  }
425
464
  export async function completeCronRun(event, runId, result) {
426
465
  const db = await getDb(event);
@@ -428,7 +467,7 @@ export async function completeCronRun(event, runId, result) {
428
467
  return;
429
468
  const now = Date.now();
430
469
  const row = await db.first("SELECT started_at FROM ai_ready_cron_runs WHERE id = ?", [runId]);
431
- const durationMs = row ? now - row.started_at : null;
470
+ const durationMs = row ? now - toNumber(row.started_at) : null;
432
471
  const status = result.errors.length > 0 ? result.pagesIndexed > 0 ? "partial" : "error" : "success";
433
472
  await db.exec(`
434
473
  UPDATE ai_ready_cron_runs SET
@@ -451,22 +490,6 @@ export async function getRecentCronRuns(event, limit = 10) {
451
490
  );
452
491
  return rows.map(rowToCronRun);
453
492
  }
454
- export async function cleanupOldCronRuns(event, keepCount = 50) {
455
- const db = await getDb(event);
456
- if (!db)
457
- return 0;
458
- const countRow = await db.first("SELECT COUNT(*) as count FROM ai_ready_cron_runs");
459
- const total = countRow?.count || 0;
460
- if (total <= keepCount)
461
- return 0;
462
- const deleteCount = total - keepCount;
463
- await db.exec(`
464
- DELETE FROM ai_ready_cron_runs WHERE id IN (
465
- SELECT id FROM ai_ready_cron_runs ORDER BY started_at ASC LIMIT ?
466
- )
467
- `, [deleteCount]);
468
- return deleteCount;
469
- }
470
493
  export async function pruneCronRunsByAge(event, maxAgeMs = 24 * 60 * 60 * 1e3) {
471
494
  const db = await getDb(event);
472
495
  if (!db)
@@ -476,7 +499,7 @@ export async function pruneCronRunsByAge(event, maxAgeMs = 24 * 60 * 60 * 1e3) {
476
499
  "SELECT COUNT(*) as count FROM ai_ready_cron_runs WHERE started_at < ?",
477
500
  [threshold]
478
501
  );
479
- const count = countRow?.count || 0;
502
+ const count = toNumber(countRow?.count);
480
503
  if (count > 0) {
481
504
  await db.exec("DELETE FROM ai_ready_cron_runs WHERE started_at < ?", [threshold]);
482
505
  }
@@ -498,33 +521,58 @@ export async function getCronFastPathStatus(event, sitemapIntervalMinutes = 5) {
498
521
  if (!row)
499
522
  return null;
500
523
  return {
501
- totalPages: row.total_pages,
502
- pendingPages: row.pending_pages,
524
+ totalPages: toNumber(row.total_pages),
525
+ pendingPages: toNumber(row.pending_pages),
503
526
  lastStaleCheck: row.last_stale_check ? Number.parseInt(row.last_stale_check, 10) : null,
504
527
  buildId: row.build_id,
505
- sitemapsNeedCrawl: row.sitemaps_need_crawl
528
+ sitemapsNeedCrawl: toNumber(row.sitemaps_need_crawl)
506
529
  };
507
530
  }
508
531
  const CRON_LOCK_TTL_MS = 3e5;
532
+ function cronLockField(db, column, key) {
533
+ if (db.dialect !== "postgres")
534
+ return `json_extract(${column}, '$.${key}')`;
535
+ const jsonb = `${column}::jsonb`;
536
+ return `CASE WHEN jsonb_typeof(${jsonb}) = 'object' THEN (${jsonb} ->> '${key}') END`;
537
+ }
509
538
  export async function tryAcquireCronLock(event) {
539
+ const token = randomUUID();
510
540
  const db = await getDb(event);
511
541
  if (!db)
512
- return true;
542
+ return { _tag: "acquired", token };
513
543
  const now = Date.now();
514
- const staleThreshold = now - CRON_LOCK_TTL_MS;
515
- await db.exec(`
516
- INSERT INTO _ai_ready_info (id, value) VALUES ('cron_lock', ?)
517
- ON CONFLICT(id) DO UPDATE SET value = ?
518
- WHERE CAST(value AS INTEGER) < ?
519
- `, [String(now), String(now), staleThreshold]);
520
- const row = await db.first("SELECT value FROM _ai_ready_info WHERE id = ?", ["cron_lock"]);
521
- return row?.value === String(now);
522
- }
523
- export async function releaseCronLock(event) {
544
+ const value = JSON.stringify({ t: token, a: now, e: now + CRON_LOCK_TTL_MS });
545
+ try {
546
+ await db.exec(`
547
+ INSERT INTO _ai_ready_info (id, value) VALUES ('cron_lock', ?)
548
+ ON CONFLICT(id) DO UPDATE SET value = excluded.value
549
+ WHERE CAST(coalesce(${cronLockField(db, "_ai_ready_info.value", "e")}, '0') AS BIGINT) < ?
550
+ `, [value, now]);
551
+ const row = await db.first(
552
+ `SELECT value FROM _ai_ready_info WHERE id = 'cron_lock' AND ${cronLockField(db, "value", "t")} = ?`,
553
+ [token]
554
+ );
555
+ return row ? { _tag: "acquired", token } : { _tag: "held" };
556
+ } catch (error) {
557
+ await releaseCronLock(event, token).catch(() => {
558
+ });
559
+ throw error;
560
+ }
561
+ }
562
+ export async function releaseCronLock(event, token) {
524
563
  const db = await getDb(event);
525
564
  if (!db)
526
565
  return;
527
- await db.exec("DELETE FROM _ai_ready_info WHERE id = ?", ["cron_lock"]);
566
+ await db.exec(`DELETE FROM _ai_ready_info WHERE id = 'cron_lock' AND ${cronLockField(db, "value", "t")} = ?`, [token]);
567
+ }
568
+ const RE_CRON_LOCK_NUMERIC = /^\d+$/;
569
+ function parseCronLockValue(value) {
570
+ if (RE_CRON_LOCK_NUMERIC.test(value))
571
+ return { acquiredAt: Number(value), expiresAt: Number(value) + CRON_LOCK_TTL_MS };
572
+ const parsed = safeJsonParse(value, null);
573
+ if (parsed && typeof parsed === "object" && typeof parsed.a === "number" && typeof parsed.e === "number")
574
+ return { acquiredAt: parsed.a, expiresAt: parsed.e };
575
+ return null;
528
576
  }
529
577
  export async function getCronLockStatus(event) {
530
578
  const db = await getDb(event);
@@ -536,14 +584,15 @@ export async function getCronLockStatus(event) {
536
584
  );
537
585
  if (!row)
538
586
  return { held: false, since: null, elapsedMs: null, stale: false };
539
- const lockTime = Number.parseInt(row.value, 10);
587
+ const record = parseCronLockValue(row.value);
588
+ if (!record)
589
+ return { held: false, since: null, elapsedMs: null, stale: false };
540
590
  const now = Date.now();
541
- const elapsed = now - lockTime;
542
- const stale = elapsed >= CRON_LOCK_TTL_MS;
591
+ const stale = now >= record.expiresAt;
543
592
  return {
544
593
  held: !stale,
545
- since: lockTime,
546
- elapsedMs: elapsed,
594
+ since: record.acquiredAt,
595
+ elapsedMs: now - record.acquiredAt,
547
596
  stale
548
597
  };
549
598
  }
@@ -554,9 +603,9 @@ function rowToSitemapEntry(row) {
554
603
  return {
555
604
  name: row.name,
556
605
  route: row.route,
557
- lastCrawledAt: row.last_crawled_at,
558
- urlCount: row.url_count || 0,
559
- errorCount: row.error_count || 0,
606
+ lastCrawledAt: toNullableNumber(row.last_crawled_at),
607
+ urlCount: toNumber(row.url_count),
608
+ errorCount: toNumber(row.error_count),
560
609
  lastError: row.last_error,
561
610
  crawlState: parsedState.state
562
611
  };
@@ -643,7 +692,7 @@ export async function getSitemapLastCrawledAt(event, name) {
643
692
  "SELECT last_crawled_at FROM ai_ready_sitemaps WHERE name = ?",
644
693
  [name]
645
694
  );
646
- return row?.last_crawled_at ?? null;
695
+ return toNullableNumber(row?.last_crawled_at);
647
696
  }
648
697
  export async function markSitemapCrawled(event, name, urlCount) {
649
698
  const db = await getDb(event);
@@ -663,6 +712,8 @@ export async function markSitemapSeeded(event, name, urlCount, expectedLastCrawl
663
712
  const db = await getDb(event);
664
713
  if (!db)
665
714
  return;
715
+ const expectedClause = expectedLastCrawledAt === null ? "last_crawled_at IS NULL" : "last_crawled_at = ?";
716
+ const expectedParams = expectedLastCrawledAt === null ? [] : [expectedLastCrawledAt];
666
717
  await db.exec(`
667
718
  UPDATE ai_ready_sitemaps SET
668
719
  last_crawled_at = ?,
@@ -672,8 +723,8 @@ export async function markSitemapSeeded(event, name, urlCount, expectedLastCrawl
672
723
  WHERE name = ?
673
724
  AND crawl_state IS NULL
674
725
  AND error_count = 0
675
- AND ((? IS NULL AND last_crawled_at IS NULL) OR last_crawled_at = ?)
676
- `, [Date.now(), urlCount, name, expectedLastCrawledAt, expectedLastCrawledAt]);
726
+ AND ${expectedClause}
727
+ `, [Date.now(), urlCount, name, ...expectedParams]);
677
728
  }
678
729
  export async function markSitemapCrawlPartial(event, name, state) {
679
730
  const db = await getDb(event);
@@ -709,7 +760,7 @@ export async function resetSitemapErrors(event) {
709
760
  const countRow = await db.first(
710
761
  "SELECT COUNT(*) as count FROM ai_ready_sitemaps WHERE error_count > 0 OR crawl_state IS NOT NULL"
711
762
  );
712
- const count = countRow?.count || 0;
763
+ const count = toNumber(countRow?.count);
713
764
  if (count > 0) {
714
765
  await db.exec("UPDATE ai_ready_sitemaps SET error_count = 0, last_error = NULL, last_crawled_at = NULL, crawl_state = NULL");
715
766
  }
@@ -733,7 +784,7 @@ export async function getRecentlyIndexedPages(event, limit = 10) {
733
784
  "SELECT route, title, indexed_at FROM ai_ready_pages WHERE indexed = 1 AND is_error = 0 ORDER BY indexed_at DESC LIMIT ?",
734
785
  [limit]
735
786
  );
736
- return rows.map((r) => ({ route: r.route, title: r.title, indexedAt: r.indexed_at }));
787
+ return rows.map((r) => ({ route: r.route, title: r.title, indexedAt: toNumber(r.indexed_at) }));
737
788
  }
738
789
  export async function countRecentlyIndexed(event, sinceMs) {
739
790
  const db = await getDb(event);
@@ -744,5 +795,5 @@ export async function countRecentlyIndexed(event, sinceMs) {
744
795
  "SELECT COUNT(*) as count FROM ai_ready_pages WHERE indexed = 1 AND indexed_at > ?",
745
796
  [threshold]
746
797
  );
747
- return row?.count || 0;
798
+ return toNumber(row?.count);
748
799
  }
@@ -152,10 +152,10 @@ export declare const pages: import("drizzle-orm/pg-core").PgTableWithColumns<{
152
152
  identity: undefined;
153
153
  generated: undefined;
154
154
  }>;
155
- indexedAt: import("drizzle-orm/pg-core").PgBuildColumn<"ai_ready_pages", import("drizzle-orm/pg-core").SetNotNull<import("drizzle-orm/pg-core").PgIntegerBuilder>, {
155
+ indexedAt: import("drizzle-orm/pg-core").PgBuildColumn<"ai_ready_pages", import("drizzle-orm/pg-core").SetNotNull<import("drizzle-orm/pg-core").PgBigInt53Builder>, {
156
156
  name: string;
157
157
  tableName: "ai_ready_pages";
158
- dataType: "number int32";
158
+ dataType: "number int53";
159
159
  data: number;
160
160
  driverParam: string | number;
161
161
  notNull: true;
@@ -212,10 +212,10 @@ export declare const pages: import("drizzle-orm/pg-core").PgTableWithColumns<{
212
212
  identity: undefined;
213
213
  generated: undefined;
214
214
  }>;
215
- lastSeenAt: import("drizzle-orm/pg-core").PgBuildColumn<"ai_ready_pages", import("drizzle-orm/pg-core").PgIntegerBuilder, {
215
+ lastSeenAt: import("drizzle-orm/pg-core").PgBuildColumn<"ai_ready_pages", import("drizzle-orm/pg-core").PgBigInt53Builder, {
216
216
  name: string;
217
217
  tableName: "ai_ready_pages";
218
- dataType: "number int32";
218
+ dataType: "number int53";
219
219
  data: number;
220
220
  driverParam: string | number;
221
221
  notNull: false;
@@ -346,10 +346,10 @@ export declare const cronRuns: import("drizzle-orm/pg-core").PgTableWithColumns<
346
346
  identity: undefined;
347
347
  generated: undefined;
348
348
  }>;
349
- startedAt: import("drizzle-orm/pg-core").PgBuildColumn<"ai_ready_cron_runs", import("drizzle-orm/pg-core").SetNotNull<import("drizzle-orm/pg-core").PgIntegerBuilder>, {
349
+ startedAt: import("drizzle-orm/pg-core").PgBuildColumn<"ai_ready_cron_runs", import("drizzle-orm/pg-core").SetNotNull<import("drizzle-orm/pg-core").PgBigInt53Builder>, {
350
350
  name: string;
351
351
  tableName: "ai_ready_cron_runs";
352
- dataType: "number int32";
352
+ dataType: "number int53";
353
353
  data: number;
354
354
  driverParam: string | number;
355
355
  notNull: true;
@@ -361,10 +361,10 @@ export declare const cronRuns: import("drizzle-orm/pg-core").PgTableWithColumns<
361
361
  identity: undefined;
362
362
  generated: undefined;
363
363
  }>;
364
- finishedAt: import("drizzle-orm/pg-core").PgBuildColumn<"ai_ready_cron_runs", import("drizzle-orm/pg-core").PgIntegerBuilder, {
364
+ finishedAt: import("drizzle-orm/pg-core").PgBuildColumn<"ai_ready_cron_runs", import("drizzle-orm/pg-core").PgBigInt53Builder, {
365
365
  name: string;
366
366
  tableName: "ai_ready_cron_runs";
367
- dataType: "number int32";
367
+ dataType: "number int53";
368
368
  data: number;
369
369
  driverParam: string | number;
370
370
  notNull: false;
@@ -488,10 +488,10 @@ export declare const sitemaps: import("drizzle-orm/pg-core").PgTableWithColumns<
488
488
  identity: undefined;
489
489
  generated: undefined;
490
490
  }>;
491
- lastCrawledAt: import("drizzle-orm/pg-core").PgBuildColumn<"ai_ready_sitemaps", import("drizzle-orm/pg-core").PgIntegerBuilder, {
491
+ lastCrawledAt: import("drizzle-orm/pg-core").PgBuildColumn<"ai_ready_sitemaps", import("drizzle-orm/pg-core").PgBigInt53Builder, {
492
492
  name: string;
493
493
  tableName: "ai_ready_sitemaps";
494
- dataType: "number int32";
494
+ dataType: "number int53";
495
495
  data: number;
496
496
  driverParam: string | number;
497
497
  notNull: false;
@@ -721,10 +721,10 @@ export declare const schema: {
721
721
  identity: undefined;
722
722
  generated: undefined;
723
723
  }>;
724
- indexedAt: import("drizzle-orm/pg-core").PgBuildColumn<"ai_ready_pages", import("drizzle-orm/pg-core").SetNotNull<import("drizzle-orm/pg-core").PgIntegerBuilder>, {
724
+ indexedAt: import("drizzle-orm/pg-core").PgBuildColumn<"ai_ready_pages", import("drizzle-orm/pg-core").SetNotNull<import("drizzle-orm/pg-core").PgBigInt53Builder>, {
725
725
  name: string;
726
726
  tableName: "ai_ready_pages";
727
- dataType: "number int32";
727
+ dataType: "number int53";
728
728
  data: number;
729
729
  driverParam: string | number;
730
730
  notNull: true;
@@ -781,10 +781,10 @@ export declare const schema: {
781
781
  identity: undefined;
782
782
  generated: undefined;
783
783
  }>;
784
- lastSeenAt: import("drizzle-orm/pg-core").PgBuildColumn<"ai_ready_pages", import("drizzle-orm/pg-core").PgIntegerBuilder, {
784
+ lastSeenAt: import("drizzle-orm/pg-core").PgBuildColumn<"ai_ready_pages", import("drizzle-orm/pg-core").PgBigInt53Builder, {
785
785
  name: string;
786
786
  tableName: "ai_ready_pages";
787
- dataType: "number int32";
787
+ dataType: "number int53";
788
788
  data: number;
789
789
  driverParam: string | number;
790
790
  notNull: false;
@@ -915,10 +915,10 @@ export declare const schema: {
915
915
  identity: undefined;
916
916
  generated: undefined;
917
917
  }>;
918
- startedAt: import("drizzle-orm/pg-core").PgBuildColumn<"ai_ready_cron_runs", import("drizzle-orm/pg-core").SetNotNull<import("drizzle-orm/pg-core").PgIntegerBuilder>, {
918
+ startedAt: import("drizzle-orm/pg-core").PgBuildColumn<"ai_ready_cron_runs", import("drizzle-orm/pg-core").SetNotNull<import("drizzle-orm/pg-core").PgBigInt53Builder>, {
919
919
  name: string;
920
920
  tableName: "ai_ready_cron_runs";
921
- dataType: "number int32";
921
+ dataType: "number int53";
922
922
  data: number;
923
923
  driverParam: string | number;
924
924
  notNull: true;
@@ -930,10 +930,10 @@ export declare const schema: {
930
930
  identity: undefined;
931
931
  generated: undefined;
932
932
  }>;
933
- finishedAt: import("drizzle-orm/pg-core").PgBuildColumn<"ai_ready_cron_runs", import("drizzle-orm/pg-core").PgIntegerBuilder, {
933
+ finishedAt: import("drizzle-orm/pg-core").PgBuildColumn<"ai_ready_cron_runs", import("drizzle-orm/pg-core").PgBigInt53Builder, {
934
934
  name: string;
935
935
  tableName: "ai_ready_cron_runs";
936
- dataType: "number int32";
936
+ dataType: "number int53";
937
937
  data: number;
938
938
  driverParam: string | number;
939
939
  notNull: false;
@@ -1057,10 +1057,10 @@ export declare const schema: {
1057
1057
  identity: undefined;
1058
1058
  generated: undefined;
1059
1059
  }>;
1060
- lastCrawledAt: import("drizzle-orm/pg-core").PgBuildColumn<"ai_ready_sitemaps", import("drizzle-orm/pg-core").PgIntegerBuilder, {
1060
+ lastCrawledAt: import("drizzle-orm/pg-core").PgBuildColumn<"ai_ready_sitemaps", import("drizzle-orm/pg-core").PgBigInt53Builder, {
1061
1061
  name: string;
1062
1062
  tableName: "ai_ready_sitemaps";
1063
- dataType: "number int32";
1063
+ dataType: "number int53";
1064
1064
  data: number;
1065
1065
  driverParam: string | number;
1066
1066
  notNull: false;
@@ -1,4 +1,4 @@
1
- import { index, integer, pgTable, serial, text } from "drizzle-orm/pg-core";
1
+ import { bigint, index, integer, pgTable, serial, text } from "drizzle-orm/pg-core";
2
2
  export const pages = pgTable("ai_ready_pages", {
3
3
  id: serial("id").primaryKey(),
4
4
  route: text("route").unique().notNull(),
@@ -10,11 +10,11 @@ export const pages = pgTable("ai_ready_pages", {
10
10
  keywords: text("keywords").notNull().default("[]"),
11
11
  contentHash: text("content_hash"),
12
12
  updatedAt: text("updated_at").notNull(),
13
- indexedAt: integer("indexed_at").notNull(),
13
+ indexedAt: bigint("indexed_at", { mode: "number" }).notNull(),
14
14
  isError: integer("is_error").notNull().default(0),
15
15
  indexed: integer("indexed").notNull().default(0),
16
16
  source: text("source").notNull().default("prerender"),
17
- lastSeenAt: integer("last_seen_at"),
17
+ lastSeenAt: bigint("last_seen_at", { mode: "number" }),
18
18
  locale: text("locale").notNull().default("")
19
19
  }, (table) => [
20
20
  index("idx_ai_ready_pages_route").on(table.route),
@@ -33,8 +33,8 @@ export const info = pgTable("_ai_ready_info", {
33
33
  });
34
34
  export const cronRuns = pgTable("ai_ready_cron_runs", {
35
35
  id: serial("id").primaryKey(),
36
- startedAt: integer("started_at").notNull(),
37
- finishedAt: integer("finished_at"),
36
+ startedAt: bigint("started_at", { mode: "number" }).notNull(),
37
+ finishedAt: bigint("finished_at", { mode: "number" }),
38
38
  durationMs: integer("duration_ms"),
39
39
  pagesIndexed: integer("pages_indexed").default(0),
40
40
  pagesRemaining: integer("pages_remaining").default(0),
@@ -46,7 +46,7 @@ export const cronRuns = pgTable("ai_ready_cron_runs", {
46
46
  export const sitemaps = pgTable("ai_ready_sitemaps", {
47
47
  name: text("name").primaryKey(),
48
48
  route: text("route").notNull(),
49
- lastCrawledAt: integer("last_crawled_at"),
49
+ lastCrawledAt: bigint("last_crawled_at", { mode: "number" }),
50
50
  urlCount: integer("url_count").default(0),
51
51
  errorCount: integer("error_count").default(0),
52
52
  lastError: text("last_error"),
@@ -44,6 +44,22 @@ export declare function initSchema(db: DatabaseAdapter, options?: InitSchemaOpti
44
44
  * one row. Widening it would let both spellings persist and race for the key.
45
45
  */
46
46
  export declare function normalizeRoute(route: string): string;
47
+ /**
48
+ * Escape LIKE/ILIKE wildcard characters in a search term so the term matches
49
+ * literal text. Pair with an `ESCAPE` clause (see `LIKE_ESCAPE`): SQLite has no
50
+ * default escape character, so without the clause the escaping is inert.
51
+ */
52
+ export declare function escapeLikeTerm(term: string): string;
53
+ /** Substring LIKE/ILIKE pattern for a term, with wildcards escaped. */
54
+ export declare function likeSubstring(term: string): string;
55
+ /** Escape character to bind next to `likeSubstring` patterns. */
56
+ export declare const LIKE_ESCAPE = "\\";
57
+ /**
58
+ * Multi-row INSERT chunk size that keeps one statement within the 100-bind
59
+ * cap shared by SQLite and D1, derived from the row's bind count so an added
60
+ * column resizes the chunk instead of breaking the insert.
61
+ */
62
+ export declare function maxRowsPerInsert(paramsPerRow: number): number;
47
63
  /**
48
64
  * Normalize route to storage key format
49
65
  * e.g., '/about/team' -> 'about:team', '/' -> 'index'