@crombolo/transfer 0.10.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crombolo/transfer",
3
- "version": "0.10.0",
3
+ "version": "0.12.1",
4
4
  "description": "Content transfer for crombolo: bundles of content moved between environments.",
5
5
  "license": "MIT",
6
6
  "author": "Crombolo",
@@ -28,7 +28,7 @@
28
28
  "access": "public"
29
29
  },
30
30
  "dependencies": {
31
- "@crombolo/core": "0.10.0",
32
- "@crombolo/data": "0.10.0"
31
+ "@crombolo/core": "0.12.1",
32
+ "@crombolo/data": "0.12.1"
33
33
  }
34
34
  }
package/src/check.ts CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  type NodeRow,
27
27
  } from '@crombolo/data'
28
28
  import type { BundleNode, ContentSet } from './model.ts'
29
+ import { type PeoplePlan, planPeople } from './people-apply.ts'
29
30
  import { referenceCandidates } from './references.ts'
30
31
 
31
32
  /** What a person can decide about a node the destination already has. */
@@ -36,6 +37,50 @@ export const RESOLUTIONS: readonly Resolution[] = ['take-bundle', 'keep-local',
36
37
  export const isResolution = (value: string): value is Resolution =>
37
38
  (RESOLUTIONS as readonly string[]).includes(value)
38
39
 
40
+ /**
41
+ * The answers for one node. `"*"` answers the whole node; `"<alias>"` or
42
+ * `"<alias>@<culture>"` answers one property, and wins over `"*"` for it.
43
+ */
44
+ export type NodeResolutions = Readonly<Record<string, Resolution>>
45
+
46
+ /** The node itself, in a node's answers. */
47
+ export const WHOLE_NODE = '*'
48
+
49
+ /** How a property is named in a node's answers and in `--resolve`. */
50
+ export const propertyHandle = (alias: string, culture: string | null | undefined): string =>
51
+ culture ? `${alias}@${culture}` : alias
52
+
53
+ /** The answer for one property: its own, its alias's across cultures, the node's, then everyone's. Pure. */
54
+ export function answerFor(
55
+ answers: NodeResolutions | undefined,
56
+ alias: string,
57
+ culture: string | null | undefined,
58
+ all: Resolution | undefined,
59
+ ): Resolution | undefined {
60
+ return (
61
+ (culture ? answers?.[propertyHandle(alias, culture)] : undefined) ??
62
+ answers?.[alias] ??
63
+ answers?.[WHOLE_NODE] ??
64
+ all
65
+ )
66
+ }
67
+
68
+ /** Why a node's answers cannot be used: `skip` belongs to a whole node, not a property. Pure. */
69
+ export function nodeResolutionProblems(key: string, answers: Record<string, unknown>): string[] {
70
+ const problems: string[] = []
71
+ for (const [handle, answer] of Object.entries(answers)) {
72
+ if (typeof answer !== 'string' || !isResolution(answer))
73
+ problems.push(
74
+ `"${key}" answers ${handle} with "${String(answer)}", which is not one of ${RESOLUTIONS.join(', ')}`,
75
+ )
76
+ else if (handle !== WHOLE_NODE && answer === 'skip')
77
+ problems.push(
78
+ `"${key}" answers ${handle} with skip, which leaves a whole node alone, not one property`,
79
+ )
80
+ }
81
+ return problems
82
+ }
83
+
39
84
  /**
40
85
  * What to suggest for a finding, as a function of its code — so the table keeps
41
86
  * its stable shape and both the CLI and the dashboard render the same advice.
@@ -44,9 +89,9 @@ export function resolutionsFor(code: string): readonly string[] {
44
89
  switch (code) {
45
90
  case 'local-edit':
46
91
  return [
47
- '--resolve <key>=take-bundle to let the bundle win',
48
- '--resolve <key>=keep-local to keep what is here',
49
- '--resolve <key>=skip to leave the node alone entirely',
92
+ '--resolve <key>/<property>=take-bundle to let the bundle win for this property',
93
+ '--resolve <key>/<property>=keep-local to keep what is here for this property',
94
+ '--resolve <key>=<choice> to answer the whole node, skip included',
50
95
  '--resolve-all=<choice> to answer every one the same way',
51
96
  ]
52
97
  case 'missing-parent':
@@ -75,6 +120,58 @@ export function resolutionsFor(code: string): readonly string[] {
75
120
  return ['rename one of them, or --resolve <key>=skip']
76
121
  case 'missing-template':
77
122
  return ['deploy the view, and the template attaches on the next save']
123
+ case 'content-sealed':
124
+ return [
125
+ '--passphrase-env <name>, or answer the prompt, with the passphrase it was exported with',
126
+ ]
127
+ case 'person-conflict':
128
+ return [
129
+ '--resolve <key>=take-bundle to update the person here with the bundle’s details',
130
+ '--resolve <key>=skip to leave this one out',
131
+ ]
132
+ case 'missing-member-type':
133
+ return ['deploy schema/ to this environment first']
134
+ default:
135
+ return []
136
+ }
137
+ }
138
+
139
+ /** One answer a finding can be given: the handle it is recorded under, and the choice. */
140
+ export interface FindingChoice {
141
+ handle: string
142
+ resolution: Resolution
143
+ }
144
+
145
+ const LEAVE_OUT: FindingChoice = { handle: WHOLE_NODE, resolution: 'skip' }
146
+
147
+ /**
148
+ * The answers a finding can be given in the backoffice, as a function of its
149
+ * code, beside `resolutionsFor`'s advice. Recorded under the finding's node,
150
+ * they are what `--resolve` would have set; a finding with none is answered
151
+ * by deploying something, not by choosing.
152
+ */
153
+ export function choicesFor(finding: {
154
+ code: string
155
+ propertyAlias: string | null
156
+ culture: string | null
157
+ }): readonly FindingChoice[] {
158
+ switch (finding.code) {
159
+ case 'local-edit': {
160
+ if (!finding.propertyAlias) return [LEAVE_OUT]
161
+ const handle = propertyHandle(finding.propertyAlias, finding.culture)
162
+ return [
163
+ { handle, resolution: 'take-bundle' },
164
+ { handle, resolution: 'keep-local' },
165
+ LEAVE_OUT,
166
+ ]
167
+ }
168
+ case 'person-conflict':
169
+ return [{ handle: WHOLE_NODE, resolution: 'take-bundle' }, LEAVE_OUT]
170
+ case 'local-trashed':
171
+ case 'missing-blob':
172
+ case 'name-collision':
173
+ case 'unresolved-reference':
174
+ return [LEAVE_OUT]
78
175
  default:
79
176
  return []
80
177
  }
@@ -83,8 +180,8 @@ export function resolutionsFor(code: string): readonly string[] {
83
180
  export interface CheckOptions {
84
181
  /** Re-root the bundle here instead of at the parents it names. */
85
182
  under?: NodeRow | undefined
86
- /** Per-node decisions, by node key. */
87
- resolutions?: Readonly<Record<string, Resolution>>
183
+ /** Decisions by node key: the node's (`"*"`) and its properties'. */
184
+ resolutions?: Readonly<Record<string, NodeResolutions>>
88
185
  /** One decision for every node that needs one. */
89
186
  resolveAll?: Resolution | undefined
90
187
  /** Template aliases with a view on disk; absent means templates are not checked. */
@@ -94,6 +191,8 @@ export interface CheckOptions {
94
191
  allowMissingBlobs?: boolean
95
192
  /** For linking a finding to the document it is about. */
96
193
  backOfficePath?: string
194
+ /** Users here the bundle's may take over by e-mail or username: a site this boot created. */
195
+ adoptUsers?: boolean
97
196
  }
98
197
 
99
198
  /** What the import would do to one node. */
@@ -104,7 +203,10 @@ export interface NodePlan {
104
203
  action: 'create' | 'update' | 'unchanged' | 'skip'
105
204
  /** Property aliases whose value would change. */
106
205
  changing: string[]
206
+ /** The node's own answer. */
107
207
  resolution: Resolution | undefined
208
+ /** Values kept as they are here, by property handle; the rest come from the bundle. */
209
+ keepLocal?: string[]
108
210
  }
109
211
 
110
212
  export interface TransferCheck {
@@ -114,6 +216,8 @@ export interface TransferCheck {
114
216
  outstanding: Finding[]
115
217
  plan: NodePlan[]
116
218
  counts: { create: number; update: number; unchanged: number; skip: number }
219
+ /** What the bundle's people would do here, when it carries any. */
220
+ people?: PeoplePlan
117
221
  }
118
222
 
119
223
  const documentLink = (backOfficePath: string | undefined, key: string): string | null =>
@@ -182,6 +286,31 @@ export async function checkBundle(
182
286
  link: null,
183
287
  })
184
288
 
289
+ // Nothing can be compared while the values are sealed, and a check that
290
+ // compared them as absent would report every one as cleared.
291
+ if (set.sealed) {
292
+ const sealed = bundleFinding(
293
+ 'blocking',
294
+ 'content-sealed',
295
+ 'this bundle’s content is encrypted — give the passphrase it was exported with to check or import it',
296
+ )
297
+ return {
298
+ bundleId: set.manifest.id,
299
+ findings: [sealed],
300
+ outstanding: [sealed],
301
+ plan,
302
+ counts: { create: 0, update: 0, unchanged: 0, skip: 0 },
303
+ }
304
+ }
305
+ if (set.manifest.content === 'omitted')
306
+ findings.push(
307
+ bundleFinding(
308
+ 'auto',
309
+ 'content-omitted',
310
+ 'this bundle was exported without its content: pages are placed and named, and no value here changes',
311
+ ),
312
+ )
313
+
185
314
  // Every content type the bundle names, matched by key first and alias second:
186
315
  // keys are stable across environments because tooling writes them into the
187
316
  // schema files, and the alias is what a person reads in the message.
@@ -285,7 +414,8 @@ export async function checkBundle(
285
414
 
286
415
  for (const node of set.nodes) {
287
416
  const name = node.variants[0]?.name ?? node.key
288
- const resolution = resolutions[node.key] ?? options.resolveAll
417
+ const answers = resolutions[node.key]
418
+ const resolution = answers?.[WHOLE_NODE] ?? options.resolveAll
289
419
  const existing = await nodes.byKey(node.key)
290
420
  const editors = await editorsFor(node.contentType.alias)
291
421
  const typeMissing = !(await typeFor(node.contentType))
@@ -491,15 +621,14 @@ export async function checkBundle(
491
621
  const currentByKey = new Map(
492
622
  (current?.values ?? []).map((v) => [`${v.alias}|${v.culture ?? ''}|${v.segment ?? ''}`, v]),
493
623
  )
494
- const changing = node.values
495
- .filter((value) => {
496
- if (!editors.has(value.property)) return false
497
- const mine = currentByKey.get(
498
- `${value.property}|${value.culture ?? ''}|${value.segment ?? ''}`,
499
- )
500
- return !sameValue(mine?.value, value.value)
501
- })
502
- .map((value) => value.property)
624
+ const changedValues = node.values.filter((value) => {
625
+ if (!editors.has(value.property)) return false
626
+ const mine = currentByKey.get(
627
+ `${value.property}|${value.culture ?? ''}|${value.segment ?? ''}`,
628
+ )
629
+ return !sameValue(mine?.value, value.value)
630
+ })
631
+ const changing = [...new Set(changedValues.map((value) => value.property))]
503
632
 
504
633
  if (changing.length === 0) {
505
634
  plan.push({
@@ -516,31 +645,94 @@ export async function checkBundle(
516
645
  // It exists, and the bundle disagrees with it. We cannot know whether
517
646
  // somebody edited it here or the bundle is simply newer, so this is a
518
647
  // decision rather than a defect — and it is the one an import waits on.
519
- if (resolution === undefined)
520
- findings.push({
521
- kind: 'person',
522
- code: 'local-edit',
523
- subjectType: node.kind === 'media' ? 'media' : 'document',
524
- subjectKey: node.key,
525
- subjectName: name,
526
- propertyAlias: null,
527
- culture: null,
528
- message: `"${name}" is already here and differs: the bundle would replace ${changing.join(', ')}`,
529
- link: link(node.key),
530
- })
648
+ // One per property, so each can be answered on its own.
649
+ const keepLocal: string[] = []
650
+ let taking = false
651
+ for (const value of changedValues) {
652
+ const handle = propertyHandle(value.property, value.culture)
653
+ const answer = answerFor(answers, value.property, value.culture, options.resolveAll)
654
+ if (answer === 'keep-local') keepLocal.push(handle)
655
+ else if (answer === 'take-bundle') taking = true
656
+ else
657
+ findings.push({
658
+ kind: 'person',
659
+ code: 'local-edit',
660
+ subjectType: node.kind === 'media' ? 'media' : 'document',
661
+ subjectKey: node.key,
662
+ subjectName: name,
663
+ propertyAlias: value.property,
664
+ culture: value.culture ?? null,
665
+ message: `"${name}" is already here and its ${value.property}${value.culture ? ` (${value.culture})` : ''} differs from the bundle's`,
666
+ link: link(node.key),
667
+ })
668
+ }
531
669
  plan.push({
532
670
  key: node.key,
533
671
  name,
534
672
  kind: node.kind,
535
- action: resolution === 'keep-local' ? 'unchanged' : 'update',
673
+ action: taking || keepLocal.length < changedValues.length ? 'update' : 'unchanged',
536
674
  changing,
537
675
  resolution,
676
+ keepLocal,
538
677
  })
539
678
  }
540
679
 
541
680
  const counts = { create: 0, update: 0, unchanged: 0, skip: 0 }
542
681
  for (const entry of plan) counts[entry.action] += 1
543
682
 
683
+ // The rules a node carries: a protected page needs its login and error
684
+ // pages, and names groups that may not exist here yet.
685
+ const groupsHere = new Set(
686
+ (
687
+ await db.query<{ text: string }>('SELECT text FROM node WHERE node_object_type = ?', [
688
+ ObjectTypes.MemberGroup,
689
+ ])
690
+ ).map((row) => String(row.text)),
691
+ )
692
+ for (const group of set.people?.memberGroups ?? []) groupsHere.add(group.name)
693
+ const skipped = new Set(plan.filter((p) => p.action === 'skip').map((p) => p.key))
694
+ for (const node of set.nodes) {
695
+ if (!node.access || skipped.has(node.key)) continue
696
+ const name = node.variants[0]?.name ?? node.key
697
+ const pages = [node.access.loginNode, node.access.errorNode]
698
+ const absent = pages.filter((key) => !carried.has(key))
699
+ const found = new Set((await nodes.byKeys(absent)).map((row) => row.key))
700
+ if (absent.some((key) => !found.has(key)))
701
+ findings.push({
702
+ kind: 'blocking',
703
+ code: 'access-page-missing',
704
+ subjectType: 'document',
705
+ subjectKey: node.key,
706
+ subjectName: name,
707
+ propertyAlias: null,
708
+ culture: null,
709
+ message: `"${name}" is protected, and its sign-in or no-access page is neither in the bundle nor here`,
710
+ link: link(node.key),
711
+ })
712
+ const unknown = node.access.memberGroups.filter((group) => !groupsHere.has(group))
713
+ if (unknown.length > 0)
714
+ findings.push({
715
+ kind: 'auto',
716
+ code: 'access-group-missing',
717
+ subjectType: 'document',
718
+ subjectKey: node.key,
719
+ subjectName: name,
720
+ propertyAlias: null,
721
+ culture: null,
722
+ message: `"${name}" is protected for ${unknown.join(', ')}, which there is no member group for here yet: nobody can see it until there is`,
723
+ link: link(node.key),
724
+ })
725
+ }
726
+
727
+ const people = set.people
728
+ ? await planPeople(db, set.people, {
729
+ resolutions,
730
+ resolveAll: options.resolveAll,
731
+ adoptUsers: options.adoptUsers,
732
+ })
733
+ : undefined
734
+ if (people) findings.push(...people.findings)
735
+
544
736
  return {
545
737
  bundleId: set.manifest.id,
546
738
  findings,
@@ -548,5 +740,6 @@ export async function checkBundle(
548
740
  outstanding: findings.filter((f) => f.kind === 'blocking' || f.kind === 'person'),
549
741
  plan,
550
742
  counts,
743
+ ...(people ? { people } : {}),
551
744
  }
552
745
  }
package/src/export.ts CHANGED
@@ -19,6 +19,8 @@ import {
19
19
  type Db,
20
20
  DocumentRepository,
21
21
  type NodeRow,
22
+ PublicAccessRepository,
23
+ RedirectRepository,
22
24
  } from '@crombolo/data'
23
25
  import {
24
26
  BUNDLE_FORMAT_VERSION,
@@ -245,6 +247,38 @@ export async function exportBundle(db: Db, options: ExportOptions): Promise<Expo
245
247
  blobs.push({ key, node: node.key, etag: null, size: null, included: false })
246
248
  }
247
249
 
250
+ // The rules each page carries: who may see it, what is scheduled, and the
251
+ // routes it used to answer on.
252
+ const access = new PublicAccessRepository(db)
253
+ const redirects = new RedirectRepository(db)
254
+ for (const node of bundleNodes) {
255
+ if (node.kind !== 'document') continue
256
+ const entry = await access.byNodeKey(node.key)
257
+ if (entry)
258
+ node.access = {
259
+ loginNode: entry.loginNodeKey,
260
+ errorNode: entry.errorNodeKey,
261
+ memberGroups: entry.memberGroupNames,
262
+ memberUserNames: entry.memberUserNames,
263
+ }
264
+ const schedule = await repoFor('document').schedules(node.key)
265
+ if (schedule.length > 0)
266
+ node.schedule = schedule.map((s) => ({
267
+ culture: s.culture,
268
+ action: s.action === 'Release' ? ('publish' as const) : ('unpublish' as const),
269
+ date: s.date.toISOString(),
270
+ }))
271
+ const tracked = (await redirects.byDocument(node.key, { take: 1000 })).items.filter(
272
+ (r) => r.source === 'tracked' && r.matchKind === 'exact',
273
+ )
274
+ if (tracked.length > 0)
275
+ node.redirects = tracked.map((r) => ({
276
+ path: r.pattern,
277
+ root: r.rootKey,
278
+ culture: r.culture,
279
+ }))
280
+ }
281
+
248
282
  const carried = new Set(bundleNodes.map((n) => n.key))
249
283
 
250
284
  // Anything referenced or parented that the bundle does not carry must already
@@ -261,6 +295,18 @@ export async function exportBundle(db: Db, options: ExportOptions): Promise<Expo
261
295
  why: 'referenced by a value in this bundle',
262
296
  })
263
297
  }
298
+ for (const node of bundleNodes)
299
+ for (const page of node.access ? [node.access.loginNode, node.access.errorNode] : []) {
300
+ if (carried.has(page) || expected.some((e) => e.key === page)) continue
301
+ const row = await nodes.byKey(page)
302
+ if (row)
303
+ expected.push({
304
+ key: row.key,
305
+ objectType: row.objectType ?? '',
306
+ name: row.text ?? '',
307
+ why: `the sign-in or no-access page of ${node.variants[0]?.name ?? node.key}`,
308
+ })
309
+ }
264
310
  for (const node of bundleNodes) {
265
311
  if (!node.parent || carried.has(node.parent)) continue
266
312
  if (expected.some((e) => e.key === node.parent)) continue