nuxt-ai-ready 2.0.1 → 2.1.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 (30) hide show
  1. package/dist/chunks/agent-skills.mjs +1 -1
  2. package/dist/chunks/prerender.mjs +1 -1
  3. package/dist/module.d.mts +1 -1
  4. package/dist/module.json +1 -1
  5. package/dist/module.mjs +1 -1
  6. package/dist/runtime/server/db/context.d.ts +2 -0
  7. package/dist/runtime/server/db/context.js +2 -0
  8. package/dist/runtime/server/db/drizzle/client.d.ts +6 -1
  9. package/dist/runtime/server/db/drizzle/client.js +66 -13
  10. package/dist/runtime/server/db/drizzle/index.d.ts +1 -1
  11. package/dist/runtime/server/db/drizzle/index.js +1 -1
  12. package/dist/runtime/server/db/drizzle/providers/dbPath.js +1 -1
  13. package/dist/runtime/server/db/drizzle/providers/postgres.d.ts +8 -0
  14. package/dist/runtime/server/db/drizzle/providers/postgres.js +17 -0
  15. package/dist/runtime/server/db/drizzle/queries.d.ts +1 -1
  16. package/dist/runtime/server/db/drizzle/queries.js +57 -22
  17. package/dist/runtime/server/db/drizzle/raw.d.ts +2 -2
  18. package/dist/runtime/server/db/drizzle/raw.js +32 -10
  19. package/dist/runtime/server/db/queries.d.ts +8 -6
  20. package/dist/runtime/server/db/queries.js +62 -34
  21. package/dist/runtime/server/db/schema/postgres.d.ts +20 -20
  22. package/dist/runtime/server/db/schema/postgres.js +6 -6
  23. package/dist/runtime/server/plugins/db-lifecycle.js +5 -4
  24. package/dist/runtime/server/plugins/sitemap-seeder.js +4 -2
  25. package/dist/runtime/server/utils/indexPage.d.ts +0 -1
  26. package/dist/runtime/server/utils/indexPage.js +3 -3
  27. package/dist/runtime/server/utils/negotiation-decision.d.ts +1 -1
  28. package/dist/runtime/types.d.ts +5 -4
  29. package/dist/shared/{nuxt-ai-ready.D8qbnNrv.mjs → nuxt-ai-ready.BTQAkSYt.mjs} +4 -3
  30. package/package.json +23 -18
@@ -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.D8qbnNrv.mjs';
5
+ import { A as AGENT_SKILLS_SCHEMA } from '../shared/nuxt-ai-ready.BTQAkSYt.mjs';
6
6
  import 'node:module';
7
7
  import '@nuxt/kit';
8
8
  import 'defu';
@@ -5,7 +5,7 @@ import { colorize } from 'consola/utils';
5
5
  import { resolveLocaleFromRoute } from 'nuxtseo-shared/i18n-runtime';
6
6
  import { collectSitemap } from 'sitemapd/parse';
7
7
  import { withLeadingSlash, joinURL, withBase } from 'ufo';
8
- import { l as logger, s as supportsNativeNodeSqlite, M as MARKDOWN_LINK_AVAILABILITY_FILE } from '../shared/nuxt-ai-ready.D8qbnNrv.mjs';
8
+ import { l as logger, s as supportsNativeNodeSqlite, M as MARKDOWN_LINK_AVAILABILITY_FILE } from '../shared/nuxt-ai-ready.BTQAkSYt.mjs';
9
9
  import { normalizePagePath, toMarkdownPath } from '../../dist/runtime/markdown-path.js';
10
10
  import { toLogicalRoute, toDeployedRoute } from '../../dist/runtime/route-path.js';
11
11
  import { initSchema, computeContentHash, insertPage, queryAllPages, exportDbDump } from '../../dist/runtime/server/db/shared.js';
package/dist/module.d.mts CHANGED
@@ -31,7 +31,7 @@ interface ResolvedApiCatalogConfig {
31
31
  };
32
32
  }
33
33
 
34
- type DatabaseType = 'sqlite' | 'bun' | 'd1' | 'libsql' | 'neon';
34
+ type DatabaseType = 'sqlite' | 'bun' | 'd1' | 'libsql' | 'neon' | 'postgres';
35
35
  /**
36
36
  * Database state after parsing. Every consumer reads `_tag` first, so a
37
37
  * disabled database can never carry a driver, a path, or credentials.
package/dist/module.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "nuxt": ">=4.0.0"
5
5
  },
6
6
  "configKey": "aiReady",
7
- "version": "2.0.1",
7
+ "version": "2.1.0",
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.D8qbnNrv.mjs';
10
+ export { m as default } from './shared/nuxt-ai-ready.BTQAkSYt.mjs';
11
11
  import '../dist/runtime/server/utils/discovery-response.js';
12
12
  import 'node:url';
13
13
  import 'pathe';
@@ -1 +1,3 @@
1
1
  export declare const DB_CONTEXT_KEY = "_aiReadyDrizzle";
2
+ export declare const DB_PROMISE_CONTEXT_KEY = "_aiReadyDrizzlePromise";
3
+ export declare const DB_WORK_CONTEXT_KEY = "_aiReadyDrizzleWork";
@@ -1 +1,3 @@
1
1
  export const DB_CONTEXT_KEY = "_aiReadyDrizzle";
2
+ export const DB_PROMISE_CONTEXT_KEY = "_aiReadyDrizzlePromise";
3
+ export const DB_WORK_CONTEXT_KEY = "_aiReadyDrizzleWork";
@@ -4,14 +4,19 @@ import type { DrizzleD1Database } from 'drizzle-orm/d1';
4
4
  import type { LibSQLDatabase } from 'drizzle-orm/libsql';
5
5
  import type { NeonHttpDatabase } from 'drizzle-orm/neon-http';
6
6
  import type { NodeSQLiteDatabase } from 'drizzle-orm/node-sqlite';
7
+ import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js';
7
8
  import type { H3Event } from '#nuxtseo/h3';
8
9
  export type DatabaseDialect = 'sqlite' | 'postgres';
9
10
  type SQLiteDB = BetterSQLite3Database | SQLiteBunDatabase | LibSQLDatabase | DrizzleD1Database | NodeSQLiteDatabase;
10
- type PostgresDB = NeonHttpDatabase;
11
+ type PostgresDB = NeonHttpDatabase | PostgresJsDatabase;
11
12
  export interface DrizzleDatabase {
12
13
  dialect: DatabaseDialect;
13
14
  db: SQLiteDB | PostgresDB;
14
15
  }
16
+ /** Keep request-scoped clients alive until deferred database work finishes. */
17
+ export declare function trackDrizzleWork<T>(event: H3Event, work: Promise<T>): Promise<T>;
18
+ /** Transfer client cleanup to deferred work after the response ends. */
19
+ export declare function finishDrizzleResponse(event: H3Event): Promise<void>;
15
20
  /**
16
21
  * Get Drizzle database instance
17
22
  */
@@ -1,29 +1,82 @@
1
- import { DB_CONTEXT_KEY } from "../context.js";
1
+ import { DB_CONTEXT_KEY, DB_PROMISE_CONTEXT_KEY, DB_WORK_CONTEXT_KEY } from "../context.js";
2
2
  import { closeDriver } from "./raw.js";
3
3
  let fallbackClient;
