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.
- package/dist/chunks/agent-skills.mjs +4 -1
- package/dist/chunks/prerender.mjs +10 -3
- package/dist/cli.mjs +67 -11
- package/dist/module.d.mts +3 -1
- package/dist/module.json +1 -1
- package/dist/module.mjs +4 -1
- package/dist/runtime/app/plugins/md-alternate.server.js +6 -2
- package/dist/runtime/llms-txt-utils.js +2 -2
- package/dist/runtime/markdown-path.d.ts +2 -0
- package/dist/runtime/markdown-path.js +4 -0
- package/dist/runtime/server/db/context.d.ts +2 -0
- package/dist/runtime/server/db/context.js +2 -0
- package/dist/runtime/server/db/drizzle/client.d.ts +6 -1
- package/dist/runtime/server/db/drizzle/client.js +66 -13
- package/dist/runtime/server/db/drizzle/index.d.ts +2 -2
- package/dist/runtime/server/db/drizzle/index.js +1 -2
- package/dist/runtime/server/db/drizzle/providers/dbPath.js +1 -1
- package/dist/runtime/server/db/drizzle/providers/postgres.d.ts +8 -0
- package/dist/runtime/server/db/drizzle/providers/postgres.js +17 -0
- package/dist/runtime/server/db/drizzle/queries.d.ts +1 -5
- package/dist/runtime/server/db/drizzle/queries.js +79 -38
- package/dist/runtime/server/db/drizzle/raw.d.ts +2 -2
- package/dist/runtime/server/db/drizzle/raw.js +32 -13
- package/dist/runtime/server/db/queries.d.ts +30 -27
- package/dist/runtime/server/db/queries.js +142 -91
- package/dist/runtime/server/db/schema/postgres.d.ts +20 -20
- package/dist/runtime/server/db/schema/postgres.js +6 -6
- package/dist/runtime/server/db/shared.d.ts +16 -0
- package/dist/runtime/server/db/shared.js +12 -0
- package/dist/runtime/server/middleware/markdown.js +10 -5
- package/dist/runtime/server/middleware/markdown.prerender.js +4 -1
- package/dist/runtime/server/plugins/db-lifecycle.js +5 -4
- package/dist/runtime/server/plugins/sitemap-seeder.js +9 -4
- package/dist/runtime/server/routes/__ai-ready/cron.get.js +10 -2
- package/dist/runtime/server/routes/__ai-ready/poll.post.js +30 -14
- package/dist/runtime/server/routes/__ai-ready/reindex.post.d.ts +2 -0
- package/dist/runtime/server/routes/__ai-ready/reindex.post.js +23 -0
- package/dist/runtime/server/routes/api-catalog.js +11 -2
- package/dist/runtime/server/routes/llms.txt.get.js +17 -12
- package/dist/runtime/server/routes/sitemap.md.get.d.ts +2 -0
- package/dist/runtime/server/routes/sitemap.md.get.js +51 -0
- package/dist/runtime/server/tasks/ai-ready-cron.js +3 -0
- package/dist/runtime/server/utils/i18n.d.ts +4 -0
- package/dist/runtime/server/utils/i18n.js +13 -0
- package/dist/runtime/server/utils/indexPage.d.ts +0 -1
- package/dist/runtime/server/utils/indexPage.js +10 -4
- package/dist/runtime/server/utils/link-header.d.ts +2 -0
- package/dist/runtime/server/utils/link-header.js +6 -0
- package/dist/runtime/server/utils/markdown-request.js +3 -1
- package/dist/runtime/server/utils/negotiation-decision.d.ts +1 -1
- package/dist/runtime/server/utils/runCron.d.ts +24 -0
- package/dist/runtime/server/utils/runCron.js +21 -4
- package/dist/runtime/server/utils/sitemap-md.d.ts +13 -0
- package/dist/runtime/server/utils/sitemap-md.js +65 -0
- package/dist/runtime/types.d.ts +21 -7
- package/dist/shared/{nuxt-ai-ready.D8qbnNrv.mjs → nuxt-ai-ready.D5URd0TD.mjs} +107 -39
- 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 {
|
|
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
|
|
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
|
-
|
|
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
|
|
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 (
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
|
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 =
|
|
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
|
|
393
|
+
const rowsPerInsert = maxRowsPerInsert(5);
|
|
345
394
|
const stmts = [];
|
|
346
|
-
for (const batch of chunk([...byRoute.values()],
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
542
|
+
return { _tag: "acquired", token };
|
|
513
543
|
const now = Date.now();
|
|
514
|
-
const
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
}
|
|
523
|
-
|
|
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(
|
|
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
|
|
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
|
|
542
|
-
const stale = elapsed >= CRON_LOCK_TTL_MS;
|
|
591
|
+
const stale = now >= record.expiresAt;
|
|
543
592
|
return {
|
|
544
593
|
held: !stale,
|
|
545
|
-
since:
|
|
546
|
-
elapsedMs:
|
|
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
|
|
559
|
-
errorCount: row.error_count
|
|
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
|
|
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
|
|
676
|
-
`, [Date.now(), urlCount, name,
|
|
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
|
|
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
|
|
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").
|
|
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
|
|
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").
|
|
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
|
|
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").
|
|
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
|
|
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").
|
|
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
|
|
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").
|
|
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
|
|
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").
|
|
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
|
|
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").
|
|
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
|
|
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").
|
|
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
|
|
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").
|
|
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
|
|
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").
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
37
|
-
finishedAt:
|
|
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:
|
|
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'
|