gazetta 0.5.0 → 0.6.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/admin-dist/assets/index-B6pVot0Y.css +1 -0
- package/admin-dist/assets/index-DniLwxJA.js +609 -0
- package/admin-dist/assets/{vendor-primevue-BnR1c_bQ.js → vendor-primevue-C0Q_YTCb.js} +330 -431
- package/admin-dist/assets/vendor-vue-D3wBSmDf.js +1 -0
- package/admin-dist/index.html +4 -4
- package/dist/admin-api/index.d.ts +19 -4
- package/dist/admin-api/index.d.ts.map +1 -1
- package/dist/admin-api/index.js +154 -18
- package/dist/admin-api/index.js.map +1 -1
- package/dist/admin-api/routes/compare.d.ts +2 -1
- package/dist/admin-api/routes/compare.d.ts.map +1 -1
- package/dist/admin-api/routes/compare.js +33 -24
- package/dist/admin-api/routes/compare.js.map +1 -1
- package/dist/admin-api/routes/fields.d.ts +2 -2
- package/dist/admin-api/routes/fields.d.ts.map +1 -1
- package/dist/admin-api/routes/fields.js +10 -3
- package/dist/admin-api/routes/fields.js.map +1 -1
- package/dist/admin-api/routes/fragments.d.ts +2 -3
- package/dist/admin-api/routes/fragments.d.ts.map +1 -1
- package/dist/admin-api/routes/fragments.js +92 -19
- package/dist/admin-api/routes/fragments.js.map +1 -1
- package/dist/admin-api/routes/history.d.ts +23 -0
- package/dist/admin-api/routes/history.d.ts.map +1 -0
- package/dist/admin-api/routes/history.js +143 -0
- package/dist/admin-api/routes/history.js.map +1 -0
- package/dist/admin-api/routes/pages.d.ts +2 -3
- package/dist/admin-api/routes/pages.d.ts.map +1 -1
- package/dist/admin-api/routes/pages.js +118 -20
- package/dist/admin-api/routes/pages.js.map +1 -1
- package/dist/admin-api/routes/preview.d.ts +2 -2
- package/dist/admin-api/routes/preview.d.ts.map +1 -1
- package/dist/admin-api/routes/preview.js +50 -15
- package/dist/admin-api/routes/preview.js.map +1 -1
- package/dist/admin-api/routes/publish.d.ts +2 -1
- package/dist/admin-api/routes/publish.d.ts.map +1 -1
- package/dist/admin-api/routes/publish.js +213 -66
- package/dist/admin-api/routes/publish.js.map +1 -1
- package/dist/admin-api/routes/site.d.ts +2 -2
- package/dist/admin-api/routes/site.d.ts.map +1 -1
- package/dist/admin-api/routes/site.js +27 -4
- package/dist/admin-api/routes/site.js.map +1 -1
- package/dist/admin-api/routes/templates.d.ts +2 -2
- package/dist/admin-api/routes/templates.d.ts.map +1 -1
- package/dist/admin-api/routes/templates.js +19 -9
- package/dist/admin-api/routes/templates.js.map +1 -1
- package/dist/admin-api/schemas/compare.d.ts +29 -0
- package/dist/admin-api/schemas/compare.d.ts.map +1 -0
- package/dist/admin-api/schemas/compare.js +30 -0
- package/dist/admin-api/schemas/compare.js.map +1 -0
- package/dist/admin-api/schemas/dependents.d.ts +15 -0
- package/dist/admin-api/schemas/dependents.d.ts.map +1 -0
- package/dist/admin-api/schemas/dependents.js +14 -0
- package/dist/admin-api/schemas/dependents.js.map +1 -0
- package/dist/admin-api/schemas/fetch.d.ts +12 -0
- package/dist/admin-api/schemas/fetch.d.ts.map +1 -0
- package/dist/admin-api/schemas/fetch.js +11 -0
- package/dist/admin-api/schemas/fetch.js.map +1 -0
- package/dist/admin-api/schemas/fields.d.ts +11 -0
- package/dist/admin-api/schemas/fields.d.ts.map +1 -0
- package/dist/admin-api/schemas/fields.js +11 -0
- package/dist/admin-api/schemas/fields.js.map +1 -0
- package/dist/admin-api/schemas/fragments.d.ts +27 -0
- package/dist/admin-api/schemas/fragments.d.ts.map +1 -0
- package/dist/admin-api/schemas/fragments.js +26 -0
- package/dist/admin-api/schemas/fragments.js.map +1 -0
- package/dist/admin-api/schemas/history.d.ts +73 -0
- package/dist/admin-api/schemas/history.d.ts.map +1 -0
- package/dist/admin-api/schemas/history.js +35 -0
- package/dist/admin-api/schemas/history.js.map +1 -0
- package/dist/admin-api/schemas/index.d.ts +32 -0
- package/dist/admin-api/schemas/index.d.ts.map +1 -0
- package/dist/admin-api/schemas/index.js +32 -0
- package/dist/admin-api/schemas/index.js.map +1 -0
- package/dist/admin-api/schemas/pages.d.ts +46 -0
- package/dist/admin-api/schemas/pages.d.ts.map +1 -0
- package/dist/admin-api/schemas/pages.js +47 -0
- package/dist/admin-api/schemas/pages.js.map +1 -0
- package/dist/admin-api/schemas/publish.d.ts +67 -0
- package/dist/admin-api/schemas/publish.d.ts.map +1 -0
- package/dist/admin-api/schemas/publish.js +60 -0
- package/dist/admin-api/schemas/publish.js.map +1 -0
- package/dist/admin-api/schemas/site.d.ts +28 -0
- package/dist/admin-api/schemas/site.d.ts.map +1 -0
- package/dist/admin-api/schemas/site.js +24 -0
- package/dist/admin-api/schemas/site.js.map +1 -0
- package/dist/admin-api/schemas/targets.d.ts +36 -0
- package/dist/admin-api/schemas/targets.d.ts.map +1 -0
- package/dist/admin-api/schemas/targets.js +19 -0
- package/dist/admin-api/schemas/targets.js.map +1 -0
- package/dist/admin-api/schemas/templates.d.ts +17 -0
- package/dist/admin-api/schemas/templates.d.ts.map +1 -0
- package/dist/admin-api/schemas/templates.js +16 -0
- package/dist/admin-api/schemas/templates.js.map +1 -0
- package/dist/admin-api/source-context.d.ts +165 -0
- package/dist/admin-api/source-context.d.ts.map +1 -0
- package/dist/admin-api/source-context.js +95 -0
- package/dist/admin-api/source-context.js.map +1 -0
- package/dist/app.js +1 -1
- package/dist/app.js.map +1 -1
- package/dist/assemble.d.ts.map +1 -1
- package/dist/assemble.js +4 -1
- package/dist/assemble.js.map +1 -1
- package/dist/cli/bootstrap.d.ts +48 -0
- package/dist/cli/bootstrap.d.ts.map +1 -0
- package/dist/cli/bootstrap.js +85 -0
- package/dist/cli/bootstrap.js.map +1 -0
- package/dist/cli/history.d.ts +45 -0
- package/dist/cli/history.d.ts.map +1 -0
- package/dist/cli/history.js +165 -0
- package/dist/cli/history.js.map +1 -0
- package/dist/cli/index.js +630 -115
- package/dist/cli/index.js.map +1 -1
- package/dist/compare.d.ts +8 -5
- package/dist/compare.d.ts.map +1 -1
- package/dist/compare.js +53 -14
- package/dist/compare.js.map +1 -1
- package/dist/content-root.d.ts +38 -0
- package/dist/content-root.d.ts.map +1 -0
- package/dist/content-root.js +29 -0
- package/dist/content-root.js.map +1 -0
- package/dist/editor/mount.d.ts +1 -1
- package/dist/editor/mount.d.ts.map +1 -1
- package/dist/editor/mount.js +61 -29
- package/dist/editor/mount.js.map +1 -1
- package/dist/hash.d.ts +34 -3
- package/dist/hash.d.ts.map +1 -1
- package/dist/hash.js +64 -7
- package/dist/hash.js.map +1 -1
- package/dist/history-provider.d.ts +49 -0
- package/dist/history-provider.d.ts.map +1 -0
- package/dist/history-provider.js +226 -0
- package/dist/history-provider.js.map +1 -0
- package/dist/history-recorder.d.ts +98 -0
- package/dist/history-recorder.d.ts.map +1 -0
- package/dist/history-recorder.js +160 -0
- package/dist/history-recorder.js.map +1 -0
- package/dist/history-restorer.d.ts +46 -0
- package/dist/history-restorer.d.ts.map +1 -0
- package/dist/history-restorer.js +105 -0
- package/dist/history-restorer.js.map +1 -0
- package/dist/history.d.ts +111 -0
- package/dist/history.d.ts.map +1 -0
- package/dist/history.js +25 -0
- package/dist/history.js.map +1 -0
- package/dist/index.d.ts +26 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -3
- package/dist/index.js.map +1 -1
- package/dist/locale.d.ts +74 -0
- package/dist/locale.d.ts.map +1 -0
- package/dist/locale.js +150 -0
- package/dist/locale.js.map +1 -0
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +16 -1
- package/dist/manifest.js.map +1 -1
- package/dist/providers/azure-blob.d.ts.map +1 -1
- package/dist/providers/azure-blob.js.map +1 -1
- package/dist/providers/r2.d.ts.map +1 -1
- package/dist/providers/r2.js +7 -4
- package/dist/providers/r2.js.map +1 -1
- package/dist/providers/s3.d.ts.map +1 -1
- package/dist/providers/s3.js +23 -15
- package/dist/providers/s3.js.map +1 -1
- package/dist/publish-locale.d.ts +44 -0
- package/dist/publish-locale.d.ts.map +1 -0
- package/dist/publish-locale.js +103 -0
- package/dist/publish-locale.js.map +1 -0
- package/dist/publish-rendered.d.ts +16 -5
- package/dist/publish-rendered.d.ts.map +1 -1
- package/dist/publish-rendered.js +89 -36
- package/dist/publish-rendered.js.map +1 -1
- package/dist/publish.d.ts +5 -7
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +21 -12
- package/dist/publish.js.map +1 -1
- package/dist/renderer.d.ts +14 -4
- package/dist/renderer.d.ts.map +1 -1
- package/dist/renderer.js +35 -23
- package/dist/renderer.js.map +1 -1
- package/dist/resolver.d.ts +7 -2
- package/dist/resolver.d.ts.map +1 -1
- package/dist/resolver.js +66 -15
- package/dist/resolver.js.map +1 -1
- package/dist/robots.d.ts +22 -0
- package/dist/robots.d.ts.map +1 -0
- package/dist/robots.js +25 -0
- package/dist/robots.js.map +1 -0
- package/dist/seo.d.ts +56 -0
- package/dist/seo.d.ts.map +1 -0
- package/dist/seo.js +72 -0
- package/dist/seo.js.map +1 -0
- package/dist/serve.d.ts +41 -3
- package/dist/serve.d.ts.map +1 -1
- package/dist/serve.js +206 -65
- package/dist/serve.js.map +1 -1
- package/dist/sidecars.d.ts +9 -5
- package/dist/sidecars.d.ts.map +1 -1
- package/dist/sidecars.js +112 -22
- package/dist/sidecars.js.map +1 -1
- package/dist/site-loader.d.ts +74 -6
- package/dist/site-loader.d.ts.map +1 -1
- package/dist/site-loader.js +138 -28
- package/dist/site-loader.js.map +1 -1
- package/dist/sitemap.d.ts +45 -0
- package/dist/sitemap.d.ts.map +1 -0
- package/dist/sitemap.js +67 -0
- package/dist/sitemap.js.map +1 -0
- package/dist/source-sidecars.d.ts +21 -2
- package/dist/source-sidecars.d.ts.map +1 -1
- package/dist/source-sidecars.js +51 -5
- package/dist/source-sidecars.js.map +1 -1
- package/dist/targets.d.ts +47 -1
- package/dist/targets.d.ts.map +1 -1
- package/dist/targets.js +78 -9
- package/dist/targets.js.map +1 -1
- package/dist/template-loader.d.ts +7 -3
- package/dist/template-loader.d.ts.map +1 -1
- package/dist/template-loader.js +27 -12
- package/dist/template-loader.js.map +1 -1
- package/dist/templates-scan-worker.js +1 -1
- package/dist/templates-scan-worker.js.map +1 -1
- package/dist/templates-scan.d.ts.map +1 -1
- package/dist/templates-scan.js +1 -1
- package/dist/templates-scan.js.map +1 -1
- package/dist/types.d.ts +116 -9
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +28 -5
- package/dist/types.js.map +1 -1
- package/dist/workers/cloudflare-r2.d.ts +11 -2
- package/dist/workers/cloudflare-r2.d.ts.map +1 -1
- package/dist/workers/cloudflare-r2.js +120 -55
- package/dist/workers/cloudflare-r2.js.map +1 -1
- package/package.json +11 -2
- package/admin-dist/assets/index-BZAFKsUp.js +0 -608
- package/admin-dist/assets/index-BpRotMuK.css +0 -1
- package/admin-dist/assets/vendor-vue-DSjyxCX6.js +0 -1
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HistoryProvider implementation on top of any StorageProvider.
|
|
3
|
+
*
|
|
4
|
+
* Layout per target (inside `.gazetta/history/` under the target's
|
|
5
|
+
* storage root):
|
|
6
|
+
*
|
|
7
|
+
* index.json { nextId: N, revisions: ['rev-0001', ...] }
|
|
8
|
+
* revisions/rev-NNNN.json one manifest per revision — metadata +
|
|
9
|
+
* items map (itemPath → blob hash)
|
|
10
|
+
* objects/<hh>/<rest> content-addressed blobs (SHA-256, sharded
|
|
11
|
+
* by first 2 hex chars)
|
|
12
|
+
*
|
|
13
|
+
* Design-decisions.md #18:
|
|
14
|
+
* - One uniform approach across all providers (no native versioning).
|
|
15
|
+
* - Content-addressed blobs → unchanged items share storage across
|
|
16
|
+
* revisions; storage scales with unique content, not revision count.
|
|
17
|
+
* - Soft undo only — every restore writes a new forward revision.
|
|
18
|
+
* - Retention default 50; oldest evicted on write.
|
|
19
|
+
*
|
|
20
|
+
* SRP: this module owns .gazetta/history/ layout and retention. Nothing
|
|
21
|
+
* else. The write pipeline (save/publish) calls `recordRevision`; undo/
|
|
22
|
+
* rollback call `readRevision` + `readBlob`. Restore happens outside —
|
|
23
|
+
* this module doesn't touch the content tree.
|
|
24
|
+
*/
|
|
25
|
+
import { createHash } from 'node:crypto';
|
|
26
|
+
import { DEFAULT_HISTORY_RETENTION } from './types.js';
|
|
27
|
+
/**
|
|
28
|
+
* Build a HistoryProvider backed by the given storage. No I/O happens
|
|
29
|
+
* at construction time — everything is lazy on first call.
|
|
30
|
+
*/
|
|
31
|
+
export function createHistoryProvider(opts) {
|
|
32
|
+
const { storage } = opts;
|
|
33
|
+
const root = opts.rootPath ?? '.gazetta/history';
|
|
34
|
+
const retention = Math.max(1, opts.retention ?? DEFAULT_HISTORY_RETENTION);
|
|
35
|
+
const indexPath = join(root, 'index.json');
|
|
36
|
+
/** Read the index or return an empty one if it doesn't exist yet. */
|
|
37
|
+
async function readIndex() {
|
|
38
|
+
if (!(await storage.exists(indexPath))) {
|
|
39
|
+
return { revisions: [] };
|
|
40
|
+
}
|
|
41
|
+
const parsed = JSON.parse(await storage.readFile(indexPath));
|
|
42
|
+
// Forward-compat: tolerate legacy `nextId` by ignoring it. Old indexes
|
|
43
|
+
// (from the numeric-id era) continue to read cleanly, new writes use
|
|
44
|
+
// the timestamp scheme. No migration pass needed — retention naturally
|
|
45
|
+
// evicts the legacy ids over time.
|
|
46
|
+
return { revisions: parsed.revisions ?? [] };
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Ensure the parent directory exists before writing. Object-store
|
|
50
|
+
* providers (R2, S3) ignore mkdir. Filesystem requires it because
|
|
51
|
+
* `writeFile` fails on missing parents, and our sharded blob paths
|
|
52
|
+
* (objects/<hh>/<rest>) plus revisions/rev-NNNN.json live in dirs
|
|
53
|
+
* that don't exist until the first write.
|
|
54
|
+
*/
|
|
55
|
+
async function writeWithParents(path, content) {
|
|
56
|
+
const parent = path.substring(0, path.lastIndexOf('/'));
|
|
57
|
+
if (parent)
|
|
58
|
+
await storage.mkdir(parent);
|
|
59
|
+
await storage.writeFile(path, content);
|
|
60
|
+
}
|
|
61
|
+
async function writeIndex(idx) {
|
|
62
|
+
await writeWithParents(indexPath, JSON.stringify(idx, null, 2) + '\n');
|
|
63
|
+
}
|
|
64
|
+
function blobPath(hash) {
|
|
65
|
+
// Shard by first two hex chars — keeps any one `objects/` subdirectory
|
|
66
|
+
// from ballooning past a few thousand entries on large sites.
|
|
67
|
+
return join(root, 'objects', hash.slice(0, 2), hash.slice(2));
|
|
68
|
+
}
|
|
69
|
+
function revisionPath(id) {
|
|
70
|
+
return join(root, 'revisions', `${id}.json`);
|
|
71
|
+
}
|
|
72
|
+
/** SHA-256 hex of the content — strong enough that collisions can be
|
|
73
|
+
* ignored for practical purposes (blob identity), cheap enough at our
|
|
74
|
+
* scale (tens of KB per item, hundreds of items per revision). */
|
|
75
|
+
function hashContent(content) {
|
|
76
|
+
return createHash('sha256').update(content).digest('hex');
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Allocate a fresh revision id. Scheme: `rev-<unixMillis>`, with a
|
|
80
|
+
* `-<seq>` suffix on same-millisecond collisions ("rev-1760...050",
|
|
81
|
+
* "rev-1760...050-2", ...). Collision tracking uses the current
|
|
82
|
+
* index's revisions list — assumes no concurrent writers against
|
|
83
|
+
* the same target (already true: Gazetta never has two admins
|
|
84
|
+
* writing the same target's history simultaneously).
|
|
85
|
+
*
|
|
86
|
+
* Why millis + suffix rather than a monotonic counter:
|
|
87
|
+
* - Retention evictions leave you with a window of revisions you
|
|
88
|
+
* can date-read from the filename alone.
|
|
89
|
+
* - No counter to overflow or reset when history is disabled then
|
|
90
|
+
* re-enabled.
|
|
91
|
+
* - Lex-sort = chrono sort (13-digit ms are fixed-width through
|
|
92
|
+
* year 5138 AD).
|
|
93
|
+
*
|
|
94
|
+
* `_clock` is injectable for deterministic tests — production uses
|
|
95
|
+
* Date.now(). Stays private to createHistoryProvider.
|
|
96
|
+
*/
|
|
97
|
+
function formatId(existing, now) {
|
|
98
|
+
const base = `rev-${now}`;
|
|
99
|
+
if (!existing.some(id => id === base || id.startsWith(`${base}-`)))
|
|
100
|
+
return base;
|
|
101
|
+
// Same-millisecond collision: bump the suffix. Start from 2 so the
|
|
102
|
+
// first duplicate gets `-2` (matches the mental model "base, then
|
|
103
|
+
// the second, then the third").
|
|
104
|
+
let seq = 2;
|
|
105
|
+
while (existing.includes(`${base}-${seq}`))
|
|
106
|
+
seq += 1;
|
|
107
|
+
return `${base}-${seq}`;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Write any blob that's not already stored. Returns the hash. Dedup
|
|
111
|
+
* check is a single `exists()` — cheaper than reading the existing
|
|
112
|
+
* blob to confirm equal content (hashes collide vanishingly).
|
|
113
|
+
*/
|
|
114
|
+
async function writeBlob(content) {
|
|
115
|
+
const hash = hashContent(content);
|
|
116
|
+
const path = blobPath(hash);
|
|
117
|
+
if (!(await storage.exists(path))) {
|
|
118
|
+
await writeWithParents(path, content);
|
|
119
|
+
}
|
|
120
|
+
return hash;
|
|
121
|
+
}
|
|
122
|
+
async function recordRevision(input) {
|
|
123
|
+
const idx = await readIndex();
|
|
124
|
+
const id = formatId(idx.revisions, Date.now());
|
|
125
|
+
// Write blobs (dedup via content-addressing) and build the
|
|
126
|
+
// path → hash snapshot.
|
|
127
|
+
const snapshot = {};
|
|
128
|
+
// Deterministic order so rev manifests diff cleanly on inspection.
|
|
129
|
+
const sortedEntries = [...input.items.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
|
130
|
+
for (const [path, content] of sortedEntries) {
|
|
131
|
+
snapshot[path] = await writeBlob(content);
|
|
132
|
+
}
|
|
133
|
+
const manifest = {
|
|
134
|
+
id,
|
|
135
|
+
timestamp: new Date().toISOString(),
|
|
136
|
+
operation: input.operation,
|
|
137
|
+
author: input.author,
|
|
138
|
+
source: input.source,
|
|
139
|
+
items: [...input.items.keys()].sort(),
|
|
140
|
+
message: input.message,
|
|
141
|
+
restoredFrom: input.restoredFrom,
|
|
142
|
+
snapshot,
|
|
143
|
+
};
|
|
144
|
+
await writeWithParents(revisionPath(id), JSON.stringify(manifest, null, 2) + '\n');
|
|
145
|
+
// Update the index (append) then apply retention. Do the index
|
|
146
|
+
// write last so a mid-write failure leaves orphan blobs and an
|
|
147
|
+
// orphan manifest (both harmless) rather than a dangling index
|
|
148
|
+
// entry pointing at a missing manifest.
|
|
149
|
+
idx.revisions.push(id);
|
|
150
|
+
await writeIndex(idx);
|
|
151
|
+
await applyRetention(idx);
|
|
152
|
+
// Return the public Revision shape (no snapshot).
|
|
153
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
154
|
+
const { snapshot: _snapshot, ...revision } = manifest;
|
|
155
|
+
return revision;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Evict oldest revisions to fit `retention`. Deletes manifests; blobs
|
|
159
|
+
* become eligible for GC if no remaining revision references them.
|
|
160
|
+
* GC is lazy — blob files stay until an explicit GC pass, which is
|
|
161
|
+
* fine for v1 (disk is cheap; a future `gazetta gc` command can walk
|
|
162
|
+
* all manifests and prune orphans).
|
|
163
|
+
*/
|
|
164
|
+
async function applyRetention(idx) {
|
|
165
|
+
const excess = idx.revisions.length - retention;
|
|
166
|
+
if (excess <= 0)
|
|
167
|
+
return;
|
|
168
|
+
const toEvict = idx.revisions.slice(0, excess);
|
|
169
|
+
idx.revisions = idx.revisions.slice(excess);
|
|
170
|
+
for (const id of toEvict) {
|
|
171
|
+
const path = revisionPath(id);
|
|
172
|
+
if (await storage.exists(path))
|
|
173
|
+
await storage.rm(path);
|
|
174
|
+
}
|
|
175
|
+
await writeIndex(idx);
|
|
176
|
+
}
|
|
177
|
+
async function listRevisions(limit) {
|
|
178
|
+
const idx = await readIndex();
|
|
179
|
+
const ids = [...idx.revisions].reverse(); // newest first
|
|
180
|
+
const sliced = typeof limit === 'number' ? ids.slice(0, limit) : ids;
|
|
181
|
+
// Read manifests in parallel; strip snapshot for the summary list.
|
|
182
|
+
return Promise.all(sliced.map(async (id) => {
|
|
183
|
+
const m = await readManifest(id);
|
|
184
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
185
|
+
const { snapshot: _snapshot, ...rev } = m;
|
|
186
|
+
return rev;
|
|
187
|
+
}));
|
|
188
|
+
}
|
|
189
|
+
async function readManifest(id) {
|
|
190
|
+
return JSON.parse(await storage.readFile(revisionPath(id)));
|
|
191
|
+
}
|
|
192
|
+
async function readRevision(id) {
|
|
193
|
+
return readManifest(id);
|
|
194
|
+
}
|
|
195
|
+
async function readBlob(hash) {
|
|
196
|
+
return storage.readFile(blobPath(hash));
|
|
197
|
+
}
|
|
198
|
+
async function deleteRevision(id) {
|
|
199
|
+
const idx = await readIndex();
|
|
200
|
+
const at = idx.revisions.indexOf(id);
|
|
201
|
+
if (at === -1)
|
|
202
|
+
return;
|
|
203
|
+
idx.revisions.splice(at, 1);
|
|
204
|
+
await writeIndex(idx);
|
|
205
|
+
const path = revisionPath(id);
|
|
206
|
+
if (await storage.exists(path))
|
|
207
|
+
await storage.rm(path);
|
|
208
|
+
// Orphan blobs left for lazy GC — see applyRetention rationale.
|
|
209
|
+
}
|
|
210
|
+
return {
|
|
211
|
+
recordRevision,
|
|
212
|
+
listRevisions,
|
|
213
|
+
readRevision,
|
|
214
|
+
readBlob,
|
|
215
|
+
deleteRevision,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* `posix.join` behavior without importing it — keeps the module self-
|
|
220
|
+
* contained and works identically across platforms. Storage providers
|
|
221
|
+
* normalize separators internally, but our stored paths are POSIX.
|
|
222
|
+
*/
|
|
223
|
+
function join(...parts) {
|
|
224
|
+
return parts.filter(Boolean).join('/').replace(/\/+/g, '/');
|
|
225
|
+
}
|
|
226
|
+
//# sourceMappingURL=history-provider.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"history-provider.js","sourceRoot":"","sources":["../src/history-provider.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Higher-level helper for recording revisions on a target.
|
|
3
|
+
*
|
|
4
|
+
* The bare `HistoryProvider.recordRevision` takes a full
|
|
5
|
+
* `items: Map<path, content>` snapshot. That's fine for testing but
|
|
6
|
+
* wasteful at runtime: every save of one page would re-hash and
|
|
7
|
+
* re-list every page + fragment on the target. This helper does the
|
|
8
|
+
* right thing:
|
|
9
|
+
*
|
|
10
|
+
* - First revision on a target: walks the content tree once to
|
|
11
|
+
* snapshot every manifest (page.json, fragment.json, site.yaml).
|
|
12
|
+
* - Subsequent revisions: reads the previous snapshot and overlays
|
|
13
|
+
* the delta (changed items the caller passes in). `readBlob` for
|
|
14
|
+
* each carried-over path gives us the content for the new
|
|
15
|
+
* revision's items map — at which point `recordRevision` dedupes
|
|
16
|
+
* the unchanged blobs via content-addressing, so no new storage.
|
|
17
|
+
*
|
|
18
|
+
* SRP: this module owns the "what goes in a revision snapshot"
|
|
19
|
+
* decision. `HistoryProvider` owns layout. Callers (admin-api save /
|
|
20
|
+
* admin-api publish / CLI publish) just describe *what they wrote*
|
|
21
|
+
* and we construct the revision.
|
|
22
|
+
*/
|
|
23
|
+
import type { HistoryProvider, RevisionOperation } from './history.js';
|
|
24
|
+
import type { ContentRoot } from './content-root.js';
|
|
25
|
+
/** A single item that was written in this save/publish. */
|
|
26
|
+
export interface WrittenItem {
|
|
27
|
+
/** Path relative to the content root, e.g. `pages/home/page.json`. */
|
|
28
|
+
path: string;
|
|
29
|
+
/** Current content as stored. `null` marks a deletion. */
|
|
30
|
+
content: string | null;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Location to scan when building the first revision's baseline
|
|
34
|
+
* snapshot. Each entry names a directory under the content root and
|
|
35
|
+
* the manifest filename to capture from every subdirectory.
|
|
36
|
+
*/
|
|
37
|
+
export interface ScanLocation {
|
|
38
|
+
/** Directory relative to the content root, e.g. `pages` or `fragments`. */
|
|
39
|
+
dir: string;
|
|
40
|
+
/** Manifest filename to capture, e.g. `page.json` or `fragment.json`. */
|
|
41
|
+
manifest: string;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Built-in content locations Gazetta knows about today. Callers can
|
|
45
|
+
* pass a superset (e.g. for future data/*, templates/*) — the list is
|
|
46
|
+
* part of `RecordWriteOptions` so this module stays open for extension
|
|
47
|
+
* without changes when new content kinds land.
|
|
48
|
+
*/
|
|
49
|
+
export declare const DEFAULT_SCAN_LOCATIONS: readonly ScanLocation[];
|
|
50
|
+
/**
|
|
51
|
+
* Flat files at the content root to capture in the baseline snapshot
|
|
52
|
+
* (no per-subdirectory recursion). `site.yaml` is the only one today.
|
|
53
|
+
*/
|
|
54
|
+
export declare const DEFAULT_SCAN_ROOT_FILES: readonly string[];
|
|
55
|
+
export interface RecordWriteOptions {
|
|
56
|
+
/** HistoryProvider for the target we're recording on. */
|
|
57
|
+
history: HistoryProvider;
|
|
58
|
+
/** Content root of the target — used to scan on first revision. */
|
|
59
|
+
contentRoot: ContentRoot;
|
|
60
|
+
operation: RevisionOperation;
|
|
61
|
+
/** Items the save/publish wrote (and optionally deleted). */
|
|
62
|
+
items: WrittenItem[];
|
|
63
|
+
/** Author identifier passed through to the manifest. */
|
|
64
|
+
author?: string;
|
|
65
|
+
/** Source target name (for publish). */
|
|
66
|
+
source?: string;
|
|
67
|
+
/** Optional human-readable note. */
|
|
68
|
+
message?: string;
|
|
69
|
+
/** For rollback/restore: the revision id this one restored from. */
|
|
70
|
+
restoredFrom?: string;
|
|
71
|
+
/**
|
|
72
|
+
* Override the directories walked during the first-revision baseline
|
|
73
|
+
* scan. Defaults to `DEFAULT_SCAN_LOCATIONS` (pages + fragments).
|
|
74
|
+
* Pass a superset if the site has extra authored content (e.g.
|
|
75
|
+
* custom `data/*.json` dirs); pass `[]` to skip directory scanning
|
|
76
|
+
* entirely (only root files + explicit items are captured).
|
|
77
|
+
*/
|
|
78
|
+
scanLocations?: readonly ScanLocation[];
|
|
79
|
+
/**
|
|
80
|
+
* Override the flat files captured from the content root. Defaults
|
|
81
|
+
* to `DEFAULT_SCAN_ROOT_FILES` (`site.yaml`). Missing files are
|
|
82
|
+
* silently skipped so empty publish-targets still record cleanly.
|
|
83
|
+
*/
|
|
84
|
+
scanRootFiles?: readonly string[];
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Build + record a revision for the given write. Reads the previous
|
|
88
|
+
* snapshot (if any), overlays the delta, and calls
|
|
89
|
+
* `history.recordRevision`. Returns the recorded Revision.
|
|
90
|
+
*
|
|
91
|
+
* Callers are expected to have already written the items to the
|
|
92
|
+
* target's storage before invoking this; the recorder reads back via
|
|
93
|
+
* the HistoryProvider's dedup path (blobs it has already seen just
|
|
94
|
+
* `exists()` and skip) so the happy path is cheap on repeated saves
|
|
95
|
+
* of the same item.
|
|
96
|
+
*/
|
|
97
|
+
export declare function recordWrite(opts: RecordWriteOptions): Promise<import("./history.js").Revision>;
|
|
98
|
+
//# sourceMappingURL=history-recorder.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"history-recorder.d.ts","sourceRoot":"","sources":["../src/history-recorder.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAIH,OAAO,KAAK,EAAE,eAAe,EAAiB,iBAAiB,EAAE,MAAM,cAAc,CAAA;AACrF,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAEpD,2DAA2D;AAC3D,MAAM,WAAW,WAAW;IAC1B,sEAAsE;IACtE,IAAI,EAAE,MAAM,CAAA;IACZ,0DAA0D;IAC1D,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;CACvB;AAED;;;;GAIG;AACH,MAAM,WAAW,YAAY;IAC3B,2EAA2E;IAC3E,GAAG,EAAE,MAAM,CAAA;IACX,yEAAyE;IACzE,QAAQ,EAAE,MAAM,CAAA;CACjB;AAED;;;;;GAKG;AACH,eAAO,MAAM,sBAAsB,EAAE,SAAS,YAAY,EAGzD,CAAA;AAED;;;GAGG;AACH,eAAO,MAAM,uBAAuB,EAAE,SAAS,MAAM,EAAkB,CAAA;AAEvE,MAAM,WAAW,kBAAkB;IACjC,yDAAyD;IACzD,OAAO,EAAE,eAAe,CAAA;IACxB,mEAAmE;IACnE,WAAW,EAAE,WAAW,CAAA;IACxB,SAAS,EAAE,iBAAiB,CAAA;IAC5B,6DAA6D;IAC7D,KAAK,EAAE,WAAW,EAAE,CAAA;IACpB,wDAAwD;IACxD,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,wCAAwC;IACxC,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,oCAAoC;IACpC,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,oEAAoE;IACpE,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB;;;;;;OAMG;IACH,aAAa,CAAC,EAAE,SAAS,YAAY,EAAE,CAAA;IACvC;;;;OAIG;IACH,aAAa,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;CAClC;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,WAAW,CAAC,IAAI,EAAE,kBAAkB,4CAmCzD"}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Higher-level helper for recording revisions on a target.
|
|
3
|
+
*
|
|
4
|
+
* The bare `HistoryProvider.recordRevision` takes a full
|
|
5
|
+
* `items: Map<path, content>` snapshot. That's fine for testing but
|
|
6
|
+
* wasteful at runtime: every save of one page would re-hash and
|
|
7
|
+
* re-list every page + fragment on the target. This helper does the
|
|
8
|
+
* right thing:
|
|
9
|
+
*
|
|
10
|
+
* - First revision on a target: walks the content tree once to
|
|
11
|
+
* snapshot every manifest (page.json, fragment.json, site.yaml).
|
|
12
|
+
* - Subsequent revisions: reads the previous snapshot and overlays
|
|
13
|
+
* the delta (changed items the caller passes in). `readBlob` for
|
|
14
|
+
* each carried-over path gives us the content for the new
|
|
15
|
+
* revision's items map — at which point `recordRevision` dedupes
|
|
16
|
+
* the unchanged blobs via content-addressing, so no new storage.
|
|
17
|
+
*
|
|
18
|
+
* SRP: this module owns the "what goes in a revision snapshot"
|
|
19
|
+
* decision. `HistoryProvider` owns layout. Callers (admin-api save /
|
|
20
|
+
* admin-api publish / CLI publish) just describe *what they wrote*
|
|
21
|
+
* and we construct the revision.
|
|
22
|
+
*/
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
/**
|
|
25
|
+
* Built-in content locations Gazetta knows about today. Callers can
|
|
26
|
+
* pass a superset (e.g. for future data/*, templates/*) — the list is
|
|
27
|
+
* part of `RecordWriteOptions` so this module stays open for extension
|
|
28
|
+
* without changes when new content kinds land.
|
|
29
|
+
*/
|
|
30
|
+
export const DEFAULT_SCAN_LOCATIONS = [
|
|
31
|
+
{ dir: 'pages', manifest: 'page.json' },
|
|
32
|
+
{ dir: 'fragments', manifest: 'fragment.json' },
|
|
33
|
+
];
|
|
34
|
+
/**
|
|
35
|
+
* Flat files at the content root to capture in the baseline snapshot
|
|
36
|
+
* (no per-subdirectory recursion). `site.yaml` is the only one today.
|
|
37
|
+
*/
|
|
38
|
+
export const DEFAULT_SCAN_ROOT_FILES = ['site.yaml'];
|
|
39
|
+
/**
|
|
40
|
+
* Build + record a revision for the given write. Reads the previous
|
|
41
|
+
* snapshot (if any), overlays the delta, and calls
|
|
42
|
+
* `history.recordRevision`. Returns the recorded Revision.
|
|
43
|
+
*
|
|
44
|
+
* Callers are expected to have already written the items to the
|
|
45
|
+
* target's storage before invoking this; the recorder reads back via
|
|
46
|
+
* the HistoryProvider's dedup path (blobs it has already seen just
|
|
47
|
+
* `exists()` and skip) so the happy path is cheap on repeated saves
|
|
48
|
+
* of the same item.
|
|
49
|
+
*/
|
|
50
|
+
export async function recordWrite(opts) {
|
|
51
|
+
const scanLocations = opts.scanLocations ?? DEFAULT_SCAN_LOCATIONS;
|
|
52
|
+
const scanRootFiles = opts.scanRootFiles ?? DEFAULT_SCAN_ROOT_FILES;
|
|
53
|
+
// On the very first write, record a baseline revision capturing the
|
|
54
|
+
// pre-write state — so "undo my first save" has something to revert
|
|
55
|
+
// to (the tree as it was before the CMS touched it). Subsequent
|
|
56
|
+
// writes overlay deltas onto the previous revision. Without this,
|
|
57
|
+
// rev-0001 would be post-save state and undo would have no earlier
|
|
58
|
+
// revision to restore.
|
|
59
|
+
const existing = await opts.history.listRevisions(1);
|
|
60
|
+
if (existing.length === 0) {
|
|
61
|
+
const baseline = await scanContentTree(opts.contentRoot, scanLocations, scanRootFiles);
|
|
62
|
+
await opts.history.recordRevision({
|
|
63
|
+
operation: 'save',
|
|
64
|
+
message: 'Initial baseline',
|
|
65
|
+
items: baseline,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
const prevItems = await loadPreviousSnapshot(opts.history, opts.contentRoot, scanLocations, scanRootFiles);
|
|
69
|
+
const nextItems = new Map(prevItems);
|
|
70
|
+
for (const it of opts.items) {
|
|
71
|
+
if (it.content === null)
|
|
72
|
+
nextItems.delete(it.path);
|
|
73
|
+
else
|
|
74
|
+
nextItems.set(it.path, it.content);
|
|
75
|
+
}
|
|
76
|
+
const input = {
|
|
77
|
+
operation: opts.operation,
|
|
78
|
+
author: opts.author,
|
|
79
|
+
source: opts.source,
|
|
80
|
+
message: opts.message,
|
|
81
|
+
restoredFrom: opts.restoredFrom,
|
|
82
|
+
items: nextItems,
|
|
83
|
+
};
|
|
84
|
+
return opts.history.recordRevision(input);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Materialize the previous revision's full content snapshot as
|
|
88
|
+
* `path → content`. If there is no previous revision, fall back to a
|
|
89
|
+
* one-time scan of the target's content tree (pages, fragments,
|
|
90
|
+
* site.yaml). That makes the first revision a proper baseline even
|
|
91
|
+
* when history was turned on after content already existed.
|
|
92
|
+
*/
|
|
93
|
+
async function loadPreviousSnapshot(history, contentRoot, scanLocations, scanRootFiles) {
|
|
94
|
+
const [head] = await history.listRevisions(1);
|
|
95
|
+
if (head) {
|
|
96
|
+
const manifest = await history.readRevision(head.id);
|
|
97
|
+
const items = new Map();
|
|
98
|
+
// Read blobs in parallel to avoid a big serial chain on large snapshots.
|
|
99
|
+
const entries = Object.entries(manifest.snapshot);
|
|
100
|
+
const contents = await Promise.all(entries.map(([, hash]) => history.readBlob(hash)));
|
|
101
|
+
entries.forEach(([path], i) => items.set(path, contents[i]));
|
|
102
|
+
return items;
|
|
103
|
+
}
|
|
104
|
+
return scanContentTree(contentRoot, scanLocations, scanRootFiles);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* One-time walk of a content root, capturing every content-defining
|
|
108
|
+
* manifest. Used only for the first revision on a target; subsequent
|
|
109
|
+
* revisions overlay deltas onto the previous snapshot.
|
|
110
|
+
*
|
|
111
|
+
* Locations walked come from `scanLocations` and `scanRootFiles` (see
|
|
112
|
+
* RecordWriteOptions) so this module stays open for extension: adding
|
|
113
|
+
* a new content kind is a caller-side change, not an edit here.
|
|
114
|
+
*/
|
|
115
|
+
async function scanContentTree(root, scanLocations, scanRootFiles) {
|
|
116
|
+
const items = new Map();
|
|
117
|
+
const { storage } = root;
|
|
118
|
+
for (const rel of scanRootFiles) {
|
|
119
|
+
const abs = root.path(rel);
|
|
120
|
+
if (await storage.exists(abs)) {
|
|
121
|
+
items.set(rel, await storage.readFile(abs));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
for (const loc of scanLocations) {
|
|
125
|
+
await scanManifestsInto(storage, root.path(loc.dir), loc.dir, loc.manifest, items);
|
|
126
|
+
}
|
|
127
|
+
return items;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Walk a `pages/` or `fragments/` tree, reading every matching manifest
|
|
131
|
+
* into `items` with relative-path keys. Recurses so nested dynamic
|
|
132
|
+
* routes (e.g. `blog/[slug]/page.json`) are captured.
|
|
133
|
+
*
|
|
134
|
+
* Cloud object stores (R2/S3/Azure Blob) have no "directory" concept —
|
|
135
|
+
* `exists()` on a prefix-only path returns false. Rely on `readDir`
|
|
136
|
+
* returning an empty array for missing paths instead of probing via
|
|
137
|
+
* `exists` first.
|
|
138
|
+
*/
|
|
139
|
+
async function scanManifestsInto(storage, absDir, relPrefix, manifestName, items) {
|
|
140
|
+
let entries;
|
|
141
|
+
try {
|
|
142
|
+
entries = await storage.readDir(absDir);
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return; // Directory doesn't exist (or provider threw) — nothing to scan.
|
|
146
|
+
}
|
|
147
|
+
for (const e of entries) {
|
|
148
|
+
if (!e.isDirectory)
|
|
149
|
+
continue;
|
|
150
|
+
const sub = join(absDir, e.name);
|
|
151
|
+
const relSub = `${relPrefix}/${e.name}`;
|
|
152
|
+
const manifestPath = join(sub, manifestName);
|
|
153
|
+
if (await storage.exists(manifestPath)) {
|
|
154
|
+
items.set(`${relSub}/${manifestName}`, await storage.readFile(manifestPath));
|
|
155
|
+
}
|
|
156
|
+
// Recurse for nested routes (pages/blog/[slug]/page.json).
|
|
157
|
+
await scanManifestsInto(storage, sub, relSub, manifestName, items);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
//# sourceMappingURL=history-recorder.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"history-recorder.js","sourceRoot":"","sources":["../src/history-recorder.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAA;AAyBhC;;;;;GAKG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAA4B;IAC7D,EAAE,GAAG,EAAE,OAAO,EAAE,QAAQ,EAAE,WAAW,EAAE;IACvC,EAAE,GAAG,EAAE,WAAW,EAAE,QAAQ,EAAE,eAAe,EAAE;CAChD,CAAA;AAED;;;GAGG;AACH,MAAM,CAAC,MAAM,uBAAuB,GAAsB,CAAC,WAAW,CAAC,CAAA;AAkCvE;;;;;;;;;;GAUG;AACH,MAAM,CAAC,KAAK,UAAU,WAAW,CAAC,IAAwB;IACxD,MAAM,aAAa,GAAG,IAAI,CAAC,aAAa,IAAI,sBAAsB,CAAA;IAClE,MAAM,aAAa,GAAG,IAAI,CAAC,aAAa,IAAI,uBAAuB,CAAA;IAEnE,oEAAoE;IACpE,oEAAoE;IACpE,gEAAgE;IAChE,kEAAkE;IAClE,mEAAmE;IACnE,uBAAuB;IACvB,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC,OAAO,CAAC,aAAa,CAAC,CAAC,CAAC,CAAA;IACpD,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC1B,MAAM,QAAQ,GAAG,MAAM,eAAe,CAAC,IAAI,CAAC,WAAW,EAAE,aAAa,EAAE,aAAa,CAAC,CAAA;QACtF,MAAM,IAAI,CAAC,OAAO,CAAC,cAAc,CAAC;YAChC,SAAS,EAAE,MAAM;YACjB,OAAO,EAAE,kBAAkB;YAC3B,KAAK,EAAE,QAAQ;SAChB,CAAC,CAAA;IACJ,CAAC;IAED,MAAM,SAAS,GAAG,MAAM,oBAAoB,CAAC,IAAI,CAAC,OAAO,EAAE,IAAI,CAAC,WAAW,EAAE,aAAa,EAAE,aAAa,CAAC,CAAA;IAC1G,MAAM,SAAS,GAAG,IAAI,GAAG,CAAC,SAAS,CAAC,CAAA;IACpC,KAAK,MAAM,EAAE,IAAI,IAAI,CAAC,KAAK,EAAE,CAAC;QAC5B,IAAI,EAAE,CAAC,OAAO,KAAK,IAAI;YAAE,SAAS,CAAC,MAAM,CAAC,EAAE,CAAC,IAAI,CAAC,CAAA;;YAC7C,SAAS,CAAC,GAAG,CAAC,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,OAAO,CAAC,CAAA;IACzC,CAAC;IACD,MAAM,KAAK,GAAkB;QAC3B,SAAS,EAAE,IAAI,CAAC,SAAS;QACzB,MAAM,EAAE,IAAI,CAAC,MAAM;QACnB,MAAM,EAAE,IAAI,CAAC,MAAM;QACnB,OAAO,EAAE,IAAI,CAAC,OAAO;QACrB,YAAY,EAAE,IAAI,CAAC,YAAY;QAC/B,KAAK,EAAE,SAAS;KACjB,CAAA;IACD,OAAO,IAAI,CAAC,OAAO,CAAC,cAAc,CAAC,KAAK,CAAC,CAAA;AAC3C,CAAC;AAED;;;;;;GAMG;AACH,KAAK,UAAU,oBAAoB,CACjC,OAAwB,EACxB,WAAwB,EACxB,aAAsC,EACtC,aAAgC;IAEhC,MAAM,CAAC,IAAI,CAAC,GAAG,MAAM,OAAO,CAAC,aAAa,CAAC,CAAC,CAAC,CAAA;IAC7C,IAAI,IAAI,EAAE,CAAC;QACT,MAAM,QAAQ,GAAG,MAAM,OAAO,CAAC,YAAY,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;QACpD,MAAM,KAAK,GAAG,IAAI,GAAG,EAAkB,CAAA;QACvC,yEAAyE;QACzE,MAAM,OAAO,GAAG,MAAM,CAAC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAA;QACjD,MAAM,QAAQ,GAAG,MAAM,OAAO,CAAC,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,EAAE,EAAE,CAAC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;QACrF,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,CAAA;QAC5D,OAAO,KAAK,CAAA;IACd,CAAC;IACD,OAAO,eAAe,CAAC,WAAW,EAAE,aAAa,EAAE,aAAa,CAAC,CAAA;AACnE,CAAC;AAED;;;;;;;;GAQG;AACH,KAAK,UAAU,eAAe,CAC5B,IAAiB,EACjB,aAAsC,EACtC,aAAgC;IAEhC,MAAM,KAAK,GAAG,IAAI,GAAG,EAAkB,CAAA;IACvC,MAAM,EAAE,OAAO,EAAE,GAAG,IAAI,CAAA;IAExB,KAAK,MAAM,GAAG,IAAI,aAAa,EAAE,CAAC;QAChC,MAAM,GAAG,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAA;QAC1B,IAAI,MAAM,OAAO,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC;YAC9B,KAAK,CAAC,GAAG,CAAC,GAAG,EAAE,MAAM,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAA;QAC7C,CAAC;IACH,CAAC;IACD,KAAK,MAAM,GAAG,IAAI,aAAa,EAAE,CAAC;QAChC,MAAM,iBAAiB,CAAC,OAAO,EAAE,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,GAAG,CAAC,GAAG,EAAE,GAAG,CAAC,QAAQ,EAAE,KAAK,CAAC,CAAA;IACpF,CAAC;IAED,OAAO,KAAK,CAAA;AACd,CAAC;AAED;;;;;;;;;GASG;AACH,KAAK,UAAU,iBAAiB,CAC9B,OAAwB,EACxB,MAAc,EACd,SAAiB,EACjB,YAAoB,EACpB,KAA0B;IAE1B,IAAI,OAAwD,CAAA;IAC5D,IAAI,CAAC;QACH,OAAO,GAAG,MAAM,OAAO,CAAC,OAAO,CAAC,MAAM,CAAC,CAAA;IACzC,CAAC;IAAC,MAAM,CAAC;QACP,OAAM,CAAC,iEAAiE;IAC1E,CAAC;IACD,KAAK,MAAM,CAAC,IAAI,OAAO,EAAE,CAAC;QACxB,IAAI,CAAC,CAAC,CAAC,WAAW;YAAE,SAAQ;QAC5B,MAAM,GAAG,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,CAAC,IAAI,CAAC,CAAA;QAChC,MAAM,MAAM,GAAG,GAAG,SAAS,IAAI,CAAC,CAAC,IAAI,EAAE,CAAA;QACvC,MAAM,YAAY,GAAG,IAAI,CAAC,GAAG,EAAE,YAAY,CAAC,CAAA;QAC5C,IAAI,MAAM,OAAO,CAAC,MAAM,CAAC,YAAY,CAAC,EAAE,CAAC;YACvC,KAAK,CAAC,GAAG,CAAC,GAAG,MAAM,IAAI,YAAY,EAAE,EAAE,MAAM,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAC,CAAC,CAAA;QAC9E,CAAC;QACD,2DAA2D;QAC3D,MAAM,iBAAiB,CAAC,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,YAAY,EAAE,KAAK,CAAC,CAAA;IACpE,CAAC;AACH,CAAC"}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apply a past revision's snapshot to a target's content tree.
|
|
3
|
+
*
|
|
4
|
+
* This is the write side of undo / rollback. Design-publishing.md:
|
|
5
|
+
* "Undo and rollback restore a prior revision — both are soft
|
|
6
|
+
* (forward-only; create a new revision reverting to the past state,
|
|
7
|
+
* never destroy history)."
|
|
8
|
+
*
|
|
9
|
+
* Algorithm:
|
|
10
|
+
* 1. Load the target revision's snapshot (itemPath → blob hash).
|
|
11
|
+
* 2. Diff against the current on-disk content (via the HistoryProvider's
|
|
12
|
+
* most-recent revision). Anything present now but absent from the
|
|
13
|
+
* target snapshot is deleted; everything in the snapshot is written
|
|
14
|
+
* from its blob.
|
|
15
|
+
* 3. Record a new revision with operation='rollback' and
|
|
16
|
+
* restoredFrom=<targetRevId>, so the audit trail shows where the
|
|
17
|
+
* state came from and history stays forward-only.
|
|
18
|
+
*
|
|
19
|
+
* The caller (admin-api / CLI) owns orchestration — picking which
|
|
20
|
+
* revision to restore (head-1 for undo, arbitrary for rollback) and
|
|
21
|
+
* any side effects beyond the content tree (e.g., sidecar writer
|
|
22
|
+
* invalidation).
|
|
23
|
+
*/
|
|
24
|
+
import type { ContentRoot } from './content-root.js';
|
|
25
|
+
import type { HistoryProvider, Revision } from './history.js';
|
|
26
|
+
export interface RestoreRevisionOptions {
|
|
27
|
+
/** HistoryProvider for the target being restored. */
|
|
28
|
+
history: HistoryProvider;
|
|
29
|
+
/** Content root of the target — destination for the restore writes. */
|
|
30
|
+
contentRoot: ContentRoot;
|
|
31
|
+
/** Id of the revision to restore to (rev-NNNN). */
|
|
32
|
+
revisionId: string;
|
|
33
|
+
/** Free-form author identifier passed to the forward revision. */
|
|
34
|
+
author?: string;
|
|
35
|
+
/** Human-readable note ("Undo publish from local"). */
|
|
36
|
+
message?: string;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Restore `revisionId`'s content onto the target. Writes any items
|
|
40
|
+
* present in the snapshot (content fetched via `readBlob`); deletes
|
|
41
|
+
* items that exist today but aren't in the restored snapshot. Returns
|
|
42
|
+
* the new forward revision — always operation='rollback' so audit
|
|
43
|
+
* consumers can distinguish restores from normal saves/publishes.
|
|
44
|
+
*/
|
|
45
|
+
export declare function restoreRevision(opts: RestoreRevisionOptions): Promise<Revision>;
|
|
46
|
+
//# sourceMappingURL=history-restorer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"history-restorer.d.ts","sourceRoot":"","sources":["../src/history-restorer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AACpD,OAAO,KAAK,EAAE,eAAe,EAAE,QAAQ,EAAuC,MAAM,cAAc,CAAA;AAElG,MAAM,WAAW,sBAAsB;IACrC,qDAAqD;IACrD,OAAO,EAAE,eAAe,CAAA;IACxB,uEAAuE;IACvE,WAAW,EAAE,WAAW,CAAA;IACxB,mDAAmD;IACnD,UAAU,EAAE,MAAM,CAAA;IAClB,kEAAkE;IAClE,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,uDAAuD;IACvD,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB;AAED;;;;;;GAMG;AACH,wBAAsB,eAAe,CAAC,IAAI,EAAE,sBAAsB,GAAG,OAAO,CAAC,QAAQ,CAAC,CA6CrF"}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apply a past revision's snapshot to a target's content tree.
|
|
3
|
+
*
|
|
4
|
+
* This is the write side of undo / rollback. Design-publishing.md:
|
|
5
|
+
* "Undo and rollback restore a prior revision — both are soft
|
|
6
|
+
* (forward-only; create a new revision reverting to the past state,
|
|
7
|
+
* never destroy history)."
|
|
8
|
+
*
|
|
9
|
+
* Algorithm:
|
|
10
|
+
* 1. Load the target revision's snapshot (itemPath → blob hash).
|
|
11
|
+
* 2. Diff against the current on-disk content (via the HistoryProvider's
|
|
12
|
+
* most-recent revision). Anything present now but absent from the
|
|
13
|
+
* target snapshot is deleted; everything in the snapshot is written
|
|
14
|
+
* from its blob.
|
|
15
|
+
* 3. Record a new revision with operation='rollback' and
|
|
16
|
+
* restoredFrom=<targetRevId>, so the audit trail shows where the
|
|
17
|
+
* state came from and history stays forward-only.
|
|
18
|
+
*
|
|
19
|
+
* The caller (admin-api / CLI) owns orchestration — picking which
|
|
20
|
+
* revision to restore (head-1 for undo, arbitrary for rollback) and
|
|
21
|
+
* any side effects beyond the content tree (e.g., sidecar writer
|
|
22
|
+
* invalidation).
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* Restore `revisionId`'s content onto the target. Writes any items
|
|
26
|
+
* present in the snapshot (content fetched via `readBlob`); deletes
|
|
27
|
+
* items that exist today but aren't in the restored snapshot. Returns
|
|
28
|
+
* the new forward revision — always operation='rollback' so audit
|
|
29
|
+
* consumers can distinguish restores from normal saves/publishes.
|
|
30
|
+
*/
|
|
31
|
+
export async function restoreRevision(opts) {
|
|
32
|
+
const { history, contentRoot, revisionId } = opts;
|
|
33
|
+
const target = await history.readRevision(revisionId);
|
|
34
|
+
// Current state = the most recent revision's snapshot. If none
|
|
35
|
+
// exists yet we're restoring onto an empty tree — nothing to delete
|
|
36
|
+
// and no "unchanged" entries to skip.
|
|
37
|
+
const currentSnapshot = await loadHeadSnapshot(history);
|
|
38
|
+
const toDelete = Object.keys(currentSnapshot).filter(p => !(p in target.snapshot));
|
|
39
|
+
// Only write items whose blob hash differs from what's currently on
|
|
40
|
+
// disk (per head snapshot). Without this, restoring typically rewrites
|
|
41
|
+
// every item in the snapshot — an undo of a single-page edit would
|
|
42
|
+
// touch every page + fragment manifest, triggering a storm of file-
|
|
43
|
+
// watch events and SSE reloads in the dev server. Equal hashes →
|
|
44
|
+
// same content → skip the write.
|
|
45
|
+
const toWrite = Object.entries(target.snapshot).filter(([path, hash]) => currentSnapshot[path] !== hash);
|
|
46
|
+
// Delete first: rolling back a "delete" in the old revision means the
|
|
47
|
+
// item came back; rolling back an "add" means the item goes away.
|
|
48
|
+
// Delete-before-write keeps storage from briefly holding both.
|
|
49
|
+
for (const path of toDelete) {
|
|
50
|
+
const abs = contentRoot.path(path);
|
|
51
|
+
try {
|
|
52
|
+
await contentRoot.storage.rm(abs);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// Best-effort: a missing path at rm time is fine (already gone).
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
for (const [path, hash] of toWrite) {
|
|
59
|
+
const content = await history.readBlob(hash);
|
|
60
|
+
const abs = contentRoot.path(path);
|
|
61
|
+
const parent = abs.substring(0, abs.lastIndexOf('/'));
|
|
62
|
+
if (parent)
|
|
63
|
+
await contentRoot.storage.mkdir(parent);
|
|
64
|
+
await contentRoot.storage.writeFile(abs, content);
|
|
65
|
+
}
|
|
66
|
+
// Record a new forward revision capturing the restored state. Uses
|
|
67
|
+
// the same snapshot we just wrote — no need to re-read from disk.
|
|
68
|
+
return recordFromSnapshot(history, target, {
|
|
69
|
+
operation: 'rollback',
|
|
70
|
+
restoredFrom: revisionId,
|
|
71
|
+
author: opts.author,
|
|
72
|
+
message: opts.message,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Re-record an existing snapshot as a forward revision. Blobs already
|
|
77
|
+
* exist (they're the same content), so the HistoryProvider's exists()
|
|
78
|
+
* check skips the writes — cheap.
|
|
79
|
+
*/
|
|
80
|
+
async function recordFromSnapshot(history, target, meta) {
|
|
81
|
+
const items = new Map();
|
|
82
|
+
for (const [path, hash] of Object.entries(target.snapshot)) {
|
|
83
|
+
items.set(path, await history.readBlob(hash));
|
|
84
|
+
}
|
|
85
|
+
return history.recordRevision({
|
|
86
|
+
operation: meta.operation,
|
|
87
|
+
author: meta.author,
|
|
88
|
+
message: meta.message,
|
|
89
|
+
restoredFrom: meta.restoredFrom,
|
|
90
|
+
items,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Head revision's snapshot, or `{}` if there are no revisions yet.
|
|
95
|
+
* Used by restore to figure out what's currently on-disk and needs
|
|
96
|
+
* deleting when the restored revision doesn't include it.
|
|
97
|
+
*/
|
|
98
|
+
async function loadHeadSnapshot(history) {
|
|
99
|
+
const [head] = await history.listRevisions(1);
|
|
100
|
+
if (!head)
|
|
101
|
+
return {};
|
|
102
|
+
const m = await history.readRevision(head.id);
|
|
103
|
+
return m.snapshot;
|
|
104
|
+
}
|
|
105
|
+
//# sourceMappingURL=history-restorer.js.map
|