canopycms 0.11.0 → 0.12.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.
@@ -2,19 +2,22 @@ import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { z } from 'zod';
4
4
  import { entryRefFromRepoPath, entryRefFromRepoPathShape, parseEntryParts, } from '../content-listing.js';
5
- import { ContentStore, ContentStoreError } from '../content-store.js';
5
+ import { BranchSyncingError, ContentConflictError, ContentStore, ContentStoreError, } from '../content-store.js';
6
6
  import { resolveCollectionPath } from '../content-id-index.js';
7
7
  import { BaseBranchMissingError } from '../git-manager.js';
8
8
  import { getBranchProtection } from '../authorization/protected-branch.js';
9
+ import { SchemaOps, SchemaStoreBusyError } from '../schema/schema-store.js';
10
+ import { canopyLogWarn } from '../utils/logger.js';
11
+ import { isRebaseInProgress } from '../utils/git.js';
9
12
  import { baseBranchOf } from '../utils/base-branch.js';
10
13
  import { findBodyFieldName } from '../utils/body-field.js';
11
14
  import { entryAsRead } from '../utils/calendar-day.js';
12
- import { isNodeError, isNotFoundError } from '../utils/error.js';
15
+ import { isNodeError, isNotFoundError, sanitizeErrorMessage } from '../utils/error.js';
13
16
  import { getFormatExtension } from '../utils/format.js';
14
17
  import { entryLogicalPath } from '../paths/index.js';
15
18
  import { defineEndpoint } from './route-builder.js';
16
19
  import { branchNameSchema, contentIdSchema, logicalPathSchema } from './validators.js';
17
- import { branchReadFailedResponse, readWhileSteady, unsteadyReadResponse, } from './branch-changes.js';
20
+ import { branchReadFailedResponse, readWhileSteady, SYNCING_ERROR, unsteadyReadResponse, } from './branch-changes.js';
18
21
  /** The most of a side's file read, so one huge file can't hold the API or the browser. */
19
22
  const MAX_SIDE_BYTES = 1024 * 1024;
20
23
  const NOT_COMPARABLE_ERROR = "This entry can't be compared.";
@@ -110,14 +113,13 @@ const readAfter = async (store, flatSchema, target, id) => {
110
113
  }
111
114
  };
112
115
  /**
113
- * The entry at `logicalPath` in `mergeBase`'s tree, matched by `contentId` when there is one.
114
- * Looked for under the collection's directory first, then the whole content root when that
115
- * directory isn't in the base tree (a collection renamed or deleted on the branch). Files map
116
- * to entries as GET /:branch/changes maps them, so the entry it lists resolves to the same
117
- * file here. A base file whose collection or entry type the schema no longer has is keyed with
118
- * `afterFields`, the saved side's, so its unchanged items carry the keys the saved side's do.
116
+ * The file of the entry at `logicalPath` in `mergeBase`'s tree, matched by `contentId` when
117
+ * there is one. Looked for under the collection's directory first, then the whole content root
118
+ * when that directory isn't in the base tree (a collection renamed or deleted on the branch).
119
+ * Files map to entries as GET /:branch/changes maps them, so the entry it lists resolves to the
120
+ * same file here.
119
121
  */
