@kirchdev/duxt 0.10.1-dev-main.8-902bddd → 0.10.1-dev-main.10-e43ad25

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://www.schemastore.org/package.json",
3
3
  "name": "@kirchdev/duxt",
4
- "version": "0.10.1-dev-main.8-902bddd",
4
+ "version": "0.10.1-dev-main.10-e43ad25",
5
5
  "description": "duxt is a Nuxt layer: extend it and your docs/ folder becomes a site, with theme, search, API reference and llms.txt included. Point it at other repositories, or at tags of the same one, and each becomes a version.",
6
6
  "keywords": [
7
7
  "content-v3",
@@ -10,10 +10,10 @@
10
10
  * reader who gives up, a crawler with a timeout, a prefetch the router drops:
11
11
  * any of them stops that replay halfway, and the row is never flipped. Every
12
12
  * later read of that collection then waits on `ready` for 90 seconds and
13
- * fails, on every isolate, until the row is removed by hand — and a docs page
14
- * reads every version's collection to draw its version switcher, so one stuck
15
- * collection hangs every page of every version. duxt.app ran into exactly
16
- * that after the v0.10.0 deploy.
13
+ * fails, on every isolate, until `content-restore-recovery.ts` clears the row —
14
+ * and a docs page reads every version's collection to draw its version
15
+ * switcher, so one stuck collection hangs every page of every version.
16
+ * duxt.app ran into exactly that after the v0.10.0 deploy.
17
17
  *
18
18
  * `waitUntil` is the one thing a Worker lets outlive a disconnect, so every
19
19
  * request hands it a promise that settles when the request is done. Anything
@@ -0,0 +1,65 @@
1
+ import {
2
+ duxtRecoverContentRestores,
3
+ type DuxtD1
4
+ } from '../utils/content-restore';
5
+
6
+ /**
7
+ * Clears Content restores a cancelled Worker left half done, from inside the
8
+ * Worker, on D1.
9
+ *
10
+ * `content-import-lifetime.ts` keeps a restore running past a disconnect, but
11
+ * only for the platform's grace period, and a row that was stranded before it
12
+ * — or by a deploy that cut a running restore off — stays unready for good:
13
+ * every read of that collection then waits 90 seconds and fails, and a docs
14
+ * page reads every version's collection to draw its switcher. duxt.app sat
15
+ * like that after the v0.10.0 deploy until the row was removed by hand.
16
+ *
17
+ * It runs here rather than in the deploy because the Worker is the one thing
18
+ * that already holds the binding: a cleanup step in CI would need a token with
19
+ * D1 rights for every repository that reads it, and would help no site but the
20
+ * one whose workflow carries it.
21
+ *
22
+ * At most once a minute per isolate, after the response, so no reader waits on
23
+ * it. A failure is logged and dropped: this is a repair, and a missing table
24
+ * on a fresh database is not an error.
25
+ */
26
+ const INTERVAL_MS = 60_000;
27
+
28
+ let last = 0;
29
+
30
+ export default defineNitroPlugin((nitroApp) => {
31
+ nitroApp.hooks.hook('request', (event) => {
32
+ const env = event.context.cloudflare?.env as
33
+ | Record<string, unknown>
34
+ | undefined;
35
+ if (!env || !event.waitUntil) return;
36
+
37
+ const database = useRuntimeConfig(event).content?.database as
38
+ | { type?: string; bindingName?: string; binding?: string }
39
+ | undefined;
40
+ if (database?.type !== 'd1') return;
41
+
42
+ const db = env[database.bindingName || database.binding || 'DB'] as
43
+ | DuxtD1
44
+ | undefined;
45
+ if (!db) return;
46
+
47
+ const now = Date.now();
48
+ if (now - last < INTERVAL_MS) return;
49
+ last = now;
50
+
51
+ event.waitUntil(
52
+ duxtRecoverContentRestores(db, now)
53
+ .then((removed) => {
54
+ if (removed.length) {
55
+ console.warn(
56
+ `[duxt] cleared abandoned Content restores: ${removed.join(', ')}`
57
+ );
58
+ }
59
+ })
60
+ .catch((error) => {
61
+ console.warn('[duxt] Content restore check failed', error);
62
+ })
63
+ );
64
+ });
65
+ });
@@ -0,0 +1,91 @@
1
+ /**
2
+ * The part of a D1 binding this file uses — written out rather than imported,
3
+ * because the layer does not depend on Cloudflare's types and no other
4
+ * runtime ever reaches this code.
5
+ */
6
+ export interface DuxtD1 {
7
+ prepare(sql: string): DuxtD1Statement;
8
+ batch(statements: DuxtD1Statement[]): Promise<unknown>;
9
+ }
10
+
11
+ export interface DuxtD1Statement {
12
+ bind(...values: unknown[]): DuxtD1Statement;
13
+ all<T>(): Promise<{ results: T[] }>;
14
+ run(): Promise<unknown>;
15
+ }
16
+
17
+ /**
18
+ * Twice the 90 seconds Content waits for another restore before giving up.
19
+ * A restore takes about ten seconds a collection on duxt.app, so a row still
20
+ * unready after this has nobody left writing it.
21
+ */
22
+ export const DUXT_STALE_RESTORE_MS = 180_000;
23
+
24
+ /** Where a row was first seen unready. Content owns `_content_info`; this is ours. */
25
+ const SEEN = '_duxt_content_unready';
26
+
27
+ /**
28
+ * Removes every Content restore that was abandoned halfway, so the next read
29
+ * of that collection restores it again instead of waiting on it forever.
30
+ *
31
+ * Content marks a collection `ready = 0` in `_content_info`, replays its dump
32
+ * and flips the row as the dump's last statement. A Worker cancelled before
33
+ * that statement leaves the row unready for good, and every later read waits
34
+ * 90 seconds and fails — see `server/plugins/content-restore-recovery.ts`.
35
+ *
36
+ * The row carries no timestamp, and an unready row is also exactly what a
37
+ * restore IN PROGRESS looks like, so a single look cannot tell the two apart.
38
+ * Two looks can: the first records when a row was seen unready, and a later
39
+ * one removes it only if the SAME version is still unready past the threshold.
40
+ * A new deploy writes a new version, which starts the clock again.
41
+ *
42
+ * Returns the ids it removed.
43
+ */
44
+ export async function duxtRecoverContentRestores(
45
+ db: DuxtD1,
46
+ now: number,
47
+ staleMs = DUXT_STALE_RESTORE_MS
48
+ ): Promise<string[]> {
49
+ const unready = await db
50
+ .prepare('SELECT id, version FROM _content_info WHERE ready = 0')
51
+ .all<{ id: string; version: string }>();
52
+
53
+ await db.batch([
54
+ db.prepare(
55
+ `CREATE TABLE IF NOT EXISTS ${SEEN} (id TEXT PRIMARY KEY, version TEXT NOT NULL, since INTEGER NOT NULL)`
56
+ ),
57
+ // A row that finished, or that Content replaced, is forgotten — otherwise
58
+ // its old clock would condemn the next restore of the same version.
59
+ db.prepare(
60
+ `DELETE FROM ${SEEN} WHERE NOT EXISTS (SELECT 1 FROM _content_info AS info WHERE info.id = ${SEEN}.id AND info.version = ${SEEN}.version AND info.ready = 0)`
61
+ ),
62
+ ...unready.results.map((row) =>
63
+ db
64
+ .prepare(
65
+ `INSERT INTO ${SEEN} (id, version, since) VALUES (?, ?, ?) ON CONFLICT (id) DO NOTHING`
66
+ )
67
+ .bind(row.id, row.version, now)
68
+ )
69
+ ]);
70
+
71
+ const stale = await db
72
+ .prepare(`SELECT id, version FROM ${SEEN} WHERE since <= ?`)
73
+ .bind(now - staleMs)
74
+ .all<{ id: string; version: string }>();
75
+ if (!stale.results.length) return [];
76
+
77
+ // `ready = 0` and the version in the condition: a restore that finished, or
78
+ // a deploy that replaced the row, since the look above keeps its row.
79
+ await db.batch(
80
+ stale.results.flatMap((row) => [
81
+ db
82
+ .prepare(
83
+ 'DELETE FROM _content_info WHERE id = ? AND version = ? AND ready = 0'
84
+ )
85
+ .bind(row.id, row.version),
86
+ db.prepare(`DELETE FROM ${SEEN} WHERE id = ?`).bind(row.id)
87
+ ])
88
+ );
89
+
90
+ return stale.results.map((row) => row.id);
91
+ }