4
+ let fallbackClientPromise;
5
+ function createDrizzleClient(event) {
6
+ return import("#ai-ready-virtual/db-provider.mjs").then(({ createClient }) => createClient(event));
7
+ }
8
+ function getDrizzleWorkState(event) {
9
+ const context = event.context;
10
+ return context[DB_WORK_CONTEXT_KEY] ??= {
11
+ _tag: "ResponseOpen",
12
+ pending: /* @__PURE__ */ new Set()
13
+ };
14
+ }
15
+ export function trackDrizzleWork(event, work) {
16
+ const state = getDrizzleWorkState(event);
17
+ const tracked = work.finally(async () => {
18
+ state.pending.delete(tracked);
19
+ if (state._tag === "ResponseEnded" && state.pending.size === 0)
20
+ await closeDrizzle(event);
21
+ });
22
+ state.pending.add(tracked);
23
+ return tracked;
24
+ }
25
+ export async function finishDrizzleResponse(event) {
26
+ const state = getDrizzleWorkState(event);
27
+ state._tag = "ResponseEnded";
28
+ if (state.pending.size === 0)
29
+ await closeDrizzle(event);
30
+ }
4
31
  export async function useDrizzle(event) {
5
32
  if (event?.context?.[DB_CONTEXT_KEY]) {
6
33
  return event.context[DB_CONTEXT_KEY];
7
34
  }
35
+ if (event?.context?.[DB_PROMISE_CONTEXT_KEY]) {
36
+ return event.context[DB_PROMISE_CONTEXT_KEY];
37
+ }
8
38
  if (!event && fallbackClient) {
9
39
  return fallbackClient;
10
40
  }
11
- const { createClient } = await import("#ai-ready-virtual/db-provider.mjs");
12
- const client = await createClient(event);
41
+ if (!event && fallbackClientPromise)
42
+ return fallbackClientPromise;
13
43
  if (event?.context) {
14
- event.context[DB_CONTEXT_KEY] = client;
15
- } else {
16
- fallbackClient = client;
44
+ const context = event.context;
45
+ const promise2 = createDrizzleClient(event).then((client) => {
46
+ context[DB_CONTEXT_KEY] = client;
47
+ return client;
48
+ }).finally(() => {
49
+ if (context[DB_PROMISE_CONTEXT_KEY] === promise2)
50
+ delete context[DB_PROMISE_CONTEXT_KEY];
51
+ });
52
+ context[DB_PROMISE_CONTEXT_KEY] = promise2;
53
+ return promise2;
17
54
  }
18
- return client;
55
+ const promise = createDrizzleClient().then((client) => {
56
+ fallbackClient = client;
57
+ return client;
58
+ }).finally(() => {
59
+ if (fallbackClientPromise === promise)
60
+ fallbackClientPromise = void 0;
61
+ });
62
+ fallbackClientPromise = promise;
63
+ return promise;
19
64
  }
