@acms-subir/core 0.3.15 → 0.3.17

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,302 @@
1
+ # Selective environment sync
2
+
3
+ Use `acms sync` to transfer selected native ACMS content between two running
4
+ sites. Both directions work: local → production, production → local, or staging
5
+ → production. Both sites must run a version that includes sync protocol 1.
6
+ Run `acms sync --help` for the command reference or `acms docs sync` to read
7
+ this guide from an installed package.
8
+
9
+ This transfers content and portable configuration, not application code,
10
+ database schemas, environment variables, or an entire database. Deploy theme
11
+ code, collection definitions and enabled modules through the application's
12
+ normal deployment process first. Secrets remain in each environment's own
13
+ Infisical project/environment/folder. Backups remain the disaster recovery tool.
14
+
15
+ ## Connect and choose a direction
16
+
17
+ An owner creates scoped keys under **Design → Access**. The existing design-key
18
+ screen also offers these permissions:
19
+
20
+ | Scope | Permission |
21
+ | --- | --- |
22
+ | `sync:read` | Read the portable inventory, drafts, media and transfer history |
23
+ | `sync:write` | Create plans, upload media and apply draft transfers; also needs `sync:read` |
24
+ | `sync:publish` | Apply live, publication and rollback plans; also needs both scopes above |
25
+
26
+ Only an owner can issue sync keys. Existing design, agent and booking keys do
27
+ not implicitly grant sync access. For a one-way transfer, the source needs
28
+ read access and the destination needs read/write (plus publish for live work).
29
+ Reverse transfers need the corresponding permissions in the other direction.
30
+
31
+ Supply the keys privately through the named environment variables, preferably
32
+ using the CLI's existing Infisical runtime-injection setup. Do not place tokens
33
+ in command arguments, plan files, source control, or chat.
34
+
35
+ ```sh
36
+ acms site connect http://localhost:4321 --site local --token-env ACMS_LOCAL_TOKEN
37
+ acms site connect https://example.com --site production --token-env ACMS_PRODUCTION_TOKEN
38
+ acms sync init --from local --to production
39
+ ```
40
+
41
+ `site connect` is an existing command. It stores the URL and environment-variable
42
+ name, not the credential. Remote sites require HTTPS; loopback HTTP is allowed.
43
+ Sync accesses each site's authenticated HTTP API, so the CLI needs network
44
+ access to both sites. It does not open a production database directly.
45
+
46
+ `init` selects a direction and resumes that pair's saved selection. It does not
47
+ transfer content. Switching directions keeps separate selections and baselines:
48
+
49
+ ```sh
50
+ acms sync init --from production --to local
51
+ ```
52
+
53
+ The CLI binds the pair to installation identities as well as URLs. A changed
54
+ profile URL or replaced installation requires a fresh `init`. Two aliases for
55
+ the same installation cannot be synchronized to each other. Cloning the entire
56
+ database also clones its installation identity; use independent installations
57
+ and content transfers rather than using a database clone as a second sync site.
58
+
59
+ ## Inspect and select
60
+
61
+ ```sh
62
+ acms sync status
63
+ acms sync status --type pages
64
+ acms sync status --selected
65
+ acms sync status --conflicts
66
+ acms sync list
67
+ acms sync list --type events --calendar community
68
+ acms sync list --search welcome
69
+ ```
70
+
71
+ Status distinguishes create, update, skip, preserve and conflict. The inventory
72
+ shows references and stable keys. Use the full key when names are ambiguous.
73
+ Posts accept `post:<collection>/<slug>`; `post:<slug>` also works when unique.
74
+ Forms and places have names rather than slugs, so quote references containing
75
+ spaces, such as `'form:Contact us'`.
76
+
77
+ ```sh
78
+ acms sync add page:home page:about
79
+ acms sync add post:posts/welcome
80
+ acms sync add event:open-house
81
+ acms sync add resource:meeting-room
82
+ acms sync add 'form:Contact us'
83
+ acms sync add calendar:community --include-events
84
+ acms sync add --type navigation
85
+ acms sync add --type booking-config
86
+ acms sync add --all
87
+ ```
88
+
89
+ `add --all` selects eligible changed source items, including conflicts for
90
+ review. It never selects customer bookings, submissions, account credentials,
91
+ secrets or deletions. `add --type` selects changed items in that category.
92
+ Selecting a calendar alone does not select all its events; `--include-events`
93
+ does. Selected events include the calendars and other referenced objects they
94
+ need. Pages include referenced media and native ID-based dependencies. Links
95
+ to arbitrary URLs are not crawled or copied.
96
+
97
+ `acms sync select` is a terminal picker: it displays numbered items and accepts
98
+ a comma-separated selection. Blank input preserves the selection. Explicit
99
+ commands provide the equivalent workflow for scripts; interactive mode cannot
100
+ be combined with `--json`.
101
+
102
+ ```sh
103
+ acms sync remove page:test-landing
104
+ acms sync remove --type events
105
+ acms sync clear
106
+ ```
107
+
108
+ Removing items only changes the pending selection. An explicitly removed
109
+ dependency blocks its parent rather than silently reselecting it. Add the
110
+ dependency again, preserve an existing destination version, or remove the parent.
111
+ `clear` clears selections, exclusions and conflict decisions, not history or
112
+ the last successful synchronization baseline.
113
+
114
+ ## Review differences and conflicts
115
+
116
+ ```sh
117
+ acms sync diff
118
+ acms sync diff page:home
119
+ acms sync diff --selected
120
+ acms sync resolve page:home --use source
121
+ acms sync resolve page:home --use destination
122
+ acms sync resolve page:home --interactive
123
+ ```
124
+
125
+ Diff shows actual before/after field values. The source inventory includes
126
+ saved pending drafts as the editable source version; it does not capture
127
+ unsaved browser edits. Live versus draft behavior on the destination is chosen
128
+ when planning.
129
+
130
+ The CLI remembers the last successfully applied source and destination hashes:
131
+
132
+ | Comparison | Default action |
133
+ | --- | --- |
134
+ | Same content on both sites | Skip |
135
+ | Only the source changed since the baseline | Update |
136
+ | Only the destination changed | Preserve destination |
137
+ | Both changed differently | Conflict |
138
+ | Existing item with no established baseline | Review before overwrite |
139
+ | Previously transferred item deleted on destination | Conflict |
140
+ | Item exists only on destination | Preserve |
141
+
142
+ Matching uses recorded identities first. A unique natural-key candidate
143
+ (such as a collection and slug) can be proposed for the initial match, but a
144
+ differing existing item requires explicit resolution. Ambiguous names are
145
+ blocked; fix ambiguity on the destination first. Slug changes keep an established
146
+ identity. Sync does not automatically create redirects for renamed URLs;
147
+ select the appropriate redirect records as well.
148
+
149
+ `--use source` approves the source version; `--use destination` leaves the
150
+ destination content intact. If a previously transferred item was deleted on
151
+ the destination, `--use source` explicitly approves recreating it. Required
152
+ preserved dependencies are linked without overwriting their data. Interactive
153
+ resolution chooses whole top-level fields;
154
+ it is not a text-editor or automatic rich-text/array merge. Conflict decisions
155
+ are tied to the reviewed source and destination versions and expire when either
156
+ changes. There is no `--force` bypass.
157
+
158
+ ## Plan, apply and publish
159
+
160
+ ```sh
161
+ acms sync plan
162
+ acms sync plan --mode draft --out deployment-plan.json
163
+ acms sync plan --mode live
164
+ acms sync show <plan-id>
165
+ acms sync apply --plan <plan-id>
166
+ acms sync apply --file deployment-plan.json
167
+ ```
168
+
169
+ Draft is the default. Pages, posts, events, forms and design support draft
170
+ transfers. New draft-capable records are created unpublished; existing live
171
+ content retains its live version while changes are staged. New required media
172
+ and tag records are added to their libraries. Updating existing media/tag
173
+ metadata and transferring navigation, calendars, resources, booking links,
174
+ places, redirects or portable settings requires an explicit live plan. A
175
+ published form's definition can be staged; its name/kind/purpose changes need
176
+ a live plan. An event with room holds must have those configured on the
177
+ destination after transfer.
178
+
179
+ Plans list selected operations, dependencies, field differences and whether
180
+ changes affect drafts or live content. They are stored on the destination and
181
+ bound to its installation ID, revision and exact payload. Planning changes
182
+ transfer metadata only, never site content. Exported files include a human-
183
+ readable copy, source/destination binding and a digest; editing their payload
184
+ does not alter the immutable server plan. Create a new plan to change it.
185
+
186
+ Apply displays the plan and asks for confirmation. Automation uses:
187
+
188
+ ```sh
189
+ acms sync apply --plan <plan-id> --yes --json
190
+ ```
191
+
192
+ `--yes` skips only the confirmation. It cannot bypass scopes, dependencies,
193
+ schema/theme compatibility, conflicts or stale revisions. Apply does not need
194
+ the current selection to match the saved plan; it applies that exact plan.
195
+
196
+ The database checks the reviewed revision inside the same transaction as all
197
+ record writes, identity links, search-index changes and the completed-run
198
+ receipt. Content/draft changes through other APIs also advance the revision.
199
+ The first implementation uses a conservative **site-wide revision**: unrelated
200
+ content edits or booking/submission activity after planning can require a new
201
+ plan too. This favors rejecting stale plans over overwriting concurrent work.
202
+
203
+ Media is uploaded before that transaction to immutable checksum-based keys.
204
+ Already present verified objects are skipped. Failed uploads or a rejected
205
+ database transaction can leave unused files, but do not partially publish the
206
+ database changes. Sync does not delete these files automatically.
207
+
208
+ If a request or terminal is interrupted, rerun the same apply command. A
209
+ completed server receipt is returned without repeating writes; otherwise
210
+ verified files are reused and the database transaction can be retried if the
211
+ reviewed revision still matches. There is no blind retry of stale writes.
212
+
213
+ After reviewing staged content in the site's editors/previews:
214
+
215
+ ```sh
216
+ acms sync plan --publish <run-id>
217
+ acms sync apply --plan <publish-plan-id>
218
+ ```
219
+
220
+ This publishes only the draft-capable items in that run. It rejects later edits
221
+ to those items instead of implicitly publishing somebody else's changes.
222
+ Design/layout changes conservatively share a settings guard.
223
+
224
+ ## History and rollback
225
+
226
+ ```sh
227
+ acms sync history
228
+ acms sync show <run-id>
229
+ acms sync plan --rollback <run-id>
230
+ acms sync apply --plan <rollback-plan-id>
231
+ ```
232
+
233
+ History lists the latest 100 plans and runs. An older known ID can still be
234
+ read with `show`. Rollback creates another reviewable live plan. It restores
235
+ previous live/draft values for changed items and can delete items that the run
236
+ created, provided they have not subsequently changed or acquired dependents or
237
+ production activity. Media bytes are retained, even if their newly created
238
+ library record is removed. Later design/settings edits may conservatively block
239
+ rollback of a page layout. Rollback does not undo delivered emails, webhook
240
+ deliveries, external effects or code deployments.
241
+
242
+ ## Transfer coverage
243
+
244
+ | Category | Included | Environment-specific data preserved |
245
+ | --- | --- | --- |
246
+ | `pages` | Native rich text, legacy blocks, translations, layout, SEO and routing fields | Unselected pages |
247
+ | `posts` | Native content, translations, metadata and byline references | Staff credentials and account authority |
248
+ | `design` | Theme selection, design overrides and branding images | Theme source code; page layouts travel with pages |
249
+ | `navigation` | Individual navigation records | Other destination navigation records |
250
+ | `media` | Metadata, originals and variants, with checksums | Unselected assets |
251
+ | `redirects` | Selected redirect records | Other destination redirects |
252
+ | `calendars` | Calendar definitions | Feed tokens and source URLs |
253
+ | `events` | Native events and registration-form definitions | Registrations and notification history |
254
+ | `resources` | Resource configuration | Private busy-feed URLs, notification recipients and feed tokens |
255
+ | `booking-config` | Booking-link definitions and calendar/booking defaults | Reservations, retry ledgers and private management links |
256
+ | `forms` | Definitions/templates, native drafts | Submitted responses |
257
+ | `site-settings` | Title, tagline, footer text, locale, timezone, announcement | Canonical site URL, integration/analytics configuration, injected code, secrets |
258
+ | `places` | Saved place definitions | Other saved places |
259
+ | `tags` | Tag metadata | Other tag records |
260
+
261
+ Byline dependencies map to existing destination profiles. Sync never creates
262
+ or edits user accounts; provision the intended profile separately and preserve
263
+ its destination version when appropriate. Feed calendars must already exist
264
+ on the destination with their own configured URL. Calendar and booking modules,
265
+ post collections, theme code and section definitions must be deployed before
266
+ their associated content can be applied.
267
+
268
+ Events with existing destination reservations and resources with active
269
+ reservations cannot be overwritten through sync. Use the destination editor
270
+ for those operational changes. Bookings and submissions created after review
271
+ invalidate the plan; rollback also checks for activity before deleting items.
272
+
273
+ ## Automation, storage and limits
274
+
275
+ - `--json` returns the existing `acms.cli/1` envelope; exit 4 denotes conflicts,
276
+ 3 authentication/permission failures, 5 network/server failures, and 2 invalid
277
+ usage/input. Diagnostics and interactive prompts use stderr.
278
+ - `--dry-run` on selection commands does not save selection changes. On a
279
+ normal plan it asks the server to validate without storing a plan; on apply
280
+ it displays the saved plan without uploading or applying. Publication and
281
+ rollback plans are also validated without storing them in dry-run mode.
282
+ - `--cwd <project>` chooses the project workspace. Selection, baselines and
283
+ exported-plan references are stored under
284
+ `${ACMS_CONFIG_DIR:-~/.config/acms}/sync/<workspace-hash>/`, with private file
285
+ permissions. They are not committed into the project. Exported plan files
286
+ contain unpublished content and should be kept out of source control.
287
+ - A workspace lock prevents simultaneous CLI commands from losing selections.
288
+ After a crashed CLI process, verify that the PID in the reported lock file
289
+ is no longer running before removing that file.
290
+ - Protocol 1 supports up to 2,000 inventory items, an 8 MiB inventory, 200
291
+ selected operations per plan and 16 MiB per media object. Oversized inputs
292
+ fail explicitly; split selections where possible. The inventory is bounded
293
+ rather than silently truncated. This version hashes media during inventory
294
+ reads; large media libraries may need a future paginated/cached inventory.
295
+ Hosting-platform request, subrequest and CPU limits can impose lower practical
296
+ limits; local D1 tests do not establish a live Cloudflare account's limits.
297
+ - Sync history lives on the destination; it is not part of portable content
298
+ backups. Sync does not promise a cross-resource transaction with object
299
+ storage. Keep normal off-site backups and test disaster recovery separately.
300
+
301
+ There is no automatic deletion propagation, background two-way synchronization,
302
+ stash command, automatic text merge, or full database transfer.
package/integration.mjs CHANGED
@@ -87,6 +87,8 @@ const ROUTES = [
87
87
  ['/api/ai/clean', 'api/ai/clean.ts'],
88
88
  ['/api/ai/draft', 'api/ai/draft.ts'],
89
89
  ['/api/design', 'api/design/index.ts'],
90
+ ['/api/sync', 'api/sync/index.ts'],
91
+ ['/api/sync/media', 'api/sync/media.ts'],
90
92
  ['/api/design/keys', 'api/design/keys.ts'],
91
93
  ['/api/design/capabilities', 'api/design/capabilities.ts'],
92
94
  ['/api/design/media', 'api/design/media.ts'],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acms-subir/core",
3
- "version": "0.3.15",
3
+ "version": "0.3.17",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -12,6 +12,9 @@ const SCOPES: { key: string; label: string; hint: string }[] = [
12
12
  { key: 'design:publish', label: 'Publish design', hint: 'Makes the draft live for visitors. Only grant this to tools you trust' },
13
13
  { key: 'media:read', label: 'Read media', hint: 'Browse the media library' },
14
14
  { key: 'media:write', label: 'Upload media', hint: 'Add images to the media library' },
15
+ { key: 'sync:read', label: 'Read environment content', hint: 'Read portable content, media and sync history, including drafts' },
16
+ { key: 'sync:write', label: 'Stage environment transfers', hint: 'Create plans, upload assets and apply draft transfers; also needs Read environment content' },
17
+ { key: 'sync:publish', label: 'Apply live environment transfers', hint: 'Publish content and apply reviewed live or rollback plans; also needs both sync permissions above' },
15
18
  ];
16
19
  const DEFAULT_SCOPES = ['design:read', 'design:write-draft'];
17
20
 
@@ -12,7 +12,7 @@ import { defaultValue, fieldVisible, initialData, normalizeData, normalizeValue,
12
12
 
13
13
  // Pages tab of the Design screen: lay out a page from the active theme's
14
14
  // section registry (builtins + the theme's own sections). Edits stay local
15
- // autosave into the design draft (1.2s after the last edit) through
15
+ // until Save draft stages them through
16
16
  // POST /api/design; nothing here publishes, and a page's own content is
17
17
  // never touched (switching a page back to "Page content" keeps its layout).
18
18
 
@@ -76,7 +76,7 @@ export function useDesignApi() {
76
76
  }
77
77
  }, []);
