canopycms 0.0.67 → 0.0.68-int.94
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/dist/ai/handler.js +8 -0
- package/dist/api/admin-branch-health.js +12 -7
- package/dist/api/admin.d.ts +21 -9
- package/dist/api/admin.js +18 -2
- package/dist/api/branch-status.js +19 -4
- package/dist/api/branch.js +11 -11
- package/dist/api/client.d.ts +12 -0
- package/dist/api/client.js +34 -11
- package/dist/api/content.d.ts +6 -5
- package/dist/api/content.js +44 -41
- package/dist/api/entries.js +16 -15
- package/dist/api/github-sync.d.ts +10 -1
- package/dist/api/github-sync.js +20 -10
- package/dist/api/reference-options.js +1 -1
- package/dist/api/resolve-references.js +14 -40
- package/dist/api/settings-helpers.d.ts +1 -1
- package/dist/api/settings-helpers.js +1 -4
- package/dist/api/user.d.ts +3 -0
- package/dist/api/user.js +3 -0
- package/dist/assets/factory.d.ts +5 -0
- package/dist/assets/factory.js +5 -0
- package/dist/authorization/content.d.ts +5 -4
- package/dist/authorization/content.js +5 -5
- package/dist/authorization/path.d.ts +11 -6
- package/dist/authorization/path.js +11 -6
- package/dist/authorization/types.d.ts +2 -2
- package/dist/branch-health.js +3 -3
- package/dist/branch-schema-cache.d.ts +5 -3
- package/dist/branch-schema-cache.js +29 -11
- package/dist/branch-workspace.js +8 -3
- package/dist/build-identity.d.ts +3 -0
- package/dist/build-identity.js +13 -0
- package/dist/cli/cli.js +1070 -388
- package/dist/cli/generate-ai-content.js +537 -150
- package/dist/cli/github-app-manifest.d.ts +4 -3
- package/dist/cli/github-app-manifest.js +4 -3
- package/dist/cli/init.js +21 -11
- package/dist/cli/migrate.js +12 -8
- package/dist/cli/sync.js +83 -37
- package/dist/cli/template-files/Dockerfile.cms.template +8 -0
- package/dist/cli/template-files/cdk-app.ts.template +4 -0
- package/dist/cli/template-files/cms-stack.ts.template +6 -0
- package/dist/cli/template-files/deploy-cms.yml.template +2 -0
- package/dist/config/schemas/config.d.ts +11 -3
- package/dist/config/schemas/config.js +5 -1
- package/dist/config/schemas/field.js +1 -0
- package/dist/config/schemas/url.d.ts +2 -0
- package/dist/config/schemas/url.js +7 -0
- package/dist/config/types.d.ts +28 -4
- package/dist/config/validation.d.ts +6 -0
- package/dist/config/validation.js +37 -0
- package/dist/content-listing.d.ts +12 -12
- package/dist/content-listing.js +10 -6
- package/dist/content-reader.d.ts +11 -2
- package/dist/content-reader.js +22 -14
- package/dist/content-store.d.ts +37 -9
- package/dist/content-store.js +70 -43
- package/dist/content-tree.d.ts +1 -1
- package/dist/content-tree.js +2 -2
- package/dist/context.d.ts +27 -9
- package/dist/context.js +154 -70
- package/dist/editor/CanopyEditor.d.ts +1 -1
- package/dist/editor/CanopyEditor.js +1 -1
- package/dist/editor/Editor.d.ts +2 -0
- package/dist/editor/Editor.js +6 -5
- package/dist/editor/FormRenderer.js +20 -5
- package/dist/editor/admin/SystemHealthPanel.js +45 -7
- package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
- package/dist/editor/components/NoEditPermissionNotice.js +17 -0
- package/dist/editor/editor-utils.d.ts +17 -15
- package/dist/editor/editor-utils.js +43 -36
- package/dist/editor/hooks/useBranchesData.js +3 -1
- package/dist/editor/hooks/useCommentSystem.js +5 -1
- package/dist/editor/hooks/useDraftManager.d.ts +1 -1
- package/dist/editor/hooks/useDraftManager.js +2 -1
- package/dist/editor/hooks/useEntriesData.js +2 -2
- package/dist/editor/hooks/useEntryManager.js +17 -11
- package/dist/editor/preview-bridge.js +4 -1
- package/dist/editor/preview-path.d.ts +10 -0
- package/dist/editor/preview-path.js +20 -0
- package/dist/entry-schema-registry.d.ts +4 -4
- package/dist/entry-schema-registry.js +10 -6
- package/dist/entry-schema.d.ts +27 -4
- package/dist/entry-schema.js +20 -3
- package/dist/git-manager.d.ts +79 -5
- package/dist/git-manager.js +308 -18
- package/dist/github-service.d.ts +8 -0
- package/dist/github-service.js +5 -1
- package/dist/http/handler.js +35 -13
- package/dist/http/index.d.ts +2 -0
- package/dist/http/index.js +2 -0
- package/dist/http/router.d.ts +2 -0
- package/dist/http/router.js +2 -1
- package/dist/http/worker-not-ready.d.ts +9 -0
- package/dist/http/worker-not-ready.js +17 -0
- package/dist/operating-mode/client-unsafe-strategy.js +0 -6
- package/dist/operating-mode/types.d.ts +0 -3
- package/dist/paths/branch-name.d.ts +5 -0
- package/dist/paths/branch-name.js +9 -0
- package/dist/paths/branch.d.ts +6 -1
- package/dist/paths/branch.js +15 -2
- package/dist/paths/index.d.ts +3 -3
- package/dist/paths/index.js +3 -3
- package/dist/paths/normalize.d.ts +7 -0
- package/dist/paths/normalize.js +9 -0
- package/dist/reference-resolver.d.ts +5 -21
- package/dist/reference-resolver.js +9 -44
- package/dist/resolve-canopy-user.js +2 -1
- package/dist/schema/schema-store.d.ts +48 -22
- package/dist/schema/schema-store.js +66 -22
- package/dist/services.d.ts +18 -6
- package/dist/services.js +85 -70
- package/dist/settings-workspace.js +42 -4
- package/dist/static/seo.d.ts +2 -14
- package/dist/static/seo.js +2 -25
- package/dist/submission-attribution.d.ts +73 -0
- package/dist/submission-attribution.js +221 -0
- package/dist/sync-core.d.ts +12 -1
- package/dist/sync-core.js +31 -13
- package/dist/task-queue/worker-status.d.ts +8 -0
- package/dist/task-queue/worker-status.js +17 -0
- package/dist/types.d.ts +31 -2
- package/dist/utils/content-write-lock.d.ts +18 -7
- package/dist/utils/content-write-lock.js +18 -9
- package/dist/utils/debug.d.ts +8 -0
- package/dist/utils/debug.js +10 -2
- package/dist/utils/git.d.ts +28 -0
- package/dist/utils/git.js +38 -0
- package/dist/utils/provisioning-lock.d.ts +15 -5
- package/dist/utils/provisioning-lock.js +31 -11
- package/dist/utils/request-timing.d.ts +25 -0
- package/dist/utils/request-timing.js +101 -0
- package/dist/utils/url-prefix.d.ts +30 -0
- package/dist/utils/url-prefix.js +61 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +3 -0
- package/dist/worker/canopy-state.d.ts +45 -0
- package/dist/worker/canopy-state.js +75 -0
- package/dist/worker/cms-worker.js +23 -2
- package/dist/worker/git-sync.d.ts +8 -2
- package/dist/worker/git-sync.js +138 -24
- package/dist/worker/provisioned-workspace.d.ts +35 -0
- package/dist/worker/provisioned-workspace.js +50 -0
- package/dist/worker/rebase.d.ts +1 -1
- package/dist/worker/rebase.js +83 -12
- package/dist/worker/task-runner.js +39 -4
- package/package.json +1 -1
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Schema Store -
|
|
3
|
-
* All mutations are branch-specific (like content edits).
|
|
2
|
+
* Schema Store - reads and writes .collection.json files, per branch.
|
|
4
3
|
*
|
|
5
4
|
* ## Concurrency
|
|
6
5
|
*
|
|
@@ -16,11 +15,11 @@
|
|
|
16
15
|
*
|
|
17
16
|
* Protection is instead layer 1 ({@link withLock}) + layer 3
|
|
18
17
|
* ({@link withOccFileLock}) on one COARSE per-branch SURROGATE lock path
|
|
19
|
-
* OUTSIDE the content tree (`{branchRoot}/.canopy-meta/schema
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
18
|
+
* OUTSIDE the content tree (`{branchRoot}/.canopy-meta/schema`), covering
|
|
19
|
+
* every schema mutation including multi-file ones, inside the [SYNC-C1]
|
|
20
|
+
* content-write lock the worker's rebase also takes (`withBranchSchemaLock`).
|
|
21
|
+
* Layer 2 (OCC read-back) is skipped, since there's no version field to
|
|
22
|
+
* check; layer 4 (generation marker) guards the schema CACHE separately.
|
|
24
23
|
*
|
|
25
24
|
* Accepted residual: `deleteBranch` does not take this lock before its
|
|
26
25
|
* recursive `rm`, so an in-flight write can race a concurrent branch
|
|
@@ -182,7 +181,30 @@ declare const updateEntryTypeInputSchema: z.ZodObject<{
|
|
|
182
181
|
* into a 409 so the editor can retry rather than surfacing a raw 400.
|
|
183
182
|
*/
|
|
184
183
|
export declare class SchemaStoreBusyError extends Error {
|
|
185
|
-
|
|
184
|
+
/** As on `ContentWriteLockBusyError`: `'unknown'` means the mutation ran and may have landed. */
|
|
185
|
+
readonly outcome: 'not-run' | 'unknown';
|
|
186
|
+
constructor(message?: string,
|
|
187
|
+
/** As on `ContentWriteLockBusyError`: `'unknown'` means the mutation ran and may have landed. */
|
|
188
|
+
outcome?: 'not-run' | 'unknown');
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Run `fn` holding every lock a schema mutation needs on `branchRoot`, in the
|
|
192
|
+
* global order (docs/concurrency.md, "Lock acquisition order"): the branch's
|
|
193
|
+
* content-write lock, then the `.canopy-meta/schema` surrogate's in-process
|
|
194
|
+
* mutex, then its lockfile. Shared with `cli/migrate.ts` so the two cannot
|
|
195
|
+
* drift.
|
|
196
|
+
*
|
|
197
|
+
* NOT re-entrant, and nothing inside `fn` may take the content-write lock
|
|
198
|
+
* (no `ContentStore` writes): proper-lockfile refuses a second acquisition in
|
|
199
|
+
* the same process, so a nested one burns its wait and fails busy.
|
|
200
|
+
*
|
|
201
|
+
* Errors propagate untranslated: `ContentWriteLockBusyError` (contention, or
|
|
202
|
+
* a compromise reported after `fn` completed) and `OccWriteConflictError`.
|
|
203
|
+
*/
|
|
204
|
+
export declare function withBranchSchemaLock<T>(branchRoot: string, fn: () => Promise<T>, contentWriteLockWaitMs?: number): Promise<T>;
|
|
205
|
+
export interface SchemaOpsOptions {
|
|
206
|
+
/** Bounded wait for the branch's content-write lock; defaults to `DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS`. */
|
|
207
|
+
contentWriteLockWaitMs?: number;
|
|
186
208
|
}
|
|
187
209
|
export declare class SchemaOps {
|
|
188
210
|
private readonly contentRoot;
|
|
@@ -199,21 +221,19 @@ export declare class SchemaOps {
|
|
|
199
221
|
* and never match the root collection's logical path).
|
|
200
222
|
*/
|
|
201
223
|
private readonly contentRootName;
|
|
202
|
-
/**
|
|
203
|
-
private readonly
|
|
204
|
-
constructor(contentRoot: string, entrySchemaRegistry: EntrySchemaRegistry, services?: CanopyServices | undefined, branchRoot?: string);
|
|
224
|
+
/** See SchemaOpsOptions.contentWriteLockWaitMs. */
|
|
225
|
+
private readonly contentWriteLockWaitMs;
|
|
226
|
+
constructor(contentRoot: string, entrySchemaRegistry: EntrySchemaRegistry, services?: CanopyServices | undefined, branchRoot?: string, options?: SchemaOpsOptions);
|
|
205
227
|
/**
|
|
206
|
-
* Serialize an entire read-modify-write schema mutation behind
|
|
207
|
-
*
|
|
208
|
-
* ({@link withLock}
|
|
209
|
-
* (
|
|
210
|
-
* exclusion, immune to NFS attribute caching), same structure as
|
|
211
|
-
* `BranchMetadataFileManager.save()` in branch-metadata.ts.
|
|
228
|
+
* Serialize an entire read-modify-write schema mutation behind
|
|
229
|
+
* {@link withBranchSchemaLock}: the content-write lock, then the surrogate
|
|
230
|
+
* ({@link withLock} wrapping {@link withOccFileLock}, as in
|
|
231
|
+
* `BranchMetadataFileManager.save()`).
|
|
212
232
|
*
|
|
213
233
|
* NOT re-entrant — callers must never invoke this from inside a callback
|
|
214
|
-
* already running under it, or
|
|
215
|
-
*
|
|
216
|
-
*
|
|
234
|
+
* already running under it, or the nested acquisition fails busy; that's
|
|
235
|
+
* why `updateOrderInner` calls `updateCollectionInner` directly instead of
|
|
236
|
+
* the public `updateCollection`.
|
|
217
237
|
*
|
|
218
238
|
* Phantom-resurrection guard: `branchRoot` can be removed by a concurrent
|
|
219
239
|
* `deleteBranch` between the caller resolving its BranchContext and this
|
|
@@ -226,7 +246,13 @@ export declare class SchemaOps {
|
|
|
226
246
|
* since `deleteBranch` never takes this lock (see the module doc comment).
|
|
227
247
|
*
|
|
228
248
|
* Translation happens ONLY at this boundary: inner code always sees the
|
|
229
|
-
* raw {@link OccWriteConflictError} bubble up here untranslated
|
|
249
|
+
* raw {@link OccWriteConflictError} bubble up here untranslated, and a
|
|
250
|
+
* {@link ContentWriteLockBusyError} becomes a `SchemaStoreBusyError` with
|
|
251
|
+
* its message, so every caller's 409 mapping covers both.
|
|
252
|
+
*
|
|
253
|
+
* A content-lock compromise is reported after `fn` completed, so the
|
|
254
|
+
* mutation is on disk while the caller's post-lock `invalidateSchemaCache()`
|
|
255
|
+
* never runs; this runs it before throwing.
|
|
230
256
|
*/
|
|
231
257
|
private withSchemaLock;
|
|
232
258
|
/**
|
|
@@ -322,7 +348,7 @@ export declare class SchemaOps {
|
|
|
322
348
|
* Body of updateCollection, holding the schema lock for its full
|
|
323
349
|
* read-modify-write. Called directly (not via the public `updateCollection`)
|
|
324
350
|
* by `updateOrderInner` for non-root collections — `withSchemaLock` is NOT
|
|
325
|
-
* re-entrant, so going through the public method there would
|
|
351
|
+
* re-entrant, so going through the public method there would fail busy.
|
|
326
352
|
*
|
|
327
353
|
* `collectionPath` here is always already normalized (content-root prefix
|
|
328
354
|
* stripped, if it had one) by whichever public method reached this — either
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Schema Store -
|
|
3
|
-
* All mutations are branch-specific (like content edits).
|
|
2
|
+
* Schema Store - reads and writes .collection.json files, per branch.
|
|
4
3
|
*
|
|
5
4
|
* ## Concurrency
|
|
6
5
|
*
|
|
@@ -16,11 +15,11 @@
|
|
|
16
15
|
*
|
|
17
16
|
* Protection is instead layer 1 ({@link withLock}) + layer 3
|
|
18
17
|
* ({@link withOccFileLock}) on one COARSE per-branch SURROGATE lock path
|
|
19
|
-
* OUTSIDE the content tree (`{branchRoot}/.canopy-meta/schema
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
18
|
+
* OUTSIDE the content tree (`{branchRoot}/.canopy-meta/schema`), covering
|
|
19
|
+
* every schema mutation including multi-file ones, inside the [SYNC-C1]
|
|
20
|
+
* content-write lock the worker's rebase also takes (`withBranchSchemaLock`).
|
|
21
|
+
* Layer 2 (OCC read-back) is skipped, since there's no version field to
|
|
22
|
+
* check; layer 4 (generation marker) guards the schema CACHE separately.
|
|
24
23
|
*
|
|
25
24
|
* Accepted residual: `deleteBranch` does not take this lock before its
|
|
26
25
|
* recursive `rm`, so an in-flight write can race a concurrent branch
|
|
@@ -34,6 +33,7 @@ import { z } from 'zod';
|
|
|
34
33
|
import { atomicWriteFile } from '../utils/atomic-write.js';
|
|
35
34
|
import { withLock } from '../utils/async-mutex.js';
|
|
36
35
|
import { createDebugLogger } from '../utils/debug.js';
|
|
36
|
+
import { ContentWriteLockBusyError, DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS, withContentWriteLock, } from '../utils/content-write-lock.js';
|
|
37
37
|
import { getErrorMessage, isNotFoundError } from '../utils/error.js';
|
|
38
38
|
import { withOccFileLock, OccWriteConflictError } from '../utils/occ-json-write.js';
|
|
39
39
|
import { resolveCollectionPath } from '../content-id-index.js';
|
|
@@ -107,13 +107,34 @@ const log = createDebugLogger({ prefix: 'SchemaOps' });
|
|
|
107
107
|
* into a 409 so the editor can retry rather than surfacing a raw 400.
|
|
108
108
|
*/
|
|
109
109
|
export class SchemaStoreBusyError extends Error {
|
|
110
|
-
constructor(message = 'Schema is being modified by another operation, try again'
|
|
110
|
+
constructor(message = 'Schema is being modified by another operation, try again',
|
|
111
|
+
/** As on `ContentWriteLockBusyError`: `'unknown'` means the mutation ran and may have landed. */
|
|
112
|
+
outcome = 'not-run') {
|
|
111
113
|
super(message);
|
|
114
|
+
this.outcome = outcome;
|
|
112
115
|
this.name = 'SchemaStoreBusyError';
|
|
113
116
|
}
|
|
114
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* Run `fn` holding every lock a schema mutation needs on `branchRoot`, in the
|
|
120
|
+
* global order (docs/concurrency.md, "Lock acquisition order"): the branch's
|
|
121
|
+
* content-write lock, then the `.canopy-meta/schema` surrogate's in-process
|
|
122
|
+
* mutex, then its lockfile. Shared with `cli/migrate.ts` so the two cannot
|
|
123
|
+
* drift.
|
|
124
|
+
*
|
|
125
|
+
* NOT re-entrant, and nothing inside `fn` may take the content-write lock
|
|
126
|
+
* (no `ContentStore` writes): proper-lockfile refuses a second acquisition in
|
|
127
|
+
* the same process, so a nested one burns its wait and fails busy.
|
|
128
|
+
*
|
|
129
|
+
* Errors propagate untranslated: `ContentWriteLockBusyError` (contention, or
|
|
130
|
+
* a compromise reported after `fn` completed) and `OccWriteConflictError`.
|
|
131
|
+
*/
|
|
132
|
+
export async function withBranchSchemaLock(branchRoot, fn, contentWriteLockWaitMs = DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS) {
|
|
133
|
+
const schemaLockPath = path.join(path.resolve(branchRoot), '.canopy-meta', 'schema');
|
|
134
|
+
return withContentWriteLock(branchRoot, () => withLock(schemaLockPath, () => withOccFileLock(schemaLockPath, fn)), contentWriteLockWaitMs);
|
|
135
|
+
}
|
|
115
136
|
export class SchemaOps {
|
|
116
|
-
constructor(contentRoot, entrySchemaRegistry, services, branchRoot) {
|
|
137
|
+
constructor(contentRoot, entrySchemaRegistry, services, branchRoot, options = {}) {
|
|
117
138
|
this.contentRoot = contentRoot;
|
|
118
139
|
this.entrySchemaRegistry = entrySchemaRegistry;
|
|
119
140
|
this.services = services;
|
|
@@ -130,20 +151,19 @@ export class SchemaOps {
|
|
|
130
151
|
.relative(this.branchRoot, resolvedContentRoot)
|
|
131
152
|
.split(path.sep)
|
|
132
153
|
.join('/');
|
|
133
|
-
this.
|
|
154
|
+
this.contentWriteLockWaitMs =
|
|
155
|
+
options.contentWriteLockWaitMs ?? DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS;
|
|
134
156
|
}
|
|
135
157
|
/**
|
|
136
|
-
* Serialize an entire read-modify-write schema mutation behind
|
|
137
|
-
*
|
|
138
|
-
* ({@link withLock}
|
|
139
|
-
* (
|
|
140
|
-
* exclusion, immune to NFS attribute caching), same structure as
|
|
141
|
-
* `BranchMetadataFileManager.save()` in branch-metadata.ts.
|
|
158
|
+
* Serialize an entire read-modify-write schema mutation behind
|
|
159
|
+
* {@link withBranchSchemaLock}: the content-write lock, then the surrogate
|
|
160
|
+
* ({@link withLock} wrapping {@link withOccFileLock}, as in
|
|
161
|
+
* `BranchMetadataFileManager.save()`).
|
|
142
162
|
*
|
|
143
163
|
* NOT re-entrant — callers must never invoke this from inside a callback
|
|
144
|
-
* already running under it, or
|
|
145
|
-
*
|
|
146
|
-
*
|
|
164
|
+
* already running under it, or the nested acquisition fails busy; that's
|
|
165
|
+
* why `updateOrderInner` calls `updateCollectionInner` directly instead of
|
|
166
|
+
* the public `updateCollection`.
|
|
147
167
|
*
|
|
148
168
|
* Phantom-resurrection guard: `branchRoot` can be removed by a concurrent
|
|
149
169
|
* `deleteBranch` between the caller resolving its BranchContext and this
|
|
@@ -156,7 +176,13 @@ export class SchemaOps {
|
|
|
156
176
|
* since `deleteBranch` never takes this lock (see the module doc comment).
|
|
157
177
|
*
|
|
158
178
|
* Translation happens ONLY at this boundary: inner code always sees the
|
|
159
|
-
* raw {@link OccWriteConflictError} bubble up here untranslated
|
|
179
|
+
* raw {@link OccWriteConflictError} bubble up here untranslated, and a
|
|
180
|
+
* {@link ContentWriteLockBusyError} becomes a `SchemaStoreBusyError` with
|
|
181
|
+
* its message, so every caller's 409 mapping covers both.
|
|
182
|
+
*
|
|
183
|
+
* A content-lock compromise is reported after `fn` completed, so the
|
|
184
|
+
* mutation is on disk while the caller's post-lock `invalidateSchemaCache()`
|
|
185
|
+
* never runs; this runs it before throwing.
|
|
160
186
|
*/
|
|
161
187
|
async withSchemaLock(fn) {
|
|
162
188
|
try {
|
|
@@ -168,13 +194,31 @@ export class SchemaOps {
|
|
|
168
194
|
}
|
|
169
195
|
throw err;
|
|
170
196
|
}
|
|
197
|
+
let landed = false;
|
|
171
198
|
try {
|
|
172
|
-
return await
|
|
199
|
+
return await withBranchSchemaLock(this.branchRoot, async () => {
|
|
200
|
+
const result = await fn();
|
|
201
|
+
landed = true;
|
|
202
|
+
return result;
|
|
203
|
+
}, this.contentWriteLockWaitMs);
|
|
173
204
|
}
|
|
174
205
|
catch (err) {
|
|
175
206
|
if (err instanceof OccWriteConflictError) {
|
|
176
207
|
throw new SchemaStoreBusyError();
|
|
177
208
|
}
|
|
209
|
+
if (err instanceof ContentWriteLockBusyError) {
|
|
210
|
+
if (landed) {
|
|
211
|
+
await this.invalidateSchemaCache().catch((invalidateErr) => {
|
|
212
|
+
log.warn('schema-cache', 'Invalidation after a lost content-write lock failed', {
|
|
213
|
+
branchRoot: this.branchRoot,
|
|
214
|
+
error: getErrorMessage(invalidateErr),
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
throw new SchemaStoreBusyError(err.outcome === 'unknown'
|
|
219
|
+
? 'This branch was being synced while the schema change was written, so it may or may not have been recorded. Reload before changing the schema again.'
|
|
220
|
+
: err.message, err.outcome);
|
|
221
|
+
}
|
|
178
222
|
throw err;
|
|
179
223
|
}
|
|
180
224
|
}
|
|
@@ -461,7 +505,7 @@ export class SchemaOps {
|
|
|
461
505
|
* Body of updateCollection, holding the schema lock for its full
|
|
462
506
|
* read-modify-write. Called directly (not via the public `updateCollection`)
|
|
463
507
|
* by `updateOrderInner` for non-root collections — `withSchemaLock` is NOT
|
|
464
|
-
* re-entrant, so going through the public method there would
|
|
508
|
+
* re-entrant, so going through the public method there would fail busy.
|
|
465
509
|
*
|
|
466
510
|
* `collectionPath` here is always already normalized (content-root prefix
|
|
467
511
|
* stripped, if it had one) by whichever public method reached this — either
|
package/dist/services.d.ts
CHANGED
|
@@ -24,11 +24,16 @@ import { GitManager } from './git-manager.js';
|
|
|
24
24
|
import { BranchRegistry } from './branch-registry.js';
|
|
25
25
|
import { type GitHubService } from './github-service.js';
|
|
26
26
|
import { BranchSchemaCache } from './branch-schema-cache.js';
|
|
27
|
+
import { type SubmissionEditor } from './submission-attribution.js';
|
|
27
28
|
/**
|
|
28
29
|
* Bootstrap admin IDs from the environment. These users are treated as Admins
|
|
29
30
|
* whatever their group membership.
|
|
30
31
|
*/
|
|
31
32
|
export declare const getBootstrapAdminIds: () => Set<string>;
|
|
33
|
+
export interface SubmitBranchResult {
|
|
34
|
+
/** Repo-relative paths the branch changes, excluding canopycms runtime metadata. */
|
|
35
|
+
changedPaths: string[];
|
|
36
|
+
}
|
|
32
37
|
export interface CanopyServices {
|
|
33
38
|
config: CanopyConfig;
|
|
34
39
|
/**
|
|
@@ -58,27 +63,34 @@ export interface CanopyServices {
|
|
|
58
63
|
githubService?: GitHubService;
|
|
59
64
|
/** Bootstrap admin user IDs that are always treated as Admins */
|
|
60
65
|
bootstrapAdminIds: Set<string>;
|
|
61
|
-
/**
|
|
66
|
+
/**
|
|
67
|
+
* Commit files to git with automatic author handling. Holds the branch's
|
|
68
|
+
* content-write lock; rejects with `ContentWriteLockBusyError` when busy.
|
|
69
|
+
*/
|
|
62
70
|
commitFiles: (options: {
|
|
63
71
|
context: BranchContext;
|
|
64
72
|
files: string | string[];
|
|
65
73
|
message: string;
|
|
66
74
|
}) => Promise<void>;
|
|
67
|
-
/**
|
|
75
|
+
/**
|
|
76
|
+
* Submit branch: commit all changes (with the submitter's trailers) and push
|
|
77
|
+
* to remote. Resolves with every path the branch changes against its base.
|
|
78
|
+
* Holds the branch's content-write lock; rejects with
|
|
79
|
+
* `ContentWriteLockBusyError` when busy.
|
|
80
|
+
*/
|
|
68
81
|
submitBranch: (options: {
|
|
69
82
|
context: BranchContext;
|
|
83
|
+
submitter?: SubmissionEditor;
|
|
70
84
|
message?: string;
|
|
71
|
-
}) => Promise<
|
|
72
|
-
/** Commit to settings branch (for permissions/groups)
|
|
85
|
+
}) => Promise<SubmitBranchResult>;
|
|
86
|
+
/** Commit to the settings branch (for permissions/groups) and push it; never opens a PR */
|
|
73
87
|
commitToSettingsBranch: (options: {
|
|
74
88
|
branchRoot: string;
|
|
75
89
|
files: string | string[];
|
|
76
90
|
message: string;
|
|
77
|
-
createPR?: boolean;
|
|
78
91
|
}) => Promise<{
|
|
79
92
|
committed: boolean;
|
|
80
93
|
pushed: boolean;
|
|
81
|
-
prUrl?: string;
|
|
82
94
|
error?: string;
|
|
83
95
|
syncStatus?: 'pending-sync' | 'synced' | 'sync-failed';
|
|
84
96
|
}>;
|
package/dist/services.js
CHANGED
|
@@ -9,8 +9,13 @@ import { operatingStrategy } from './operating-mode/index.js';
|
|
|
9
9
|
import { BranchSchemaCache } from './branch-schema-cache.js';
|
|
10
10
|
import { enqueueTask } from './task-queue/cms-task-queue.js';
|
|
11
11
|
import { getTaskQueueDir } from './task-queue/task-queue-config.js';
|
|
12
|
-
import { detectHeadBranch } from './utils/git.js';
|
|
12
|
+
import { detectHeadBranch, isCanopyInternalPath } from './utils/git.js';
|
|
13
13
|
import { readsFromCheckout } from './build-mode.js';
|
|
14
|
+
import { timeRequestPhase } from './utils/request-timing.js';
|
|
15
|
+
import { BRANCH_META_DIR } from './branch-metadata-file.js';
|
|
16
|
+
import { appendTrailers, buildEditorTrailers, } from './submission-attribution.js';
|
|
17
|
+
import { getErrorMessage, redactCredentials } from './utils/error.js';
|
|
18
|
+
import { withContentWriteLock } from './utils/content-write-lock.js';
|
|
14
19
|
/**
|
|
15
20
|
* A per-instance active-branch detector with its own 5s TTL cache, in priority
|
|
16
21
|
* order: an explicitly configured value; `defaultBaseBranch ?? 'main'` with no
|
|
@@ -97,7 +102,7 @@ async function _createCanopyServicesInternal(config, options) {
|
|
|
97
102
|
const branchSchemaCache = options.branchSchemaCache ?? new BranchSchemaCache(config.mode);
|
|
98
103
|
const checkBranchAccess = createCheckBranchAccess(config.defaultBranchAccess ?? 'deny', config);
|
|
99
104
|
// Content access loads permissions dynamically from the settings branch (orphan git branch)
|
|
100
|
-
const
|
|
105
|
+
const ensureSettingsBranchRoot = options.getSettingsBranchRoot ??
|
|
101
106
|
(async () => {
|
|
102
107
|
const strategy = operatingStrategy(config.mode);
|
|
103
108
|
const settingsRoot = strategy.getSettingsRoot();
|
|
@@ -111,9 +116,10 @@ async function _createCanopyServicesInternal(config, options) {
|
|
|
111
116
|
});
|
|
112
117
|
return settingsRoot;
|
|
113
118
|
});
|
|
119
|
+
const getSettingsBranchRoot = () => timeRequestPhase('settingsRoot', ensureSettingsBranchRoot);
|
|
114
120
|
const contentAccessDeps = {
|
|
115
121
|
checkBranchAccess,
|
|
116
|
-
loadPathPermissions,
|
|
122
|
+
loadPathPermissions: (repoRoot, mode) => timeRequestPhase('permissions', () => loadPathPermissions(repoRoot, mode)),
|
|
117
123
|
defaultPathAccess: config.defaultPathAccess ?? 'deny',
|
|
118
124
|
mode: config.mode,
|
|
119
125
|
getSettingsBranchRoot,
|
|
@@ -137,8 +143,13 @@ async function _createCanopyServicesInternal(config, options) {
|
|
|
137
143
|
name: config.gitBotAuthorName,
|
|
138
144
|
email: config.gitBotAuthorEmail,
|
|
139
145
|
});
|
|
140
|
-
|
|
141
|
-
|
|
146
|
+
// [SYNC-C1] Unlocked, a commit made while a rebase is stopped on a conflict
|
|
147
|
+
// lands on its detached head with the rebase's half-resolved index, and the
|
|
148
|
+
// rebase's `--abort` discards it.
|
|
149
|
+
await withContentWriteLock(options.context.branchRoot, async () => {
|
|
150
|
+
await git.add(options.files);
|
|
151
|
+
await git.commit(options.message);
|
|
152
|
+
});
|
|
142
153
|
};
|
|
143
154
|
const submitBranch = async (options) => {
|
|
144
155
|
// Defense-in-depth: refuse to push the base branch to itself even if the
|
|
@@ -156,23 +167,53 @@ async function _createCanopyServicesInternal(config, options) {
|
|
|
156
167
|
name: config.gitBotAuthorName,
|
|
157
168
|
email: config.gitBotAuthorEmail,
|
|
158
169
|
});
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
170
|
+
// [SYNC-C1] Commits the whole working tree, so it holds the branch's
|
|
171
|
+
// content-write lock from checkout through push. Unlocked, its checkout
|
|
172
|
+
// succeeds while the worker's rebase is stopped on a conflict, the commit
|
|
173
|
+
// lands on the branch, and the rebase's `--abort` resets the branch past it
|
|
174
|
+
// after this reported success. The push stays inside so no rebase rewrites
|
|
175
|
+
// the commit between commit and push.
|
|
176
|
+
const submitted = await withContentWriteLock(options.context.branchRoot, async () => {
|
|
177
|
+
await git.checkoutBranch(options.context.branch.name);
|
|
178
|
+
const status = await git.status();
|
|
179
|
+
// Commit and push answer two DIFFERENT questions. Committing cleans the
|
|
180
|
+
// working tree, so one combined "tree is dirty" gate makes a retry after a
|
|
181
|
+
// failed push a silent no-op: nothing left to commit, the block is skipped,
|
|
182
|
+
// and success is reported though the commit never reached the remote. Push
|
|
183
|
+
// whenever there is something new to send — we just committed, or the local
|
|
184
|
+
// branch already had unpushed commits from an earlier attempt.
|
|
185
|
+
// canopycms's own state under .canopy-meta/ is never content, so it neither
|
|
186
|
+
// makes a commit worth creating nor gets staged into one.
|
|
187
|
+
let committed = false;
|
|
188
|
+
if (status.files.some((f) => !isCanopyInternalPath(f.path))) {
|
|
189
|
+
await git.addAllExceptCanopyState();
|
|
190
|
+
const trailers = buildEditorTrailers(options.submitter ? [options.submitter] : [], {
|
|
191
|
+
editedBy: config.gitEditedByTrailers ?? true,
|
|
192
|
+
coAuthoredBy: config.gitCoAuthoredByTrailers ?? false,
|
|
193
|
+
});
|
|
194
|
+
await git.commit(appendTrailers(options.message ?? `Submit ${options.context.branch.name}`, trailers));
|
|
195
|
+
committed = true;
|
|
196
|
+
}
|
|
197
|
+
if (committed || (await git.hasUnpushedCommits(options.context.branch.name))) {
|
|
198
|
+
await git.push(options.context.branch.name);
|
|
199
|
+
}
|
|
200
|
+
return status;
|
|
201
|
+
});
|
|
202
|
+
// The list only feeds the PR body, so a failure to compute it falls back to
|
|
203
|
+
// this submit's own working-tree changes rather than failing a submit that
|
|
204
|
+
// has already been pushed.
|
|
205
|
+
let changedPaths;
|
|
206
|
+
try {
|
|
207
|
+
changedPaths = await git.listChangedPathsSinceBase();
|
|
172
208
|
}
|
|
173
|
-
|
|
174
|
-
|
|
209
|
+
catch (err) {
|
|
210
|
+
console.warn(`CanopyCMS: Could not list the changes on ${options.context.branch.name} against its base; ` +
|
|
211
|
+
"the PR body lists only this submit's changes:", redactCredentials(getErrorMessage(err)));
|
|
212
|
+
changedPaths = submitted.files.map((f) => f.path);
|
|
175
213
|
}
|
|
214
|
+
return {
|
|
215
|
+
changedPaths: changedPaths.filter((p) => !p.startsWith(`${BRANCH_META_DIR}/`)),
|
|
216
|
+
};
|
|
176
217
|
};
|
|
177
218
|
// Must be initialized before closures that reference it (commitToSettingsBranch)
|
|
178
219
|
let githubService;
|
|
@@ -233,57 +274,31 @@ async function _createCanopyServicesInternal(config, options) {
|
|
|
233
274
|
error: error instanceof Error ? error.message : 'Push failed',
|
|
234
275
|
};
|
|
235
276
|
}
|
|
236
|
-
//
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
}
|
|
261
|
-
catch (err) {
|
|
262
|
-
console.warn('Failed to create/update PR:', err);
|
|
263
|
-
return { committed: true, pushed: true, syncStatus: 'sync-failed' };
|
|
264
|
-
}
|
|
265
|
-
return { committed: true, pushed: true, prUrl, syncStatus: 'synced' };
|
|
266
|
-
}
|
|
267
|
-
// Async path: queue task for worker (prod Lambda has no internet)
|
|
268
|
-
const taskDir = getTaskQueueDir(config);
|
|
269
|
-
try {
|
|
270
|
-
await enqueueTask(taskDir, {
|
|
271
|
-
action: 'push-and-create-or-update-pr',
|
|
272
|
-
payload: {
|
|
273
|
-
branch: settingsBranch,
|
|
274
|
-
baseBranch: config.defaultBaseBranch ?? 'main',
|
|
275
|
-
title: 'Update permissions and groups',
|
|
276
|
-
body: settingsPRBody,
|
|
277
|
-
},
|
|
278
|
-
});
|
|
279
|
-
return { committed: true, pushed: true, syncStatus: 'pending-sync' };
|
|
280
|
-
}
|
|
281
|
-
catch (err) {
|
|
282
|
-
console.warn('Failed to enqueue settings PR task:', err);
|
|
283
|
-
return { committed: true, pushed: true, syncStatus: 'sync-failed' };
|
|
284
|
-
}
|
|
277
|
+
// The settings branch is an orphan: it shares no history with the base
|
|
278
|
+
// branch, so GitHub rejects a PR for it. The change took effect when it
|
|
279
|
+
// was committed and pushed above (permissions and groups are read live
|
|
280
|
+
// from the settings workspace); the only GitHub step is mirroring the
|
|
281
|
+
// branch, so there is never a PR.
|
|
282
|
+
if (!operatingStrategy(mode).supportsPullRequests()) {
|
|
283
|
+
return { committed: true, pushed: true };
|
|
284
|
+
}
|
|
285
|
+
// With a githubService the push above already reached GitHub: both it and
|
|
286
|
+
// the workspace remote come from `defaultRemoteUrl`. Without one (prod
|
|
287
|
+
// Lambda has no internet) the worker pushes it.
|
|
288
|
+
if (githubService) {
|
|
289
|
+
return { committed: true, pushed: true, syncStatus: 'synced' };
|
|
290
|
+
}
|
|
291
|
+
try {
|
|
292
|
+
await enqueueTask(getTaskQueueDir(config), {
|
|
293
|
+
action: 'push-branch',
|
|
294
|
+
payload: { branch: settingsBranch },
|
|
295
|
+
});
|
|
296
|
+
return { committed: true, pushed: true, syncStatus: 'pending-sync' };
|
|
297
|
+
}
|
|
298
|
+
catch (err) {
|
|
299
|
+
console.warn('Failed to enqueue settings push task:', err);
|
|
300
|
+
return { committed: true, pushed: true, syncStatus: 'sync-failed' };
|
|
285
301
|
}
|
|
286
|
-
return { committed: true, pushed: true };
|
|
287
302
|
}
|
|
288
303
|
catch (error) {
|
|
289
304
|
return {
|
|
@@ -9,6 +9,36 @@ const log = createDebugLogger({ prefix: 'SettingsWorkspace' });
|
|
|
9
9
|
// In-memory lock against concurrent init within one process. One lock suffices,
|
|
10
10
|
// unlike content branches, which need one per branch.
|
|
11
11
|
let settingsInitLock = null;
|
|
12
|
+
/**
|
|
13
|
+
* Settings workspaces this process has fully ensured, keyed by {@link ensuredKey}. A hit
|
|
14
|
+
* skips the guard, the init lock and initializeWorkspace's dozen git subprocesses, which
|
|
15
|
+
* otherwise ran on every API request. It is sound because settings freshness never comes from
|
|
16
|
+
* that pass: it reads the remote only to provision the settings branch or repair an empty one
|
|
17
|
+
* (GitManager.createOrphanSettingsBranch), saves pull, and every process reads the one shared
|
|
18
|
+
* workspace. Everything it verified is fixed for the process: the settings-branch name resolves once
|
|
19
|
+
* from config, the remote URL comes from config, and nothing in CanopyCMS checks the settings
|
|
20
|
+
* workspace out onto another branch. groups.json and permissions.json are still read from
|
|
21
|
+
* disk on every request; only the provisioning is memoized. Failures are never recorded. In
|
|
22
|
+
* dev, a hit also skips re-seeding a deleted `.canopy-dev/remote.git`; a dev-server restart
|
|
23
|
+
* re-seeds it.
|
|
24
|
+
*
|
|
25
|
+
* A hit still reads `.git/HEAD`, so a workspace removed, re-cloned onto another branch, or
|
|
26
|
+
* caught mid-clone by another process (HEAD still on the base branch) misses and runs the full
|
|
27
|
+
* path, guard and lock included.
|
|
28
|
+
*/
|
|
29
|
+
const ensuredSettingsWorkspaces = new Set();
|
|
30
|
+
function ensuredKey(options) {
|
|
31
|
+
return `${path.resolve(options.settingsRoot)}\0${options.branchName}`;
|
|
32
|
+
}
|
|
33
|
+
async function checkedOutOn(settingsRoot, branchName) {
|
|
34
|
+
try {
|
|
35
|
+
const head = await fs.readFile(path.join(settingsRoot, '.git', 'HEAD'), 'utf-8');
|
|
36
|
+
return head.trim() === `ref: refs/heads/${branchName}`;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
12
42
|
const SETTINGS_INIT_LOCK_DIR = '.settings-init';
|
|
13
43
|
const SETTINGS_INIT_LOCK_NAME = 'lock';
|
|
14
44
|
/**
|
|
@@ -54,8 +84,8 @@ async function settingsFilesPresent(settingsRoot) {
|
|
|
54
84
|
* branch name.
|
|
55
85
|
*
|
|
56
86
|
* Without it, GitManager.initializeWorkspace sees an existing .git, skips the
|
|
57
|
-
* clone, and calls createOrphanSettingsBranch(branchName); for
|
|
58
|
-
* git then runs `checkout --orphan <name>` + `rm -rf .` + an empty commit.
|
|
87
|
+
* clone, and calls createOrphanSettingsBranch(branchName); for a name neither the
|
|
88
|
+
* workspace nor its remote has, git then runs `checkout --orphan <name>` + `rm -rf .` + an empty commit.
|
|
59
89
|
* Orphan branches share no history, so that is not a migration — it
|
|
60
90
|
* PERMANENTLY WIPES permissions.json/groups.json with nothing to recover from.
|
|
61
91
|
* The trigger is almost always deploymentName / settingsBranch /
|
|
@@ -103,8 +133,9 @@ async function assertSettingsWorkspaceIdentity(options) {
|
|
|
103
133
|
`deploymentName, settingsBranch, or CANOPYCMS_DEPLOYMENT_NAME changed on a ` +
|
|
104
134
|
`deployment that already has a populated settings workspace. To resolve: restore ` +
|
|
105
135
|
`the previous value so this resolves back to ` +
|
|
106
|
-
`'${currentBranch ?? options.branchName}', or, if
|
|
107
|
-
`
|
|
136
|
+
`'${currentBranch ?? options.branchName}', or, if switching is genuinely intended, ` +
|
|
137
|
+
`move ${options.settingsRoot} aside manually first: the next start checks out ` +
|
|
138
|
+
`'${options.branchName}' from the remote, or starts it empty if the remote has none.`);
|
|
108
139
|
}
|
|
109
140
|
}
|
|
110
141
|
/**
|
|
@@ -122,6 +153,12 @@ export class SettingsWorkspaceManager {
|
|
|
122
153
|
this.config = config;
|
|
123
154
|
}
|
|
124
155
|
async ensureGitWorkspace(options) {
|
|
156
|
+
const key = ensuredKey(options);
|
|
157
|
+
if (ensuredSettingsWorkspaces.has(key)) {
|
|
158
|
+
if (await checkedOutOn(options.settingsRoot, options.branchName))
|
|
159
|
+
return;
|
|
160
|
+
ensuredSettingsWorkspaces.delete(key);
|
|
161
|
+
}
|
|
125
162
|
return log.timed('workspace', 'ensureGitWorkspace', async () => {
|
|
126
163
|
// Layer 1: In-memory lock (prevents redundant async calls within same process)
|
|
127
164
|
if (settingsInitLock) {
|
|
@@ -170,6 +207,7 @@ export class SettingsWorkspaceManager {
|
|
|
170
207
|
gitBotAuthorName: this.config.gitBotAuthorName,
|
|
171
208
|
gitBotAuthorEmail: this.config.gitBotAuthorEmail,
|
|
172
209
|
});
|
|
210
|
+
ensuredSettingsWorkspaces.add(key);
|
|
173
211
|
}
|
|
174
212
|
finally {
|
|
175
213
|
try {
|