@crombolo/transfer 0.0.0-stage → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/NOTICE +69 -0
- package/package.json +32 -4
- package/src/check.ts +552 -0
- package/src/export.ts +316 -0
- package/src/import.ts +331 -0
- package/src/index.ts +10 -0
- package/src/load.ts +360 -0
- package/src/model.ts +194 -0
- package/src/package.ts +84 -0
- package/src/references.ts +71 -0
- package/src/resolutions.ts +89 -0
- package/src/revert.ts +387 -0
- package/src/write.ts +220 -0
- package/README.md +0 -3
package/src/export.ts
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Database → bundle.
|
|
3
|
+
*
|
|
4
|
+
* Reads the selected subtrees through `DocumentRepository`'s own public reads,
|
|
5
|
+
* so the values a bundle carries are exactly the values the site would serve or
|
|
6
|
+
* show, converters and culture handling included.
|
|
7
|
+
*
|
|
8
|
+
* The snapshot rule: a published node carries its **published** values, because
|
|
9
|
+
* a bundle should hold what the source site actually serves; an unpublished one
|
|
10
|
+
* carries its draft and says so. `--drafts` takes the current draft throughout,
|
|
11
|
+
* for the case where the point is to move work in progress.
|
|
12
|
+
*/
|
|
13
|
+
import type { DocumentAggregate, DocumentValue } from '@crombolo/core'
|
|
14
|
+
import { ObjectTypes } from '@crombolo/core'
|
|
15
|
+
import {
|
|
16
|
+
ComponentRepository,
|
|
17
|
+
type ContentKind,
|
|
18
|
+
currentSchemaState,
|
|
19
|
+
type Db,
|
|
20
|
+
DocumentRepository,
|
|
21
|
+
type NodeRow,
|
|
22
|
+
} from '@crombolo/data'
|
|
23
|
+
import {
|
|
24
|
+
BUNDLE_FORMAT_VERSION,
|
|
25
|
+
type BundleBlob,
|
|
26
|
+
type BundleDependency,
|
|
27
|
+
type BundleKind,
|
|
28
|
+
type BundleNode,
|
|
29
|
+
type BundleSnapshot,
|
|
30
|
+
type BundleValue,
|
|
31
|
+
type ContentSet,
|
|
32
|
+
} from './model.ts'
|
|
33
|
+
import { referenceCandidates } from './references.ts'
|
|
34
|
+
|
|
35
|
+
/** The kinds a bundle carries, and the repository kind each reads through. */
|
|
36
|
+
const KINDS: Record<BundleKind, { objectType: string; contentKind: ContentKind }> = {
|
|
37
|
+
document: { objectType: ObjectTypes.Document, contentKind: 'document' },
|
|
38
|
+
media: { objectType: ObjectTypes.Media, contentKind: 'media' },
|
|
39
|
+
element: { objectType: ObjectTypes.Element, contentKind: 'element' },
|
|
40
|
+
blueprint: { objectType: ObjectTypes.DocumentBlueprint, contentKind: 'blueprint' },
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Editors that hold a file rather than a reference to one. */
|
|
44
|
+
const FILE_EDITORS = new Set(['Umbraco.ImageCropper', 'Umbraco.UploadField'])
|
|
45
|
+
|
|
46
|
+
export interface ExportOptions {
|
|
47
|
+
/** Resolved subtree roots, by key. */
|
|
48
|
+
roots: readonly NodeRow[]
|
|
49
|
+
/** What was typed, kept for the manifest. */
|
|
50
|
+
asGiven: readonly string[]
|
|
51
|
+
descendants: boolean
|
|
52
|
+
snapshot: BundleSnapshot
|
|
53
|
+
/** Include blueprints reached from the selection. */
|
|
54
|
+
blueprints: boolean
|
|
55
|
+
/**
|
|
56
|
+
* Whether the bundle is to carry its media files.
|
|
57
|
+
*
|
|
58
|
+
* The bytes are not read here: where media lives is the store's business, and
|
|
59
|
+
* this package knows nothing about it. The keys come back in `blobKeys`
|
|
60
|
+
* whatever this says, and the writer decides `included` from the bytes the
|
|
61
|
+
* caller actually managed to read.
|
|
62
|
+
*/
|
|
63
|
+
withBlobs: boolean
|
|
64
|
+
siteName: string
|
|
65
|
+
nodeId: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface ExportResult {
|
|
69
|
+
set: ContentSet
|
|
70
|
+
/** Media store keys the caller should copy when `withBlobs` is set. */
|
|
71
|
+
blobKeys: string[]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* `/media/ab12cd34/photo.jpg` → `ab12cd34/photo.jpg`, the store's own key.
|
|
76
|
+
* `MediaFiles.key` does this in `@crombolo/server`, but the prefix is a URL
|
|
77
|
+
* convention rather than store behaviour and this package must not depend on
|
|
78
|
+
* the composition root to read it.
|
|
79
|
+
*/
|
|
80
|
+
function storeKey(src: unknown): string | undefined {
|
|
81
|
+
const text = typeof src === 'string' ? src : undefined
|
|
82
|
+
if (!text) return undefined
|
|
83
|
+
const match = /^\/?media\/(.+)$/.exec(text)
|
|
84
|
+
return match?.[1]
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function blobKeysOf(values: readonly DocumentValue[]): string[] {
|
|
88
|
+
const found: string[] = []
|
|
89
|
+
for (const value of values) {
|
|
90
|
+
if (!value.editorAlias || !FILE_EDITORS.has(value.editorAlias)) continue
|
|
91
|
+
const raw = value.value
|
|
92
|
+
const src =
|
|
93
|
+
typeof raw === 'string'
|
|
94
|
+
? raw
|
|
95
|
+
: typeof raw === 'object' && raw !== null
|
|
96
|
+
? (raw as Record<string, unknown>).src
|
|
97
|
+
: undefined
|
|
98
|
+
const key = storeKey(src)
|
|
99
|
+
if (key) found.push(key)
|
|
100
|
+
}
|
|
101
|
+
return found
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function toBundleValue(value: DocumentValue, references: readonly string[]): BundleValue {
|
|
105
|
+
return {
|
|
106
|
+
property: value.alias,
|
|
107
|
+
culture: value.culture,
|
|
108
|
+
segment: value.segment,
|
|
109
|
+
editor: value.editorAlias ?? '',
|
|
110
|
+
value: value.value,
|
|
111
|
+
references: references.length > 0 ? [...references] : undefined,
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function toBundleNode(
|
|
116
|
+
node: NodeRow,
|
|
117
|
+
kind: BundleKind,
|
|
118
|
+
aggregate: DocumentAggregate,
|
|
119
|
+
parentKeyByNodeId: (id: number) => string | null,
|
|
120
|
+
componentAlias: string | null,
|
|
121
|
+
candidates: Map<DocumentValue, string[]>,
|
|
122
|
+
): BundleNode {
|
|
123
|
+
return {
|
|
124
|
+
key: node.key,
|
|
125
|
+
kind,
|
|
126
|
+
contentType: { key: aggregate.contentTypeKey, alias: aggregate.contentTypeAlias },
|
|
127
|
+
parent: parentKeyByNodeId(node.parentId),
|
|
128
|
+
sortOrder: node.sortOrder,
|
|
129
|
+
template: componentAlias,
|
|
130
|
+
variants: aggregate.variants.map((v) => ({
|
|
131
|
+
culture: v.culture,
|
|
132
|
+
segment: v.segment,
|
|
133
|
+
name: v.name,
|
|
134
|
+
published: v.state === 'Published' || v.state === 'PublishedPendingChanges',
|
|
135
|
+
})),
|
|
136
|
+
values: aggregate.values.map((v) => toBundleValue(v, candidates.get(v) ?? [])),
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Every node of the selection, roots first and parents before children. */
|
|
141
|
+
async function selected(
|
|
142
|
+
db: Db,
|
|
143
|
+
options: ExportOptions,
|
|
144
|
+
): Promise<Array<{ node: NodeRow; kind: BundleKind }>> {
|
|
145
|
+
const byKind = new Map<string, BundleKind>(
|
|
146
|
+
Object.entries(KINDS).map(([kind, v]) => [v.objectType, kind as BundleKind]),
|
|
147
|
+
)
|
|
148
|
+
const wanted: string[] = [ObjectTypes.Document, ObjectTypes.Media, ObjectTypes.Element]
|
|
149
|
+
if (options.blueprints) wanted.push(ObjectTypes.DocumentBlueprint)
|
|
150
|
+
|
|
151
|
+
const found = new Map<number, { node: NodeRow; kind: BundleKind }>()
|
|
152
|
+
const nodes = new DocumentRepository(db).nodes
|
|
153
|
+
for (const root of options.roots) {
|
|
154
|
+
const kind = byKind.get(root.objectType ?? '')
|
|
155
|
+
if (kind) found.set(root.id, { node: root, kind })
|
|
156
|
+
if (!options.descendants) continue
|
|
157
|
+
for (const descendant of await nodes.descendants(root, wanted)) {
|
|
158
|
+
const k = byKind.get(descendant.objectType ?? '')
|
|
159
|
+
if (k) found.set(descendant.id, { node: descendant, kind: k })
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return [...found.values()].sort((a, b) => a.node.level - b.node.level || a.node.id - b.node.id)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function exportBundle(db: Db, options: ExportOptions): Promise<ExportResult> {
|
|
166
|
+
const chosen = await selected(db, options)
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Read at the state the database is at, not at the repository's default.
|
|
170
|
+
*
|
|
171
|
+
* A repository constructed without a `nodeState` runs at the install
|
|
172
|
+
* baseline, and reads are as-of that state — so `visibleStateIdFor` finds no
|
|
173
|
+
* state row at or below it and every value is filtered out. The export then
|
|
174
|
+
* succeeds and carries nothing, which is the worst possible failure. A tool
|
|
175
|
+
* reading content is not a node asserting a deployment; it takes the
|
|
176
|
+
* database's own state.
|
|
177
|
+
*/
|
|
178
|
+
const state = await currentSchemaState(db)
|
|
179
|
+
const nodeState = { version: state.version, revision: state.revision }
|
|
180
|
+
|
|
181
|
+
const repos = new Map<ContentKind, DocumentRepository>()
|
|
182
|
+
const repoFor = (kind: BundleKind): DocumentRepository => {
|
|
183
|
+
const contentKind = KINDS[kind].contentKind
|
|
184
|
+
const existing = repos.get(contentKind)
|
|
185
|
+
if (existing) return existing
|
|
186
|
+
const repo = new DocumentRepository(db, { kind: contentKind, nodeState })
|
|
187
|
+
repos.set(contentKind, repo)
|
|
188
|
+
return repo
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const nodes = new DocumentRepository(db).nodes
|
|
192
|
+
const templateRepo = new ComponentRepository(db)
|
|
193
|
+
const keyOfNodeId = new Map<number, string>()
|
|
194
|
+
for (const { node } of chosen) keyOfNodeId.set(node.id, node.key)
|
|
195
|
+
|
|
196
|
+
const bundleNodes: BundleNode[] = []
|
|
197
|
+
const blobs: BundleBlob[] = []
|
|
198
|
+
const referenced = new Set<string>()
|
|
199
|
+
const contentTypes = new Map<string, string>()
|
|
200
|
+
const templates = new Set<string>()
|
|
201
|
+
const languages = new Set<string>()
|
|
202
|
+
const counts: Partial<Record<BundleKind, number>> = {}
|
|
203
|
+
|
|
204
|
+
for (const { node, kind } of chosen) {
|
|
205
|
+
const repo = repoFor(kind)
|
|
206
|
+
// Media is never published, so there is no published snapshot to prefer.
|
|
207
|
+
const published =
|
|
208
|
+
options.snapshot === 'published' && kind !== 'media'
|
|
209
|
+
? await repo.byKeyPublished(node.key)
|
|
210
|
+
: undefined
|
|
211
|
+
const aggregate = published ?? (await repo.byKey(node.key))
|
|
212
|
+
if (!aggregate) continue
|
|
213
|
+
|
|
214
|
+
const candidates = new Map<DocumentValue, string[]>()
|
|
215
|
+
for (const value of aggregate.values) {
|
|
216
|
+
const keys = referenceCandidates(value.value)
|
|
217
|
+
if (keys.length > 0) candidates.set(value, keys)
|
|
218
|
+
for (const key of keys) referenced.add(key)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// The parent travels as a key only when it is in the bundle; otherwise it is
|
|
222
|
+
// an expected dependency, which the destination must already have. The
|
|
223
|
+
// system containers — the tree root and the recycle bins — have real `node`
|
|
224
|
+
// rows with keys of their own, and a node sitting under one has no parent as
|
|
225
|
+
// far as a bundle is concerned.
|
|
226
|
+
const parentKey =
|
|
227
|
+
node.parentId <= 0
|
|
228
|
+
? null
|
|
229
|
+
: (keyOfNodeId.get(node.parentId) ?? (await nodes.byId(node.parentId))?.key ?? null)
|
|
230
|
+
// A template's definition is its file, so the alias travels and the key does
|
|
231
|
+
// not: the same view has a different key in every environment.
|
|
232
|
+
const componentAlias = aggregate.componentKey
|
|
233
|
+
? ((await templateRepo.byKey(aggregate.componentKey))?.alias ?? null)
|
|
234
|
+
: null
|
|
235
|
+
|
|
236
|
+
bundleNodes.push(
|
|
237
|
+
toBundleNode(node, kind, aggregate, () => parentKey, componentAlias, candidates),
|
|
238
|
+
)
|
|
239
|
+
counts[kind] = (counts[kind] ?? 0) + 1
|
|
240
|
+
contentTypes.set(aggregate.contentTypeAlias, aggregate.contentTypeKey)
|
|
241
|
+
if (componentAlias) templates.add(componentAlias)
|
|
242
|
+
for (const variant of aggregate.variants) if (variant.culture) languages.add(variant.culture)
|
|
243
|
+
if (kind === 'media')
|
|
244
|
+
for (const key of blobKeysOf(aggregate.values))
|
|
245
|
+
blobs.push({ key, node: node.key, etag: null, size: null, included: false })
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const carried = new Set(bundleNodes.map((n) => n.key))
|
|
249
|
+
|
|
250
|
+
// Anything referenced or parented that the bundle does not carry must already
|
|
251
|
+
// exist at the destination. A candidate that resolves to no node here was
|
|
252
|
+
// never a reference — an undashed uuid is indistinguishable from any other 32
|
|
253
|
+
// hex characters — so it is dropped rather than demanded.
|
|
254
|
+
const outside = [...referenced].filter((key) => !carried.has(key))
|
|
255
|
+
const expected: BundleDependency[] = []
|
|
256
|
+
for (const row of await nodes.byKeys(outside)) {
|
|
257
|
+
expected.push({
|
|
258
|
+
key: row.key,
|
|
259
|
+
objectType: row.objectType ?? '',
|
|
260
|
+
name: row.text ?? '',
|
|
261
|
+
why: 'referenced by a value in this bundle',
|
|
262
|
+
})
|
|
263
|
+
}
|
|
264
|
+
for (const node of bundleNodes) {
|
|
265
|
+
if (!node.parent || carried.has(node.parent)) continue
|
|
266
|
+
if (expected.some((e) => e.key === node.parent)) continue
|
|
267
|
+
const row = await nodes.byKey(node.parent)
|
|
268
|
+
if (row)
|
|
269
|
+
expected.push({
|
|
270
|
+
key: row.key,
|
|
271
|
+
objectType: row.objectType ?? '',
|
|
272
|
+
name: row.text ?? '',
|
|
273
|
+
why: `parent of ${node.variants[0]?.name ?? node.key}`,
|
|
274
|
+
})
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
set: {
|
|
279
|
+
manifest: {
|
|
280
|
+
formatVersion: BUNDLE_FORMAT_VERSION,
|
|
281
|
+
id: crypto.randomUUID(),
|
|
282
|
+
createdAt: new Date().toISOString(),
|
|
283
|
+
createdBy: options.nodeId,
|
|
284
|
+
// writeBundle recomputes this over the node files it emits.
|
|
285
|
+
integrity: '',
|
|
286
|
+
snapshot: options.snapshot,
|
|
287
|
+
provenance: {
|
|
288
|
+
siteName: options.siteName,
|
|
289
|
+
schemaVersion: state.version,
|
|
290
|
+
schemaRevision: state.revision,
|
|
291
|
+
schemaHash: state.hash,
|
|
292
|
+
},
|
|
293
|
+
selector: {
|
|
294
|
+
roots: options.roots.map((r) => r.key),
|
|
295
|
+
descendants: options.descendants,
|
|
296
|
+
asGiven: [...options.asGiven],
|
|
297
|
+
},
|
|
298
|
+
counts,
|
|
299
|
+
dependencies: {
|
|
300
|
+
carried: [...carried],
|
|
301
|
+
expected,
|
|
302
|
+
schema: {
|
|
303
|
+
contentTypes: [...contentTypes].map(([alias, key]) => ({ key, alias })),
|
|
304
|
+
components: [...templates],
|
|
305
|
+
languages: [...languages],
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
blobs,
|
|
309
|
+
},
|
|
310
|
+
nodes: bundleNodes,
|
|
311
|
+
},
|
|
312
|
+
// Every key, carried or not: a caller that is not carrying them still has
|
|
313
|
+
// to be able to say how many files this bundle expects to find elsewhere.
|
|
314
|
+
blobKeys: [...new Set(blobs.map((b) => b.key))],
|
|
315
|
+
}
|
|
316
|
+
}
|
package/src/import.ts
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bundle → database.
|
|
3
|
+
*
|
|
4
|
+
* Four properties the implementation is shaped around, in order of how badly
|
|
5
|
+
* getting them wrong would hurt:
|
|
6
|
+
*
|
|
7
|
+
* 1. **Everything goes through `DocumentRepository`.** `is_current` is
|
|
8
|
+
* maintained on insert by the repository's own write path; a writer reaching
|
|
9
|
+
* into `property_value` would corrupt the flag that every hot-path read
|
|
10
|
+
* depends on.
|
|
11
|
+
* 2. **Values are an overlay, never a replacement.** `#appendValues` treats what
|
|
12
|
+
* it is given as the complete set and *clears* anything current that is
|
|
13
|
+
* absent from it. So the values handed to `update` are this node's current
|
|
14
|
+
* values with the bundle's laid over them — otherwise an import silently
|
|
15
|
+
* empties every property the bundle does not carry.
|
|
16
|
+
* 3. **One lock and one transaction for the whole run**, so a failure anywhere
|
|
17
|
+
* leaves nothing behind. `inTreeLock` on the repositories is what makes that
|
|
18
|
+
* possible without deadlocking on re-entry.
|
|
19
|
+
* 4. **Two passes.** Every node is created before any value is written, so a
|
|
20
|
+
* page that links to a page that links back needs no ordering cleverness.
|
|
21
|
+
*
|
|
22
|
+
* Sibling order needs no handling of its own: nodes are created in the bundle's
|
|
23
|
+
* own order within a parent, and `nodes.create` appends, so new siblings land
|
|
24
|
+
* in bundle order after whatever was already there. The bundle's `sortOrder` is
|
|
25
|
+
* therefore not replayed — appending is the only sane answer when the
|
|
26
|
+
* destination already has children, and it is what this gives.
|
|
27
|
+
*
|
|
28
|
+
* Idempotence falls out of (2): the repository appends only what differs, so
|
|
29
|
+
* importing the same bundle twice writes no values the second time.
|
|
30
|
+
*/
|
|
31
|
+
import type { DocumentValue } from '@crombolo/core'
|
|
32
|
+
import {
|
|
33
|
+
appendCacheInstruction,
|
|
34
|
+
ComponentRepository,
|
|
35
|
+
type ContentKind,
|
|
36
|
+
ContentTypeRepository,
|
|
37
|
+
currentSchemaState,
|
|
38
|
+
type Db,
|
|
39
|
+
DocumentRepository,
|
|
40
|
+
Locks,
|
|
41
|
+
PublishBlockedError,
|
|
42
|
+
type TransferAction,
|
|
43
|
+
TransferRunRepository,
|
|
44
|
+
} from '@crombolo/data'
|
|
45
|
+
import { type CheckOptions, checkBundle, type NodePlan, type TransferCheck } from './check.ts'
|
|
46
|
+
import type { BundleKind, BundleNode, ContentSet } from './model.ts'
|
|
47
|
+
|
|
48
|
+
const CONTENT_KIND: Record<BundleKind, ContentKind> = {
|
|
49
|
+
document: 'document',
|
|
50
|
+
media: 'media',
|
|
51
|
+
element: 'element',
|
|
52
|
+
blueprint: 'blueprint',
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ImportOptions extends CheckOptions {
|
|
56
|
+
/** Publish what the bundle says was live at the source. Without it, everything lands as a draft. */
|
|
57
|
+
publish?: boolean
|
|
58
|
+
/** Recorded as who applied the run. */
|
|
59
|
+
nodeId?: string
|
|
60
|
+
/** A human label for the run listing. */
|
|
61
|
+
label?: string | undefined
|
|
62
|
+
/** The npm package the bundle arrived in, recorded on the run. */
|
|
63
|
+
package?: { name: string; version: string } | undefined
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ImportResult {
|
|
67
|
+
/** Undefined when the run was refused: nothing was written. */
|
|
68
|
+
runId: string | undefined
|
|
69
|
+
check: TransferCheck
|
|
70
|
+
applied: NodePlan[]
|
|
71
|
+
/** Nodes published, by key. */
|
|
72
|
+
published: string[]
|
|
73
|
+
/** Set when `--publish` was asked for and a business rule refused one. */
|
|
74
|
+
publishFailures: Array<{ key: string; reason: string }>
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Parents before children, for whatever the bundle carries. */
|
|
78
|
+
function inTreeOrder(nodes: readonly BundleNode[]): BundleNode[] {
|
|
79
|
+
const byKey = new Map(nodes.map((n) => [n.key, n]))
|
|
80
|
+
const done = new Set<string>()
|
|
81
|
+
const ordered: BundleNode[] = []
|
|
82
|
+
const visit = (node: BundleNode, seen: Set<string>): void => {
|
|
83
|
+
if (done.has(node.key) || seen.has(node.key)) return
|
|
84
|
+
seen.add(node.key)
|
|
85
|
+
const parent = node.parent ? byKey.get(node.parent) : undefined
|
|
86
|
+
if (parent) visit(parent, seen)
|
|
87
|
+
if (done.has(node.key)) return
|
|
88
|
+
done.add(node.key)
|
|
89
|
+
ordered.push(node)
|
|
90
|
+
}
|
|
91
|
+
for (const node of nodes) visit(node, new Set())
|
|
92
|
+
return ordered
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The bundle's values laid over what is here, keyed as the database keys them. */
|
|
96
|
+
function overlay(current: readonly DocumentValue[], node: BundleNode): DocumentValue[] {
|
|
97
|
+
const at = (v: { culture: string | null; segment: string | null }, alias: string) =>
|
|
98
|
+
`${alias}|${v.culture ?? ''}|${v.segment ?? ''}`
|
|
99
|
+
const merged = new Map<string, DocumentValue>()
|
|
100
|
+
for (const value of current) merged.set(at(value, value.alias), value)
|
|
101
|
+
for (const value of node.values)
|
|
102
|
+
merged.set(at(value, value.property), {
|
|
103
|
+
alias: value.property,
|
|
104
|
+
culture: value.culture,
|
|
105
|
+
segment: value.segment,
|
|
106
|
+
value: value.value,
|
|
107
|
+
})
|
|
108
|
+
return [...merged.values()]
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function importBundle(
|
|
112
|
+
db: Db,
|
|
113
|
+
set: ContentSet,
|
|
114
|
+
options: ImportOptions = {},
|
|
115
|
+
): Promise<ImportResult> {
|
|
116
|
+
return db.locks.withLock(Locks.ContentTree, async () =>
|
|
117
|
+
db.transaction(async (tx) => {
|
|
118
|
+
// The check runs again inside the lock, over the tree as it is now, so a
|
|
119
|
+
// decision made against a stale read cannot slip through. Same discipline
|
|
120
|
+
// `upgrade` uses before it cuts over.
|
|
121
|
+
const check = await checkBundle(tx, set, options)
|
|
122
|
+
if (check.outstanding.length > 0)
|
|
123
|
+
return {
|
|
124
|
+
runId: undefined,
|
|
125
|
+
check,
|
|
126
|
+
applied: [],
|
|
127
|
+
published: [],
|
|
128
|
+
publishFailures: [],
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const state = await currentSchemaState(tx)
|
|
132
|
+
const nodeState = { version: state.version, revision: state.revision }
|
|
133
|
+
const runs = new TransferRunRepository(tx)
|
|
134
|
+
const types = new ContentTypeRepository(tx)
|
|
135
|
+
// A media node's type is a different object type, invisible to a
|
|
136
|
+
// repository reading content types. See the same pairing in `check.ts`.
|
|
137
|
+
const mediaTypes = new ContentTypeRepository(tx, { kind: 'media' })
|
|
138
|
+
const typesFor = (kind: BundleKind): ContentTypeRepository =>
|
|
139
|
+
CONTENT_KIND[kind] === 'media' ? mediaTypes : types
|
|
140
|
+
const templates = new ComponentRepository(tx)
|
|
141
|
+
|
|
142
|
+
const repos = new Map<ContentKind, DocumentRepository>()
|
|
143
|
+
const repoFor = (kind: BundleKind): DocumentRepository => {
|
|
144
|
+
const contentKind = CONTENT_KIND[kind]
|
|
145
|
+
const existing = repos.get(contentKind)
|
|
146
|
+
if (existing) return existing
|
|
147
|
+
const repo = new DocumentRepository(tx, {
|
|
148
|
+
kind: contentKind,
|
|
149
|
+
nodeState,
|
|
150
|
+
nodeId: options.nodeId,
|
|
151
|
+
// One lock for the run, taken above.
|
|
152
|
+
inTreeLock: true,
|
|
153
|
+
})
|
|
154
|
+
repos.set(contentKind, repo)
|
|
155
|
+
return repo
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const runId = crypto.randomUUID()
|
|
159
|
+
await runs.start({
|
|
160
|
+
id: runId,
|
|
161
|
+
bundleId: set.manifest.id,
|
|
162
|
+
bundleLabel: options.label ?? null,
|
|
163
|
+
packageName: options.package?.name ?? null,
|
|
164
|
+
packageVersion: options.package?.version ?? null,
|
|
165
|
+
direction: 'import',
|
|
166
|
+
revertsRunId: null,
|
|
167
|
+
schemaStateId: state.id,
|
|
168
|
+
startedAt: new Date(),
|
|
169
|
+
appliedBy: options.nodeId ?? null,
|
|
170
|
+
nodeCount: 0,
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
const planByKey = new Map(check.plan.map((p) => [p.key, p]))
|
|
174
|
+
const ordered = inTreeOrder(set.nodes).filter(
|
|
175
|
+
(node) => planByKey.get(node.key)?.action !== 'skip',
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
// What each node was before this run, read before anything is written:
|
|
179
|
+
// the two numbers a revert needs to put the site back.
|
|
180
|
+
const before = new Map<string, { head: number | undefined; published: number | undefined }>()
|
|
181
|
+
for (const node of ordered) {
|
|
182
|
+
const markers = await repoFor(node.kind).eventMarkers(node.key)
|
|
183
|
+
if (markers) before.set(node.key, markers)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Pass one: every node exists, with its name, and nothing else. A value
|
|
187
|
+
// written in pass two can therefore refer to any node in the bundle.
|
|
188
|
+
const created = new Set<string>()
|
|
189
|
+
/** Where each created node actually landed, which the sort below groups by. */
|
|
190
|
+
const landedUnder = new Map<string, string | null>()
|
|
191
|
+
for (const node of ordered) {
|
|
192
|
+
if (before.has(node.key)) continue
|
|
193
|
+
const repo = repoFor(node.kind)
|
|
194
|
+
const forKind = typesFor(node.kind)
|
|
195
|
+
const type =
|
|
196
|
+
(node.contentType.key ? await forKind.byKey(node.contentType.key) : undefined) ??
|
|
197
|
+
(await forKind.byAlias(node.contentType.alias))
|
|
198
|
+
// The check raises `missing-content-type` for this and refuses the run,
|
|
199
|
+
// so reaching here means the two disagree. Throwing rolls the whole
|
|
200
|
+
// transaction back; skipping the node would import part of the bundle
|
|
201
|
+
// and report success.
|
|
202
|
+
if (!type)
|
|
203
|
+
throw new Error(
|
|
204
|
+
`No content type "${node.contentType.alias}" for "${node.key}", which the check should have refused.`,
|
|
205
|
+
)
|
|
206
|
+
const parentKey =
|
|
207
|
+
node.parent && !created.has(node.parent) && !before.has(node.parent)
|
|
208
|
+
? (options.under?.key ?? null)
|
|
209
|
+
: (node.parent ?? options.under?.key ?? null)
|
|
210
|
+
await repo.create({
|
|
211
|
+
key: node.key,
|
|
212
|
+
contentTypeKey: type.key,
|
|
213
|
+
componentKey: node.template
|
|
214
|
+
? ((await templates.byAlias(node.template))?.key ?? null)
|
|
215
|
+
: null,
|
|
216
|
+
parentKey,
|
|
217
|
+
values: [],
|
|
218
|
+
variants: node.variants.map((v) => ({
|
|
219
|
+
culture: v.culture,
|
|
220
|
+
segment: v.segment,
|
|
221
|
+
name: v.name,
|
|
222
|
+
})),
|
|
223
|
+
})
|
|
224
|
+
created.add(node.key)
|
|
225
|
+
landedUnder.set(node.key, parentKey)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// The order a bundle's nodes sit in among their siblings travels with it.
|
|
229
|
+
// Without this they keep the order the run happened to create them in,
|
|
230
|
+
// which is a hash order, not an editor's — a site's navigation arrives
|
|
231
|
+
// shuffled. Only the nodes this run created are placed, and only into the
|
|
232
|
+
// slots they already occupy, so siblings that were here keep theirs.
|
|
233
|
+
const newByParent = new Map<string | null, BundleNode[]>()
|
|
234
|
+
for (const node of ordered) {
|
|
235
|
+
if (!created.has(node.key) || node.kind !== 'document') continue
|
|
236
|
+
const parent = landedUnder.get(node.key) ?? null
|
|
237
|
+
const group = newByParent.get(parent)
|
|
238
|
+
if (group) group.push(node)
|
|
239
|
+
else newByParent.set(parent, [node])
|
|
240
|
+
}
|
|
241
|
+
for (const [parent, group] of newByParent) {
|
|
242
|
+
if (group.length < 2) continue
|
|
243
|
+
const documents = repoFor('document')
|
|
244
|
+
const rows = await documents.nodes.byKeys(group.map((n) => n.key))
|
|
245
|
+
const slots = rows.map((row) => row.sortOrder).sort((a, b) => a - b)
|
|
246
|
+
const wanted = [...group].sort((a, b) => a.sortOrder - b.sortOrder)
|
|
247
|
+
await documents.sort(
|
|
248
|
+
parent,
|
|
249
|
+
wanted.flatMap((node, index) => {
|
|
250
|
+
const sortOrder = slots[index]
|
|
251
|
+
return sortOrder === undefined ? [] : [{ key: node.key, sortOrder }]
|
|
252
|
+
}),
|
|
253
|
+
)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Pass two: the values, overlaid onto whatever is here.
|
|
257
|
+
const applied: NodePlan[] = []
|
|
258
|
+
for (const node of ordered) {
|
|
259
|
+
const plan = planByKey.get(node.key)
|
|
260
|
+
if (plan?.resolution === 'keep-local') {
|
|
261
|
+
applied.push(plan)
|
|
262
|
+
continue
|
|
263
|
+
}
|
|
264
|
+
const repo = repoFor(node.kind)
|
|
265
|
+
const current = await repo.byKey(node.key)
|
|
266
|
+
if (!current) continue
|
|
267
|
+
await repo.update({
|
|
268
|
+
key: node.key,
|
|
269
|
+
contentTypeKey: current.contentTypeKey,
|
|
270
|
+
componentKey: node.template
|
|
271
|
+
? ((await templates.byAlias(node.template))?.key ?? current.componentKey)
|
|
272
|
+
: current.componentKey,
|
|
273
|
+
parentKey: current.parentKey,
|
|
274
|
+
values: overlay(current.values, node),
|
|
275
|
+
variants: node.variants.map((v) => ({
|
|
276
|
+
culture: v.culture,
|
|
277
|
+
segment: v.segment,
|
|
278
|
+
name: v.name,
|
|
279
|
+
})),
|
|
280
|
+
})
|
|
281
|
+
if (plan) applied.push(plan)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Publishing is last, and parents first: an unpublished ancestor makes a
|
|
285
|
+
// published descendant unreachable, so the repository refuses it.
|
|
286
|
+
const published: string[] = []
|
|
287
|
+
const publishFailures: Array<{ key: string; reason: string }> = []
|
|
288
|
+
if (options.publish) {
|
|
289
|
+
for (const node of ordered) {
|
|
290
|
+
const live = node.variants.filter((v) => v.published)
|
|
291
|
+
if (live.length === 0 || node.kind === 'media') continue
|
|
292
|
+
const cultures = live.map((v) => v.culture).filter((c): c is string => c !== null)
|
|
293
|
+
try {
|
|
294
|
+
await repoFor(node.kind).publish(node.key, cultures.length > 0 ? cultures : null)
|
|
295
|
+
published.push(node.key)
|
|
296
|
+
} catch (error) {
|
|
297
|
+
if (!(error instanceof PublishBlockedError)) throw error
|
|
298
|
+
publishFailures.push({ key: node.key, reason: error.message })
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
for (const node of ordered) {
|
|
304
|
+
const markers = await repoFor(node.kind).eventMarkers(node.key)
|
|
305
|
+
const was = before.get(node.key)
|
|
306
|
+
const action: TransferAction = !was
|
|
307
|
+
? 'create'
|
|
308
|
+
: (planByKey.get(node.key)?.action ?? 'unchanged')
|
|
309
|
+
await runs.record({
|
|
310
|
+
runId,
|
|
311
|
+
nodeKey: node.key,
|
|
312
|
+
kind: node.kind,
|
|
313
|
+
action,
|
|
314
|
+
beforeEventId: was?.head ?? null,
|
|
315
|
+
beforePublishedEventId: was?.published ?? null,
|
|
316
|
+
eventId: markers?.head ?? null,
|
|
317
|
+
})
|
|
318
|
+
}
|
|
319
|
+
await runs.finish(runId, ordered.length)
|
|
320
|
+
|
|
321
|
+
// Other nodes drop their published cache; this one already has.
|
|
322
|
+
await appendCacheInstruction(tx, {
|
|
323
|
+
kind: 'content',
|
|
324
|
+
payload: { bundle: set.manifest.id },
|
|
325
|
+
by: options.nodeId,
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
return { runId, check, applied, published, publishFailures }
|
|
329
|
+
}),
|
|
330
|
+
)
|
|
331
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export * from './check.ts'
|
|
2
|
+
export * from './export.ts'
|
|
3
|
+
export * from './import.ts'
|
|
4
|
+
export * from './load.ts'
|
|
5
|
+
export * from './model.ts'
|
|
6
|
+
export * from './package.ts'
|
|
7
|
+
export * from './references.ts'
|
|
8
|
+
export * from './resolutions.ts'
|
|
9
|
+
export * from './revert.ts'
|
|
10
|
+
export * from './write.ts'
|