@micacms/runtime 0.4.1 → 0.5.0-rc.2
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/README.md +2 -2
- package/dist/app.d.ts +8 -37
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +7 -42
- package/dist/app.js.map +1 -1
- package/dist/config.d.ts +12 -82
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +14 -82
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts +37 -66
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +24 -75
- package/dist/context.js.map +1 -1
- package/dist/http.d.ts +8 -16
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +6 -13
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +13 -14
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -14
- package/dist/index.js.map +1 -1
- package/dist/lib/errors.js.map +1 -1
- package/dist/lib/file-type.js.map +1 -1
- package/dist/lib/id.js.map +1 -1
- package/dist/lib/logger.js.map +1 -1
- package/dist/lib/ports.js.map +1 -1
- package/dist/lib/rate-limit.js.map +1 -1
- package/dist/lib/schema-stats.js.map +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/middleware/index.js.map +1 -1
- package/dist/routes/admin-content.d.ts +4 -17
- package/dist/routes/admin-content.d.ts.map +1 -1
- package/dist/routes/admin-content.js +11 -51
- package/dist/routes/admin-content.js.map +1 -1
- package/dist/routes/admin-media.d.ts +3 -14
- package/dist/routes/admin-media.d.ts.map +1 -1
- package/dist/routes/admin-media.js +7 -29
- package/dist/routes/admin-media.js.map +1 -1
- package/dist/routes/admin-spa.js.map +1 -1
- package/dist/routes/admin-staging.d.ts +4 -9
- package/dist/routes/admin-staging.d.ts.map +1 -1
- package/dist/routes/admin-staging.js +31 -18
- package/dist/routes/admin-staging.js.map +1 -1
- package/dist/routes/admin-system.d.ts +2 -8
- package/dist/routes/admin-system.d.ts.map +1 -1
- package/dist/routes/admin-system.js +9 -44
- package/dist/routes/admin-system.js.map +1 -1
- package/dist/routes/health.d.ts +3 -10
- package/dist/routes/health.d.ts.map +1 -1
- package/dist/routes/health.js +9 -26
- package/dist/routes/health.js.map +1 -1
- package/dist/schema.d.ts +10 -38
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +14 -65
- package/dist/schema.js.map +1 -1
- package/dist/server.d.ts +2 -10
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +3 -9
- package/dist/server.js.map +1 -1
- package/dist/services/changes.d.ts +150 -0
- package/dist/services/changes.d.ts.map +1 -0
- package/dist/services/changes.js +339 -0
- package/dist/services/changes.js.map +1 -0
- package/dist/services/content-store.d.ts +61 -0
- package/dist/services/content-store.d.ts.map +1 -0
- package/dist/services/content-store.js +330 -0
- package/dist/services/content-store.js.map +1 -0
- package/dist/services/media.d.ts +9 -29
- package/dist/services/media.d.ts.map +1 -1
- package/dist/services/media.js +17 -57
- package/dist/services/media.js.map +1 -1
- package/dist/services/staging.d.ts +35 -167
- package/dist/services/staging.d.ts.map +1 -1
- package/dist/services/staging.js +195 -458
- package/dist/services/staging.js.map +1 -1
- package/dist/workspace/developer.d.ts +14 -0
- package/dist/workspace/developer.d.ts.map +1 -0
- package/dist/workspace/developer.js +31 -0
- package/dist/workspace/developer.js.map +1 -0
- package/dist/workspace/files.d.ts +70 -0
- package/dist/workspace/files.d.ts.map +1 -0
- package/dist/workspace/files.js +176 -0
- package/dist/workspace/files.js.map +1 -0
- package/dist/workspace/git.d.ts +45 -0
- package/dist/workspace/git.d.ts.map +1 -0
- package/dist/workspace/git.js +68 -0
- package/dist/workspace/git.js.map +1 -0
- package/dist/workspace/locks.d.ts +33 -0
- package/dist/workspace/locks.d.ts.map +1 -0
- package/dist/workspace/locks.js +76 -0
- package/dist/workspace/locks.js.map +1 -0
- package/dist/workspace/policy.d.ts +92 -0
- package/dist/workspace/policy.d.ts.map +1 -0
- package/dist/workspace/policy.js +7 -0
- package/dist/workspace/policy.js.map +1 -0
- package/dist/workspace/tree.d.ts +73 -0
- package/dist/workspace/tree.d.ts.map +1 -0
- package/dist/workspace/tree.js +229 -0
- package/dist/workspace/tree.js.map +1 -0
- package/package.json +2 -3
- package/dist/db/driver.d.ts +0 -35
- package/dist/db/driver.d.ts.map +0 -1
- package/dist/db/driver.js +0 -95
- package/dist/db/driver.js.map +0 -1
- package/dist/lib/git.d.ts +0 -29
- package/dist/lib/git.d.ts.map +0 -1
- package/dist/lib/git.js +0 -62
- package/dist/lib/git.js.map +0 -1
- package/dist/services/staging-local.d.ts +0 -58
- package/dist/services/staging-local.d.ts.map +0 -1
- package/dist/services/staging-local.js +0 -391
- package/dist/services/staging-local.js.map +0 -1
package/dist/services/staging.js
CHANGED
|
@@ -1,488 +1,225 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* The staging service on a Git working tree. Autosave has already written the
|
|
3
|
+
* files; Publish commits the reviewed paths.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* real commit and push through the developer's own Git.
|
|
11
|
-
* - draft store (this file): the repository is remote and writing through
|
|
12
|
-
* its API would already be a commit, so saves accumulate in SQLite and
|
|
13
|
-
* Publish turns the whole set into one provider-side commit.
|
|
14
|
-
*
|
|
15
|
-
* The editor sees one API either way. What differs is only what Saved means —
|
|
16
|
-
* and the two must stay visibly different words in the UI, because in neither
|
|
17
|
-
* mode does Saved imply that a change is in Git.
|
|
18
|
-
*/
|
|
19
|
-
import { MANIFEST_PATH, ORDER_FILE, collectionEntryPath, entryLayout, entryTitle, isValidSlug, readCollection, readCollectionEntry, readSingleton, relativizeMedia, resolveSlugField, singletonPath, toContentChange, validateFields, } from "@micacms/core";
|
|
20
|
-
import { ApiError, badRequest } from "../lib/errors.js";
|
|
21
|
-
/**
|
|
22
|
-
* Reads one document from the repository (`published`) and from the pending
|
|
23
|
-
* workspace (`staged`) — the whole of a before/after comparison, shared by
|
|
24
|
-
* both staging implementations since a `ContentSource` is all it needs.
|
|
25
|
-
*
|
|
26
|
-
* An order target compares the collection's ordering rather than a document:
|
|
27
|
-
* each side becomes `{ order: [{ slug, title }] }`, in position order.
|
|
28
|
-
*/
|
|
29
|
-
export async function compareSources(model, published, staged, target) {
|
|
30
|
-
if (target.kind === "singleton") {
|
|
31
|
-
const def = model.singletons[target.name];
|
|
32
|
-
if (!def)
|
|
33
|
-
throw badRequest(`There is no singleton called "${target.name}".`);
|
|
34
|
-
return {
|
|
35
|
-
before: await readSingleton(published, def, target.name),
|
|
36
|
-
after: await readSingleton(staged, def, target.name),
|
|
37
|
-
};
|
|
38
|
-
}
|
|
39
|
-
const def = model.collections[target.collection];
|
|
40
|
-
if (!def)
|
|
41
|
-
throw badRequest(`There is no collection called "${target.collection}".`);
|
|
42
|
-
if (target.kind === "entry") {
|
|
43
|
-
const from = target.from ?? target.slug;
|
|
44
|
-
for (const slug of [target.slug, from]) {
|
|
45
|
-
if (!isValidSlug(slug))
|
|
46
|
-
throw badRequest(`"${slug}" is not a slug.`);
|
|
47
|
-
}
|
|
48
|
-
const before = await readCollectionEntry(published, def, target.collection, from);
|
|
49
|
-
const after = await readCollectionEntry(staged, def, target.collection, target.slug);
|
|
50
|
-
if (from === target.slug)
|
|
51
|
-
return { before, after };
|
|
52
|
-
// A rename: colocated media paths name the entry's folder, so they are
|
|
53
|
-
// compared in the on-disk form, where a moved file keeps its path.
|
|
54
|
-
return {
|
|
55
|
-
before: before && onDisk(def, target.collection, from, before),
|
|
56
|
-
after: after && onDisk(def, target.collection, target.slug, after),
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
|
-
const ordering = async (source) => {
|
|
60
|
-
const entries = await readCollection(source, def, target.collection);
|
|
61
|
-
if (entries.length === 0)
|
|
62
|
-
return null;
|
|
63
|
-
return {
|
|
64
|
-
order: entries.map((entry) => ({ slug: entry.slug, title: entryTitle(def, entry.data) })),
|
|
65
|
-
};
|
|
66
|
-
};
|
|
67
|
-
return { before: await ordering(published), after: await ordering(staged) };
|
|
68
|
-
}
|
|
69
|
-
/* ------------------------------------------------------------------ *
|
|
70
|
-
* Understanding a change set
|
|
71
|
-
* ------------------------------------------------------------------ */
|
|
72
|
-
/**
|
|
73
|
-
* Annotates a change set with owners and validates the entries it touches.
|
|
74
|
-
*
|
|
75
|
-
* One pass on purpose: naming a change and validating it both need the same
|
|
76
|
-
* read of the same entries, and content trees are small enough to read the
|
|
77
|
-
* affected collections whole.
|
|
78
|
-
*
|
|
79
|
-
* Validation is scoped to *changed* documents. Content that was already in
|
|
80
|
-
* the repository is not this publication's problem — refusing
|
|
81
|
-
* to publish a fixed typo because somebody once hand-committed a broken entry
|
|
82
|
-
* elsewhere would make the editor hostage to history it cannot touch.
|
|
5
|
+
* - Every Git operation is limited to an explicit path set, so other work in
|
|
6
|
+
* the same checkout is never swept in.
|
|
7
|
+
* - Only the reviewed files are published, as the editor saw them. Drafts of
|
|
8
|
+
* other entries stay drafts.
|
|
9
|
+
* - When the push fails, the commit stays as `committed-pending-push`; a
|
|
10
|
+
* retry pushes and never commits again.
|
|
83
11
|
*/
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
}
|
|
94
|
-
const
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
const entries = await readCollection(source, def, name);
|
|
101
|
-
const bySlug = new Map(entries.map((entry) => [entry.slug, entry.data]));
|
|
102
|
-
entriesByCollection.set(name, bySlug);
|
|
103
|
-
for (const [slug, data] of bySlug)
|
|
104
|
-
titles.set(`${name}/${slug}`, entryTitle(def, data));
|
|
105
|
-
}
|
|
106
|
-
catch (error) {
|
|
107
|
-
// The collection cannot be read at all — a malformed file, possibly one
|
|
108
|
-
// this change set did not touch. Publication needs the whole picture,
|
|
109
|
-
// so the honest answer is the error, at the path it names.
|
|
110
|
-
const path = error.path ?? name;
|
|
111
|
-
problems.push({
|
|
112
|
-
path,
|
|
113
|
-
label: def.label,
|
|
114
|
-
issues: [{ path: "", message: error.message }],
|
|
115
|
-
});
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
if (published) {
|
|
119
|
-
await detectRenames(model, published, source, changes, owners, entriesByCollection);
|
|
120
|
-
}
|
|
121
|
-
/* ---------------------------------------------------- validation */
|
|
122
|
-
const validated = new Set();
|
|
123
|
-
for (const owner of owners) {
|
|
124
|
-
if (owner.kind === "entry") {
|
|
125
|
-
const key = `${owner.collection}/${owner.slug}`;
|
|
126
|
-
if (validated.has(key))
|
|
12
|
+
import { MANIFEST_PATH, memorySource } from "@micacms/core";
|
|
13
|
+
import { ApiError, badRequest, notFound } from "../lib/errors.js";
|
|
14
|
+
import { developerPolicy } from "../workspace/developer.js";
|
|
15
|
+
import { GitError } from "../workspace/git.js";
|
|
16
|
+
import { createWorkspaceLocks } from "../workspace/locks.js";
|
|
17
|
+
import { aheadOfUpstream, commitPaths, currentBranch, discardChange, headRevision, headSource, listTreeChanges, locateTree, } from "../workspace/tree.js";
|
|
18
|
+
import { analyseChanges, compareSources, describeProblems, flattenProblems, } from "./changes.js";
|
|
19
|
+
export function createWorkingTreeStagingService(deps) {
|
|
20
|
+
const { model, files, contentRoot, logger } = deps;
|
|
21
|
+
const policy = deps.policy ?? developerPolicy({ publishEnabled: deps.publishEnabled, logger });
|
|
22
|
+
const locks = deps.locks ?? createWorkspaceLocks();
|
|
23
|
+
const analyse = async (tree, treeChanges) => {
|
|
24
|
+
const { changes, validation } = await analyseChanges(model, files, treeChanges, headSource(tree));
|
|
25
|
+
// The field validator cannot see a merge conflict: the file holds Git's conflict format.
|
|
26
|
+
for (const change of treeChanges) {
|
|
27
|
+
if (!change.conflicted)
|
|
127
28
|
continue;
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
const result = validateFields(def.fields, data, "storage");
|
|
136
|
-
if (!result.ok) {
|
|
137
|
-
problems.push({
|
|
138
|
-
path: entryDataPath(def, owner.collection, owner.slug),
|
|
139
|
-
label: labelEntry(def, owner.slug, titles.get(key)),
|
|
140
|
-
issues: result.issues,
|
|
141
|
-
});
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
if (owner.kind === "singleton" && !validated.has(`singleton:${owner.name}`)) {
|
|
145
|
-
validated.add(`singleton:${owner.name}`);
|
|
146
|
-
const def = model.singletons[owner.name];
|
|
147
|
-
const dataPath = singletonPath(def.fields, owner.name);
|
|
148
|
-
try {
|
|
149
|
-
const data = await readSingleton(source, def, owner.name);
|
|
150
|
-
if (data === null)
|
|
151
|
-
continue;
|
|
152
|
-
const result = validateFields(def.fields, data, "storage");
|
|
153
|
-
if (!result.ok) {
|
|
154
|
-
problems.push({ path: dataPath, label: def.label, issues: result.issues });
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
catch (error) {
|
|
158
|
-
problems.push({
|
|
159
|
-
path: error.path ?? dataPath,
|
|
160
|
-
label: def.label,
|
|
161
|
-
issues: [{ path: "", message: error.message }],
|
|
162
|
-
});
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
/* ---------------------------------------------------- annotation */
|
|
167
|
-
const annotated = changes.map((change, index) => {
|
|
168
|
-
const owner = owners[index];
|
|
169
|
-
if (owner.kind === "entry") {
|
|
170
|
-
const def = model.collections[owner.collection];
|
|
171
|
-
return {
|
|
172
|
-
...change,
|
|
173
|
-
owner: { ...owner, label: labelEntry(def, owner.slug, titles.get(`${owner.collection}/${owner.slug}`)) },
|
|
174
|
-
};
|
|
29
|
+
validation.problems.push({
|
|
30
|
+
path: change.path,
|
|
31
|
+
label: change.path,
|
|
32
|
+
issues: [
|
|
33
|
+
{ path: "", message: "This file has a Git merge conflict. Resolve it with your Git tools." },
|
|
34
|
+
],
|
|
35
|
+
});
|
|
175
36
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
return {
|
|
179
|
-
changes: annotated,
|
|
180
|
-
validation: { ok: problems.length === 0, problems },
|
|
37
|
+
validation.ok = validation.problems.length === 0;
|
|
38
|
+
return { changes, validation };
|
|
181
39
|
};
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
* `singleton.json` or `singleton/…`, and the shared `media/` directory.
|
|
189
|
-
*/
|
|
190
|
-
export function classifyPath(model, path) {
|
|
191
|
-
const segments = path.split("/");
|
|
192
|
-
const head = segments[0] ?? "";
|
|
193
|
-
const collection = model.collections[head];
|
|
194
|
-
if (collection && segments.length > 1) {
|
|
195
|
-
if (segments.length === 2 && segments[1] === ORDER_FILE) {
|
|
196
|
-
return { kind: "order", collection: head, label: `${collection.label}: order` };
|
|
197
|
-
}
|
|
198
|
-
if (entryLayout(collection.fields) === "directory") {
|
|
199
|
-
const slug = segments[1];
|
|
200
|
-
if (segments.length >= 3 && isValidSlug(slug)) {
|
|
201
|
-
return { kind: "entry", collection: head, slug, label: slug };
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
else if (segments.length === 2 && segments[1].endsWith(".json")) {
|
|
205
|
-
const slug = segments[1].slice(0, -".json".length);
|
|
206
|
-
if (isValidSlug(slug))
|
|
207
|
-
return { kind: "entry", collection: head, slug, label: slug };
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
const singletonDir = model.singletons[head];
|
|
211
|
-
if (singletonDir && segments.length > 1) {
|
|
212
|
-
return { kind: "singleton", name: head, label: singletonDir.label };
|
|
213
|
-
}
|
|
214
|
-
if (head.endsWith(".json")) {
|
|
215
|
-
const name = head.slice(0, -".json".length);
|
|
216
|
-
const singleton = model.singletons[name];
|
|
217
|
-
if (singleton && segments.length === 1) {
|
|
218
|
-
return { kind: "singleton", name, label: singleton.label };
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
if (head === "media")
|
|
222
|
-
return { kind: "media", label: "Shared media" };
|
|
223
|
-
return { kind: "other", label: path };
|
|
224
|
-
}
|
|
225
|
-
/** Content share that two versions of an entry need to count as one moved entry, as in Git. */
|
|
226
|
-
const RENAME_SIMILARITY = 0.5;
|
|
227
|
-
/**
|
|
228
|
-
* Marks entries that a pending rename moved, in place in `owners`.
|
|
229
|
-
*
|
|
230
|
-
* A rename is a deleted and a created entry in one collection. The change set
|
|
231
|
-
* holds only files and an entry has no identity but its slug, so the pairs are
|
|
232
|
-
* found by content, like Git's rename detection: the published version of the
|
|
233
|
-
* deleted entry against the staged version of the created one.
|
|
234
|
-
*
|
|
235
|
-
* An order file whose only change is the renamed slug joins the renamed entry:
|
|
236
|
-
* it is part of the same edit, and discarding one without the other would
|
|
237
|
-
* leave the order naming a slug that does not exist.
|
|
238
|
-
*/
|
|
239
|
-
async function detectRenames(model, published, source, changes, owners, staged) {
|
|
240
|
-
const kinds = new Map();
|
|
241
|
-
owners.forEach((owner, index) => {
|
|
242
|
-
if (owner.kind !== "entry")
|
|
243
|
-
return;
|
|
244
|
-
const bySlug = kinds.get(owner.collection) ?? new Map();
|
|
245
|
-
kinds.set(owner.collection, bySlug);
|
|
246
|
-
const set = bySlug.get(owner.slug) ?? new Set();
|
|
247
|
-
bySlug.set(owner.slug, set);
|
|
248
|
-
set.add(changes[index].kind);
|
|
249
|
-
});
|
|
250
|
-
for (const [name, bySlug] of kinds) {
|
|
251
|
-
const after = staged.get(name);
|
|
252
|
-
if (!after)
|
|
253
|
-
continue;
|
|
254
|
-
const only = (kind) => [...bySlug].filter(([, set]) => set.size === 1 && set.has(kind)).map(([slug]) => slug);
|
|
255
|
-
const deleted = only("delete").filter((slug) => !after.has(slug));
|
|
256
|
-
const created = only("create").filter((slug) => after.has(slug));
|
|
257
|
-
if (deleted.length === 0 || created.length === 0)
|
|
258
|
-
continue;
|
|
259
|
-
const def = model.collections[name];
|
|
260
|
-
let before;
|
|
261
|
-
try {
|
|
262
|
-
const entries = await readCollection(published, def, name);
|
|
263
|
-
before = new Map(entries.map((entry) => [entry.slug, entry.data]));
|
|
264
|
-
}
|
|
265
|
-
catch {
|
|
266
|
-
// A published side that cannot be read only costs the pairing.
|
|
267
|
-
continue;
|
|
268
|
-
}
|
|
269
|
-
const candidates = [];
|
|
270
|
-
for (const from of deleted) {
|
|
271
|
-
const old = before.get(from);
|
|
272
|
-
if (!old)
|
|
273
|
-
continue;
|
|
274
|
-
for (const to of created) {
|
|
275
|
-
if (before.has(to))
|
|
276
|
-
continue;
|
|
277
|
-
const score = similarity(def, onDisk(def, name, from, old), onDisk(def, name, to, after.get(to)));
|
|
278
|
-
if (score >= RENAME_SIMILARITY)
|
|
279
|
-
candidates.push({ from, to, score });
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
const renamedTo = new Map();
|
|
283
|
-
const paired = new Set();
|
|
284
|
-
for (const { from, to } of candidates.sort((a, b) => b.score - a.score)) {
|
|
285
|
-
if (renamedTo.has(from) || paired.has(to))
|
|
286
|
-
continue;
|
|
287
|
-
renamedTo.set(from, to);
|
|
288
|
-
paired.add(to);
|
|
289
|
-
}
|
|
290
|
-
if (renamedTo.size === 0)
|
|
291
|
-
continue;
|
|
292
|
-
const renamedFrom = new Map([...renamedTo].map(([from, to]) => [to, from]));
|
|
293
|
-
const orderMovesOnlySlugs = await onlyRenamedInOrder(published, source, name, renamedTo);
|
|
294
|
-
const [firstFrom, firstTo] = [...renamedTo][0];
|
|
295
|
-
owners.forEach((owner, index) => {
|
|
296
|
-
if (owner.kind === "order" && owner.collection === name && orderMovesOnlySlugs) {
|
|
297
|
-
owners[index] = {
|
|
298
|
-
kind: "entry",
|
|
299
|
-
collection: name,
|
|
300
|
-
slug: firstTo,
|
|
301
|
-
label: firstTo,
|
|
302
|
-
renamedFrom: firstFrom,
|
|
303
|
-
};
|
|
304
|
-
}
|
|
305
|
-
if (owner.kind !== "entry" || owner.collection !== name)
|
|
306
|
-
return;
|
|
307
|
-
const to = renamedTo.get(owner.slug);
|
|
308
|
-
if (to)
|
|
309
|
-
owners[index] = { ...owner, slug: to, label: to, renamedFrom: owner.slug };
|
|
310
|
-
const from = renamedFrom.get(owner.slug);
|
|
311
|
-
if (from)
|
|
312
|
-
owners[index] = { ...owner, renamedFrom: from };
|
|
313
|
-
});
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
/** True when a collection's staged order is the published one with the renamed slugs replaced. */
|
|
317
|
-
async function onlyRenamedInOrder(published, staged, name, renamedTo) {
|
|
318
|
-
const path = `${name}/${ORDER_FILE}`;
|
|
319
|
-
const parse = (raw) => {
|
|
320
|
-
try {
|
|
321
|
-
return raw === null ? null : JSON.parse(raw);
|
|
322
|
-
}
|
|
323
|
-
catch {
|
|
324
|
-
return null;
|
|
325
|
-
}
|
|
40
|
+
const locate = async () => deps.tree ?? locateTree(contentRoot);
|
|
41
|
+
const located = async () => {
|
|
42
|
+
const tree = await locate();
|
|
43
|
+
if ("reason" in tree)
|
|
44
|
+
throw badRequest(tree.reason);
|
|
45
|
+
return tree;
|
|
326
46
|
};
|
|
327
|
-
const
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
if (
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
}
|
|
348
|
-
/**
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
}
|
|
354
|
-
function isEmpty(value) {
|
|
355
|
-
return value === undefined || value === null || value === "" || (Array.isArray(value) && value.length === 0);
|
|
356
|
-
}
|
|
357
|
-
/** JSON with sorted keys, so two equal objects compare equal. */
|
|
358
|
-
function canonical(value) {
|
|
359
|
-
return JSON.stringify(value ?? null, (_key, entry) => {
|
|
360
|
-
if (entry === null || typeof entry !== "object" || Array.isArray(entry))
|
|
361
|
-
return entry;
|
|
362
|
-
const record = entry;
|
|
363
|
-
return Object.fromEntries(Object.keys(record).sort().map((key) => [key, record[key]]));
|
|
364
|
-
});
|
|
365
|
-
}
|
|
366
|
-
function labelEntry(def, slug, title) {
|
|
367
|
-
return `${def.labelSingular ?? def.label}: ${title ?? slug}`;
|
|
368
|
-
}
|
|
369
|
-
function entryDataPath(def, collection, slug) {
|
|
370
|
-
const base = collectionEntryPath(def.fields, collection, slug);
|
|
371
|
-
return entryLayout(def.fields) === "directory" ? `${base}/index.json` : base;
|
|
372
|
-
}
|
|
373
|
-
export function createDraftStagingService(deps) {
|
|
374
|
-
const { model, drafts, repository, overlay } = deps;
|
|
375
|
-
// One publication at a time. Not a lock on editing — saves keep landing in
|
|
376
|
-
// the draft store — just a refusal to run two commits of the same set.
|
|
377
|
-
let publishing = false;
|
|
378
|
-
const statusOf = async () => {
|
|
379
|
-
const rows = await drafts.list();
|
|
380
|
-
const summary = rows.map(toContentChange);
|
|
381
|
-
const { changes, validation } = await analyseChanges(model, overlay, summary, repository);
|
|
382
|
-
return { rows, changes, validation };
|
|
47
|
+
const publishable = async () => {
|
|
48
|
+
const availability = policy.publishAvailability();
|
|
49
|
+
if (!availability.enabled)
|
|
50
|
+
throw badRequest(availability.reason);
|
|
51
|
+
return located();
|
|
52
|
+
};
|
|
53
|
+
/** The key that keeps an entry's files together: a publication never splits one. */
|
|
54
|
+
const groupOf = (change) => {
|
|
55
|
+
const owner = change.owner;
|
|
56
|
+
if (owner.kind === "entry")
|
|
57
|
+
return `entry:${owner.collection}/${owner.slug}`;
|
|
58
|
+
if (owner.kind === "singleton")
|
|
59
|
+
return `singleton:${owner.name}`;
|
|
60
|
+
if (owner.kind === "order")
|
|
61
|
+
return `order:${owner.collection}`;
|
|
62
|
+
return `file:${change.path}`;
|
|
63
|
+
};
|
|
64
|
+
const withRevisions = async (changes) => Promise.all(changes.map(async (change) => ({
|
|
65
|
+
...change,
|
|
66
|
+
revision: change.kind === "delete" ? null : await files.revision(change.path),
|
|
67
|
+
})));
|
|
68
|
+
/** Refused for the developer's checkout: Mica never fetches or merges there. */
|
|
69
|
+
const managedOnly = (action) => {
|
|
70
|
+
if (action === undefined)
|
|
71
|
+
throw notFound("This is the developer's checkout; Git there is theirs.");
|
|
72
|
+
return action;
|
|
383
73
|
};
|
|
384
74
|
return {
|
|
385
75
|
async status() {
|
|
386
|
-
const
|
|
76
|
+
const tree = await locate();
|
|
77
|
+
const sync = policy.syncStatus?.() ?? null;
|
|
78
|
+
if ("reason" in tree) {
|
|
79
|
+
return {
|
|
80
|
+
owner: policy.owner,
|
|
81
|
+
publish: { enabled: false, reason: tree.reason },
|
|
82
|
+
head: null,
|
|
83
|
+
branch: null,
|
|
84
|
+
ahead: null,
|
|
85
|
+
changes: [],
|
|
86
|
+
validation: emptyValidation(),
|
|
87
|
+
sync,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
const { changes, validation, head, branch } = await locks.tree.read(async () => {
|
|
91
|
+
const analysed = await analyse(tree, await listTreeChanges(tree));
|
|
92
|
+
return {
|
|
93
|
+
...analysed,
|
|
94
|
+
changes: await withRevisions(analysed.changes),
|
|
95
|
+
head: await headRevision(tree),
|
|
96
|
+
branch: await currentBranch(tree),
|
|
97
|
+
};
|
|
98
|
+
});
|
|
387
99
|
return {
|
|
388
|
-
|
|
389
|
-
publish:
|
|
390
|
-
|
|
391
|
-
branch
|
|
392
|
-
ahead:
|
|
100
|
+
owner: policy.owner,
|
|
101
|
+
publish: policy.publishAvailability(),
|
|
102
|
+
head,
|
|
103
|
+
branch,
|
|
104
|
+
ahead: await aheadOfUpstream(tree),
|
|
393
105
|
changes,
|
|
394
106
|
validation,
|
|
395
|
-
|
|
107
|
+
sync,
|
|
396
108
|
};
|
|
397
109
|
},
|
|
398
110
|
async publish(input) {
|
|
399
111
|
const message = input.message.trim();
|
|
400
112
|
if (!message)
|
|
401
113
|
throw badRequest("A publication needs a commit message.");
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
114
|
+
const reviewed = Object.keys(input.paths);
|
|
115
|
+
if (reviewed.length === 0)
|
|
116
|
+
throw badRequest("Choose at least one change to publish.");
|
|
117
|
+
const tree = await publishable();
|
|
118
|
+
// One publication at a time from sync to push: no sync can run in
|
|
119
|
+
// between, and no second commit can stack up.
|
|
120
|
+
return locks.remote.run(async () => {
|
|
121
|
+
await policy.prepare(tree);
|
|
122
|
+
const revision = await locks.tree.write(async () => {
|
|
123
|
+
const pending = await withRevisions((await analyse(tree, await listTreeChanges(tree))).changes);
|
|
124
|
+
const byPath = new Map(pending.map((change) => [change.path, change]));
|
|
125
|
+
// The review gate refuses a file saved again since the review, a
|
|
126
|
+
// reviewed file that is no longer pending, or another file of a
|
|
127
|
+
// reviewed entry. Drafts of other entries never block a publication.
|
|
128
|
+
const reviewedGroups = new Set(reviewed.map((path) => byPath.get(path)).flatMap((change) => (change ? [groupOf(change)] : [])));
|
|
129
|
+
const stale = [
|
|
130
|
+
...reviewed.filter((path) => byPath.get(path)?.revision !== input.paths[path] || !byPath.has(path)),
|
|
131
|
+
...pending
|
|
132
|
+
.filter((change) => reviewedGroups.has(groupOf(change)) && !(change.path in input.paths))
|
|
133
|
+
.map((change) => change.path),
|
|
134
|
+
];
|
|
135
|
+
if (stale.length > 0) {
|
|
136
|
+
throw new ApiError("conflict", "Some of these changes were edited after you reviewed them. Review them again, then publish.");
|
|
137
|
+
}
|
|
138
|
+
// The model drift gate: validation used the model that this process started with.
|
|
139
|
+
if ((await files.revision(MANIFEST_PATH)) !== deps.schemaRevision) {
|
|
140
|
+
throw new ApiError("conflict", "The content model changed after this server started. Restart Mica to load " +
|
|
141
|
+
"the new model, then review the pending changes and publish again.");
|
|
142
|
+
}
|
|
143
|
+
const selected = (await listTreeChanges(tree)).filter((change) => change.path in input.paths);
|
|
144
|
+
const { validation } = await analyse(tree, selected);
|
|
145
|
+
if (!validation.ok) {
|
|
146
|
+
throw new ApiError("validation_failed", describeProblems(validation.problems), {
|
|
147
|
+
issues: flattenProblems(validation.problems),
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
const revision = await commitPaths(tree, reviewed, message, policy.commitAuthor(input.author));
|
|
152
|
+
logger.info("Published", { revision, files: reviewed.length, owner: policy.owner });
|
|
153
|
+
return revision;
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
if (error instanceof GitError) {
|
|
157
|
+
// Git's message is the actionable part: no identity, a hook that
|
|
158
|
+
// refused, a rebase in progress.
|
|
159
|
+
throw new ApiError("conflict", `Git refused the commit: ${error.message}`);
|
|
160
|
+
}
|
|
161
|
+
throw error;
|
|
434
162
|
}
|
|
435
|
-
: { type: "delete", path: row.path });
|
|
436
|
-
// The per-path gate: every file this publication writes or removes,
|
|
437
|
-
// expected to be as it was when first staged. A branch that moved on
|
|
438
|
-
// other paths sails through, which is the point.
|
|
439
|
-
const result = await repository.commit({
|
|
440
|
-
message,
|
|
441
|
-
changes,
|
|
442
|
-
expect: Object.fromEntries(snapshot.map((row) => [row.path, row.baseRevision])),
|
|
443
|
-
author: input.author,
|
|
444
163
|
});
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
164
|
+
return policy.push(tree, revision);
|
|
165
|
+
});
|
|
166
|
+
},
|
|
167
|
+
async push() {
|
|
168
|
+
const tree = await publishable();
|
|
169
|
+
return locks.remote.run(async () => {
|
|
170
|
+
const revision = await headRevision(tree);
|
|
171
|
+
if (revision === null)
|
|
172
|
+
throw badRequest("There is no commit to push yet.");
|
|
173
|
+
return policy.push(tree, revision);
|
|
174
|
+
});
|
|
175
|
+
},
|
|
176
|
+
async discard(requested) {
|
|
177
|
+
const tree = await located();
|
|
178
|
+
return locks.tree.write(async () => {
|
|
179
|
+
// Discard is destructive, so it reaches only paths that the change list names.
|
|
180
|
+
const current = new Map((await listTreeChanges(tree)).map((change) => [change.path, change]));
|
|
181
|
+
const discarded = [];
|
|
182
|
+
for (const path of requested) {
|
|
183
|
+
const change = current.get(path);
|
|
184
|
+
if (!change)
|
|
185
|
+
continue;
|
|
186
|
+
await discardChange(tree, change);
|
|
187
|
+
discarded.push(path);
|
|
452
188
|
}
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
return { state: "published", revision: result.revision };
|
|
456
|
-
}
|
|
457
|
-
finally {
|
|
458
|
-
publishing = false;
|
|
459
|
-
}
|
|
189
|
+
return { discarded };
|
|
190
|
+
});
|
|
460
191
|
},
|
|
461
|
-
async
|
|
462
|
-
|
|
192
|
+
async compare(target, against = "head") {
|
|
193
|
+
const tree = await locate();
|
|
194
|
+
if (against === "origin" && !policy.upstreamRef)
|
|
195
|
+
throw badRequest("There is no website version to compare with here.");
|
|
196
|
+
// Without a repository nothing is published, so "before" is an empty source.
|
|
197
|
+
const published = "reason" in tree
|
|
198
|
+
? memorySource({}, "unpublished")
|
|
199
|
+
: headSource(tree, against === "origin" ? policy.upstreamRef : "HEAD");
|
|
200
|
+
return locks.tree.read(() => compareSources(model, published, files, target));
|
|
463
201
|
},
|
|
464
|
-
async
|
|
465
|
-
|
|
466
|
-
throw badRequest("Publications here are single repository commits. There is nothing to push.");
|
|
202
|
+
async sync() {
|
|
203
|
+
await managedOnly(policy.sync)();
|
|
467
204
|
},
|
|
468
|
-
async
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
205
|
+
async resolve(group, choice) {
|
|
206
|
+
await managedOnly(policy.resolve)(group, choice);
|
|
207
|
+
},
|
|
208
|
+
async repository() {
|
|
209
|
+
const tree = await locate();
|
|
210
|
+
const described = (await policy.describe?.()) ?? null;
|
|
211
|
+
return {
|
|
212
|
+
owner: policy.owner,
|
|
213
|
+
branch: "reason" in tree ? null : await currentBranch(tree),
|
|
214
|
+
head: "reason" in tree ? null : await headRevision(tree),
|
|
215
|
+
url: described?.url ?? null,
|
|
216
|
+
cloneBytes: described?.cloneBytes ?? null,
|
|
217
|
+
sync: policy.syncStatus?.() ?? null,
|
|
218
|
+
};
|
|
472
219
|
},
|
|
473
220
|
};
|
|
474
221
|
}
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
const names = problems.map((problem) => problem.label);
|
|
478
|
-
const listed = names.slice(0, 3).join(", ") + (names.length > 3 ? ", …" : "");
|
|
479
|
-
return `The pending changes cannot be published until they are valid: ${listed}.`;
|
|
480
|
-
}
|
|
481
|
-
/** Field issues with the file path folded in, for the flat API envelope. */
|
|
482
|
-
export function flattenProblems(problems) {
|
|
483
|
-
return problems.flatMap((problem) => problem.issues.map((issue) => ({
|
|
484
|
-
path: issue.path ? `${problem.path}: ${issue.path}` : problem.path,
|
|
485
|
-
message: issue.message,
|
|
486
|
-
})));
|
|
222
|
+
function emptyValidation() {
|
|
223
|
+
return { ok: true, problems: [] };
|
|
487
224
|
}
|
|
488
225
|
//# sourceMappingURL=staging.js.map
|