@crombolo/transfer 0.11.0 → 0.12.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/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 { type CheckOptions, checkBundle, type NodePlan, type TransferCheck } from './check.ts'
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(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
- }
131
+ db.transaction((tx) => applyBundle(tx, set, options)),
132
+ )
133
+ }
130
134
 
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)
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
- 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
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
- 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,
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
- 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
- )
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
- // 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
- }
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
- // 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
- }
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
- // 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
- }),
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
- // 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
- })),
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 (plan) applied.push(plan)
387
+ if (saved) rules.access += 1
282
388
  }
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
- }
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 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
- })
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
- await runs.finish(runId, ordered.length)
440
+ }
320
441
 
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,
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
- return { runId, check, applied, published, publishFailures }
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'