@stelstone/server 0.26.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.
@@ -0,0 +1,577 @@
1
+ import { createGitHubApi } from "./github-api.mjs";
2
+ import { sanitize, safeFileName, sortPages, buildDuplicateData, listScheduledDue, commitMsg, buildListEntry, isEntryFile } from "./_shared.mjs";
3
+
4
+ /**
5
+ * GitHub Contents API adapter — serverless content backend.
6
+ *
7
+ * Every writePage/createPage/deletePage is an immediate commit to GitHub.
8
+ * No git binary required. Safe to run on Vercel, Cloudflare Workers, etc.
9
+ *
10
+ * With `draftBranch` set, those commits land on the draft branch instead of
11
+ * the deploy branch: saving stops publishing, and Publish (whole-site or a
12
+ * single record) moves files onto the deploy branch — the same
13
+ * save-then-publish model as the fs backend.
14
+ *
15
+ * Listing strategy: each collection keeps a `_index.json` manifest that
16
+ * contains only the listing metadata (id, slug, lang, title, file, meta).
17
+ * listPages fetches this single file — 1 subrequest regardless of size.
18
+ * On first call (no index yet) it bootstraps via GraphQL and persists the
19
+ * index. All write operations keep the index in sync.
20
+ *
21
+ * Config (cms.config.mjs):
22
+ * content: {
23
+ * provider: "github",
24
+ * githubTokenEnv: "GITHUB_TOKEN", // PAT with repo scope
25
+ * owner: "your-org",
26
+ * repo: "your-site",
27
+ * branch: "main",
28
+ * pagesDir: "src/pages-data",
29
+ * commitMessage: (ts) => `Content updated ${ts}`,
30
+ * list: {
31
+ * strategy: "index", // "index" (default) — only built-in strategy
32
+ * rebuild: "build", // "build" (default) | "lazy"
33
+ * indexFile: "_index.json", // manifest filename
34
+ * resolve: undefined, // optional async (collection, { sortConfig }) => entries[]|null
35
+ * },
36
+ * }
37
+ *
38
+ * @param {Object} opts
39
+ * @param {string} opts.token
40
+ * @param {string} opts.owner
41
+ * @param {string} opts.repo
42
+ * @param {string} opts.branch
43
+ * @param {string} [opts.draftBranch] Opt-in draft workflow. When set, every
44
+ * CMS read and write targets this branch instead of `branch`; Publish
45
+ * moves the record's file onto `branch` with a git-data commit. Without
46
+ * it, behaviour is the original: saving commits straight to `branch`,
47
+ * i.e. saving IS publishing. The gap this closes: with commit-on-save
48
+ * to the deploy branch, editing a LIVE page put it in production the
49
+ * moment it was saved — there was no "work on it, publish when ready".
50
+ * @param {string} opts.pagesDir
51
+ * @param {(ts: string) => string} opts.commitMessage
52
+ * @param {Object} [opts.list] Listing strategy config (see above)
53
+ * @returns {import('./types.mjs').ContentAdapter}
54
+ */
55
+ export function createGitHubContent({ token, owner, repo, branch, draftBranch, pagesDir, commitMessage, list = {}, collections = {} }) {
56
+ const listConfig = {
57
+ strategy: "index",
58
+ rebuild: "build",
59
+ indexFile: "_index.json",
60
+ resolve: undefined,
61
+ ...list,
62
+ };
63
+ const { apiGet, apiPut, apiDelete, apiPost, apiPatch, graphql } = createGitHubApi({ token, owner, repo });
64
+ // SHA cache: avoid extra GET before every PUT. Invalidated on write.
65
+ const shaCache = new Map();
66
+
67
+ // The branch the CMS works on. In draft mode that is the draft branch and
68
+ // `branch` remains the published/deploy branch; otherwise they are one and
69
+ // the adapter behaves exactly as before this option existed.
70
+ const draftMode = !!draftBranch && draftBranch !== branch;
71
+ const workBranch = draftMode ? draftBranch : branch;
72
+
73
+ // Lazily create the draft branch off the published branch. Checked once per
74
+ // process; a 404 on the ref is the "first ever use" case, not an error.
75
+ let draftBranchReady = !draftMode;
76
+ async function ensureDraftBranch() {
77
+ if (draftBranchReady) return;
78
+ const ref = await apiGet(`/git/ref/heads/${draftBranch}`);
79
+ if (!ref) {
80
+ const base = await apiGet(`/git/ref/heads/${branch}`);
81
+ if (!base) throw new Error(`Published branch "${branch}" does not exist`);
82
+ await apiPost(`/git/refs`, { ref: `refs/heads/${draftBranch}`, sha: base.object.sha });
83
+ }
84
+ draftBranchReady = true;
85
+ }
86
+
87
+ function contentPath(collection, file) {
88
+ return `${pagesDir}/${collection}/${file}`;
89
+ }
90
+
91
+ function indexFilePath(collection) {
92
+ return `${pagesDir}/${collection}/${listConfig.indexFile}`;
93
+ }
94
+
95
+ async function getFileSha(path) {
96
+ if (shaCache.has(path)) return shaCache.get(path);
97
+ const data = await apiGet(`/contents/${path}?ref=${workBranch}`);
98
+ if (!data || Array.isArray(data)) return null;
99
+ shaCache.set(path, data.sha);
100
+ return data.sha;
101
+ }
102
+
103
+ function decodeContent(data) {
104
+ const raw = Buffer.from(data.content, "base64").toString("utf-8");
105
+ return JSON.parse(raw);
106
+ }
107
+
108
+ function encodeContent(obj) {
109
+ return Buffer.from(JSON.stringify(obj, null, 2), "utf-8").toString("base64");
110
+ }
111
+
112
+ /** Build the listing record stored in _index.json for a single entry. */
113
+ function buildIndexEntry(collection, file, data) {
114
+ return buildListEntry(collections[collection], collection, file, data);
115
+ }
116
+
117
+ /** Read _index.json for a collection; returns { entries: [] } if absent. */
118
+ async function readIndex(collection) {
119
+ try {
120
+ const path = indexFilePath(collection);
121
+ const data = await apiGet(`/contents/${path}?ref=${workBranch}`);
122
+ if (!data || Array.isArray(data)) return { entries: [] };
123
+ shaCache.set(path, data.sha);
124
+ return decodeContent(data);
125
+ } catch {
126
+ return { entries: [] };
127
+ }
128
+ }
129
+
130
+ /** Persist an updated index object to _index.json (single commit). */
131
+ async function writeIndex(collection, index) {
132
+ const path = indexFilePath(collection);
133
+ const sha = shaCache.get(path) ?? await getFileSha(path);
134
+ const result = await apiPut(`/contents/${path}`, {
135
+ message: commitMsg(commitMessage),
136
+ content: encodeContent(index),
137
+ branch: workBranch,
138
+ ...(sha ? { sha } : {}),
139
+ });
140
+ shaCache.set(path, result.content?.sha);
141
+ }
142
+
143
+ /** Add or replace one entry in _index.json, then persist. */
144
+ async function upsertIndexEntry(collection, file, data) {
145
+ const index = await readIndex(collection);
146
+ const entry = buildIndexEntry(collection, file, data);
147
+ const pos = index.entries.findIndex((e) => e.file === file);
148
+ if (pos >= 0) index.entries[pos] = entry;
149
+ else index.entries.push(entry);
150
+ await writeIndex(collection, index);
151
+ }
152
+
153
+ /** Remove one entry from _index.json, then persist. */
154
+ async function removeIndexEntry(collection, file) {
155
+ const index = await readIndex(collection);
156
+ index.entries = index.entries.filter((e) => e.file !== file);
157
+ await writeIndex(collection, index);
158
+ }
159
+
160
+ // GraphQL query for lazy bootstrap: fetch all blob text in one request.
161
+ const BOOTSTRAP_QUERY = `
162
+ query($owner: String!, $repo: String!, $expr: String!) {
163
+ repository(owner: $owner, name: $repo) {
164
+ object(expression: $expr) {
165
+ ... on Tree {
166
+ entries {
167
+ name
168
+ oid
169
+ type
170
+ object {
171
+ ... on Blob {
172
+ text
173
+ }
174
+ }
175
+ }
176
+ }
177
+ }
178
+ }
179
+ }
180
+ `;
181
+
182
+ return {
183
+ async listCollections() {
184
+ await ensureDraftBranch();
185
+ const items = await apiGet(`/contents/${pagesDir}?ref=${workBranch}`);
186
+ if (!items || !Array.isArray(items)) return [];
187
+ const dirs = items.filter((i) => i.type === "dir");
188
+ return Promise.all(
189
+ dirs.map(async (d) => {
190
+ const files = await apiGet(`/contents/${pagesDir}/${d.name}?ref=${workBranch}`);
191
+ const count = Array.isArray(files)
192
+ ? files.filter((f) => isEntryFile(f.name) && f.name !== listConfig.indexFile).length
193
+ : 0;
194
+ return { name: d.name, count };
195
+ }),
196
+ );
197
+ },
198
+
199
+ async listPages(collection, sortConfig = null) {
200
+ await ensureDraftBranch();
201
+ const safeCollection = sanitize(collection);
202
+
203
+ // 1. Custom resolver takes full control — nothing else runs.
204
+ if (typeof listConfig.resolve === "function") {
205
+ return await listConfig.resolve(collection, { sortConfig });
206
+ }
207
+
208
+ // 2. strategy "index": try the pre-built manifest first (1 subrequest).
209
+ const idxPath = indexFilePath(safeCollection);
210
+ try {
211
+ const data = await apiGet(`/contents/${idxPath}?ref=${workBranch}`);
212
+ if (data && !Array.isArray(data)) {
213
+ shaCache.set(idxPath, data.sha);
214
+ const index = decodeContent(data);
215
+ if (Array.isArray(index.entries)) {
216
+ return sortPages(index.entries, sortConfig);
217
+ }
218
+ }
219
+ } catch {
220
+ // Non-404 error reading index — fall through to rebuild logic.
221
+ }
222
+
223
+ // Index absent: behaviour depends on rebuild strategy.
224
+ if (listConfig.rebuild === "lazy") {
225
+ // Bootstrap: one GraphQL call fetching all blob text, then persist.
226
+ // On GraphQL failure, throw — never fall back to REST N+1.
227
+ const expression = `${workBranch}:${pagesDir}/${safeCollection}`;
228
+ const gqlData = await graphql(BOOTSTRAP_QUERY, { owner, repo, expr: expression });
229
+
230
+ const treeObj = gqlData?.repository?.object;
231
+ if (!treeObj) return null;
232
+
233
+ const pages = [];
234
+ for (const entry of treeObj.entries || []) {
235
+ if (entry.type !== "blob" || !entry.name.endsWith(".json") || entry.name === listConfig.indexFile) continue;
236
+ shaCache.set(`${pagesDir}/${safeCollection}/${entry.name}`, entry.oid);
237
+ let data;
238
+ try { data = JSON.parse(entry.object.text); } catch { continue; }
239
+ pages.push(buildIndexEntry(collection, entry.name, data));
240
+ }
241
+
242
+ // Persist for all future calls.
243
+ try {
244
+ await writeIndex(safeCollection, { entries: pages });
245
+ } catch (err) {
246
+ console.warn(`[listPages] Could not write ${listConfig.indexFile}:`, err?.message);
247
+ }
248
+
249
+ return sortPages(pages, sortConfig);
250
+ }
251
+
252
+ // rebuild === "build" (default): never fetch all bodies at runtime.
253
+ // Check whether the collection directory actually exists.
254
+ const items = await apiGet(`/contents/${pagesDir}/${safeCollection}?ref=${workBranch}`);
255
+ if (!Array.isArray(items)) return null; // 404 or non-dir → unknown collection
256
+
257
+ // Collection exists but index hasn't been built yet.
258
+ console.warn(
259
+ `[listPages] No ${listConfig.indexFile} found for collection "${safeCollection}". ` +
260
+ `Run \`stelstone build-index\` to generate it.`,
261
+ );
262
+ return [];
263
+ },
264
+
265
+ async readPage(collection, file) {
266
+ await ensureDraftBranch();
267
+ if (!safeFileName(file)) return null;
268
+ const path = contentPath(collection, sanitize(file));
269
+ const data = await apiGet(`/contents/${path}?ref=${workBranch}`);
270
+ if (!data || Array.isArray(data)) return null;
271
+ shaCache.set(path, data.sha);
272
+ return decodeContent(data);
273
+ },
274
+
275
+ /** Version token for optimistic concurrency — the blob SHA GitHub already keeps. */
276
+ async versionOf(collection, file) {
277
+ if (!safeFileName(file)) return null;
278
+ return getFileSha(contentPath(collection, sanitize(file)));
279
+ },
280
+
281
+ /**
282
+ * @param {Object} [opts]
283
+ * @param {string} [opts.expectedVersion] The blob SHA the caller last read.
284
+ * Passing it through means GitHub itself enforces the precondition:
285
+ * it rejects a PUT whose sha is not current. Re-reading the sha at
286
+ * write time — which is what this did before — discarded that
287
+ * guarantee and silently overwrote a concurrent edit.
288
+ */
289
+ async writePage(collection, file, data, { expectedVersion } = {}) {
290
+ await ensureDraftBranch();
291
+ const path = contentPath(collection, sanitize(file));
292
+ const sha = expectedVersion ?? (await getFileSha(path));
293
+ let result;
294
+ try {
295
+ result = await apiPut(`/contents/${path}`, {
296
+ message: commitMsg(commitMessage),
297
+ content: encodeContent(data),
298
+ branch: workBranch,
299
+ ...(sha ? { sha } : {}),
300
+ });
301
+ } catch (err) {
302
+ // GitHub answers 409 when the supplied sha is stale.
303
+ if (expectedVersion && (err.upstreamStatus === 409 || err.status === 409)) {
304
+ shaCache.delete(path);
305
+ const conflict = new Error("This entry changed since you loaded it.");
306
+ conflict.status = 412;
307
+ throw conflict;
308
+ }
309
+ throw err;
310
+ }
311
+ shaCache.set(path, result.content?.sha);
312
+ await upsertIndexEntry(collection, file, data);
313
+ },
314
+
315
+ async createPage(collection, data) {
316
+ await ensureDraftBranch();
317
+ const slug = data.slug || data.id || `new-${Date.now()}`;
318
+ if (!sanitize(slug) || sanitize(slug) !== String(slug)) {
319
+ // Reject rather than silently stripping characters — otherwise the
320
+ // file name and data.slug would diverge (e.g. "ürünler" → "rnler").
321
+ const err = new Error(
322
+ `Slug "${slug}" contains unsupported characters — use only a-z, 0-9, dots, dashes, underscores.`,
323
+ );
324
+ err.status = 400;
325
+ throw err;
326
+ }
327
+ const fileName = `${sanitize(data.lang || "en")}-${sanitize(slug)}.json`;
328
+ const path = contentPath(collection, fileName);
329
+ if (await getFileSha(path)) {
330
+ const err = new Error(
331
+ `An entry already exists for "${data.lang || "en"}/${slug}". Change the slug or language.`,
332
+ );
333
+ err.status = 409;
334
+ throw err;
335
+ }
336
+ const result = await apiPut(`/contents/${path}`, {
337
+ message: commitMsg(commitMessage),
338
+ content: encodeContent(data),
339
+ branch: workBranch,
340
+ });
341
+ shaCache.set(path, result.content?.sha);
342
+ await upsertIndexEntry(collection, fileName, data);
343
+ return { file: fileName };
344
+ },
345
+
346
+ async deletePage(collection, file) {
347
+ await ensureDraftBranch();
348
+ if (!safeFileName(file)) return null;
349
+ const path = contentPath(collection, sanitize(file));
350
+ const sha = await getFileSha(path);
351
+ if (!sha) return false;
352
+ await apiDelete(`/contents/${path}`, { message: commitMsg(commitMessage), sha, branch: workBranch });
353
+ shaCache.delete(path);
354
+ await removeIndexEntry(collection, file);
355
+ return true;
356
+ },
357
+
358
+ async duplicatePage(collection, file) {
359
+ if (!safeFileName(file)) return null;
360
+ const src = await this.readPage(collection, file);
361
+ if (!src) return null;
362
+ const { data, fileName } = buildDuplicateData(src);
363
+ return this.createPage(collection, data);
364
+ },
365
+
366
+ // In draft mode saving and publishing are separate steps, exactly like
367
+ // the fs backend. Without a draft branch, writes commit straight to the
368
+ // deploy branch — saving IS publishing — and the per-entry publish route
369
+ // answers 501 instead of pretending.
370
+ capabilities: draftMode
371
+ ? { deferredPublish: true, perEntryPublish: true }
372
+ : { deferredPublish: false, perEntryPublish: false },
373
+
374
+ async pendingChanges() {
375
+ if (!draftMode) return { hasChanges: false, changedFiles: 0, files: [] };
376
+ await ensureDraftBranch();
377
+ // One compare call: which files differ between published and draft?
378
+ // (GitHub caps the file list at 300 — orders of magnitude above any
379
+ // real collection here; still, say so rather than rely on it silently.)
380
+ const cmp = await apiGet(`/compare/${branch}...${draftBranch}`);
381
+ const files = (cmp?.files ?? [])
382
+ .filter((f) => f.filename.startsWith(`${pagesDir}/`))
383
+ // Index manifests live on the draft branch only — the site build
384
+ // ignores files starting with "_", so they are never published.
385
+ .filter((f) => !f.filename.endsWith(`/${listConfig.indexFile}`))
386
+ .map((f) => {
387
+ const rel = f.filename.slice(pagesDir.length + 1);
388
+ const slash = rel.indexOf("/");
389
+ return slash > 0
390
+ ? { path: f.filename, collection: rel.slice(0, slash), file: rel.slice(slash + 1), status: f.status }
391
+ : { path: f.filename, status: f.status };
392
+ });
393
+ return { hasChanges: files.length > 0, changedFiles: files.length, files };
394
+ },
395
+
396
+ /**
397
+ * Draft mode: move the draft branch's version of the chosen files onto
398
+ * the published branch with one git-data commit. Blobs already live in
399
+ * the repo (they were committed to the draft branch), so the tree
400
+ * references them by SHA — nothing is re-uploaded. A `removed` status
401
+ * becomes a tree entry with `sha: null`, which is how git-data deletes.
402
+ *
403
+ * @param {string} [message]
404
+ * @param {{ entries?: {collection: string, file: string}[] }} [opts]
405
+ */
406
+ async publish(message, { entries } = {}) {
407
+ if (!draftMode) {
408
+ // Writes are committed instantly; trigger is external (Netlify webhook on push)
409
+ return { ok: true, message: "All changes are already committed to GitHub" };
410
+ }
411
+ await ensureDraftBranch();
412
+ const pending = await this.pendingChanges();
413
+ if (!pending.hasChanges) return { ok: false, message: "No changes to publish" };
414
+
415
+ const scoped = Array.isArray(entries) && entries.length > 0;
416
+ let targets = pending.files;
417
+ if (scoped) {
418
+ const wanted = new Set(
419
+ entries.map((e) => contentPath(sanitize(e.collection), sanitize(e.file))),
420
+ );
421
+ targets = pending.files.filter((f) => wanted.has(f.path));
422
+ if (!targets.length) return { ok: false, message: "No changes to publish" };
423
+ }
424
+
425
+ const treeItems = await Promise.all(
426
+ targets.map(async (f) => {
427
+ if (f.status === "removed") return { path: f.path, mode: "100644", type: "blob", sha: null };
428
+ const blob = await apiGet(`/contents/${f.path}?ref=${draftBranch}`);
429
+ return { path: f.path, mode: "100644", type: "blob", sha: blob.sha };
430
+ }),
431
+ );
432
+
433
+ // The ref update races with concurrent pushes to the published branch;
434
+ // retry once from a fresh base, as writeBatch does.
435
+ let lastErr;
436
+ for (let attempt = 0; attempt < 2; attempt++) {
437
+ try {
438
+ const refData = await apiGet(`/git/ref/heads/${branch}`);
439
+ const baseCommitSha = refData.object.sha;
440
+ const baseCommit = await apiGet(`/git/commits/${baseCommitSha}`);
441
+ const timestamp = new Date().toISOString().replace("T", " ").slice(0, 19);
442
+ const msg = message || commitMsg(commitMessage) || `cms: publish ${timestamp}`;
443
+ const newTree = await apiPost(`/git/trees`, { base_tree: baseCommit.tree.sha, tree: treeItems });
444
+ const newCommit = await apiPost(`/git/commits`, {
445
+ message: msg,
446
+ tree: newTree.sha,
447
+ parents: [baseCommitSha],
448
+ });
449
+ await apiPatch(`/git/refs/heads/${branch}`, { sha: newCommit.sha });
450
+ const shortSha = newCommit.sha.slice(0, 7);
451
+ return {
452
+ ok: true,
453
+ message: `Published ${targets.length} file(s) to ${branch} (${shortSha})`,
454
+ sha: newCommit.sha,
455
+ shortSha,
456
+ branch,
457
+ scope: scoped ? "entries" : "all",
458
+ };
459
+ } catch (err) {
460
+ lastErr = err;
461
+ }
462
+ }
463
+ throw lastErr;
464
+ },
465
+
466
+ async listHistory(collection, file) {
467
+ if (!safeFileName(file)) return null;
468
+ const path = contentPath(collection, sanitize(file));
469
+ const commits = await apiGet(`/commits?path=${encodeURIComponent(path)}&per_page=20&sha=${workBranch}`);
470
+ if (!Array.isArray(commits)) return [];
471
+ return commits.map((c) => ({
472
+ ts: c.sha,
473
+ label: c.commit.message,
474
+ date: c.commit.author?.date,
475
+ author: c.commit.author?.name || c.commit.author?.email || "",
476
+ size: null,
477
+ }));
478
+ },
479
+
480
+ async restoreHistory(collection, file, commitSha) {
481
+ if (!safeFileName(file)) return null;
482
+ const path = contentPath(collection, sanitize(file));
483
+ const fileData = await apiGet(`/contents/${path}?ref=${commitSha}`);
484
+ if (!fileData || Array.isArray(fileData)) return false;
485
+ const data = decodeContent(fileData);
486
+ await this.writePage(collection, file, data);
487
+ return true;
488
+ },
489
+
490
+ async writeBatch(items, message) {
491
+ await ensureDraftBranch();
492
+ if (!items.length) return { ok: true, commitCount: 0 };
493
+
494
+ // The ref update (step 7) races with concurrent pushes: if the branch
495
+ // moved after we read it, GitHub rejects the non-fast-forward PATCH.
496
+ // Retry the whole flow once from a fresh base before giving up.
497
+ let lastErr;
498
+ for (let attempt = 0; attempt < 2; attempt++) {
499
+ try {
500
+ return await this._writeBatchOnce(items, message);
501
+ } catch (err) {
502
+ lastErr = err;
503
+ }
504
+ }
505
+ throw lastErr;
506
+ },
507
+
508
+ async _writeBatchOnce(items, message) {
509
+ // 1. Get current ref → base commit SHA
510
+ const refData = await apiGet(`/git/ref/heads/${workBranch}`);
511
+ const baseCommitSha = refData.object.sha;
512
+
513
+ // 2. Get base commit → base tree SHA
514
+ const baseCommit = await apiGet(`/git/commits/${baseCommitSha}`);
515
+ const baseTreeSha = baseCommit.tree.sha;
516
+
517
+ // 3. Create one blob per content file
518
+ const treeItems = await Promise.all(
519
+ items.map(async ({ collection, file, data }) => {
520
+ const filePath = contentPath(sanitize(collection), sanitize(file));
521
+ const content = Buffer.from(JSON.stringify(data, null, 2), "utf-8").toString("base64");
522
+ const blob = await apiPost(`/git/blobs`, { content, encoding: "base64" });
523
+ return { path: filePath, mode: "100644", type: "blob", sha: blob.sha };
524
+ }),
525
+ );
526
+
527
+ // 4. Compute updated _index.json for each affected collection and add
528
+ // them to the same tree so the index stays in sync atomically.
529
+ const byCollection = {};
530
+ for (const item of items) {
531
+ const col = sanitize(item.collection ?? item.data?.collection ?? "");
532
+ if (!col) continue;
533
+ (byCollection[col] ??= []).push(item);
534
+ }
535
+ for (const [col, colItems] of Object.entries(byCollection)) {
536
+ const index = await readIndex(col);
537
+ for (const { file, data } of colItems) {
538
+ const entry = buildIndexEntry(col, file, data);
539
+ const pos = index.entries.findIndex((e) => e.file === file);
540
+ if (pos >= 0) index.entries[pos] = entry;
541
+ else index.entries.push(entry);
542
+ }
543
+ const idxContent = Buffer.from(JSON.stringify(index, null, 2), "utf-8").toString("base64");
544
+ const idxBlob = await apiPost(`/git/blobs`, { content: idxContent, encoding: "base64" });
545
+ treeItems.push({ path: indexFilePath(col), mode: "100644", type: "blob", sha: idxBlob.sha });
546
+ }
547
+
548
+ // 5. Create tree
549
+ const newTree = await apiPost(`/git/trees`, { base_tree: baseTreeSha, tree: treeItems });
550
+
551
+ // 6. Create commit
552
+ const msg = message || commitMsg(commitMessage, "Batch content update");
553
+ const newCommit = await apiPost(`/git/commits`, {
554
+ message: msg,
555
+ tree: newTree.sha,
556
+ parents: [baseCommitSha],
557
+ });
558
+
559
+ // 7. Update ref
560
+ await apiPatch(`/git/refs/heads/${workBranch}`, { sha: newCommit.sha });
561
+
562
+ // 8. Invalidate shaCache for all written paths
563
+ for (const { collection, file } of items) {
564
+ shaCache.delete(contentPath(sanitize(collection), sanitize(file)));
565
+ }
566
+ for (const col of Object.keys(byCollection)) {
567
+ shaCache.delete(indexFilePath(col));
568
+ }
569
+
570
+ return { ok: true, sha: newCommit.sha, commitCount: 1 };
571
+ },
572
+
573
+ async listScheduled() {
574
+ return listScheduledDue(this);
575
+ },
576
+ };
577
+ }