78
78
  useEffect(() => { void load(); }, [load]);
79
- /** update local state after our own write (e.g. an autosave) without a refetch */
79
+ /** update local state after our own write (e.g. Save draft) without a refetch */
80
80
  const patch = useCallback((fn: (d: DesignApiState) => DesignApiState) => setData((d) => (d ? fn(d) : d)), []);
81
81
  return { data, error, loading, loadCount, reload: load, patch };
82
82
  }
@@ -196,8 +196,8 @@ export default function ThemeSectionsEditor({ api, activeTheme, unsplashEnabled,
196
196
  return next;
197
197
  });
198
198
 
199
- // `base` is what the server holds (the staged draft); edits autosave into
200
- // it. A real reload rehydrates everything; our own autosaves only move
199
+ // `base` is what the server holds (the staged draft). A real reload
200
+ // rehydrates everything; our own saves only move
201
201
  // `base` and the revision forward, so typing is never overwritten.
202
202
  const [base, setBase] = useState<SectionPages>({});
203
203
  const revRef = useRef(0);
@@ -222,9 +222,12 @@ export default function ThemeSectionsEditor({ api, activeTheme, unsplashEnabled,
222
222
  // eslint-disable-next-line react-hooks/exhaustive-deps
223
223
  }, [loadCount]);
