@crombolo/transfer 0.11.0 → 0.13.1
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/package.json +3 -3
- package/src/check.ts +221 -28
- package/src/export.ts +46 -0
- package/src/import.ts +331 -193
- package/src/index.ts +3 -0
- package/src/load.ts +166 -2
- package/src/model.ts +59 -1
- package/src/package.ts +17 -0
- package/src/people-apply.ts +572 -0
- package/src/people.ts +167 -0
- package/src/resolutions.ts +69 -7
- package/src/revert.ts +44 -5
- package/src/seal.ts +129 -0
- package/src/write.ts +173 -22
package/src/import.ts
CHANGED
|
@@ -37,13 +37,23 @@ import {
|
|
|
37
37
|
currentSchemaState,
|
|
38
38
|
type Db,
|
|
39
39
|
DocumentRepository,
|
|
40
|
+
insideTransaction,
|
|
40
41
|
Locks,
|
|
42
|
+
PublicAccessRepository,
|
|
41
43
|
PublishBlockedError,
|
|
44
|
+
RedirectRepository,
|
|
42
45
|
type TransferAction,
|
|
43
46
|
TransferRunRepository,
|
|
44
47
|
} from '@crombolo/data'
|
|
45
|
-
import {
|
|
48
|
+
import {
|
|
49
|
+
type CheckOptions,
|
|
50
|
+
checkBundle,
|
|
51
|
+
type NodePlan,
|
|
52
|
+
propertyHandle,
|
|
53
|
+
type TransferCheck,
|
|
54
|
+
} from './check.ts'
|
|
46
55
|
import type { BundleKind, BundleNode, ContentSet } from './model.ts'
|
|
56
|
+
import { type AppliedPerson, applyPeople } from './people-apply.ts'
|
|
47
57
|
|
|
48
58
|
const CONTENT_KIND: Record<BundleKind, ContentKind> = {
|
|
49
59
|
document: 'document',
|
|
@@ -72,6 +82,10 @@ export interface ImportResult {
|
|
|
72
82
|
published: string[]
|
|
73
83
|
/** Set when `--publish` was asked for and a business rule refused one. */
|
|
74
84
|
publishFailures: Array<{ key: string; reason: string }>
|
|
85
|
+
/** Members, users and groups created or updated. */
|
|
86
|
+
people: AppliedPerson[]
|
|
87
|
+
/** Protected pages, schedules and redirects written. */
|
|
88
|
+
rules: { access: number; schedules: number; redirects: number }
|
|
75
89
|
}
|
|
76
90
|
|
|
77
91
|
/** Parents before children, for whatever the bundle carries. */
|
|
@@ -114,218 +128,342 @@ export async function importBundle(
|
|
|
114
128
|
options: ImportOptions = {},
|
|
115
129
|
): Promise<ImportResult> {
|
|
116
130
|
return db.locks.withLock(Locks.ContentTree, async () =>
|
|
117
|
-
db.transaction(
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
}
|
|
131
|
+
db.transaction((tx) => applyBundle(tx, set, options)),
|
|
132
|
+
)
|
|
133
|
+
}
|
|
130
134
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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)
|
|
135
|
+
/** Thrown to roll a rehearsal back, carrying what the run would have done. */
|
|
136
|
+
class Rehearsed extends Error {
|
|
137
|
+
constructor(readonly result: ImportResult) {
|
|
138
|
+
super('A rehearsal is always rolled back.')
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
141
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
142
|
+
/**
|
|
143
|
+
* Which pages `--publish` would refuse, and why, found by doing the import and
|
|
144
|
+
* rolling it back: the rules and their wording are the import's own. Media
|
|
145
|
+
* files are not placed, since that happens outside the database. Undefined
|
|
146
|
+
* while the check has something outstanding, because the import would stop
|
|
147
|
+
* there and publish nothing.
|
|
148
|
+
*/
|
|
149
|
+
export async function rehearsePublish(
|
|
150
|
+
db: Db,
|
|
151
|
+
set: ContentSet,
|
|
152
|
+
options: ImportOptions = {},
|
|
153
|
+
): Promise<Array<{ key: string; reason: string }> | undefined> {
|
|
154
|
+
// Inside an enclosing transaction this one would join it, and catching the
|
|
155
|
+
// throw would leave every write in place. A rehearsal must be its own.
|
|
156
|
+
if (insideTransaction()) throw new Error('A rehearsal cannot run inside another transaction.')
|
|
157
|
+
try {
|
|
158
|
+
await db.locks.withLock(Locks.ContentTree, () =>
|
|
159
|
+
db.transaction(async (tx) => {
|
|
160
|
+
throw new Rehearsed(await applyBundle(tx, set, { ...options, publish: true }))
|
|
161
|
+
}),
|
|
162
|
+
)
|
|
163
|
+
} catch (error) {
|
|
164
|
+
if (!(error instanceof Rehearsed)) throw error
|
|
165
|
+
return error.result.runId ? error.result.publishFailures : undefined
|
|
166
|
+
}
|
|
167
|
+
return undefined
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function applyBundle(tx: Db, set: ContentSet, options: ImportOptions): Promise<ImportResult> {
|
|
171
|
+
{
|
|
172
|
+
// The check runs again inside the lock, over the tree as it is now, so a
|
|
173
|
+
// decision made against a stale read cannot slip through. Same discipline
|
|
174
|
+
// `upgrade` uses before it cuts over.
|
|
175
|
+
const check = await checkBundle(tx, set, options)
|
|
176
|
+
if (check.outstanding.length > 0)
|
|
177
|
+
return {
|
|
178
|
+
runId: undefined,
|
|
179
|
+
check,
|
|
180
|
+
applied: [],
|
|
181
|
+
published: [],
|
|
182
|
+
publishFailures: [],
|
|
183
|
+
people: [],
|
|
184
|
+
rules: { access: 0, schedules: 0, redirects: 0 },
|
|
156
185
|
}
|
|
157
186
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
187
|
+
const state = await currentSchemaState(tx)
|
|
188
|
+
const nodeState = { version: state.version, revision: state.revision }
|
|
189
|
+
const runs = new TransferRunRepository(tx)
|
|
190
|
+
const types = new ContentTypeRepository(tx)
|
|
191
|
+
// A media node's type is a different object type, invisible to a
|
|
192
|
+
// repository reading content types. See the same pairing in `check.ts`.
|
|
193
|
+
const mediaTypes = new ContentTypeRepository(tx, { kind: 'media' })
|
|
194
|
+
const typesFor = (kind: BundleKind): ContentTypeRepository =>
|
|
195
|
+
CONTENT_KIND[kind] === 'media' ? mediaTypes : types
|
|
196
|
+
const templates = new ComponentRepository(tx)
|
|
197
|
+
|
|
198
|
+
const repos = new Map<ContentKind, DocumentRepository>()
|
|
199
|
+
const repoFor = (kind: BundleKind): DocumentRepository => {
|
|
200
|
+
const contentKind = CONTENT_KIND[kind]
|
|
201
|
+
const existing = repos.get(contentKind)
|
|
202
|
+
if (existing) return existing
|
|
203
|
+
const repo = new DocumentRepository(tx, {
|
|
204
|
+
kind: contentKind,
|
|
205
|
+
nodeState,
|
|
206
|
+
nodeId: options.nodeId,
|
|
207
|
+
// One lock for the run, taken above.
|
|
208
|
+
inTreeLock: true,
|
|
171
209
|
})
|
|
210
|
+
repos.set(contentKind, repo)
|
|
211
|
+
return repo
|
|
212
|
+
}
|
|
172
213
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
214
|
+
const runId = crypto.randomUUID()
|
|
215
|
+
await runs.start({
|
|
216
|
+
id: runId,
|
|
217
|
+
bundleId: set.manifest.id,
|
|
218
|
+
bundleLabel: options.label ?? null,
|
|
219
|
+
packageName: options.package?.name ?? null,
|
|
220
|
+
packageVersion: options.package?.version ?? null,
|
|
221
|
+
direction: 'import',
|
|
222
|
+
revertsRunId: null,
|
|
223
|
+
schemaStateId: state.id,
|
|
224
|
+
startedAt: new Date(),
|
|
225
|
+
appliedBy: options.nodeId ?? null,
|
|
226
|
+
nodeCount: 0,
|
|
227
|
+
})
|
|
177
228
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
const markers = await repoFor(node.kind).eventMarkers(node.key)
|
|
183
|
-
if (markers) before.set(node.key, markers)
|
|
184
|
-
}
|
|
229
|
+
const planByKey = new Map(check.plan.map((p) => [p.key, p]))
|
|
230
|
+
const ordered = inTreeOrder(set.nodes).filter(
|
|
231
|
+
(node) => planByKey.get(node.key)?.action !== 'skip',
|
|
232
|
+
)
|
|
185
233
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
}
|
|
234
|
+
// What each node was before this run, read before anything is written:
|
|
235
|
+
// the two numbers a revert needs to put the site back.
|
|
236
|
+
const before = new Map<string, { head: number | undefined; published: number | undefined }>()
|
|
237
|
+
for (const node of ordered) {
|
|
238
|
+
const markers = await repoFor(node.kind).eventMarkers(node.key)
|
|
239
|
+
if (markers) before.set(node.key, markers)
|
|
240
|
+
}
|
|
227
241
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
}),
|
|
242
|
+
// Pass one: every node exists, with its name, and nothing else. A value
|
|
243
|
+
// written in pass two can therefore refer to any node in the bundle.
|
|
244
|
+
const created = new Set<string>()
|
|
245
|
+
/** Where each created node actually landed, which the sort below groups by. */
|
|
246
|
+
const landedUnder = new Map<string, string | null>()
|
|
247
|
+
for (const node of ordered) {
|
|
248
|
+
if (before.has(node.key)) continue
|
|
249
|
+
const repo = repoFor(node.kind)
|
|
250
|
+
const forKind = typesFor(node.kind)
|
|
251
|
+
const type =
|
|
252
|
+
(node.contentType.key ? await forKind.byKey(node.contentType.key) : undefined) ??
|
|
253
|
+
(await forKind.byAlias(node.contentType.alias))
|
|
254
|
+
// The check raises `missing-content-type` for this and refuses the run,
|
|
255
|
+
// so reaching here means the two disagree. Throwing rolls the whole
|
|
256
|
+
// transaction back; skipping the node would import part of the bundle
|
|
257
|
+
// and report success.
|
|
258
|
+
if (!type)
|
|
259
|
+
throw new Error(
|
|
260
|
+
`No content type "${node.contentType.alias}" for "${node.key}", which the check should have refused.`,
|
|
253
261
|
)
|
|
262
|
+
const parentKey =
|
|
263
|
+
node.parent && !created.has(node.parent) && !before.has(node.parent)
|
|
264
|
+
? (options.under?.key ?? null)
|
|
265
|
+
: (node.parent ?? options.under?.key ?? null)
|
|
266
|
+
await repo.create({
|
|
267
|
+
key: node.key,
|
|
268
|
+
contentTypeKey: type.key,
|
|
269
|
+
componentKey: node.template
|
|
270
|
+
? ((await templates.byAlias(node.template))?.key ?? null)
|
|
271
|
+
: null,
|
|
272
|
+
parentKey,
|
|
273
|
+
values: [],
|
|
274
|
+
variants: node.variants.map((v) => ({
|
|
275
|
+
culture: v.culture,
|
|
276
|
+
segment: v.segment,
|
|
277
|
+
name: v.name,
|
|
278
|
+
})),
|
|
279
|
+
})
|
|
280
|
+
created.add(node.key)
|
|
281
|
+
landedUnder.set(node.key, parentKey)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// The order a bundle's nodes sit in among their siblings travels with it.
|
|
285
|
+
// Without this they keep the order the run happened to create them in,
|
|
286
|
+
// which is a hash order, not an editor's — a site's navigation arrives
|
|
287
|
+
// shuffled. Only the nodes this run created are placed, and only into the
|
|
288
|
+
// slots they already occupy, so siblings that were here keep theirs.
|
|
289
|
+
const newByParent = new Map<string | null, BundleNode[]>()
|
|
290
|
+
for (const node of ordered) {
|
|
291
|
+
if (!created.has(node.key) || node.kind !== 'document') continue
|
|
292
|
+
const parent = landedUnder.get(node.key) ?? null
|
|
293
|
+
const group = newByParent.get(parent)
|
|
294
|
+
if (group) group.push(node)
|
|
295
|
+
else newByParent.set(parent, [node])
|
|
296
|
+
}
|
|
297
|
+
for (const [parent, group] of newByParent) {
|
|
298
|
+
if (group.length < 2) continue
|
|
299
|
+
const documents = repoFor('document')
|
|
300
|
+
const rows = await documents.nodes.byKeys(group.map((n) => n.key))
|
|
301
|
+
const slots = rows.map((row) => row.sortOrder).sort((a, b) => a - b)
|
|
302
|
+
const wanted = [...group].sort((a, b) => a.sortOrder - b.sortOrder)
|
|
303
|
+
await documents.sort(
|
|
304
|
+
parent,
|
|
305
|
+
wanted.flatMap((node, index) => {
|
|
306
|
+
const sortOrder = slots[index]
|
|
307
|
+
return sortOrder === undefined ? [] : [{ key: node.key, sortOrder }]
|
|
308
|
+
}),
|
|
309
|
+
)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Pass two: the values, overlaid onto whatever is here.
|
|
313
|
+
const applied: NodePlan[] = []
|
|
314
|
+
for (const node of ordered) {
|
|
315
|
+
const plan = planByKey.get(node.key)
|
|
316
|
+
const kept = new Set(plan?.keepLocal ?? [])
|
|
317
|
+
// Every difference answered keep-local: nothing of the bundle's to write.
|
|
318
|
+
if (plan?.action === 'unchanged' && kept.size > 0) {
|
|
319
|
+
applied.push(plan)
|
|
320
|
+
continue
|
|
254
321
|
}
|
|
322
|
+
const repo = repoFor(node.kind)
|
|
323
|
+
const current = await repo.byKey(node.key)
|
|
324
|
+
if (!current) continue
|
|
325
|
+
// The values kept here are left out of the overlay, so they stay as they
|
|
326
|
+
// are; the rest are the bundle's. One update, so one new version.
|
|
327
|
+
const taken =
|
|
328
|
+
kept.size > 0
|
|
329
|
+
? {
|
|
330
|
+
...node,
|
|
331
|
+
values: node.values.filter(
|
|
332
|
+
(value) => !kept.has(propertyHandle(value.property, value.culture)),
|
|
333
|
+
),
|
|
334
|
+
}
|
|
335
|
+
: node
|
|
336
|
+
// A node answered keep-local as a whole keeps its names too.
|
|
337
|
+
const keepNames = plan?.resolution === 'keep-local'
|
|
338
|
+
await repo.update({
|
|
339
|
+
key: node.key,
|
|
340
|
+
contentTypeKey: current.contentTypeKey,
|
|
341
|
+
componentKey: node.template
|
|
342
|
+
? ((await templates.byAlias(node.template))?.key ?? current.componentKey)
|
|
343
|
+
: current.componentKey,
|
|
344
|
+
parentKey: current.parentKey,
|
|
345
|
+
values: overlay(current.values, taken),
|
|
346
|
+
variants: keepNames
|
|
347
|
+
? current.variants.map((v) => ({
|
|
348
|
+
culture: v.culture,
|
|
349
|
+
segment: v.segment,
|
|
350
|
+
name: v.name,
|
|
351
|
+
}))
|
|
352
|
+
: node.variants.map((v) => ({
|
|
353
|
+
culture: v.culture,
|
|
354
|
+
segment: v.segment,
|
|
355
|
+
name: v.name,
|
|
356
|
+
})),
|
|
357
|
+
})
|
|
358
|
+
if (plan) applied.push(plan)
|
|
359
|
+
}
|
|
255
360
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
361
|
+
// People before the rules that name them, and after the content their
|
|
362
|
+
// values may refer to.
|
|
363
|
+
const people =
|
|
364
|
+
set.people && check.people
|
|
365
|
+
? await applyPeople(tx, set.people, check.people.plan, {
|
|
366
|
+
id: runId,
|
|
367
|
+
runs,
|
|
368
|
+
nodeState,
|
|
369
|
+
nodeId: options.nodeId,
|
|
370
|
+
})
|
|
371
|
+
: []
|
|
372
|
+
|
|
373
|
+
// The rules a node carries, once every node they name exists.
|
|
374
|
+
const rules = { access: 0, schedules: 0, redirects: 0 }
|
|
375
|
+
const access = new PublicAccessRepository(tx)
|
|
376
|
+
const redirects = new RedirectRepository(tx)
|
|
377
|
+
const nodesHere = repoFor('document').nodes
|
|
378
|
+
for (const node of ordered) {
|
|
379
|
+
if (node.access) {
|
|
380
|
+
const saved = await access.save({
|
|
381
|
+
nodeKey: node.key,
|
|
382
|
+
loginNodeKey: node.access.loginNode,
|
|
383
|
+
errorNodeKey: node.access.errorNode,
|
|
384
|
+
memberGroupNames: node.access.memberGroups,
|
|
385
|
+
memberUserNames: node.access.memberUserNames ?? [],
|
|
280
386
|
})
|
|
281
|
-
if (
|
|
387
|
+
if (saved) rules.access += 1
|
|
282
388
|
}
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
}
|
|
389
|
+
if (node.schedule && node.schedule.length > 0 && node.kind === 'document') {
|
|
390
|
+
const byCulture = new Map<
|
|
391
|
+
string | null,
|
|
392
|
+
{ publishTime: Date | null; unpublishTime: Date | null }
|
|
393
|
+
>()
|
|
394
|
+
for (const entry of node.schedule) {
|
|
395
|
+
const at = byCulture.get(entry.culture) ?? { publishTime: null, unpublishTime: null }
|
|
396
|
+
if (entry.action === 'publish') at.publishTime = new Date(entry.date)
|
|
397
|
+
else at.unpublishTime = new Date(entry.date)
|
|
398
|
+
byCulture.set(entry.culture, at)
|
|
300
399
|
}
|
|
400
|
+
await repoFor('document').setSchedule(
|
|
401
|
+
node.key,
|
|
402
|
+
[...byCulture].map(([culture, times]) => ({ culture, ...times })),
|
|
403
|
+
)
|
|
404
|
+
rules.schedules += node.schedule.length
|
|
405
|
+
}
|
|
406
|
+
for (const redirect of node.redirects ?? []) {
|
|
407
|
+
const root = redirect.root && (await nodesHere.byKey(redirect.root)) ? redirect.root : null
|
|
408
|
+
await redirects.save({
|
|
409
|
+
source: 'tracked',
|
|
410
|
+
matchKind: 'exact',
|
|
411
|
+
pattern: redirect.path,
|
|
412
|
+
rootKey: root,
|
|
413
|
+
culture: redirect.culture,
|
|
414
|
+
targetKind: 'document',
|
|
415
|
+
target: node.key,
|
|
416
|
+
statusCode: 301,
|
|
417
|
+
sortOrder: 0,
|
|
418
|
+
})
|
|
419
|
+
rules.redirects += 1
|
|
301
420
|
}
|
|
421
|
+
}
|
|
302
422
|
|
|
423
|
+
// Publishing is last, and parents first: an unpublished ancestor makes a
|
|
424
|
+
// published descendant unreachable, so the repository refuses it.
|
|
425
|
+
const published: string[] = []
|
|
426
|
+
const publishFailures: Array<{ key: string; reason: string }> = []
|
|
427
|
+
if (options.publish) {
|
|
303
428
|
for (const node of ordered) {
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
const
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
beforeEventId: was?.head ?? null,
|
|
315
|
-
beforePublishedEventId: was?.published ?? null,
|
|
316
|
-
eventId: markers?.head ?? null,
|
|
317
|
-
})
|
|
429
|
+
const live = node.variants.filter((v) => v.published)
|
|
430
|
+
if (live.length === 0 || node.kind === 'media') continue
|
|
431
|
+
const cultures = live.map((v) => v.culture).filter((c): c is string => c !== null)
|
|
432
|
+
try {
|
|
433
|
+
await repoFor(node.kind).publish(node.key, cultures.length > 0 ? cultures : null)
|
|
434
|
+
published.push(node.key)
|
|
435
|
+
} catch (error) {
|
|
436
|
+
if (!(error instanceof PublishBlockedError)) throw error
|
|
437
|
+
publishFailures.push({ key: node.key, reason: error.message })
|
|
438
|
+
}
|
|
318
439
|
}
|
|
319
|
-
|
|
440
|
+
}
|
|
320
441
|
|
|
321
|
-
|
|
322
|
-
await
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
442
|
+
for (const node of ordered) {
|
|
443
|
+
const markers = await repoFor(node.kind).eventMarkers(node.key)
|
|
444
|
+
const was = before.get(node.key)
|
|
445
|
+
const action: TransferAction = !was
|
|
446
|
+
? 'create'
|
|
447
|
+
: (planByKey.get(node.key)?.action ?? 'unchanged')
|
|
448
|
+
await runs.record({
|
|
449
|
+
runId,
|
|
450
|
+
nodeKey: node.key,
|
|
451
|
+
kind: node.kind,
|
|
452
|
+
action,
|
|
453
|
+
beforeEventId: was?.head ?? null,
|
|
454
|
+
beforePublishedEventId: was?.published ?? null,
|
|
455
|
+
eventId: markers?.head ?? null,
|
|
326
456
|
})
|
|
457
|
+
}
|
|
458
|
+
await runs.finish(runId, ordered.length + people.length)
|
|
327
459
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
460
|
+
// Other nodes drop their published cache; this one already has.
|
|
461
|
+
await appendCacheInstruction(tx, {
|
|
462
|
+
kind: 'content',
|
|
463
|
+
payload: { bundle: set.manifest.id },
|
|
464
|
+
by: options.nodeId,
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
return { runId, check, applied, published, publishFailures, people, rules }
|
|
468
|
+
}
|
|
331
469
|
}
|
package/src/index.ts
CHANGED
|
@@ -4,7 +4,10 @@ export * from './import.ts'
|
|
|
4
4
|
export * from './load.ts'
|
|
5
5
|
export * from './model.ts'
|
|
6
6
|
export * from './package.ts'
|
|
7
|
+
export * from './people.ts'
|
|
8
|
+
export * from './people-apply.ts'
|
|
7
9
|
export * from './references.ts'
|
|
8
10
|
export * from './resolutions.ts'
|
|
9
11
|
export * from './revert.ts'
|
|
12
|
+
export * from './seal.ts'
|
|
10
13
|
export * from './write.ts'
|