120
- const readBefore = async (git, mergeBase, logicalPath, match, branchContext, contentRoot) => {
122
+ const findBaseFile = async (git, mergeBase, logicalPath, contentId, branchContext, contentRoot) => {
121
123
  const { flatSchema, branchRoot } = branchContext;
122
124
  const collectionPath = logicalPath.slice(0, logicalPath.lastIndexOf('/'));
123
125
  const collectionDir = await resolveCollectionPath(branchRoot, collectionPath);
@@ -132,35 +134,9 @@ const readBefore = async (git, mergeBase, logicalPath, match, branchContext, con
132
134
  const ref = placed.kind === 'other' ? entryRefFromRepoPathShape(file.path, contentRoot) : placed;
133
135
  if (ref?.kind !== 'entry' || ref.logicalPath !== logicalPath)
134
136
  continue;
135
- if (match.contentId !== undefined && ref.contentId !== match.contentId)
137
+ if (contentId !== undefined && ref.contentId !== contentId)
136
138
  continue;
137
- if (file.size > MAX_SIDE_BYTES)
138
- return 'too-large';
139
- const entryType = entryTypeIn(flatSchema, ref.collectionPath, ref.entryType);
140
- const fields = entryType?.schema ?? match.afterFields ?? [];
141
- // An entry type the schema no longer has is parsed by its extension.
142
- const format = entryType?.format ?? FORMATS.find((f) => file.path.endsWith(getFormatExtension(f)));
143
- if (!format)
144
- return 'unreadable';
145
- const raw = await git.showFile(mergeBase, file.path);
146
- if (raw === undefined)
147
- return undefined;
148
- let parsed;
149
- try {
150
- parsed = parseEntryParts(raw, format);
151
- }
152
- catch {
153
- return 'unreadable';
154
- }
155
- return {
156
- format,
157
- entryType: ref.entryType,
158
- data: entryAsRead(fields, parsed.data),
159
- ...(parsed.body !== undefined && {
160
- body: parsed.body,
161
- bodyFieldName: findBodyFieldName(fields),
162
- }),
163
- };
139
+ return { path: file.path, size: file.size, ref, placed: placed.kind === 'entry' };
164
140
  }
165
141
  // The collection's directory is in the base tree, so an entry not in it is new.
166
142
  if (files.length > 0)
@@ -168,6 +144,44 @@ const readBefore = async (git, mergeBase, logicalPath, match, branchContext, con
168
144
  }
169
145
  return undefined;
170
146
  };
147
+ /**
148
+ * The entry at `logicalPath` in `mergeBase`'s tree (`findBaseFile`), read as the editor reads
149
+ * one. A base file whose collection or entry type the schema no longer has is keyed with
150
+ * `afterFields`, the saved side's, so its unchanged items carry the keys the saved side's do.
151
+ */
152
+ const readBefore = async (git, mergeBase, logicalPath, match, branchContext, contentRoot) => {
153
+ const file = await findBaseFile(git, mergeBase, logicalPath, match.contentId, branchContext, contentRoot);
154
+ if (!file)
155
+ return undefined;
156
+ const { ref } = file;
157
+ if (file.size > MAX_SIDE_BYTES)
158
+ return 'too-large';
159
+ const entryType = entryTypeIn(branchContext.flatSchema, ref.collectionPath, ref.entryType);
160
+ const fields = entryType?.schema ?? match.afterFields ?? [];
161
+ // An entry type the schema no longer has is parsed by its extension.
162
+ const format = entryType?.format ?? FORMATS.find((f) => file.path.endsWith(getFormatExtension(f)));
163
+ if (!format)
164
+ return 'unreadable';
165
+ const raw = await git.showFile(mergeBase, file.path);
166
+ if (raw === undefined)
167
+ return undefined;
168
+ let parsed;
169
+ try {
170
+ parsed = parseEntryParts(raw, format);
171
+ }
172
+ catch {
173
+ return 'unreadable';
174
+ }
175
+ return {
176
+ format,
177
+ entryType: ref.entryType,
178
+ data: entryAsRead(fields, parsed.data),
179
+ ...(parsed.body !== undefined && {
180
+ body: parsed.body,
181
+ bodyFieldName: findBodyFieldName(fields),
182
+ }),
183
+ };
184
+ };
171
185
  const entryChangeHandler = async (gc, ctx, req, params) => {
172
186
  const { branchContext } = gc;
173
187
  const { config } = ctx.services;
@@ -255,6 +269,226 @@ export const readBranchEntryChange = defineEndpoint({
255
269
  guards: ['branchAccessWithSchema'],
256
270
  handler: entryChangeHandler,
257
271
  });
272
+ const restoreParamsSchema = z.object({
273
+ branch: branchNameSchema,
274
+ entryPath: logicalPathSchema,
275
+ });
276
+ const restoreBodySchema = z.object({
277
+ /** The deleted entry's content id, which the base file must carry and the restore keeps. */
278
+ id: contentIdSchema,
279
+ });
280
+ const NOT_RESTORABLE_ERROR = "This entry isn't on the base branch, so it can't be restored.";
281
+ const TYPE_CHANGED_ERROR = "This entry's type or format has changed since it was deleted, so it can't be restored.";
282
+ const TOO_LARGE_TO_RESTORE_ERROR = 'This entry is too large to restore here.';
283
+ const UNREADABLE_TO_RESTORE_ERROR = "This entry's saved file doesn't read, so it can't be restored.";
284
+ /** `order` with `id` put back after its nearest predecessor in `baseOrder` that `order` holds. */
285
+ const orderWith = (order, id, baseOrder) => {
286
+ const at = baseOrder.indexOf(id);
287
+ for (let i = at - 1; i >= 0; i--) {
288
+ const index = order.indexOf(baseOrder[i]);
289
+ if (index >= 0)
290
+ return [...order.slice(0, index + 1), id, ...order.slice(index + 1)];
291
+ }
292
+ return [id, ...order];
293
+ };
294
+ /** A collection's `order` in its `.collection.json` at `rev`, or undefined when it has none. */
295
+ const orderAt = async (git, rev, collectionRepoDir) => {
296
+ const raw = await git.showFile(rev, `${collectionRepoDir}/.collection.json`);
297
+ if (raw === undefined)
298
+ return undefined;
299
+ try {
300
+ const order = JSON.parse(raw)?.order;
301
+ return Array.isArray(order) && order.every((id) => typeof id === 'string') ? order : undefined;
302
+ }
303
+ catch {
304
+ return undefined;
305
+ }
306
+ };
307
+ /** Git failing in a restore's pre-write HEAD check, answered as a failed read is. */
308
+ class HeadReadError extends Error {
309
+ constructor(failure) {
310
+ super("Couldn't read the branch's HEAD");
311
+ this.failure = failure;
312
+ }
313
+ }
314
+ const restoreEntryHandler = async (gc, ctx, req, params, body) => {
315
+ const { branchContext } = gc;
316
+ const { branchRoot } = branchContext;
317
+ const { config } = ctx.services;
318
+ const base = branchContext.branch.baseBranch ?? baseBranchOf(config);
319
+ const contentRoot = config.contentRoot || 'content';
320
+ const store = new ContentStore(branchRoot, branchContext.flatSchema, {
321
+ contentRootName: contentRoot,
322
+ });
323
+ const target = canonicalPath(store, underContentRoot(params.entryPath, contentRoot));
324
+ const notRestorable = {
325
+ ok: false,
326
+ status: 404,
327
+ error: NOT_RESTORABLE_ERROR,
328
+ };
329
+ if (!target.stored)
330
+ return notRestorable;
331
+ const checkAccess = await ctx.services.createContentAccessChecker(branchContext, branchRoot, req.user);
332
+ if (!checkAccess(target.logicalPath, 'edit').allowed) {
333
+ return { ok: false, status: 403, error: 'Forbidden' };
334
+ }
335
+ const { collectionPath, slug } = target.stored;
336
+ const collectionDir = await resolveCollectionPath(branchRoot, collectionPath);
337
+ const collectionRepoDir = collectionDir && path.relative(branchRoot, collectionDir).split(path.sep).join('/');
338
+ const git = ctx.services.createGitManagerFor(branchRoot, {
339
+ baseBranch: branchContext.branch.baseBranch,
340
+ });
341
+ let read;
342
+ try {
343
+ read = await readWhileSteady(branchRoot, git, async () => {
344
+ const mergeBase = await git.mergeBaseWithBase();
345
+ if (!mergeBase)
346
+ return undefined;
347
+ const file = await findBaseFile(git, mergeBase, target.logicalPath, body.id, branchContext, contentRoot);
348
+ if (!file)
349
+ return undefined;
350
+ // Only a file the current schema places as this entry type, by name and extension, goes
351
+ // back: another format's bytes under this type's extension would not read.
352
+ if (!file.placed)
353
+ return 'type-changed';
354
+ if (file.size > MAX_SIDE_BYTES)
355
+ return 'too-large';
356
+ const raw = await git.showFile(mergeBase, file.path);
357
+ if (raw === undefined)
358
+ return undefined;
359
+ const baseOrder = collectionRepoDir
360
+ ? await orderAt(git, mergeBase, collectionRepoDir)
361
+ : undefined;
362
+ return {
363
+ raw,
364
+ entryType: file.ref.entryType,
365
+ head: await git.headSha(),
366
+ ...(baseOrder && { baseOrder }),
367
+ };
368
+ });
369
+ }
370
+ catch (err) {
371
+ if (err instanceof BaseBranchMissingError)
372
+ return notRestorable;
373
+ return branchReadFailedResponse(branchContext.branch.name, base, err);
374
+ }
375
+ if (!read.steady)
376
+ return unsteadyReadResponse(read.reason, base);
377
+ const entry = read.value;
378
+ if (entry === 'too-large')
379
+ return { ok: false, status: 413, error: TOO_LARGE_TO_RESTORE_ERROR };
380
+ if (entry === 'type-changed')
381
+ return { ok: false, status: 409, error: TYPE_CHANGED_ERROR };
382
+ if (!entry)
383
+ return notRestorable;
384
+ const entryType = entryTypeIn(branchContext.flatSchema, collectionPath, entry.entryType);
385
+ if (!entryType)
386
+ return { ok: false, status: 409, error: TYPE_CHANGED_ERROR };
387
+ try {
388
+ parseEntryParts(entry.raw, entryType.format);
389
+ }
390
+ catch {
391
+ return { ok: false, status: 422, error: UNREADABLE_TO_RESTORE_ERROR };
392
+ }
393
+ // As a create checks it (SCH-H3, api/content.ts), best-effort: count-then-write isn't atomic.
394
+ const { maxItems } = entryType;
395
+ if (maxItems !== undefined &&
396
+ (await store.countEntriesOfType(collectionPath, entry.entryType)) >= maxItems) {
397
+ return {
398
+ ok: false,
399
+ status: 422,
400
+ error: `Cannot restore entry: type "${entry.entryType}" allows at most ${maxItems} ${maxItems === 1 ? 'entry' : 'entries'}`,
401
+ };
402
+ }
403
+ try {
404
+ await store.restore(collectionPath, slug, { id: body.id, entryTypeName: entry.entryType, raw: entry.raw }, {
405
+ // The bytes are the merge base's as HEAD stood at the read; a sync since may have
406
+ // updated the entry upstream, so a moved HEAD refuses rather than reverting that.
407
+ beforeWrite: async () => {
408
+ let moved;
409
+ try {
410
+ moved = (await isRebaseInProgress(branchRoot)) || (await git.headSha()) !== entry.head;
411
+ }
412
+ catch (err) {
413
+ throw new HeadReadError(err);
414
+ }
415
+ if (moved)
416
+ throw new BranchSyncingError(SYNCING_ERROR(base));
417
+ },
418
+ });
419
+ }
420
+ catch (err) {
421
+ if (err instanceof HeadReadError) {
422
+ return branchReadFailedResponse(branchContext.branch.name, base, err.failure);
423
+ }
424
+ if (err instanceof BranchSyncingError) {
425
+ return {
426
+ ok: false,
427
+ status: 409,
428
+ error: err.message,
429
+ code: err.outcome === 'unknown' ? 'WRITE_OUTCOME_UNKNOWN' : 'BRANCH_SYNCING',
430
+ };
431
+ }
432
+ // A refusal the editor can act on: the slug or the id is taken, or a URL is contested.
433
+ if (err instanceof ContentConflictError)
434
+ return { ok: false, status: 409, error: err.message };
435
+ if (err instanceof ContentStoreError) {
436
+ return { ok: false, status: 400, error: sanitizeErrorMessage(err.message) };
437
+ }
438
+ throw err;
439
+ }
440
+ const warning = await restoreOrder(ctx, branchContext, collectionPath, body.id, entry.baseOrder);
441
+ return {
442
+ ok: true,
443
+ status: 200,
444
+ data: { logicalPath: target.logicalPath, contentId: body.id, ...(warning && { warning }) },
445
+ };
446
+ };
447
+ /**
448
+ * Puts a restored entry's id back in its collection's manual order, after its nearest neighbour
449
+ * at the base, undoing what a delete took out (api/entries.ts). Best-effort: the entry is back
450
+ * either way, so a failure is a warning. Runs after the restore, whose content-write lock a
451
+ * schema write also takes.
452
+ */
453
+ const restoreOrder = async (ctx, branchContext, collectionPath, id, baseOrder) => {
454
+ const collection = branchContext.flatSchema.find((item) => item.type === 'collection' && item.logicalPath === collectionPath);
455
+ const order = collection?.order;
456
+ if (!order || !baseOrder?.includes(id) || order.includes(id))
457
+ return undefined;
458
+ const schemaStore = new SchemaOps(path.join(branchContext.branchRoot, ctx.services.config.contentRoot || 'content'), ctx.services.entrySchemaRegistry, ctx.services, branchContext.branchRoot);
459
+ try {
460
+ await schemaStore.updateOrder(collectionPath, orderWith(order, id, baseOrder));
461
+ return undefined;
462
+ }
463
+ catch (err) {
464
+ // An `'unknown'` outcome means the update ran to completion.
465
+ if (err instanceof SchemaStoreBusyError && err.outcome === 'unknown')
466
+ return undefined;
467
+ canopyLogWarn(`CanopyCMS: restored ${collectionPath}/${id} but not its place in the order:`, err);
468
+ return 'Entry restored, but its place in the collection order couldn’t be put back, so it is listed last.';
469
+ }
470
+ };
471
+ /**
472
+ * Puts back an entry deleted on the branch, as the commit the branch forked from holds it, with
473
+ * its content id (`ContentStore.restore`).
474
+ * POST /:branch/restore-entry/...entryPath
475
+ * @internal Exported for tests.
476
+ */
477
+ export const restoreBranchEntry = defineEndpoint({
478
+ namespace: 'changes',
479
+ name: 'restore',
480
+ method: 'POST',
481
+ path: '/:branch/restore-entry/...entryPath',
482
+ params: restoreParamsSchema,
483
+ body: restoreBodySchema,
484
+ bodyType: 'RestoreEntryBody',
485
+ responseType: 'RestoreEntryResponse',
486
+ response: {},
487
+ defaultMockData: { logicalPath: '', contentId: '' },
488
+ guards: ['schema', 'writableBranch'],
489
+ handler: restoreEntryHandler,
490
+ });
258
491
  export const BRANCH_ENTRY_CHANGE_ROUTES = {
259
492
  entry: readBranchEntryChange,
493
+ restore: restoreBranchEntry,
260
494
  };
@@ -10,7 +10,7 @@ export { isApiTimeoutError, WRITE_TIMED_OUT_MESSAGE, type RequestOptions } from
10
10
  import type { OperatingMode } from '../operating-mode/types.js';
11
11
  import type { BranchDeleteResponse, BranchListItemResponse, BranchListResponse, CreateBranchBody, UpdateBranchAccessBody } from './branch.js';
12
12
  import type { BranchChangesResponse } from './branch-changes.js';
13
- import type { BranchEntryChangeResponse } from './branch-entry-changes.js';
13
+ import type { BranchEntryChangeResponse, RestoreEntryBody, RestoreEntryResponse } from './branch-entry-changes.js';
14
14
  import type { BranchMergeResponse, RequestChangesBody, SubmitBranchBody, SubmitBranchResponse } from './branch-status.js';
15
15
  import type { AdminDeleteTaskResponse, AdminRetryTaskResponse, AdminStatusResponse, AdminTasksResponse, BranchHealthResponse, DeleteArchivedBranchesBody, DeleteArchivedBranchesResponse, DismissUnpublishedResponse, PurgeBranchDirResponse, RepairBranchDirResponse, RepairContentDuplicatesResponse } from './admin.js';
16
16
  import type { AddCommentBody, AddCommentResponse, CommentsResponse, ResolveCommentResponse } from './comments.js';
@@ -92,6 +92,8 @@ export declare class CanopyApiClient {
92
92
  list: (params: Record<string, string>, options?: RequestOptions) => Promise<BranchChangesResponse>;
93
93
  /** GET /:branch/changes/...entryPath */
94
94
  entry: (params: Record<string, string>, options?: RequestOptions) => Promise<BranchEntryChangeResponse>;
95
+ /** POST /:branch/restore-entry/...entryPath */
96
+ restore: (params: Record<string, string>, body: RestoreEntryBody, options?: RequestOptions) => Promise<RestoreEntryResponse>;
95
97
  };
96
98
  readonly workflow: {
97
99
  /** POST /:branch/withdraw */
@@ -50,6 +50,10 @@ export class CanopyApiClient {
50
50
  entry: (params, options) => {
51
51
  return this.request('GET', this.buildPath('/:branch/changes/...entryPath', params), undefined, 'standard', options);
52
52
  },
53
+ /** POST /:branch/restore-entry/...entryPath */
54
+ restore: (params, body, options) => {
55
+ return this.request('POST', this.buildPath('/:branch/restore-entry/...entryPath', params), body, 'standard', options);
56
+ },
53
57
  };
54
58
  this.workflow = {
55
59
  /** POST /:branch/withdraw */
package/dist/cli/cli.js CHANGED
@@ -8454,7 +8454,7 @@ async function generateAIContentCLI(options) {
8454
8454
  console.log(` Output: ${result.outputDir}
8455
8455
  `);
8456
8456
  }
8457
- var __defProp3, __getOwnPropNames3, __esm3, __export3, init_error3, init_atomic_write2, init_field_traversal, ITEM_KEY, ITEM_KEY_PATTERN, isItemKey, BASE58, isListField, init_item_keys, active2, init_logger2, SENTINEL_BASE2, DOT_SEGMENT2, init_sanitize_href2, init_normalize, RESERVED_SETTINGS_BRANCH_PREFIX2, init_branch_name, BASE58_PATTERN2, CONTENT_ID_PATTERN2, PHYSICAL_SEGMENT_PATTERN2, MAX_BRANCH_NAME_LENGTH2, init_validation, ROOT_COLLECTION_ID, init_types2, normalizePathValue, flattenSchema, init_flatten, scalarFieldTypes2, markdownFieldTypes2, primitiveFieldTypes2, fieldTypes2, init_types22, SAFE_HTML_TAGS2, COMPONENT_NAME2, MDX_BINDINGS2, FORBIDDEN_ATTRIBUTES2, init_mdx_allowlist2, fieldBaseSchema2, selectOptionSchema2, referenceOptionSchema2, primitiveFieldSchema2, mdxPropAllowSchema2, mdxAllowlistSchema2, markdownFieldSchema2, ASPECT_RATIO_RE2, imageFieldSchema2, selectFieldSchema2, referenceSortFieldSchema2, referenceFieldSchema2, fieldHolder2, blockSchema2, blockFieldSchema2, objectFieldSchema2, inlineGroupFieldSchema2, customFieldSchema2, knownFieldSchema2, fieldSchema2, init_field2, relativePathSchema2, entryTypeSchema2, collectionSchema2, init_collection2, uploadTargetUrlSchema2, previewPrefixSchema2, editorUrlSchema2, init_url2, mediaSchema2, init_media2, defaultBranchAccessSchema2, defaultPathAccessLevelSchema2, defaultPathAccessLevelsSchema2, defaultPathAccessSchema2, defaultBaseBranchSchema2, defaultRemoteNameSchema2, defaultRemoteUrlSchema2, gitBotAuthorNameSchema2, gitBotAuthorEmailSchema2, githubTokenEnvVarSchema2, operatingModeSchema2, deployedAsSchema2, contentRootSchema2, sourceRootSchema2, basePathSchema2, deploymentNameSchema2, devContentSyncSchema2, devConfigSchema2, editorConfigSchema2, seoFieldNameSchema2, seoSchema2, CanopyConfigSchema2, DEFAULT_PROD_WORKSPACE2, init_config3, SERVER_MODE_ENV_VAR, BROWSER_MODE_ENV_VAR, warned22, init_mode_env, forEachReferenceField, init_validation2, ProdClientSafeStrategy2, DevClientSafeStrategy2, clientStrategyCache, init_client_safe_strategy, warned3, VALID_DEPLOYMENT_NAME2, isValidDeploymentName2, init_deployment_name, warned4, DEPLOYMENT_PLACEHOLDER2, DEFAULT_BRANCH_NAMESPACE_TEMPLATE2, BRANCH_NAMESPACE_ENV2, init_branch_namespace, init_config22, ProdStrategy2, DevStrategy2, strategyCache2, init_client_unsafe_strategy, operating_mode_exports, init_operating_mode, task_queue_config_exports2, init_task_queue_config2, worker_status_exports, WORKER_STATUS_FILE, init_worker_status, BLOCK_DISCRIMINATOR_KEYS, BLOCK_STRUCTURAL_KEYS, DAY, MS_PER_DAY, isRealDay, dayParts, isCalendarDay, dayOfBareDate, mapItems, blockItemWithDays, valueWithDays, entryAsRead, envFlag2, LOG_LEVELS2, DebugLogger2, testLogger2, MARKDOWN_EXPORT_OPTIONS, BULLETS, DELIMITERS, EMPHASIS, STRONG, RULE, WORD_CHAR, GAP, Restyler, END, FRESH, LINE_WIDTH, MIN_LINE_WIDTH, SourceSplicer, log2, MAX_LCS_CELLS, Splicer, log22, EVIDENCE_MAX_DEPTH, log3, locks, PROVISIONING_LOCK_STALE_MS, sleep, META_DIR, CONTENT_WRITE_LOCK_NAME, DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS, ContentWriteLockBusyError, sleep2, CONTENT_WRITE_LOCK_STALE_MS, heldRoots, getFormatExtension, isDataOnlyFormat, asRecord, DEFAULT_SEO_FIELD_NAMES, asRecord2, asText, asFlag, asImageUrl, asOgType, asTwitterCard, RESTRICTED_REFERENCE_MARKER, SEO_GROUP_FIELDS, MAX_WARNED, warned5, SchemaUnavailableError, isValidId, parseTypedFilename, EMPTY_LOGICAL_PATH, ENTRY_EXTENSIONS, ContentIdIndex, CONTENT_EXTENSIONS, registry, finalization, log4, META_DIR2, entryTypeMetaSchema, collectionMetaSchema, rootCollectionMetaSchema, fingerprints, editDistance, closestName, forEachEntryType, collectEntryTypeNames, validateReferenceEntryTypes, isDeployedStatic, isBuildMode, readsFromCheckout, STATIC_DEPLOY_USER, storage, log5, BaseBranchUnresolvedError, NETWORK_SCHEME_PATTERN, TRANSPORT_HELPER_PATTERN, LEADING_OPTION_PATTERN, WINDOWS_DRIVE_PATTERN, COLON_BEFORE_SLASH_PATTERN, MISSING_REMOTE_REF_REASONS, CANOPY_META_DIR, SCHEMA_CACHE_VERSION, MTIME_CHECK_DEBOUNCE_MS, SNAPSHOT_MEMO_MAX_BYTES, SCHEMA_CACHE_FILE, SCHEMA_GENERATION_RESOURCE, reportedIssues, BranchSchemaCache, RESOURCE, BranchPathError, resolveContentBranchesRoot, ContentStoreError, ContentConflictError, EntryGoneError, BranchSyncingError, DuplicateContentIdError, UrlPathConflictError, isEmptyFieldValue, DEFAULT_INDEX_FRESHNESS_INTERVAL_MS, FORCED_REFRESH_MIN_INTERVAL_MS, STALE_LOOKUP, isMissingReference, createReferenceResolveCache, ContentStore, ATTR_CONTENT, BLOCK_PREFIX, BLOCK_SUFFIX, BLOCK_RESTORE_RE, INLINE_PREFIX, INLINE_SUFFIX, INLINE_RESTORE_RE, DEFAULT_REFERENCE_RENDERING, FLAT_CELL_TYPES, log6, BASE58_CHAR, ENTRY_LINK_PATTERN, INLINE_LINK_TARGET, BranchMetadataCorruptError, BRANCH_META_DIR, BRANCH_META_FILE, branchMetadataFilePath, UNKNOWN_TIME, enumOf, branchMetadataShape, branchMetadataSchema, branchMetadataFileSchema, log7, REGISTRY_FILE, REGISTRY_STALE_FILE, REGISTRY_TEMP_FILE, REGISTRY_VERSION, RESOURCE2, GET_MISS_REFRESH_MIN_INTERVAL_MS, snapshotSchema, reportedUnreadable, BranchRegistry, log8, OccWriteConflictError, CURRENT_SCHEMA_VERSION, BranchMetadataConflictError, BranchMetadataFileManager, loadBranchContext, GitProcessError, STDERR_CAP_BYTES, KILL_GRACE_MS, log9, GIT_ENV_PASSTHROUGH, FORCE_C_LOCALE, GIT_NETWORK_ENV_PASSTHROUGH, NO_AUTO_GC_CONFIG, HASH_INACTIVITY_TIMEOUT_MS, MAX_HASH_RUNS, LS_REMOTE_INACTIVITY_TIMEOUT_MS, symlinkBlobSha, REMOTE_GIT_CONFIG, REMOTE_GIT_MAX_LOOSE_OBJECTS, REMOTE_GIT_MAX_PACKS, PARALLEL_CHECKOUT_ARGS, remoteInitLocks, GITHUB_TRACKING_REF_PREFIX, BaseBranchMissingError, GitConflictError, GitRemoteRefMissingError, RemoteNotReadyError, SettingsRemoteUnreadableError, SettingsBranchDivergedError, SETTINGS_BRANCH_FILES, SettingsBranchHasContentHistoryError, GitManager, PUBLISH_LOCK_WAIT_MS, CREATE_RESIDUE_MIN_QUIET_MS, STAGING_MAX_AGE_MS, REPAIR_MAX_AGE_MS, DELETING_MAX_AGE_MS, SETTINGS_TRASH_MAX_AGE_MS, BranchProvisioningBusyError, BranchDirOccupiedError, STAGING_PREFIX, REPAIR_PREFIX, TRASH_PREFIX, stagingDirName, NO_GIT, RENAME_BLOCKED, SPARSE_CONE_RECORD, defaultSink, sink, ProvisionLog, testHooks, provisionsInFlight, BranchWorkspaceManager, AI_USER_ID, log10, normalizePath, parseEntryParts, parseEntryData, readEntryData, createReferenceResolver, withoutUnavailableEntries, resolveCollectionItemReferences, looksLikeMalformedEntry, listCollectionEntries, MAX_INPUT_PIXELS, CROP_PRECISION, KNOWN_FIELD_TYPES, STRING_FIELD_TYPES, selectOptionValues, GENERATED_RECORD_FILENAME, SAFE_BUILD_ID, BUILD_ID_RULE, jiti;
8457
+ var __defProp3, __getOwnPropNames3, __esm3, __export3, init_error3, init_atomic_write2, init_field_traversal, ITEM_KEY, ITEM_KEY_PATTERN, isItemKey, BASE58, isListField, init_item_keys, active2, init_logger2, SENTINEL_BASE2, DOT_SEGMENT2, init_sanitize_href2, init_normalize, RESERVED_SETTINGS_BRANCH_PREFIX2, init_branch_name, BASE58_PATTERN2, CONTENT_ID_PATTERN2, PHYSICAL_SEGMENT_PATTERN2, MAX_BRANCH_NAME_LENGTH2, init_validation, ROOT_COLLECTION_ID, init_types2, normalizePathValue, flattenSchema, init_flatten, scalarFieldTypes2, markdownFieldTypes2, primitiveFieldTypes2, fieldTypes2, init_types22, SAFE_HTML_TAGS2, COMPONENT_NAME2, MDX_BINDINGS2, FORBIDDEN_ATTRIBUTES2, init_mdx_allowlist2, fieldBaseSchema2, selectOptionSchema2, referenceOptionSchema2, primitiveFieldSchema2, mdxPropAllowSchema2, mdxAllowlistSchema2, markdownFieldSchema2, ASPECT_RATIO_RE2, imageFieldSchema2, selectFieldSchema2, referenceSortFieldSchema2, referenceFieldSchema2, fieldHolder2, blockSchema2, blockFieldSchema2, objectFieldSchema2, inlineGroupFieldSchema2, customFieldSchema2, knownFieldSchema2, fieldSchema2, init_field2, relativePathSchema2, entryTypeSchema2, collectionSchema2, init_collection2, uploadTargetUrlSchema2, previewPrefixSchema2, editorUrlSchema2, init_url2, mediaSchema2, init_media2, defaultBranchAccessSchema2, defaultPathAccessLevelSchema2, defaultPathAccessLevelsSchema2, defaultPathAccessSchema2, defaultBaseBranchSchema2, defaultRemoteNameSchema2, defaultRemoteUrlSchema2, gitBotAuthorNameSchema2, gitBotAuthorEmailSchema2, githubTokenEnvVarSchema2, operatingModeSchema2, deployedAsSchema2, contentRootSchema2, sourceRootSchema2, basePathSchema2, deploymentNameSchema2, devContentSyncSchema2, devConfigSchema2, editorConfigSchema2, seoFieldNameSchema2, seoSchema2, CanopyConfigSchema2, DEFAULT_PROD_WORKSPACE2, init_config3, SERVER_MODE_ENV_VAR, BROWSER_MODE_ENV_VAR, warned22, init_mode_env, forEachReferenceField, init_validation2, ProdClientSafeStrategy2, DevClientSafeStrategy2, clientStrategyCache, init_client_safe_strategy, warned3, VALID_DEPLOYMENT_NAME2, isValidDeploymentName2, init_deployment_name, warned4, DEPLOYMENT_PLACEHOLDER2, DEFAULT_BRANCH_NAMESPACE_TEMPLATE2, BRANCH_NAMESPACE_ENV2, init_branch_namespace, init_config22, ProdStrategy2, DevStrategy2, strategyCache2, init_client_unsafe_strategy, operating_mode_exports, init_operating_mode, task_queue_config_exports2, init_task_queue_config2, worker_status_exports, WORKER_STATUS_FILE, init_worker_status, BLOCK_DISCRIMINATOR_KEYS, BLOCK_STRUCTURAL_KEYS, DAY, MS_PER_DAY, isRealDay, dayParts, isCalendarDay, dayOfBareDate, mapItems, blockItemWithDays, valueWithDays, entryAsRead, envFlag2, LOG_LEVELS2, DebugLogger2, testLogger2, MARKDOWN_EXPORT_OPTIONS, BULLETS, DELIMITERS, EMPHASIS, STRONG, RULE, WORD_CHAR, GAP, Restyler, END, FRESH, LINE_WIDTH, MIN_LINE_WIDTH, SourceSplicer, log2, MAX_LCS_CELLS, Splicer, log22, EVIDENCE_MAX_DEPTH, log3, locks, PROVISIONING_LOCK_STALE_MS, sleep, META_DIR, CONTENT_WRITE_LOCK_NAME, DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS, ContentWriteLockBusyError, sleep2, CONTENT_WRITE_LOCK_STALE_MS, heldRoots, getFormatExtension, isDataOnlyFormat, asRecord, DEFAULT_SEO_FIELD_NAMES, asRecord2, asText, asFlag, asImageUrl, asOgType, asTwitterCard, RESTRICTED_REFERENCE_MARKER, SEO_GROUP_FIELDS, MAX_WARNED, warned5, SchemaUnavailableError, isValidId, parseTypedFilename, EMPTY_LOGICAL_PATH, ENTRY_EXTENSIONS, ContentIdIndex, CONTENT_EXTENSIONS, registry, finalization, log4, META_DIR2, entryTypeMetaSchema, collectionMetaSchema, rootCollectionMetaSchema, fingerprints, editDistance, closestName, forEachEntryType, collectEntryTypeNames, validateReferenceEntryTypes, isDeployedStatic, isBuildMode, readsFromCheckout, STATIC_DEPLOY_USER, storage, log5, BaseBranchUnresolvedError, NETWORK_SCHEME_PATTERN, TRANSPORT_HELPER_PATTERN, LEADING_OPTION_PATTERN, WINDOWS_DRIVE_PATTERN, COLON_BEFORE_SLASH_PATTERN, MISSING_REMOTE_REF_REASONS, CANOPY_META_DIR, SCHEMA_CACHE_VERSION, MTIME_CHECK_DEBOUNCE_MS, SNAPSHOT_MEMO_MAX_BYTES, SCHEMA_CACHE_FILE, SCHEMA_GENERATION_RESOURCE, reportedIssues, BranchSchemaCache, RESOURCE, BranchPathError, resolveContentBranchesRoot, ContentStoreError, RESTORE_SLUG_TAKEN_MESSAGE, RESTORE_ID_TAKEN_MESSAGE, ContentConflictError, EntryGoneError, BranchSyncingError, DuplicateContentIdError, UrlPathConflictError, isEmptyFieldValue, DEFAULT_INDEX_FRESHNESS_INTERVAL_MS, FORCED_REFRESH_MIN_INTERVAL_MS, STALE_LOOKUP, isMissingReference, createReferenceResolveCache, ContentStore, ATTR_CONTENT, BLOCK_PREFIX, BLOCK_SUFFIX, BLOCK_RESTORE_RE, INLINE_PREFIX, INLINE_SUFFIX, INLINE_RESTORE_RE, DEFAULT_REFERENCE_RENDERING, FLAT_CELL_TYPES, log6, BASE58_CHAR, ENTRY_LINK_PATTERN, INLINE_LINK_TARGET, BranchMetadataCorruptError, BRANCH_META_DIR, BRANCH_META_FILE, branchMetadataFilePath, UNKNOWN_TIME, enumOf, branchMetadataShape, branchMetadataSchema, branchMetadataFileSchema, log7, REGISTRY_FILE, REGISTRY_STALE_FILE, REGISTRY_TEMP_FILE, REGISTRY_VERSION, RESOURCE2, GET_MISS_REFRESH_MIN_INTERVAL_MS, snapshotSchema, reportedUnreadable, BranchRegistry, log8, OccWriteConflictError, CURRENT_SCHEMA_VERSION, BranchMetadataConflictError, BranchMetadataFileManager, loadBranchContext, GitProcessError, STDERR_CAP_BYTES, KILL_GRACE_MS, log9, GIT_ENV_PASSTHROUGH, FORCE_C_LOCALE, GIT_NETWORK_ENV_PASSTHROUGH, NO_AUTO_GC_CONFIG, HASH_INACTIVITY_TIMEOUT_MS, MAX_HASH_RUNS, LS_REMOTE_INACTIVITY_TIMEOUT_MS, symlinkBlobSha, REMOTE_GIT_CONFIG, REMOTE_GIT_MAX_LOOSE_OBJECTS, REMOTE_GIT_MAX_PACKS, PARALLEL_CHECKOUT_ARGS, remoteInitLocks, GITHUB_TRACKING_REF_PREFIX, BaseBranchMissingError, GitConflictError, GitRemoteRefMissingError, RemoteNotReadyError, SettingsRemoteUnreadableError, SettingsBranchDivergedError, SETTINGS_BRANCH_FILES, SettingsBranchHasContentHistoryError, GitManager, PUBLISH_LOCK_WAIT_MS, CREATE_RESIDUE_MIN_QUIET_MS, STAGING_MAX_AGE_MS, REPAIR_MAX_AGE_MS, DELETING_MAX_AGE_MS, SETTINGS_TRASH_MAX_AGE_MS, BranchProvisioningBusyError, BranchDirOccupiedError, STAGING_PREFIX, REPAIR_PREFIX, TRASH_PREFIX, stagingDirName, NO_GIT, RENAME_BLOCKED, SPARSE_CONE_RECORD, defaultSink, sink, ProvisionLog, testHooks, provisionsInFlight, BranchWorkspaceManager, AI_USER_ID, log10, normalizePath, parseEntryParts, parseEntryData, readEntryData, createReferenceResolver, withoutUnavailableEntries, resolveCollectionItemReferences, looksLikeMalformedEntry, listCollectionEntries, MAX_INPUT_PIXELS, CROP_PRECISION, KNOWN_FIELD_TYPES, STRING_FIELD_TYPES, selectOptionValues, GENERATED_RECORD_FILENAME, SAFE_BUILD_ID, BUILD_ID_RULE, jiti;
8458
8458
  var init_generate_ai_content = __esm({
8459
8459
  "dist/cli/generate-ai-content.js"() {
8460
8460
  "use strict";
@@ -11170,6 +11170,8 @@ var init_generate_ai_content = __esm({
11170
11170
  this.code = code;
11171
11171
  }
11172
11172
  };
11173
+ RESTORE_SLUG_TAKEN_MESSAGE = "Another entry now has this entry's URL. Change that entry's URL, then restore this one.";
11174
+ RESTORE_ID_TAKEN_MESSAGE = "This entry is already on the branch, so there is nothing to restore. Refresh to see where it is.";
11173
11175
  ContentConflictError = class extends Error {
11174
11176
  constructor(message = "Content was modified by another editor") {
11175
11177
  super(message);
@@ -12014,6 +12016,65 @@ var init_generate_ai_content = __esm({
12014
12016
  throw new ContentConflictError();
12015
12017
  });
12016
12018
  }
12019
+ /**
12020
+ * Puts back an entry deleted on this branch: `raw`, its file as the base holds it, verbatim,
12021
+ * under its old slug, entry type and content ID, so references and comment threads find it
12022
+ * again and the branch no longer differs from the base there.
12023
+ *
12024
+ * A create under write()'s locks and create-only guards that never replaces or relocates
12025
+ * anything. It refuses with `ContentConflictError` while a file holds the slug, or the index
12026
+ * or the target directory holds the ID: unlike write(), it has no index repair, so an ID
12027
+ * found elsewhere is a moved entry, not a deleted one, and is left alone. The index is
12028
+ * warmed outside the lock, as write() warms it, and rescanned under the lock only when the
12029
+ * generation marker says another host changed the tree.
12030
+ */
12031
+ async restore(collectionPath, slug, entry, options = {}) {
12032
+ await this.idIndex();
12033
+ const collection = this.assertCollection(collectionPath);
12034
+ if (!collection.entries?.some((e) => e.name === entry.entryTypeName)) {
12035
+ throw new ContentStoreError(`Entry type '${entry.entryTypeName}' not found in collection`, "NO_SCHEMA_ITEM");
12036
+ }
12037
+ this.assertEntryTypeAvailable(collection, entry.entryTypeName);
12038
+ return this.withContentWriteExclusion(async () => {
12039
+ await options.beforeWrite?.();
12040
+ if (await readContentIndexGeneration(this.root) !== this.loadedDiskGeneration) {
12041
+ this.invalidateIndex();
12042
+ }
12043
+ await this.idIndex();
12044
+ return withLock(this.createLockKey(collection, slug), async () => {
12045
+ const target = await this.buildPaths(collection, slug, {
12046
+ existingId: entry.id,
12047
+ entryTypeName: entry.entryTypeName
12048
+ });
12049
+ const dir = path112.dirname(target.absolutePath);
12050
+ const liveIndex = this._idIndex;
12051
+ if (liveIndex.findById(entry.id) || liveIndex.getDuplicateFor(entry.id) || (await this.findEntryPathsById(dir, entry.id)).length > 0) {
12052
+ throw new ContentConflictError(RESTORE_ID_TAKEN_MESSAGE);
12053
+ }
12054
+ if ((await this.buildPaths(collection, slug)).existed) {
12055
+ throw new ContentConflictError(RESTORE_SLUG_TAKEN_MESSAGE);
12056
+ }
12057
+ const routable = parseSlug(target.slug);
12058
+ if (!routable.ok) {
12059
+ throw new ContentStoreError(`Cannot restore entry "${target.slug}": ${routable.error}.`, "VALIDATION");
12060
+ }
12061
+ await this.assertUrlPathAvailable(dir, target.slug);
12062
+ await fs9.mkdir(dir, { recursive: true });
12063
+ await atomicWriteFile2(target.absolutePath, entry.raw);
12064
+ const index = this._idIndex;
12065
+ if (!index.findById(entry.id)) {
12066
+ index.add({
12067
+ type: "entry",
12068
+ relativePath: target.relativePath,
12069
+ collection: collectionPath,
12070
+ slug
12071
+ });
12072
+ }
12073
+ await this.recordOwnMutation(index);
12074
+ return { relativePath: target.relativePath };
12075
+ });
12076
+ });
12077
+ }
12017
12078
  /**
12018
12079
  * Rename an entry by changing its slug (middle segment of filename).
12019
12080
  * Entry filename pattern: {entryTypeName}.{slug}.{id}.{ext}
@@ -5325,6 +5325,8 @@ var ContentStoreError = class extends Error {
5325
5325
  this.code = code;
5326
5326
  }
5327
5327
  };
5328
+ var RESTORE_SLUG_TAKEN_MESSAGE = "Another entry now has this entry's URL. Change that entry's URL, then restore this one.";
5329
+ var RESTORE_ID_TAKEN_MESSAGE = "This entry is already on the branch, so there is nothing to restore. Refresh to see where it is.";
5328
5330
  var ContentConflictError = class extends Error {
5329
5331
  constructor(message = "Content was modified by another editor") {
5330
5332
  super(message);
@@ -6218,6 +6220,65 @@ var ContentStore = class {
6218
6220
  throw new ContentConflictError();
6219
6221
  });
6220
6222
  }
6223
+ /**
6224
+ * Puts back an entry deleted on this branch: `raw`, its file as the base holds it, verbatim,
6225
+ * under its old slug, entry type and content ID, so references and comment threads find it
6226
+ * again and the branch no longer differs from the base there.
6227
+ *
6228
+ * A create under write()'s locks and create-only guards that never replaces or relocates
6229
+ * anything. It refuses with `ContentConflictError` while a file holds the slug, or the index
6230
+ * or the target directory holds the ID: unlike write(), it has no index repair, so an ID
6231
+ * found elsewhere is a moved entry, not a deleted one, and is left alone. The index is
6232
+ * warmed outside the lock, as write() warms it, and rescanned under the lock only when the
6233
+ * generation marker says another host changed the tree.
6234
+ */
6235
+ async restore(collectionPath, slug, entry, options = {}) {
6236
+ await this.idIndex();
6237
+ const collection = this.assertCollection(collectionPath);
6238
+ if (!collection.entries?.some((e) => e.name === entry.entryTypeName)) {
6239
+ throw new ContentStoreError(`Entry type '${entry.entryTypeName}' not found in collection`, "NO_SCHEMA_ITEM");
6240
+ }
6241
+ this.assertEntryTypeAvailable(collection, entry.entryTypeName);
6242
+ return this.withContentWriteExclusion(async () => {
6243
+ await options.beforeWrite?.();
6244
+ if (await readContentIndexGeneration(this.root) !== this.loadedDiskGeneration) {
6245
+ this.invalidateIndex();
6246
+ }
6247
+ await this.idIndex();
6248
+ return withLock(this.createLockKey(collection, slug), async () => {
6249
+ const target = await this.buildPaths(collection, slug, {
6250
+ existingId: entry.id,
6251
+ entryTypeName: entry.entryTypeName
6252
+ });
6253
+ const dir = path11.dirname(target.absolutePath);
6254
+ const liveIndex = this._idIndex;
6255
+ if (liveIndex.findById(entry.id) || liveIndex.getDuplicateFor(entry.id) || (await this.findEntryPathsById(dir, entry.id)).length > 0) {
6256
+ throw new ContentConflictError(RESTORE_ID_TAKEN_MESSAGE);
6257
+ }
6258
+ if ((await this.buildPaths(collection, slug)).existed) {
6259
+ throw new ContentConflictError(RESTORE_SLUG_TAKEN_MESSAGE);
6260
+ }
6261
+ const routable = parseSlug(target.slug);
6262
+ if (!routable.ok) {
6263
+ throw new ContentStoreError(`Cannot restore entry "${target.slug}": ${routable.error}.`, "VALIDATION");
6264
+ }
6265
+ await this.assertUrlPathAvailable(dir, target.slug);
6266
+ await fs9.mkdir(dir, { recursive: true });
6267
+ await atomicWriteFile(target.absolutePath, entry.raw);
6268
+ const index = this._idIndex;
6269
+ if (!index.findById(entry.id)) {
6270
+ index.add({
6271
+ type: "entry",
6272
+ relativePath: target.relativePath,
6273
+ collection: collectionPath,
6274
+ slug
6275
+ });
6276
+ }
6277
+ await this.recordOwnMutation(index);
6278
+ return { relativePath: target.relativePath };
6279
+ });
6280
+ });
6281
+ }
6221
6282
  /**
6222
6283
  * Rename an entry by changing its slug (middle segment of filename).
6223
6284
  * Entry filename pattern: {entryTypeName}.{slug}.{id}.{ext}
@@ -43,6 +43,10 @@ export declare class ContentStoreError extends Error {
43
43
  code: ContentStoreErrorCode;
44
44
  constructor(message: string, code: ContentStoreErrorCode);
45
45
  }
46
+ /** Why a restore refuses: another entry took the slug after this one was deleted. */
47
+ export declare const RESTORE_SLUG_TAKEN_MESSAGE = "Another entry now has this entry's URL. Change that entry's URL, then restore this one.";
48
+ /** Why a restore refuses: the entry, by its content ID, is on the branch already. */
49
+ export declare const RESTORE_ID_TAKEN_MESSAGE = "This entry is already on the branch, so there is nothing to restore. Refresh to see where it is.";
46
50
  /**
47
51
  * Thrown when `expectedVersion` on a write doesn't match the file's current mtime.
48
52
  * Indicates a cross-process concurrent write — the caller should reload and retry.
@@ -480,6 +484,28 @@ export declare class ContentStore {
480
484
  protected afterPrePassForTesting(): Promise<void>;
481
485
  /** Delete an entry and remove it from the index. */
482
486
  delete(collectionPath: LogicalPath, slug: Slug): Promise<void>;
487
+ /**
488
+ * Puts back an entry deleted on this branch: `raw`, its file as the base holds it, verbatim,
489
+ * under its old slug, entry type and content ID, so references and comment threads find it
490
+ * again and the branch no longer differs from the base there.
491
+ *
492
+ * A create under write()'s locks and create-only guards that never replaces or relocates
493
+ * anything. It refuses with `ContentConflictError` while a file holds the slug, or the index
494
+ * or the target directory holds the ID: unlike write(), it has no index repair, so an ID
495
+ * found elsewhere is a moved entry, not a deleted one, and is left alone. The index is
496
+ * warmed outside the lock, as write() warms it, and rescanned under the lock only when the
497
+ * generation marker says another host changed the tree.
498
+ */
499
+ restore(collectionPath: LogicalPath, slug: Slug, entry: {
500
+ id: ContentId;
501
+ entryTypeName: string;
502
+ raw: string;
503
+ }, options?: {
504
+ /** Runs under the content-write lock before anything is written; throwing refuses. */
505
+ beforeWrite?: () => Promise<void>;
506
+ }): Promise<{
507
+ relativePath: PhysicalPath;
508
+ }>;
483
509
  /**
484
510
  * Rename an entry by changing its slug (middle segment of filename).
485
511
  * Entry filename pattern: {entryTypeName}.{slug}.{id}.{ext}