@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.
|
|
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
|
|
14
|
-
* reads every version's collection to draw its version
|
|
15
|
-
* collection hangs every page of every version.
|
|
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
|
+
}
|