@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.
- package/CHANGELOG.md +12 -0
- package/cli/common.mjs +2 -2
- package/cli/main.mjs +10 -3
- package/cli/migrations.generated.mjs +49 -1
- package/cli/remote.mjs +1 -1
- package/cli/sync.mjs +876 -0
- package/docs/environment-sync.md +302 -0
- package/integration.mjs +2 -0
- package/package.json +1 -1
- package/src/components/DesignAccess.tsx +3 -0
- package/src/components/ThemeSectionsEditor.tsx +53 -46
- package/src/lib/repo/migrations.ts +2 -0
- package/src/lib/repo/sql.ts +3 -0
- package/src/lib/repo/types.ts +2 -0
- package/src/lib/schema.ts +1 -0
- package/src/lib/sync/auth.ts +24 -0
- package/src/lib/sync/catalog.ts +250 -0
- package/src/lib/sync/migration.ts +51 -0
- package/src/lib/sync/protocol.mjs +125 -0
- package/src/lib/sync/service.ts +994 -0
- package/src/lib/sync/store.ts +349 -0
- package/src/pages/admin/integrations.astro +2 -0
- package/src/pages/api/design/keys.ts +7 -3
- package/src/pages/api/sync/index.ts +114 -0
- package/src/pages/api/sync/media.ts +90 -0
- package/src/styles/theme-editor.css +2 -1
|
@@ -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
|
@@ -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
|
-
//
|
|
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.
|
|
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)
|
|
200
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
298
|
-
if (
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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.
|
|
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>
|
|
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' :
|
|
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
|
-
|
|
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;
|
package/src/lib/repo/sql.ts
CHANGED
|
@@ -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; });
|
package/src/lib/repo/types.ts
CHANGED
|
@@ -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
|
+
}
|