20
65
  export async function closeDrizzle(event) {
21
- if (event?.context?.[DB_CONTEXT_KEY]) {
22
- const client = event.context[DB_CONTEXT_KEY];
23
- closeDriver(client.db);
24
- delete event.context[DB_CONTEXT_KEY];
25
- } else if (!event && fallbackClient) {
26
- closeDriver(fallbackClient.db);
66
+ if (event?.context) {
67
+ const context = event.context;
68
+ const client = context[DB_CONTEXT_KEY] ?? await context[DB_PROMISE_CONTEXT_KEY];
69
+ if (!client)
70
+ return;
71
+ await closeDriver(client.db);
72
+ delete context[DB_CONTEXT_KEY];
73
+ delete context[DB_PROMISE_CONTEXT_KEY];
74
+ } else if (!event) {
75
+ const client = fallbackClient ?? await fallbackClientPromise;
76
+ if (!client)
77
+ return;
78
+ await closeDriver(client.db);
27
79
  fallbackClient = void 0;
80
+ fallbackClientPromise = void 0;
28
81
  }
29
82
  }
@@ -2,7 +2,7 @@
2
2
  * Drizzle ORM database layer for nuxt-ai-ready
3
3
  */
4
4
  export * from '#ai-ready-virtual/db-schema.mjs';
5
- export { closeDrizzle, useDrizzle } from './client.js';
5
+ export { closeDrizzle, finishDrizzleResponse, trackDrizzleWork, useDrizzle } from './client.js';
6
6
  export type { DatabaseDialect, DrizzleDatabase } from './client.js';
7
7
  export { completeCronRun, countPages, deleteInfoValue, deletePage, getAllPages, getContentHashes, getInfoValue, getNextSitemapToCrawl, getPageByRoute, getPageLastmods, getPendingPages, getRecentCronRuns, getSitemapStatus, initSchema, 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';
@@ -1,5 +1,5 @@
1
1
  export * from "#ai-ready-virtual/db-schema.mjs";
2
- export { closeDrizzle, useDrizzle } from "./client.js";
2
+ export { closeDrizzle, finishDrizzleResponse, trackDrizzleWork, useDrizzle } from "./client.js";
3
3
  export {
4
4
  completeCronRun,
5
5
  countPages,
@@ -36,7 +36,7 @@ export async function resolveWritableDbPath(dbPath) {
36
36
  const fallbackErr = await ensureWritableDir(fallbackDir);
37
37
  if (fallbackErr) {
38
38
  throw new Error(
39
- `[ai-ready] Database directory '${dir}' is not writable (${err.code}) and the temp dir fallback ('${fallbackDir}') also failed: ${fallbackErr.message}. Set database.filename to a writable path, or use a serverless driver: database.type 'd1' (Cloudflare), 'neon' (Vercel/Postgres), or 'libsql' (Turso).`
39
+ `[ai-ready] Database directory '${dir}' is not writable (${err.code}) and the temp dir fallback ('${fallbackDir}') also failed: ${fallbackErr.message}. Set database.filename to a writable path, or use a remote driver: database.type 'd1' (Cloudflare), 'neon' (Vercel), 'postgres' (PostgreSQL), or 'libsql' (Turso).`
40
40
  );
41
41
  }
42
42
  const fallback = join(fallbackDir, "pages.db");
@@ -0,0 +1,8 @@
1
+ import type { H3Event } from '#nuxtseo/h3';
2
+ import postgres from 'postgres';
3
+ export declare function createClient(event?: H3Event): Promise<{
4
+ dialect: "postgres";
5
+ db: import("drizzle-orm/postgres-js").PostgresJsDatabase<import("drizzle-orm").EmptyRelations> & {
6
+ $client: postgres.Sql<{}>;
7
+ };
8
+ }>;
@@ -0,0 +1,17 @@
1
+ import { drizzle } from "drizzle-orm/postgres-js";
2
+ import postgres from "postgres";
3
+ import { useRuntimeConfig } from "#nuxtseo/nitro";
4
+ import { logger } from "../../../logger.js";
5
+ import { registerDriver } from "../raw.js";
6
+ export async function createClient(event) {
7
+ const config = useRuntimeConfig(event)["nuxt-ai-ready"];
8
+ const connectionString = config.database.url || process.env.POSTGRES_URL || process.env.DATABASE_URL;
9
+ if (!connectionString) {
10
+ throw new Error("[ai-ready] Missing database URL. Set DATABASE_URL or configure database.url");
11
+ }
12
+ logger.debug("[drizzle] Connecting to PostgreSQL");
13
+ const sqlClient = postgres(connectionString, { prepare: false });
14
+ const db = drizzle({ client: sqlClient });
15
+ registerDriver(db, "postgres", sqlClient);
16
+ return { dialect: "postgres", db };
17
+ }
@@ -100,7 +100,7 @@ export declare function setInfoValue(event: H3Event | undefined, key: string, va
100
100
  */
101
101
  export declare function deleteInfoValue(event: H3Event | undefined, key: string): Promise<void>;
102
102
  /**
103
- * Initialize database schema. Rebuilds on SCHEMA_VERSION change or when the
103
+ * Initialize database schema. Rebuilds on a schema version change or when the
104
104
  * SQLite FTS5 tokenizer differs from the one baked into the existing virtual
105
105
  * table (Postgres has no FTS5; tokenizer comparison is SQLite-only).
106
106
  */
@@ -4,7 +4,7 @@ import { useRuntimeConfig } from "#nuxtseo/nitro";
4
4
  import { parseSitemapCrawlState, serializeSitemapCrawlState } from "../../utils/sitemap-crawl-state.js";
5
5
  import { resolveFtsTokenizer as validateFtsTokenizer } from "../schema-sql.js";
6
6
  import { useDrizzle } from "./client.js";
7
- import { useRawDb } from "./raw.js";
7
+ import { getRawExecutor, useRawDb } from "./raw.js";
8
8
  function resolveFtsTokenizer(event) {
9
9
  const cfg = useRuntimeConfig(event);
10
10
  return validateFtsTokenizer(cfg["nuxt-ai-ready"]?.ftsTokenizer);
@@ -229,16 +229,23 @@ export async function deleteInfoValue(event, key) {
229
229
  const client = await useDrizzle(event);
230
230
  await client.db.delete(info).where(eq(info.id, key));
231
231
  }
232
- const SCHEMA_VERSION = "v2.3.0-drizzle";
232
+ const SQLITE_SCHEMA_VERSION = "v2.3.0-drizzle";
233
+ const LEGACY_POSTGRES_INTEGER_SCHEMA_VERSION = "v2.3.0-drizzle";
234
+ const POSTGRES_SCHEMA_VERSION = "v2.3.0-drizzle-postgres-bigint";
233
235
  export async function initSchema(event) {
234
236
  const client = await useDrizzle(event);
235
237
  const tokenizer = resolveFtsTokenizer(event);
238
+ const schemaVersion = client.dialect === "postgres" ? POSTGRES_SCHEMA_VERSION : SQLITE_SCHEMA_VERSION;
236
239
  const currentVersion = await getSchemaVersion(client);
237
240
  const currentTokenizer = client.dialect === "postgres" ? null : await getStoredTokenizer(client);
238
- const versionMatches = currentVersion === SCHEMA_VERSION;
241
+ const versionMatches = currentVersion === schemaVersion;
239
242
  const tokenizerMatches = client.dialect === "postgres" || !currentTokenizer || currentTokenizer === tokenizer;
240
243
  if (versionMatches && tokenizerMatches)
241
244
  return;
245
+ if (client.dialect === "postgres" && currentVersion === LEGACY_POSTGRES_INTEGER_SCHEMA_VERSION) {
246
+ await migratePostgresIntegerTimestamps(client);
247
+ return;
248
+ }
242
249
  if (currentVersion) {
243
250
  if (client.dialect === "postgres") {
244
251
  await dropPostgresTables(client);
@@ -251,9 +258,9 @@ export async function initSchema(event) {
251
258
  } else {
252
259
  await createSQLiteTables(client, tokenizer);
253
260
  }
254
- await client.db.insert(info).values({ id: "schema", version: SCHEMA_VERSION }).onConflictDoUpdate({
261
+ await client.db.insert(info).values({ id: "schema", version: schemaVersion }).onConflictDoUpdate({
255
262
  target: info.id,
256
- set: { version: SCHEMA_VERSION }
263
+ set: { version: schemaVersion }
257
264
  });
258
265
  if (client.dialect !== "postgres") {
259
266
  await client.db.insert(info).values({ id: "fts_tokenizer", value: tokenizer }).onConflictDoUpdate({
@@ -264,23 +271,38 @@ export async function initSchema(event) {
264
271
  }
265
272
  async function getSchemaVersion(client) {
266
273
  try {
267
- const result = await client.db.all(
268
- sql`SELECT version FROM _ai_ready_info WHERE id = 'schema'`
269
- );
270
- return result?.[0]?.version || null;
271
- } catch {
272
- return null;
274
+ const [row] = await client.db.select({ version: info.version }).from(info).where(eq(info.id, "schema")).limit(1);
275
+ return row?.version || null;
276
+ } catch (error) {
277
+ if (isMissingInfoTableError(error))
278
+ return null;
279
+ throw error;
273
280
  }
274
281
  }
275
282
  async function getStoredTokenizer(client) {
276
283
  try {
277
- const result = await client.db.all(
278
- sql`SELECT value FROM _ai_ready_info WHERE id = 'fts_tokenizer'`
279
- );
280
- return result?.[0]?.value || null;
281
- } catch {
282
- return null;
284
+ const [row] = await client.db.select({ value: info.value }).from(info).where(eq(info.id, "fts_tokenizer")).limit(1);
285
+ return row?.value || null;
286
+ } catch (error) {
287
+ if (isMissingInfoTableError(error))
288
+ return null;
289
+ throw error;
290
+ }
291
+ }
292
+ function isMissingInfoTableError(error) {
293
+ let current = error;
294
+ const seen = /* @__PURE__ */ new Set();
295
+ while (current && typeof current === "object" && !seen.has(current)) {
296
+ seen.add(current);
297
+ const candidate = current;
298
+ if (candidate.code === "42P01")
299
+ return true;
300
+ if (typeof candidate.message === "string" && candidate.message.includes("no such table") && candidate.message.includes("_ai_ready_info")) {
301
+ return true;
302
+ }
303
+ current = candidate.cause;
283
304
  }
305
+ return false;
284
306
  }
285
307
  const SQLITE_DROP_STATEMENTS = [
286
308
  sql`DROP TABLE IF EXISTS ai_ready_pages_fts`,
@@ -305,6 +327,19 @@ async function dropPostgresTables(client) {
305
327
  for (const stmt of POSTGRES_DROP_STATEMENTS)
306
328
  await client.db.execute(stmt);
307
329
  }
330
+ async function migratePostgresIntegerTimestamps(client) {
331
+ await getRawExecutor(client).batch([
332
+ { sql: "ALTER TABLE ai_ready_pages ALTER COLUMN indexed_at TYPE BIGINT USING indexed_at::BIGINT" },
333
+ { sql: "ALTER TABLE ai_ready_pages ALTER COLUMN last_seen_at TYPE BIGINT USING last_seen_at::BIGINT" },
334
+ { sql: "ALTER TABLE ai_ready_cron_runs ALTER COLUMN started_at TYPE BIGINT USING started_at::BIGINT" },
335
+ { sql: "ALTER TABLE ai_ready_cron_runs ALTER COLUMN finished_at TYPE BIGINT USING finished_at::BIGINT" },
336
+ { sql: "ALTER TABLE ai_ready_sitemaps ALTER COLUMN last_crawled_at TYPE BIGINT USING last_crawled_at::BIGINT" },
337
+ {
338
+ sql: "UPDATE _ai_ready_info SET version = ? WHERE id = 'schema'",
339
+ params: [POSTGRES_SCHEMA_VERSION]
340
+ }
341
+ ]);
342
+ }
308
343
  async function createSQLiteTables(client, ftsTokenizer) {
309
344
  const statements = [
310
345
  sql`CREATE TABLE IF NOT EXISTS ai_ready_pages (
@@ -404,11 +439,11 @@ async function createPostgresTables(client) {
404
439
  keywords TEXT NOT NULL DEFAULT '[]',
405
440
  content_hash TEXT,
406
441
  updated_at TEXT NOT NULL,
407
- indexed_at INTEGER NOT NULL,
442
+ indexed_at BIGINT NOT NULL,
408
443
  is_error INTEGER NOT NULL DEFAULT 0,
409
444
  indexed INTEGER NOT NULL DEFAULT 0,
410
445
  source TEXT NOT NULL DEFAULT 'prerender',
411
- last_seen_at INTEGER,
446
+ last_seen_at BIGINT,
412
447
  locale TEXT NOT NULL DEFAULT ''
413
448
  )`,
414
449
  sql`CREATE TABLE IF NOT EXISTS _ai_ready_info (
@@ -420,8 +455,8 @@ async function createPostgresTables(client) {
420
455
  )`,
421
456
  sql`CREATE TABLE IF NOT EXISTS ai_ready_cron_runs (
422
457
  id SERIAL PRIMARY KEY,
423
- started_at INTEGER NOT NULL,
424
- finished_at INTEGER,
458
+ started_at BIGINT NOT NULL,
459
+ finished_at BIGINT,
425
460
  duration_ms INTEGER,
426
461
  pages_indexed INTEGER DEFAULT 0,
427
462
  pages_remaining INTEGER DEFAULT 0,
@@ -431,7 +466,7 @@ async function createPostgresTables(client) {
431
466
  sql`CREATE TABLE IF NOT EXISTS ai_ready_sitemaps (
432
467
  name TEXT PRIMARY KEY,
433
468
  route TEXT NOT NULL,
434
- last_crawled_at INTEGER,
469
+ last_crawled_at BIGINT,
435
470
  url_count INTEGER DEFAULT 0,
436
471
  error_count INTEGER DEFAULT 0,
437
472
  last_error TEXT,
@@ -7,7 +7,7 @@ import type { DrizzleDatabase } from './client.js';
7
7
  /**
8
8
  * Register the underlying driver for raw SQL access
9
9
  */
10
- export declare function registerDriver(db: DrizzleDatabase['db'], type: 'better-sqlite3' | 'node-sqlite' | 'libsql' | 'neon' | 'd1', driver: unknown): void;
10
+ export declare function registerDriver(db: DrizzleDatabase['db'], type: 'better-sqlite3' | 'node-sqlite' | 'libsql' | 'neon' | 'postgres' | 'd1', driver: unknown): void;
11
11
  /**
12
12
  * Get raw SQL executor for a Drizzle client
13
13
  */
@@ -33,4 +33,4 @@ export declare function useRawDb(event?: H3Event): Promise<RawExecutor>;
33
33
  /**
34
34
  * Close underlying database driver connection
35
35
  */
36
- export declare function closeDriver(db: DrizzleDatabase['db']): void;
36
+ export declare function closeDriver(db: DrizzleDatabase['db']): Promise<void>;
@@ -5,6 +5,10 @@ export function registerDriver(db, type, driver) {
5
5
  }
6
6
  const RE_PARAM_PLACEHOLDER = /\?/g;
7
7
  const MAX_BATCH_STATEMENTS = 100;
8
+ function toPostgresQuery(query) {
9
+ let index = 0;
10
+ return query.replace(RE_PARAM_PLACEHOLDER, () => `$${++index}`);
11
+ }
8
12
  export function getRawExecutor(client) {
9
13
  const cached = driverCache.get(client.db);
10
14
  if (!cached) {
@@ -35,11 +39,13 @@ export function getRawExecutor(client) {
35
39
  }
36
40
  case "neon": {
37
41
  const sqlFn = driver;
38
- let idx = 0;
39
- const pgQuery = query.replace(RE_PARAM_PLACEHOLDER, () => `$${++idx}`);
40
- const result = await sqlFn.query(pgQuery, params);
42
+ const result = await sqlFn.query(toPostgresQuery(query), params);
41
43
  return result.rows || result;
42
44
  }
45
+ case "postgres": {
46
+ const sqlClient = driver;
47
+ return await sqlClient.unsafe(toPostgresQuery(query), params);
48
+ }
43
49
  }
44
50
  },
45
51
  async first(query, params = []) {
@@ -70,9 +76,12 @@ export function getRawExecutor(client) {
70
76
  }
71
77
  case "neon": {
72
78
  const sqlFn = driver;
73
- let idx = 0;
74
- const pgQuery = query.replace(RE_PARAM_PLACEHOLDER, () => `$${++idx}`);
75
- await sqlFn.query(pgQuery, params);
79
+ await sqlFn.query(toPostgresQuery(query), params);
80
+ break;
81
+ }
82
+ case "postgres": {
83
+ const sqlClient = driver;
84
+ await sqlClient.unsafe(toPostgresQuery(query), params);
76
85
  break;
77
86
  }
78
87
  }
@@ -128,14 +137,22 @@ export function getRawExecutor(client) {
128
137
  for (let i = 0; i < queries.length; i += MAX_BATCH_STATEMENTS) {
129
138
  const chunk = queries.slice(i, i + MAX_BATCH_STATEMENTS);
130
139
  const pgQueries = chunk.map((q) => {
131
- let idx = 0;
132
- const pgQuery = q.sql.replace(RE_PARAM_PLACEHOLDER, () => `$${++idx}`);
133
- return sqlFn.query(pgQuery, q.params || []);
140
+ return sqlFn.query(toPostgresQuery(q.sql), q.params || []);
134
141
  });
135
142
  await sqlFn.transaction(pgQueries);
136
143
  }
137
144
  break;
138
145
  }
146
+ case "postgres": {
147
+ const sqlClient = driver;
148
+ for (let i = 0; i < queries.length; i += MAX_BATCH_STATEMENTS) {
149
+ const chunk = queries.slice(i, i + MAX_BATCH_STATEMENTS);
150
+ await sqlClient.begin((transaction) => Promise.all(chunk.map(
151
+ (q) => transaction.unsafe(toPostgresQuery(q.sql), q.params || [])
152
+ )));
153
+ }
154
+ break;
155
+ }
139
156
  }
140
157
  }
141
158
  };
@@ -144,7 +161,7 @@ export async function useRawDb(event) {
144
161
  const client = await useDrizzle(event);
145
162
  return getRawExecutor(client);
146
163
  }
147
- export function closeDriver(db) {
164
+ export async function closeDriver(db) {
148
165
  const cached = driverCache.get(db);
149
166
  if (!cached)
150
167
  return;
@@ -165,6 +182,11 @@ export function closeDriver(db) {
165
182
  client.close?.();
166
183
  break;
167
184
  }
185
+ case "postgres": {
186
+ const sqlClient = driver;
187
+ await sqlClient.end();
188
+ break;
189
+ }
168
190
  }
169
191
  driverCache.delete(db);
170
192
  }
@@ -48,6 +48,7 @@ export interface QueryPagesOptions {
48
48
  limit?: number;
49
49
  offset?: number;
50
50
  }
51
+ type DatabaseNumber = number | string | bigint;
51
52
  /**
52
53
  * Get lastmod (updatedAt) for all indexed pages
53
54
  * Returns a Map for O(1) lookup when enriching sitemaps
@@ -92,7 +93,7 @@ export interface SearchPagesOptions {
92
93
  limit?: number;
93
94
  }
94
95
  /**
95
- * Full-text search using FTS5
96
+ * Full-text search using FTS5 or PostgreSQL ILIKE
96
97
  * Note: FTS is only available at runtime, not during prerender
97
98
  */
98
99
  export declare function searchPages(event: H3Event | undefined, query: string, options?: SearchPagesOptions): Promise<SearchResult[]>;
@@ -159,11 +160,11 @@ export declare function pruneStaleRoutes(event: H3Event | undefined, staleThresh
159
160
  export declare function getStaleRoutes(event: H3Event | undefined, staleThresholdSeconds: number): Promise<string[]>;
160
161
  export interface CronRunRow {
161
162
  id: number;
162
- started_at: number;
163
- finished_at: number | null;
164
- duration_ms: number | null;
165
- pages_indexed: number;
166
- pages_remaining: number;
163
+ started_at: DatabaseNumber;
164
+ finished_at: DatabaseNumber | null;
165
+ duration_ms: DatabaseNumber | null;
166
+ pages_indexed: DatabaseNumber;
167
+ pages_remaining: DatabaseNumber;
167
168
  errors: string;
168
169
  status: 'running' | 'success' | 'partial' | 'error';
169
170
  }
@@ -303,3 +304,4 @@ export declare function getRecentlyIndexedPages(event: H3Event | undefined, limi
303
304
  * Count pages indexed in a time window
304
305
  */
305
306
  export declare function countRecentlyIndexed(event: H3Event | undefined, sinceMs: number): Promise<number>;
307
+ export {};
@@ -24,7 +24,7 @@ function getEventFromContext(providedEvent) {
24
24
  }
25
25
  }
26
26
  let devWarningShown = false;
27
- let schemaInitialized = false;
27
+ let schemaInitializationState = { _tag: "Uninitialized" };
28
28
  const RE_FTS_CHARS = /[*:^"()]/g;
29
29
  const RE_WHITESPACE = /\s+/;
30
30
  async function getDb(event) {
@@ -43,10 +43,17 @@ async function getDb(event) {
43
43
  if (cfg["nuxt-ai-ready"]?.database?._tag === "Disabled")
44
44
  return null;
45
45
  const db = await useRawDb(resolvedEvent);
46
- if (!schemaInitialized) {
47
- await initSchema(resolvedEvent);
48
- schemaInitialized = true;
46
+ if (schemaInitializationState._tag === "Uninitialized") {
47
+ const promise = initSchema(resolvedEvent).then(() => {
48
+ schemaInitializationState = { _tag: "Initialized" };
49
+ }).catch((error) => {
50
+ schemaInitializationState = { _tag: "Uninitialized" };
51
+ throw error;
52
+ });
53
+ schemaInitializationState = { _tag: "Initializing", promise };
49
54
  }
55
+ if (schemaInitializationState._tag === "Initializing")
56
+ await schemaInitializationState.promise;
50
57
  return db;
51
58
  }
52
59
  async function getPrerenderDb() {
@@ -149,6 +156,12 @@ function safeJsonParse(json, fallback) {
149
156
  return fallback;
150
157
  }
151
158
  }
159
+ function toNumber(value, fallback = 0) {
160
+ return value === null || value === void 0 ? fallback : Number(value);
161
+ }
162
+ function toNullableNumber(value) {
163
+ return value === null || value === void 0 ? null : Number(value);
164
+ }
152
165
  function rowToEntry(row) {
153
166
  return {
154
167
  route: row.route,
@@ -238,7 +251,7 @@ export async function countPages(event, options = {}) {
238
251
  `SELECT COUNT(*) as count FROM ai_ready_pages ${whereClause}`,
239
252
  params
240
253
  );
241
- return row?.count || 0;
254
+ return toNumber(row?.count);
242
255
  }
243
256
  export async function searchPages(event, query, options = {}) {
244
257
  if (import.meta.dev || import.meta.prerender)
@@ -250,6 +263,17 @@ export async function searchPages(event, query, options = {}) {
250
263
  const sanitized = query.replace(RE_FTS_CHARS, " ").trim();
251
264
  if (!sanitized)
252
265
  return [];
266
+ if (db.dialect === "postgres") {
267
+ const searchTerm = `%${sanitized}%`;
268
+ return db.all(`
269
+ SELECT route, title, description, 0 AS score
270
+ FROM ai_ready_pages
271
+ WHERE is_error = 0
272
+ AND (title ILIKE ? OR description ILIKE ? OR markdown ILIKE ? OR headings ILIKE ?)
273
+ ORDER BY route
274
+ LIMIT ?
275
+ `, [searchTerm, searchTerm, searchTerm, searchTerm, limit]);
276
+ }
253
277
  const terms = sanitized.split(RE_WHITESPACE).map((t) => `${t}*`).join(" ");
254
278
  return db.all(`
255
279
  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
@@ -299,7 +323,7 @@ export async function isPageFresh(event, route, ttlSeconds) {
299
323
  const row = await db.first("SELECT indexed_at FROM ai_ready_pages WHERE route = ?", [route]);
300
324
  if (!row)
301
325
  return false;
302
- const age = (Date.now() - row.indexed_at) / 1e3;
326
+ const age = (Date.now() - toNumber(row.indexed_at)) / 1e3;
303
327
  return age < ttlSeconds;
304
328
  }
305
329
  export async function getPageIndexState(event, route) {
@@ -310,7 +334,7 @@ export async function getPageIndexState(event, route) {
310
334
  "SELECT indexed_at, content_hash FROM ai_ready_pages WHERE route = ?",
311
335
  [route]
312
336
  );
313
- return row ? { indexedAt: row.indexed_at, contentHash: row.content_hash } : void 0;
337
+ return row ? { indexedAt: toNumber(row.indexed_at), contentHash: row.content_hash } : void 0;
314
338
  }
315
339
  export async function getPageHash(event, route) {
316
340
  const db = await getDb(event);
@@ -369,7 +393,10 @@ export async function setSitemapSeededAt(event, timestamp) {
369
393
  const db = await getDb(event);
370
394
  if (!db)
371
395
  return;
372
- await db.exec("INSERT OR REPLACE INTO _ai_ready_info (id, value) VALUES (?, ?)", ["sitemap_seeded_at", String(timestamp)]);
396
+ await db.exec(`
397
+ INSERT INTO _ai_ready_info (id, value) VALUES (?, ?)
398
+ ON CONFLICT(id) DO UPDATE SET value = excluded.value
399
+ `, ["sitemap_seeded_at", String(timestamp)]);
373
400
  }
374
401
  export async function pruneStaleRoutes(event, staleThresholdSeconds, protectedSince) {
375
402
  const db = await getDb(event);
@@ -381,7 +408,7 @@ export async function pruneStaleRoutes(event, staleThresholdSeconds, protectedSi
381
408
  "SELECT COUNT(*) as count FROM ai_ready_pages WHERE source = ? AND last_seen_at < ?",
382
409
  ["runtime", threshold]
383
410
  );
384
- const count = countRow?.count || 0;
411
+ const count = toNumber(countRow?.count);
385
412
  if (count > 0) {
386
413
  await db.exec("DELETE FROM ai_ready_pages WHERE source = ? AND last_seen_at < ?", ["runtime", threshold]);
387
414
  }
@@ -401,11 +428,11 @@ export async function getStaleRoutes(event, staleThresholdSeconds) {
401
428
  function rowToCronRun(row) {
402
429
  return {
403
430
  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,
431
+ startedAt: toNumber(row.started_at),
432
+ finishedAt: toNullableNumber(row.finished_at),
433
+ durationMs: toNullableNumber(row.duration_ms),
434
+ pagesIndexed: toNumber(row.pages_indexed),
435
+ pagesRemaining: toNumber(row.pages_remaining),
409
436
  errors: safeJsonParse(row.errors, []),
410
437
  status: row.status
411
438
  };
@@ -415,12 +442,11 @@ export async function startCronRun(event) {
415
442
  if (!db)
416
443
  return null;
417
444
  const now = Date.now();
418
- await db.exec(
419
- "INSERT INTO ai_ready_cron_runs (started_at, status) VALUES (?, ?)",
445
+ const row = await db.first(
446
+ "INSERT INTO ai_ready_cron_runs (started_at, status) VALUES (?, ?) RETURNING id",
420
447
  [now, "running"]
421
448
  );
422
- const row = await db.first("SELECT last_insert_rowid() as id");
423
- return row?.id || null;
449
+ return row ? toNumber(row.id) : null;
424
450
  }
425
451
  export async function completeCronRun(event, runId, result) {
426
452
  const db = await getDb(event);
@@ -428,7 +454,7 @@ export async function completeCronRun(event, runId, result) {
428
454
  return;
429
455
  const now = Date.now();
430
456
  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;
457
+ const durationMs = row ? now - toNumber(row.started_at) : null;
432
458
  const status = result.errors.length > 0 ? result.pagesIndexed > 0 ? "partial" : "error" : "success";
433
459
  await db.exec(`
434
460
  UPDATE ai_ready_cron_runs SET
@@ -456,7 +482,7 @@ export async function cleanupOldCronRuns(event, keepCount = 50) {
456
482
  if (!db)
457
483
  return 0;
458
484
  const countRow = await db.first("SELECT COUNT(*) as count FROM ai_ready_cron_runs");
459
- const total = countRow?.count || 0;
485
+ const total = toNumber(countRow?.count);
460
486
  if (total <= keepCount)
461
487
  return 0;
462
488
  const deleteCount = total - keepCount;
@@ -476,7 +502,7 @@ export async function pruneCronRunsByAge(event, maxAgeMs = 24 * 60 * 60 * 1e3) {
476
502
  "SELECT COUNT(*) as count FROM ai_ready_cron_runs WHERE started_at < ?",
477
503
  [threshold]
478
504
  );
479
- const count = countRow?.count || 0;
505
+ const count = toNumber(countRow?.count);
480
506
  if (count > 0) {
481
507
  await db.exec("DELETE FROM ai_ready_cron_runs WHERE started_at < ?", [threshold]);
482
508
  }
@@ -498,11 +524,11 @@ export async function getCronFastPathStatus(event, sitemapIntervalMinutes = 5) {
498
524
  if (!row)
499
525
  return null;
500
526
  return {
501
- totalPages: row.total_pages,
502
- pendingPages: row.pending_pages,
527
+ totalPages: toNumber(row.total_pages),
528
+ pendingPages: toNumber(row.pending_pages),
503
529
  lastStaleCheck: row.last_stale_check ? Number.parseInt(row.last_stale_check, 10) : null,
504
530
  buildId: row.build_id,
505
- sitemapsNeedCrawl: row.sitemaps_need_crawl
531
+ sitemapsNeedCrawl: toNumber(row.sitemaps_need_crawl)
506
532
  };
507
533
  }
508
534
  const CRON_LOCK_TTL_MS = 3e5;
@@ -515,7 +541,7 @@ export async function tryAcquireCronLock(event) {
515
541
  await db.exec(`
516
542
  INSERT INTO _ai_ready_info (id, value) VALUES ('cron_lock', ?)
517
543
  ON CONFLICT(id) DO UPDATE SET value = ?
518
- WHERE CAST(value AS INTEGER) < ?
544
+ WHERE CAST(_ai_ready_info.value AS BIGINT) < ?
519
545
  `, [String(now), String(now), staleThreshold]);
520
546
  const row = await db.first("SELECT value FROM _ai_ready_info WHERE id = ?", ["cron_lock"]);
521
547
  return row?.value === String(now);
@@ -554,9 +580,9 @@ function rowToSitemapEntry(row) {
554
580
  return {
555
581
  name: row.name,
556
582
  route: row.route,
557
- lastCrawledAt: row.last_crawled_at,
558
- urlCount: row.url_count || 0,
559
- errorCount: row.error_count || 0,
583
+ lastCrawledAt: toNullableNumber(row.last_crawled_at),
584
+ urlCount: toNumber(row.url_count),
585
+ errorCount: toNumber(row.error_count),
560
586
  lastError: row.last_error,
561
587
  crawlState: parsedState.state
562
588
  };
@@ -643,7 +669,7 @@ export async function getSitemapLastCrawledAt(event, name) {
643
669
  "SELECT last_crawled_at FROM ai_ready_sitemaps WHERE name = ?",
644
670
  [name]
645
671
  );
646
- return row?.last_crawled_at ?? null;
672
+ return toNullableNumber(row?.last_crawled_at);
647
673
  }
648
674
  export async function markSitemapCrawled(event, name, urlCount) {
649
675
  const db = await getDb(event);
@@ -663,6 +689,8 @@ export async function markSitemapSeeded(event, name, urlCount, expectedLastCrawl
663
689
  const db = await getDb(event);
664
690
  if (!db)
665
691
  return;
692
+ const expectedClause = expectedLastCrawledAt === null ? "last_crawled_at IS NULL" : "last_crawled_at = ?";
693
+ const expectedParams = expectedLastCrawledAt === null ? [] : [expectedLastCrawledAt];
666
694
  await db.exec(`
667
695
  UPDATE ai_ready_sitemaps SET
668
696
  last_crawled_at = ?,
@@ -672,8 +700,8 @@ export async function markSitemapSeeded(event, name, urlCount, expectedLastCrawl
672
700
  WHERE name = ?
673
701
  AND crawl_state IS NULL
674
702
  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]);
703
+ AND ${expectedClause}
704
+ `, [Date.now(), urlCount, name, ...expectedParams]);
677
705
  }
678
706
  export async function markSitemapCrawlPartial(event, name, state) {
679
707
  const db = await getDb(event);
@@ -709,7 +737,7 @@ export async function resetSitemapErrors(event) {
709
737
  const countRow = await db.first(
710
738
  "SELECT COUNT(*) as count FROM ai_ready_sitemaps WHERE error_count > 0 OR crawl_state IS NOT NULL"
711
739
  );
712
- const count = countRow?.count || 0;
740
+ const count = toNumber(countRow?.count);
713
741
  if (count > 0) {
714
742
  await db.exec("UPDATE ai_ready_sitemaps SET error_count = 0, last_error = NULL, last_crawled_at = NULL, crawl_state = NULL");
715
743
  }
@@ -733,7 +761,7 @@ export async function getRecentlyIndexedPages(event, limit = 10) {
733
761
  "SELECT route, title, indexed_at FROM ai_ready_pages WHERE indexed = 1 AND is_error = 0 ORDER BY indexed_at DESC LIMIT ?",
734
762
  [limit]
735
763
  );
736
- return rows.map((r) => ({ route: r.route, title: r.title, indexedAt: r.indexed_at }));
764
+ return rows.map((r) => ({ route: r.route, title: r.title, indexedAt: toNumber(r.indexed_at) }));
737
765
  }
738
766
  export async function countRecentlyIndexed(event, sinceMs) {
739
767
  const db = await getDb(event);
@@ -744,5 +772,5 @@ export async function countRecentlyIndexed(event, sinceMs) {
744
772
  "SELECT COUNT(*) as count FROM ai_ready_pages WHERE indexed = 1 AND indexed_at > ?",
745
773
  [threshold]
746
774
  );
747
- return row?.count || 0;
775
+ return toNumber(row?.count);
748
776
  }
@@ -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"),
@@ -1,10 +1,11 @@
1
- import { DB_CONTEXT_KEY } from "../db/context.js";
1
+ import { DB_CONTEXT_KEY, DB_PROMISE_CONTEXT_KEY, DB_WORK_CONTEXT_KEY } from "../db/context.js";
2
2
  export default function dbLifecyclePlugin(nitroApp) {
3
3
  nitroApp.hooks.hook("afterResponse", async (event) => {
4
- if (!event.context?.[DB_CONTEXT_KEY])
4
+ if (!event.context?.[DB_CONTEXT_KEY] && !event.context?.[DB_PROMISE_CONTEXT_KEY] && !event.context?.[DB_WORK_CONTEXT_KEY]) {
5
5
  return;
6
- const { closeDrizzle } = await import("../db/index.js");
7
- await closeDrizzle(event);
6
+ }
7
+ const { finishDrizzleResponse } = await import("../db/index.js");
8
+ await finishDrizzleResponse(event);
8
9
  });
9
10
  nitroApp.hooks.hook("close", async () => {
10
11
  const { closeDrizzle } = await import("../db/index.js");
@@ -1,4 +1,5 @@
1
1
  import { useRuntimeConfig } from "#nuxtseo/nitro";
2
+ import { trackDrizzleWork } from "../db/drizzle/client.js";
2
3
  import { getPageLastmods, getSitemapLastCrawledAt, markSitemapSeeded, seedRoutes } from "../db/queries.js";
3
4
  import { logger } from "../logger.js";
4
5
  import { mapSitemapRoutes } from "../utils/sitemap-routes.js";
@@ -106,9 +107,10 @@ export default function sitemapSeederPlugin(nitroApp) {
106
107
  logger.debug(`[sitemap-seeder] Seeded ${seeded} routes from ${sitemapName} in ${seedMs}ms`);
107
108
  };
108
109
  if (event.waitUntil) {
109
- event.waitUntil(seed().catch(
110
+ const backgroundSeed = trackDrizzleWork(event, seed()).catch(
110
111
  (err) => logger.error(`[sitemap-seeder] Background seed failed: ${err.message}`)
111
- ));
112
+ );
113
+ event.waitUntil(backgroundSeed);
112
114
  } else {
113
115
  await seed();
114
116
  }
@@ -1,5 +1,4 @@
1
1
  import type { H3Event } from '#nuxtseo/h3';
2
- export declare const INDEXING_HEADER = "x-ai-ready-indexing";
3
2
  export interface IndexPageOptions {
4
3
  /** Skip if page was indexed within TTL (uses config ttl if not specified) */
5
4
  ttl?: number;
@@ -5,7 +5,7 @@ import { logger } from "../logger.js";
5
5
  import { convertHtmlToMarkdown } from "../utils.js";
6
6
  import { createUniversalContext } from "./context.js";
7
7
  import { extractKeywords } from "./keywords.js";
8
- export const INDEXING_HEADER = "x-ai-ready-indexing";
8
+ import { INTERNAL_HEADER } from "./negotiation-decision.js";
9
9
  export async function indexPage(route, html, options = {}, event) {
10
10
  const config = useRuntimeConfig()["nuxt-ai-ready"];
11
11
  const ttl = options.ttl ?? config.runtimeSync.ttl;
@@ -70,10 +70,10 @@ export async function indexPage(route, html, options = {}, event) {
70
70
  export async function indexPageByRoute(route, event, options = {}) {
71
71
  logger.debug(`[indexPageByRoute] Fetching HTML for ${route} (timeout: 10000ms)`);
72
72
  const html = await (event ? fetchWithEvent(event, route, {
73
- headers: { [INDEXING_HEADER]: "1" },
73
+ headers: { accept: "text/html", [INTERNAL_HEADER]: "1" },
74
74
  timeout: 1e4
75
75
  }) : globalThis.$fetch(route, {
76
- headers: { [INDEXING_HEADER]: "1" },
76
+ headers: { accept: "text/html", [INTERNAL_HEADER]: "1" },
77
77
  timeout: 1e4
78
78
  // 10s timeout per page (must fit within CF worker limit)
79
79
  })).catch((err) => {
@@ -1,7 +1,7 @@
1
1
  import type { ContentNegotiationPolicy } from '../../types.js';
2
2
  import type { ContentNegotiationResolution, NegotiationRouteRule } from './content-negotiation.js';
3
3
  import type { MarkdownRequest } from './markdown-request.js';
4
- /** Marks the internal HTML fetch that the explicit `.md` handler makes. */
4
+ /** Marks an internal HTML fetch that must bypass content negotiation. */
5
5
  export declare const INTERNAL_HEADER = "x-ai-ready-internal";
6
6
  /**
7
7
  * Where the decision runs. The early stage sits in front of the Nitro static
@@ -231,7 +231,7 @@ export interface ModuleOptions {
231
231
  llmsTxtCacheSeconds?: number;
232
232
  /**
233
233
  * Database configuration for page storage
234
- * Supports SQLite, LibSQL/Turso, Cloudflare D1, and Neon Postgres
234
+ * Supports SQLite, LibSQL/Turso, Cloudflare D1, Neon, and PostgreSQL
235
235
  *
236
236
  * Storage stays off until a runtime feature or page tool needs it.
237
237
  * Set to `false` (or `{ type: 'none' }`) to force it off.
@@ -247,9 +247,10 @@ export interface ModuleOptions {
247
247
  * - 'bun': Bun SQLite via bun:sqlite (auto-detected on Bun) [experimental]
248
248
  * - 'libsql': Turso/LibSQL [experimental]
249
249
  * - 'neon': Vercel Postgres via Neon serverless (auto-detected on Vercel with POSTGRES_URL) [experimental]
250
+ * - 'postgres': PostgreSQL via Postgres.js [experimental]
250
251
  * @default 'sqlite' when a requested feature needs storage
251
252
  */
252
- type?: 'none' | 'sqlite' | 'bun' | 'd1' | 'libsql' | 'neon';
253
+ type?: 'none' | 'sqlite' | 'bun' | 'd1' | 'libsql' | 'neon' | 'postgres';
253
254
  /**
254
255
  * SQLite filename (relative to rootDir or absolute)
255
256
  * @default '.data/ai-ready/pages.db'
@@ -261,8 +262,8 @@ export interface ModuleOptions {
261
262
  */
262
263
  bindingName?: string;
263
264
  /**
264
- * Database URL for LibSQL/Turso or Neon/Vercel Postgres
265
- * For Vercel: auto-reads from POSTGRES_URL env var
265
+ * Database URL for LibSQL/Turso, Neon, or PostgreSQL
266
+ * PostgreSQL drivers also read POSTGRES_URL or DATABASE_URL.
266
267
  */
267
268
  url?: string;
268
269
  /**
@@ -598,7 +598,7 @@ function resolveDatabaseConfig(input) {
598
598
  database: { _tag: "Enabled", type, bindingName: config.bindingName || "DB" }
599
599
  };
600
600
  }
601
- if (type === "neon") {
601
+ if (type === "neon" || type === "postgres") {
602
602
  return {
603
603
  _tag: "Resolved",
604
604
  logs,
@@ -1579,12 +1579,13 @@ export const logger = createModuleLogger('nuxt-ai-ready', ${!!config.debug})
1579
1579
  bun: "#ai-ready/server/db/drizzle/providers/bun",
1580
1580
  d1: "#ai-ready/server/db/drizzle/providers/d1",
1581
1581
  libsql: "#ai-ready/server/db/drizzle/providers/libsql",
1582
- neon: "#ai-ready/server/db/drizzle/providers/neon"
1582
+ neon: "#ai-ready/server/db/drizzle/providers/neon",
1583
+ postgres: "#ai-ready/server/db/drizzle/providers/postgres"
1583
1584
  };
1584
1585
  nitroConfig.virtual["#ai-ready-virtual/db-provider.mjs"] = database._tag === "Enabled" ? `export { createClient } from '${providerMap[database.type] || providerMap.sqlite}'` : `export function createClient() {
1585
1586
  throw new Error('[nuxt-ai-ready] The database is disabled. Set \`aiReady.database\` to store pages at runtime.')
1586
1587
  }`;
1587
- const schemaPath = database._tag === "Enabled" && database.type === "neon" ? "#ai-ready/server/db/schema/postgres" : "#ai-ready/server/db/schema/sqlite";
1588
+ const schemaPath = database._tag === "Enabled" && (database.type === "neon" || database.type === "postgres") ? "#ai-ready/server/db/schema/postgres" : "#ai-ready/server/db/schema/sqlite";
1588
1589
  nitroConfig.virtual["#ai-ready-virtual/db-schema.mjs"] = `export * from '${schemaPath}'`;
1589
1590
  nitroConfig.virtual["#ai-ready-virtual/devtools-meta.mjs"] = `export const devtoolsMeta = ${JSON.stringify({
1590
1591
  contentSignal: config.contentSignal || false,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nuxt-ai-ready",
3
3
  "type": "module",
4
- "version": "2.0.1",
4
+ "version": "2.1.0",
5
5
  "description": "Best practice AI & LLM discoverability for Nuxt sites.",
6
6
  "author": {
7
7
  "name": "Harlan Wilton",
@@ -48,7 +48,8 @@
48
48
  "@libsql/client": "^0.14.0",
49
49
  "@neondatabase/serverless": "^1.0.0",
50
50
  "@nuxtjs/sitemap": ">=8.3.1",
51
- "better-sqlite3": "^11.0.0 || ^12.0.0 || ^13.0.0"
51
+ "better-sqlite3": "^11.0.0 || ^12.0.0 || ^13.0.0",
52
+ "postgres": "^3.4.9"
52
53
  },
53
54
  "peerDependenciesMeta": {
54
55
  "@libsql/client": {
@@ -59,6 +60,9 @@
59
60
  },
60
61
  "better-sqlite3": {
61
62
  "optional": true
63
+ },
64
+ "postgres": {
65
+ "optional": true
62
66
  }
63
67
  },
64
68
  "dependencies": {
@@ -67,7 +71,7 @@
67
71
  "citty": "^0.2.2",
68
72
  "consola": "^3.4.2",
69
73
  "defu": "^6.1.7",
70
- "drizzle-orm": "1.0.0-rc.4",
74
+ "drizzle-orm": "^1.0.0-rc.4",
71
75
  "mdream": "^1.7.0",
72
76
  "nuxt-site-config": "^4.2.3",
73
77
  "nuxtseo-shared": "^5.3.14",
@@ -82,41 +86,42 @@
82
86
  "devDependencies": {
83
87
  "@antfu/eslint-config": "^9.3.0",
84
88
  "@arethetypeswrong/cli": "^0.18.5",
85
- "@harlan-zw/comark-content": "0.1.4",
89
+ "@harlan-zw/comark-content": "^0.1.5",
86
90
  "@libsql/client": "^0.17.4",
87
- "@nuxt/content": "^3.15.2",
91
+ "@nuxt/content": "^3.16.0",
88
92
  "@nuxt/module-builder": "^1.0.3",
89
- "@nuxt/test-utils": "^4.1.0",
93
+ "@nuxt/test-utils": "^4.2.0",
90
94
  "@nuxtjs/eslint-config-typescript": "^12.1.0",
91
95
  "@nuxtjs/mcp-toolkit": "^0.19.0",
92
- "@nuxtjs/robots": "^6.1.5",
93
- "@nuxtjs/sitemap": "^8.4.0",
96
+ "@nuxtjs/robots": "^6.2.0",
97
+ "@nuxtjs/sitemap": "^8.5.0",
94
98
  "@types/better-sqlite3": "^9.6.0",
95
99
  "@vitest/coverage-v8": "^4.1.11",
96
- "@vue/test-utils": "^2.4.11",
100
+ "@vue/test-utils": "^2.5.0",
97
101
  "@vueuse/nuxt": "^14.4.0",
98
102
  "better-sqlite3": "^13.0.3",
99
- "bumpp": "^12.2.1",
100
- "eslint": "^10.8.1",
101
- "eslint-plugin-harlanzw": "^0.20.0",
103
+ "bumpp": "^12.2.2",
104
+ "eslint": "^10.9.1",
105
+ "eslint-plugin-harlanzw": "^0.21.0",
102
106
  "execa": "^10.0.1",
103
107
  "h3": "^1.15.11",
104
- "happy-dom": "^20.11.2",
108
+ "happy-dom": "^20.12.0",
105
109
  "nitropack": "^2.13.4",
106
110
  "nuxt": "^4.5.2",
107
111
  "nuxt-site-config": "^4.2.3",
108
112
  "nuxtseo-layer-devtools": "^5.3.14",
109
113
  "playwright": "^1.62.1",
110
114
  "playwright-core": "^1.62.1",
115
+ "postgres": "^3.4.9",
111
116
  "tinyglobby": "^0.2.17",
112
117
  "typescript": "6.0.3",
113
118
  "unbuild": "^3.6.1",
114
119
  "vitest": "^4.1.11",
115
- "vue": "^3.5.41",
116
- "vue-router": "^5.2.0",
117
- "vue-tsc": "^3.3.10",
118
- "wrangler": "^4.124.0",
119
- "zod": "^4.4.3"
120
+ "vue": "^3.5.42",
121
+ "vue-router": "^5.3.0",
122
+ "vue-tsc": "^3.3.11",
123
+ "wrangler": "^4.127.1",
124
+ "zod": "^4.5.4"
120
125
  },
121
126
  "scripts": {
122
127
  "lint": "eslint .",