224
224
 
225
- // click-to-edit from the preview
225
+ // Consume each preview click once. A save patches `data`; it must not
226
+ // replay an old click and close the section the user is editing now.
227
+ const appliedFocus = useRef<SectionFocus | null>(null);
226
228
  useEffect(() => {
227
- if (!focus || !data) return;
229
+ if (!focus || !data || appliedFocus.current === focus) return;
230
+ appliedFocus.current = focus;
228
231
  if (data.pages.some((p) => p.id === focus.pageId)) setPageId(focus.pageId);
229
232
  setOpenId(focus.sectionId);
230
233
  }, [focus, data]);
@@ -265,40 +268,44 @@ export default function ThemeSectionsEditor({ api, activeTheme, unsplashEnabled,
265
268
  if (!res.ok) { setSaveError(await errorText(res, `Couldn't save (${res.status}).`)); return null; }
266
269
  return (await res.json()) as { revision: number };
267
270
  };
268
- // autosave: stage into the design draft 1.2s after the last edit
271
+ // Save an explicit snapshot. Edits made while the request is in flight
272
+ // remain local and dirty; another save must never silently include them.
269
273
  const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
270
- const saving$ = useRef(false);
274
+ const saveInFlight = useRef<Promise<void> | null>(null);
271
275
  const staleRef = useRef(false);
272
- const autosave = async () => {
273
- if (saving$.current) return;
274
- saving$.current = true;
275
- setSaveState('saving');
276
- const snapshot = pagesRef.current;
277
- try {
278
- const out = await post({ operation: 'stage', revision: revRef.current, patch: { sectionPages: snapshot } });
279
- if (!out) { setSaveState('error'); return; }
280
- revRef.current = out.revision;
281
- setBase(snapshot);
282
- setSaveState('saved');
283
- patch((d) => ({ ...d, revision: out.revision, draft: { ...(d.draft ?? {}), sectionPages: snapshot } }));
284
- // the preview couldn't swap (new layout / mode change): reload it now
285
- if (staleRef.current) { staleRef.current = false; onSaved?.(); }
286
- } catch {
287
- setSaveState('error');
288
- setSaveError("Couldn't reach the server. Your edits are kept here and will save when you're back online.");
289
- } finally {
290
- saving$.current = false;
291
- }
292
- };
293
- const autosaveRef = useRef(autosave);
294
- autosaveRef.current = autosave;
295
276
  const pagesRef = useRef(pages);
296
277
  pagesRef.current = pages;
297
- useEffect(() => {
298
- if (!data || !dirty || conflict) return;
299
- const t = setTimeout(() => void autosaveRef.current(), 1200);
300
- return () => clearTimeout(t);
301
- }, [pages, dirty, conflict, data, saveState]);
278
+ const saveDraft = (): Promise<void> => {
279
+ if (saveInFlight.current) return saveInFlight.current;
280
+ if (conflict || JSON.stringify(pagesRef.current) === JSON.stringify(baseRef.current)) return Promise.resolve();
281
+ const task = (async () => {
282
+ setSaveState('saving');
283
+ setSaveError('');
284
+ setFlash('');
285
+ const snapshot = pagesRef.current;
286
+ try {
287
+ const out = await post({ operation: 'stage', revision: revRef.current, patch: { sectionPages: snapshot } });
288
+ if (!out) { setSaveState('error'); return; }
289
+ revRef.current = out.revision;
290
+ // Flush callers need the acknowledged snapshot immediately, before
291
+ // React commits the state update (Publish must not lose the click).
292
+ baseRef.current = snapshot;
293
+ setBase(snapshot);
294
+ setSaveState('saved');
295
+ patch((d) => ({ ...d, revision: out.revision, draft: { ...(d.draft ?? {}), sectionPages: snapshot } }));
296
+ if (staleRef.current) { staleRef.current = false; onSaved?.(); }
297
+ } catch {
298
+ setSaveState('error');
299
+ setSaveError("Couldn't reach the server. Your edits are kept here. Try Save draft again when you're back online.");
300
+ } finally {
301
+ saveInFlight.current = null;
302
+ }
303
+ })();
304
+ saveInFlight.current = task;
305
+ return task;
306
+ };
307
+ const saveDraftRef = useRef(saveDraft);
308
+ saveDraftRef.current = saveDraft;
302
309
 
303
310
  // live preview: render the unsaved layout ~0.35s after you pause typing
304
311
  const liveSent = useRef('');
@@ -397,15 +404,16 @@ export default function ThemeSectionsEditor({ api, activeTheme, unsplashEnabled,
397
404
  // eslint-disable-next-line react-hooks/exhaustive-deps
398
405
  }, [pages, lang, i18nInfo?.drafts]);
399
406
 
400
- // ⌘Z / ⌘⇧Z (Ctrl+Y) while editing sections; rich text keeps its own undo
407
+ // Save from any section field; rich text keeps its own undo shortcuts.
401
408
  const rootRef = useRef<HTMLDivElement>(null);
402
409
  useEffect(() => {
403
410
  const onKey = (e: KeyboardEvent) => {
404
411
  if (!(e.metaKey || e.ctrlKey) || !rootRef.current || rootRef.current.offsetParent === null) return;
405
412
  const t = e.target as HTMLElement | null;
406
- if (t?.isContentEditable) return;
407
413
  if (t && t !== document.body && !rootRef.current.contains(t)) return;
408
414
  const k = e.key.toLowerCase();
415
+ if (k === 's') { e.preventDefault(); void saveDraftRef.current(); return; }
416
+ if (t?.isContentEditable) return;
409
417
  if (k === 'z' && !e.shiftKey) { e.preventDefault(); undo(); }
410
418
  else if ((k === 'z' && e.shiftKey) || k === 'y') { e.preventDefault(); redo(); }
411
419
  };
@@ -545,8 +553,7 @@ export default function ThemeSectionsEditor({ api, activeTheme, unsplashEnabled,
545
553
  };
546
554
  /** save now (the Save button, and before publishing/upgrading) */
547
555
  const flush = async (): Promise<boolean> => {
548
- if (!dirty) return true;
549
- await autosaveRef.current();
556
+ await saveDraftRef.current();
550
557
  return JSON.stringify(pagesRef.current) === JSON.stringify(baseRef.current);
551
558
  };
552
559
  if (flushRef) flushRef.current = flush;
@@ -633,13 +640,13 @@ export default function ThemeSectionsEditor({ api, activeTheme, unsplashEnabled,
633
640
  <div className="te" ref={rootRef}>
634
641
  <p className="te-intro">
635
642
  Build a page from your theme's sections. Sections marked <b>coded</b> come from your theme's code: their look
636
- changes in code, their content changes here. Edits save to a private draft as you type; visitors see them after you <b>Publish</b>.
643
+ changes in code, their content changes here. Click <b>Save draft</b> to keep your edits; visitors see them after you <b>Publish</b>.
637
644
  </p>
638
645
  <details className="te-help-panel">
639
646
  <summary>How editing works</summary>
640
647
  <ul>
641
648
  <li><b>Click anything in the preview</b> to open its section here. The preview updates as you type.</li>
642
- <li><b>Your changes save themselves</b> to a draft. Undo with ⌘Z (Ctrl+Z), redo with ⌘⇧Z.</li>
649
+ <li><b>Save draft</b> (⌘S / Ctrl+S) keeps your changes without publishing them. Undo with ⌘Z (Ctrl+Z), redo with ⌘⇧Z.</li>
643
650
  <li><b>Publish</b> (top of this screen) makes every change live. <b>Publish this section</b> sends just one section and leaves the rest in the draft.</li>
644
651
  <li><b>Linked</b> sections (like upcoming events) fill themselves from your calendar or posts, so there's nothing to update here.</li>
645
652
  <li>Fields marked in red need fixing before that section can be published.</li>
@@ -788,7 +795,7 @@ export default function ThemeSectionsEditor({ api, activeTheme, unsplashEnabled,
788
795
  {s.variant !== 'default' && s.variant}
789
796
  {s.hidden && ' · hidden'}
790
797
  {!def && ' · unavailable'}
791
- {unsaved && ' · saving'}
798
+ {unsaved && (saveState === 'saving' ? ' · saving' : ' · unsaved')}
792
799
  {(s.showFrom || s.showUntil) && ' · scheduled'}
793
800
  </span>
794
801
  {issueCount > 0 && <span className="te-issue-mark">{issueCount} to fix</span>}
@@ -977,13 +984,13 @@ export default function ThemeSectionsEditor({ api, activeTheme, unsplashEnabled,
977
984
  {saveError && <p className="flash error te-alert" role="alert">{saveError}</p>}
978
985
 
979
986
  <div className="te-savebar">
980
- <span className={`te-savestate ${saveState === 'error' ? 'is-dirty' : ''}`} aria-live="polite">
981
- {flash || (saveState === 'error' ? 'Not saved yet' : dirty || saveState === 'saving' ? 'Saving draft…' : saveState === 'saved' ? 'Draft saved · publish when ready' : 'All changes saved')}
987
+ <span className={`te-savestate ${dirty || saveState === 'error' ? 'is-dirty' : ''}`} aria-live="polite">
988
+ {flash || (saveState === 'error' ? 'Not saved yet' : saveState === 'saving' ? 'Saving draft…' : dirty ? 'Unsaved changes' : saveState === 'saved' ? 'Draft saved · publish when ready' : 'All changes saved')}
982
989
  {totalIssues > 0 && <> · <button type="button" className="te-issue-link" onClick={() => setOpenId(firstIssueId)}>{totalIssues} {totalIssues === 1 ? 'field needs' : 'fields need'} fixing before publishing</button></>}
983
990
  </span>
984
991
  <button className="btn sm ghost" type="button" onClick={undo} disabled={!past.current.length} title="Undo (⌘Z)">Undo</button>
985
992
  <button className="btn sm ghost" type="button" onClick={redo} disabled={!future.current.length} title="Redo (⌘⇧Z)">Redo</button>
986
- {saveState === 'error' && <button className="btn sm" type="button" onClick={() => void autosaveRef.current()} disabled={conflict}>Try saving again</button>}
993
+ <button className="btn sm primary" type="button" onClick={() => void saveDraftRef.current()} disabled={!dirty || saveState === 'saving' || saving || conflict}>{saveState === 'saving' ? 'Saving…' : 'Save draft'}</button>
987
994
  </div>
988
995
 
989
996
  <ImagePicker
@@ -7,6 +7,7 @@ import { EDITOR_MIGRATION, EDITOR_GRACE_MIGRATION } from '../editor/migration';
7
7
  import { CALENDAR_SETTINGS_MIGRATION, ROOM_HOLD_MIGRATION } from '../calendar/migration';
8
8
  import { PLACES_MIGRATION } from '../maps/migration';
9
9
  import { ROLES_MIGRATION } from '../permissions/migration';
10
+ import { SYNC_MIGRATION } from '../sync/migration';
10
11
  // Versioned schema migrations shared by the two SQLite-dialect adapters
11
12
  // (D1 and libSQL). Applied versions are recorded in the `migrations` table;
12
13
  // each adapter's runner applies whatever is newer, in order, at startup.
@@ -632,6 +633,7 @@ export const MIGRATIONS: Migration[] = [
632
633
  `INSERT INTO backup_state (id) VALUES (1)`,
633
634
  ] },
634
635
  ROLES_MIGRATION,
636
+ SYNC_MIGRATION,
635
637
  ];
636
638
 
637
639
  export const LATEST_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
@@ -1,4 +1,5 @@
1
1
  import { AUTH } from '../auth/config';
2
+ import { SyncStore } from '../sync/store';
2
3
  import { AuthStore } from '../auth/store';
3
4
  import { FormStore, formInsert, submissionInsert } from '../forms/store';
4
5
  import { GuestStore } from '../mobile/guest-store';
@@ -260,6 +261,7 @@ function safeSnippet(raw: unknown): string {
260
261
  }
261
262
 
262
263
  export class SqlRepository implements ContentRepository {
264
+ readonly sync: SyncStore;
263
265
  private driver!: SqlDriver;
264
266
  private ready: Promise<void>;
265
267
  readonly auth: AuthStore;
@@ -274,6 +276,7 @@ export class SqlRepository implements ContentRepository {
274
276
 
275
277
  constructor(driver: SqlDriver | (() => Promise<SqlDriver>)) {
276
278
  this.ready = this.init(driver);
279
+ this.sync = new SyncStore(async () => { await this.ready; return this.driver; });
277
280
  this.forms = new FormStore(async () => { await this.ready; return this.driver; });
278
281
  this.guests = new GuestStore(async () => { await this.ready; return this.driver; });
279
282
  this.live = new LiveStore(async () => { await this.ready; return this.driver; });
@@ -1,4 +1,5 @@
1
1
  import { AuthStore } from '../auth/store';
2
+ import type { SyncStore } from '../sync/store';
2
3
  import type { FormStore } from '../forms/store';
3
4
  import type { BackupStore } from '../backup/store';
4
5
  import type { GuestStore } from '../mobile/guest-store';
@@ -53,6 +54,7 @@ export type EventUpdate = Partial<
53
54
  // The single contract both backends implement. The app only ever imports
54
55
  // this — never a concrete adapter.
55
56
  export interface ContentRepository {
57
+ readonly sync: SyncStore;
56
58
  readonly auth: AuthStore;
57
59
  readonly email: EmailStore;
58
60
  readonly forms: FormStore;
package/src/lib/schema.ts CHANGED
@@ -637,6 +637,7 @@ export const API_SCOPES = [
637
637
  'bookings:read', 'bookings:write',
638
638
  'resources:read', 'resources:write',
639
639
  'design:read', 'design:write-draft', 'design:publish', 'media:read', 'media:write',
640
+ 'sync:read', 'sync:write', 'sync:publish',
640
641
  ] as const;
641
642
  export const ApiScope = z.enum(API_SCOPES);
642
643
  export type ApiScope = z.infer<typeof ApiScope>;
@@ -0,0 +1,24 @@
1
+ import { ApiError } from "../api";
2
+ import { hashAgentKey } from "../ai-config";
3
+ import type { ContentRepository } from "../repo/types";
4
+ export const SYNC_SCOPES = ["sync:read", "sync:write", "sync:publish"] as const;
5
+ export async function syncIdentity(
6
+ request: Request,
7
+ repo: ContentRepository,
8
+ required: readonly string[],
9
+ ) {
10
+ const origin = request.headers.get("origin");
11
+ if (
12
+ request.headers.get("sec-fetch-site") === "cross-site" ||
13
+ (origin && origin !== new URL(request.url).origin)
14
+ )
15
+ throw new ApiError(403, "Cross-origin sync requests are not allowed.");
16
+ const header = request.headers.get("authorization") ?? "";
17
+ if (!header.startsWith("Bearer "))
18
+ throw new ApiError(401, "Use a scoped sync key.");
19
+ const key = await repo.findApiKeyByHash(hashAgentKey(header.slice(7)));
20
+ if (!key) throw new ApiError(401, "Invalid sync key.");
21
+ if (required.some((s) => !key.scopes.includes(s as any)))
22
+ throw new ApiError(403, `Required scopes: ${required.join(", ")}`);
23
+ return { actor: `key:${key.id}`, scopes: key.scopes };
24
+ }