@crombolo/content 0.23.0 → 0.25.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crombolo/content",
3
- "version": "0.23.0",
3
+ "version": "0.25.0",
4
4
  "description": "The rules every document write follows, whoever starts it: who may, approvals, and what it announces. No transport.",
5
5
  "license": "MIT",
6
6
  "author": "Crombolo",
@@ -29,7 +29,7 @@
29
29
  "access": "public"
30
30
  },
31
31
  "dependencies": {
32
- "@crombolo/core": "0.23.0",
33
- "@crombolo/data": "0.23.0"
32
+ "@crombolo/core": "0.25.0",
33
+ "@crombolo/data": "0.25.0"
34
34
  }
35
35
  }
package/src/events.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * notifications — and the server adds live backoffice refresh. The mappings here
6
6
  * are pure, so every transport turns one event into the same entries.
7
7
  */
8
+ import type { Db } from '@crombolo/data'
8
9
  import { type Actor, actorName, actorNote, actorUserKey } from './actor.ts'
9
10
 
10
11
  export type DocumentEvent =
@@ -31,18 +32,27 @@ export type DocumentEvent =
31
32
  | { type: 'domainsAssigned'; key: string }
32
33
  | { type: 'publicAccessChanged'; key: string; change: 'added' | 'set' | 'removed' }
33
34
 
34
- /** Where an operation reports what it did, once the change has committed. */
35
+ /**
36
+ * Where an operation reports what it did (story 171). `record` runs inside the
37
+ * change's own transaction, so what must not be lost — the audit entry, the
38
+ * announcement waiting to go out — commits with the change or not at all.
39
+ * `afterCommit` is for what only a running server does once it has.
40
+ */
35
41
  export interface EventSink {
36
- emit(event: DocumentEvent, actor: Actor): Promise<void>
42
+ record(tx: Db, event: DocumentEvent, actor: Actor): Promise<void>
43
+ afterCommit?(event: DocumentEvent, actor: Actor): void
37
44
  }
38
45
 
39
46
  /** A sink that hears nothing: for tests and tools that announce nothing. */
40
- export const SILENT: EventSink = { emit: async () => {} }
47
+ export const SILENT: EventSink = { record: async () => {} }
41
48
 
42
49
  /** Several sinks heard in turn. */
43
50
  export const both = (...sinks: EventSink[]): EventSink => ({
44
- async emit(event, actor) {
45
- for (const sink of sinks) await sink.emit(event, actor)
51
+ async record(tx, event, actor) {
52
+ for (const sink of sinks) await sink.record(tx, event, actor)
53
+ },
54
+ afterCommit(event, actor) {
55
+ for (const sink of sinks) sink.afterCommit?.(event, actor)
46
56
  },
47
57
  })
48
58
 
package/src/operations.ts CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  PublicAccessRepository,
24
24
  PublishBlockedError,
25
25
  type SetDomainsResult,
26
+ StaleRevisionError,
26
27
  WriteRejectedError,
27
28
  } from '@crombolo/data'
28
29
  import type { Actor, ContentPrincipal } from './actor.ts'
@@ -63,6 +64,10 @@ export interface Refusal {
63
64
  | 'conflict'
64
65
  | 'blocked'
65
66
  | 'validation'
67
+ /** The page changed after the copy the write was based on (412). */
68
+ | 'stale'
69
+ /** A user's write named no revision to check against (428). */
70
+ | 'revisionRequired'
66
71
  status: number
67
72
  reason: string
68
73
  errors?: DocumentValidationError[]
@@ -141,6 +146,18 @@ export type PublicAccessStatus =
141
146
  | 'forbidden'
142
147
 
143
148
  const NOT_FOUND = 'The document could not be found.'
149
+ const STALE =
150
+ 'Someone else changed this page after you opened it. Your changes have not been saved; copy what you need, then reload the page to see theirs.'
151
+ const REVISION_REQUIRED =
152
+ 'Read the page first and send back the revision it came with (its ETag, as If-Match), so a newer change is not overwritten.'
153
+
154
+ /** What a write was based on (story 172). */
155
+ export interface Precondition {
156
+ /** The page's revision when the copy being saved was read. */
157
+ expectedRevision?: string
158
+ /** The sender said to replace whatever is there (`If-Match: *`): checked by no revision. */
159
+ overwrite?: boolean
160
+ }
144
161
 
145
162
  const refuse = (kind: Refusal['kind'], status: number, reason: string): Refusal => ({
146
163
  ok: false,
@@ -154,6 +171,7 @@ function refusalFrom(error: unknown, extra?: (error: unknown) => string | undefi
154
171
  if (error instanceof WriteRejectedError) return refuse('conflict', 409, error.message)
155
172
  if (error instanceof InvalidParentError) return refuse('invalid', 400, error.message)
156
173
  if (error instanceof PublishBlockedError) return refuse('blocked', 400, error.message)
174
+ if (error instanceof StaleRevisionError) return refuse('stale', 412, STALE)
157
175
  const reason = extra?.(error)
158
176
  if (reason) return refuse('invalid', 400, reason)
159
177
  throw error
@@ -166,7 +184,29 @@ export function createDocumentOperations(options: DocumentOperationsOptions) {
166
184
  const repo = new DocumentRepository(db, options.repository)
167
185
  const nodes = new NodeRepository(db)
168
186
  const changed = () => options.onChange?.()
169
- const emit = (event: DocumentEvent, actor: Actor) => events.emit(event, actor)
187
+
188
+ /**
189
+ * Writes and records in one transaction (story 171): the change, its audit
190
+ * entry and what it announces commit together or not at all. What only a
191
+ * running server does follows the commit.
192
+ */
193
+ const commit = async <T>(
194
+ actor: Actor,
195
+ write: (tx: DocumentRepository, txDb: Db) => Promise<T>,
196
+ announce: (result: T) => readonly DocumentEvent[],
197
+ ): Promise<T> => {
198
+ const recorded: DocumentEvent[] = []
199
+ const result = await repo.atomically(async (tx, txDb) => {
200
+ const written = await write(tx, txDb)
201
+ for (const event of announce(written)) {
202
+ await events.record(txDb, event, actor)
203
+ recorded.push(event)
204
+ }
205
+ return written
206
+ })
207
+ for (const event of recorded) events.afterCommit?.(event, actor)
208
+ return result
209
+ }
170
210
 
171
211
  /** The version author: the user's row, when the actor is one. */
172
212
  const userIdOf = async (actor: Actor): Promise<number | undefined> => {
@@ -214,6 +254,11 @@ export function createDocumentOperations(options: DocumentOperationsOptions) {
214
254
  }
215
255
 
216
256
  const forbidden = () => refuse('forbidden', 403, 'You may not do that to this page.')
257
+ /** A user replaces a draft only from a copy whose revision is checked; the site acts on the head. */
258
+ const revisionRefusal = (actor: Actor, precondition: Precondition): Refusal | undefined =>
259
+ actor.kind === 'user' && precondition.expectedRevision === undefined && !precondition.overwrite
260
+ ? refuse('revisionRequired', 428, REVISION_REQUIRED)
261
+ : undefined
217
262
 
218
263
  /** Whether the actor may work in these languages; only a user is limited to some. */
219
264
  const languagesAllowed = (actor: Actor, cultures: readonly string[] | null) =>
@@ -285,16 +330,25 @@ export function createDocumentOperations(options: DocumentOperationsOptions) {
285
330
  )
286
331
  if (denied) return denied
287
332
  try {
288
- const created = await repo.create({ ...input, userId: await userIdOf(actor) })
333
+ const userId = await userIdOf(actor)
334
+ const created = await commit(
335
+ actor,
336
+ (tx) => tx.create({ ...input, userId }),
337
+ (doc) => [{ type: 'created', key: doc.key }],
338
+ )
289
339
  changed()
290
- await emit({ type: 'created', key: created.key }, actor)
291
340
  return { ok: true, key: created.key }
292
341
  } catch (error) {
293
342
  return refusalFrom(error, options.refusalReason)
294
343
  }
295
344
  },
296
345
 
297
- async save(actor: Actor, key: string, input: DocumentInput): Promise<Result> {
346
+ async save(
347
+ actor: Actor,
348
+ key: string,
349
+ input: DocumentInput,
350
+ precondition: Precondition = {},
351
+ ): Promise<Result<{ revision?: string }>> {
298
352
  const existing = await repo.byKey(key)
299
353
  if (!existing) return refuse('notFound', 404, NOT_FOUND)
300
354
  if (!(await permits(actor, 'save', key))) return forbidden()
@@ -306,24 +360,32 @@ export function createDocumentOperations(options: DocumentOperationsOptions) {
306
360
  input.values,
307
361
  )
308
362
  if (denied) return denied
363
+ const unbased = revisionRefusal(actor, precondition)
364
+ if (unbased) return unbased
309
365
  const gate = gateFor(actor)
310
366
  if (!gate.skip) {
311
367
  const waiting = await options.approvals?.beforeSave(gate.who, key)
312
368
  if (waiting) return needsApproval(waiting)
313
369
  }
314
370
  try {
315
- // The content type never changes on a save.
316
- const updated = await repo.update({
317
- ...input,
318
- key,
319
- contentTypeKey: existing.contentTypeKey,
320
- userId: await userIdOf(actor),
321
- })
371
+ const userId = await userIdOf(actor)
372
+ const updated = await commit(
373
+ actor,
374
+ (tx) =>
375
+ tx.update({
376
+ ...input,
377
+ key,
378
+ // The content type never changes on a save.
379
+ contentTypeKey: existing.contentTypeKey,
380
+ userId,
381
+ expectedRevision: precondition.expectedRevision,
382
+ }),
383
+ (doc) => (doc ? [{ type: 'saved', key }] : []),
384
+ )
322
385
  if (!updated) return refuse('notFound', 404, NOT_FOUND)
323
386
  changed()
324
387
  if (!gate.skip) await options.approvals?.afterSave(gate.who, key)
325
- await emit({ type: 'saved', key }, actor)
326
- return { ok: true }
388
+ return { ok: true, revision: updated.revision }
327
389
  } catch (error) {
328
390
  return refusalFrom(error, options.refusalReason)
329
391
  }
@@ -358,12 +420,14 @@ export function createDocumentOperations(options: DocumentOperationsOptions) {
358
420
  try {
359
421
  const userId = await userIdOf(actor)
360
422
  // One transaction: a page that cannot be published is not left behind as a draft.
361
- const result = await repo.atomically((tx) =>
362
- tx.createAndPublish({ ...input, userId }, cultures),
423
+ const result = await commit(
424
+ actor,
425
+ (tx) => tx.createAndPublish({ ...input, userId }, cultures),
426
+ (done) =>
427
+ done.ok ? [{ type: 'createdAndPublished', key: done.document.key, cultures }] : [],
363
428
  )
364
429
  if (!result.ok) return validationRefusal(result.errors)
365
430
  changed()
366
- await emit({ type: 'createdAndPublished', key: result.document.key, cultures }, actor)
367
431
  return { ok: true, key: result.document.key }
368
432
  } catch (error) {
369
433
  return refusalFrom(error, options.refusalReason)
@@ -375,7 +439,8 @@ export function createDocumentOperations(options: DocumentOperationsOptions) {
375
439
  key: string,
376
440
  input: DocumentInput,
377
441
  cultures: string[] | null,
378
- ): Promise<Result> {
442
+ precondition: Precondition = {},
443
+ ): Promise<Result<{ revision?: string }>> {
379
444
  const existing = await repo.byKey(key)
380
445
  if (!existing) return refuse('notFound', 404, NOT_FOUND)
381
446
  if (!(await permits(actor, 'publish', key))) return forbidden()
@@ -388,6 +453,8 @@ export function createDocumentOperations(options: DocumentOperationsOptions) {
388
453
  input.values,
389
454
  )
390
455
  if (denied) return denied
456
+ const unbased = revisionRefusal(actor, precondition)
457
+ if (unbased) return unbased
391
458
  const gate = gateFor(actor)
392
459
  if (!gate.skip) {
393
460
  const refusal =
@@ -398,16 +465,21 @@ export function createDocumentOperations(options: DocumentOperationsOptions) {
398
465
  try {
399
466
  const before = await options.redirects?.capture(key)
400
467
  const userId = await userIdOf(actor)
401
- const result = await repo.atomically((tx) =>
402
- tx.updateAndPublish({ ...input, key, userId }, cultures),
468
+ const result = await commit(
469
+ actor,
470
+ (tx) =>
471
+ tx.updateAndPublish(
472
+ { ...input, key, userId, expectedRevision: precondition.expectedRevision },
473
+ cultures,
474
+ ),
475
+ (done) => (done?.ok ? [{ type: 'savedAndPublished', key, cultures }] : []),
403
476
  )
404
477
  if (!result) return refuse('notFound', 404, NOT_FOUND)
405
478
  if (!result.ok) return validationRefusal(result.errors)
406
479
  changed()
407
480
  await options.redirects?.commit(before)
408
481
  if (!gate.skip) await options.approvals?.afterDirectPublish(gate.who, key, 'published it')
409
- await emit({ type: 'savedAndPublished', key, cultures }, actor)
410
- return { ok: true }
482
+ return { ok: true, revision: result.document.revision }
411
483
  } catch (error) {
412
484
  return refusalFrom(error, options.refusalReason)
413
485
  }
@@ -424,8 +496,12 @@ export function createDocumentOperations(options: DocumentOperationsOptions) {
424
496
  cultures: string[] | null
425
497
  schedules?: readonly ScheduleEntry[]
426
498
  now?: number
499
+ /** Checked when sent: a publish from a stale copy would publish what its sender never saw. */
500
+ expectedRevision?: string
501
+ /** The event an approval was given for; a later one is not published. */
502
+ expectedHead?: number
427
503
  },
428
- ): Promise<Result<{ scheduledOnly: boolean }>> {
504
+ ): Promise<Result<{ scheduledOnly: boolean; revision?: string }>> {
429
505
  if (!(await repo.byKey(key))) return refuse('notFound', 404, NOT_FOUND)
430
506
  if (!(await permits(actor, 'publish', key))) return forbidden()
431
507
  const schedules = request.schedules ?? []
@@ -442,30 +518,45 @@ export function createDocumentOperations(options: DocumentOperationsOptions) {
442
518
  const now = request.now ?? Date.now()
443
519
  const later = (s: ScheduleEntry) => s.publishTime !== null && s.publishTime.getTime() > now
444
520
  try {
445
- if (schedules.length > 0)
446
- await repo.setSchedule(
447
- key,
448
- schedules.map((s) => ({ ...s, publishTime: later(s) ? s.publishTime : null })),
449
- )
450
521
  const immediate = schedules.filter((s) => !later(s))
451
- if (schedules.length > 0 && immediate.length === 0) {
522
+ const scheduledOnly = schedules.length > 0 && immediate.length === 0
523
+ const cultures = immediate.some((s) => s.culture !== null)
524
+ ? immediate.flatMap((s) => (s.culture === null ? [] : [s.culture]))
525
+ : request.cultures
526
+ const before = scheduledOnly ? undefined : await options.redirects?.capture(key)
527
+ // The schedule and the publish are one change: neither lands without the other.
528
+ const published = await commit(
529
+ actor,
530
+ async (tx) => {
531
+ if (schedules.length > 0)
532
+ await tx.setSchedule(
533
+ key,
534
+ schedules.map((s) => ({ ...s, publishTime: later(s) ? s.publishTime : null })),
535
+ )
536
+ if (scheduledOnly) return 'scheduled' as const
537
+ return tx.publish(key, cultures, {
538
+ expectedRevision: request.expectedRevision,
539
+ expectedHead: request.expectedHead,
540
+ })
541
+ },
542
+ (done): DocumentEvent[] =>
543
+ done === 'scheduled'
544
+ ? [{ type: 'scheduled', key }]
545
+ : done
546
+ ? [{ type: 'published', key, cultures }]
547
+ : [],
548
+ )
549
+ if (published === 'scheduled') {
452
550
  changed()
453
551
  if (!gate.skip)
454
552
  await options.approvals?.afterDirectPublish(gate.who, key, 'rescheduled it')
455
- await emit({ type: 'scheduled', key }, actor)
456
553
  return { ok: true, scheduledOnly: true }
457
554
  }
458
- const cultures = immediate.some((s) => s.culture !== null)
459
- ? immediate.flatMap((s) => (s.culture === null ? [] : [s.culture]))
460
- : request.cultures
461
- const before = await options.redirects?.capture(key)
462
- const published = await repo.publish(key, cultures)
463
555
  if (!published) return refuse('notFound', 404, NOT_FOUND)
464
556
  changed()
465
557
  await options.redirects?.commit(before)
466
558
  if (!gate.skip) await options.approvals?.afterDirectPublish(gate.who, key, 'published it')
467
- await emit({ type: 'published', key, cultures }, actor)
468
- return { ok: true, scheduledOnly: false }
559
+ return { ok: true, scheduledOnly: false, revision: published.revision }
469
560
  } catch (error) {
470
561
  return refusalFrom(error, options.refusalReason)
471
562
  }
@@ -481,11 +572,14 @@ export function createDocumentOperations(options: DocumentOperationsOptions) {
481
572
  if (refusal) return needsApproval(refusal)
482
573
  }
483
574
  try {
484
- const result = await repo.unpublish(key, cultures)
575
+ const result = await commit(
576
+ actor,
577
+ (tx) => tx.unpublish(key, cultures),
578
+ (done) => (done ? [{ type: 'unpublished', key, cultures }] : []),
579
+ )
485
580
  if (!result) return refuse('notFound', 404, NOT_FOUND)
486
581
  changed()
487
582
  if (!gate.skip) await options.approvals?.afterDirectPublish(gate.who, key, 'took it down')
488
- await emit({ type: 'unpublished', key, cultures }, actor)
489
583
  return { ok: true }
490
584
  } catch (error) {
491
585
  return refusalFrom(error, options.refusalReason)
@@ -544,17 +638,27 @@ export function createDocumentOperations(options: DocumentOperationsOptions) {
544
638
  if (action === 'publish')
545
639
  for (const node of targets)
546
640
  befores.set(node.key, await options.redirects?.capture(node.key))
547
- const done = await repo.atomically(async (tx) => {
548
- const out: string[] = []
549
- for (const node of targets) {
550
- const result =
551
- action === 'publish'
552
- ? await tx.publish(node.key, cultures)
553
- : await tx.unpublish(node.key, cultures)
554
- if (result) out.push(node.key)
555
- }
556
- return out
557
- })
641
+ const done = await commit(
642
+ actor,
643
+ async (tx) => {
644
+ const out: string[] = []
645
+ for (const node of targets) {
646
+ const result =
647
+ action === 'publish'
648
+ ? await tx.publish(node.key, cultures)
649
+ : await tx.unpublish(node.key, cultures)
650
+ if (result) out.push(node.key)
651
+ }
652
+ return out
653
+ },
654
+ (keys) =>
655
+ keys.map(
656
+ (key): DocumentEvent =>
657
+ action === 'publish'
658
+ ? { type: 'published', key, cultures }
659
+ : { type: 'unpublished', key, cultures },
660
+ ),
661
+ )
558
662
  changed()
559
663
  for (const key of done) {
560
664
  if (action === 'publish') await options.redirects?.commit(befores.get(key))
@@ -564,12 +668,6 @@ export function createDocumentOperations(options: DocumentOperationsOptions) {
564
668
  key,
565
669
  action === 'publish' ? 'published it' : 'took it down',
566
670
  )
567
- await emit(
568
- action === 'publish'
569
- ? { type: 'published', key, cultures }
570
- : { type: 'unpublished', key, cultures },
571
- actor,
572
- )
573
671
  }
574
672
  return { ok: true, changed: done }
575
673
  } catch (error) {
@@ -594,10 +692,13 @@ export function createDocumentOperations(options: DocumentOperationsOptions) {
594
692
  if (refusal) return needsApproval(refusal)
595
693
  }
596
694
  try {
597
- await repo.setSchedule(key, entries)
695
+ await commit(
696
+ actor,
697
+ (tx) => tx.setSchedule(key, entries),
698
+ () => [{ type: 'scheduled', key }],
699
+ )
598
700
  changed()
599
701
  if (!gate.skip) await options.approvals?.afterDirectPublish(gate.who, key, 'rescheduled it')
600
- await emit({ type: 'scheduled', key }, actor)
601
702
  return { ok: true }
602
703
  } catch (error) {
603
704
  return refusalFrom(error, options.refusalReason)
@@ -629,14 +730,17 @@ export function createDocumentOperations(options: DocumentOperationsOptions) {
629
730
  }
630
731
  try {
631
732
  const before = await options.redirects?.capture(key)
632
- const result = await repo.publishBranch(key, cultures, includeUnpublished)
733
+ const result = await commit(
734
+ actor,
735
+ (tx) => tx.publishBranch(key, cultures, includeUnpublished),
736
+ (done) => (done ? [{ type: 'branchPublished', key, published: done.published }] : []),
737
+ )
633
738
  if (!result) return refuse('notFound', 404, NOT_FOUND)
634
739
  changed()
635
740
  await options.redirects?.commit(before)
636
741
  if (!gate.skip)
637
742
  for (const published of result.published)
638
743
  await options.approvals?.afterDirectPublish(gate.who, published, 'published its branch')
639
- await emit({ type: 'branchPublished', key, published: result.published }, actor)
640
744
  return { ok: true, ...result }
641
745
  } catch (error) {
642
746
  return refusalFrom(error, options.refusalReason)
@@ -645,10 +749,13 @@ export function createDocumentOperations(options: DocumentOperationsOptions) {
645
749
 
646
750
  async trash(actor: Actor, key: string): Promise<Result> {
647
751
  if (!(await permits(actor, 'trash', key))) return forbidden()
648
- const moved = await repo.moveToRecycleBin(key)
752
+ const moved = await commit(
753
+ actor,
754
+ (tx) => tx.moveToRecycleBin(key),
755
+ (done) => (done ? [{ type: 'trashed', key }] : []),
756
+ )
649
757
  if (!moved) return refuse('notFound', 404, NOT_FOUND)
650
758
  changed()
651
- await emit({ type: 'trashed', key }, actor)
652
759
  return { ok: true }
653
760
  },
654
761
 
@@ -659,20 +766,35 @@ export function createDocumentOperations(options: DocumentOperationsOptions) {
659
766
  if (options_.fromBin && !node.trashed)
660
767
  return refuse('invalid', 400, 'The document is not in the recycle bin')
661
768
  if (!(await permits(actor, 'delete', options_.fromBin ? 'bin' : key))) return forbidden()
662
- const removed = await repo.delete(key)
769
+ const removed = await commit(
770
+ actor,
771
+ (tx) => tx.delete(key),
772
+ (done) => (done ? [{ type: 'deleted', key, name: node.text ?? null }] : []),
773
+ )
663
774
  if (!removed) return refuse('notFound', 404, NOT_FOUND)
664
775
  changed()
665
- await emit({ type: 'deleted', key, name: node.text ?? null }, actor)
666
776
  return { ok: true }
667
777
  },
668
778
 
669
779
  async emptyBin(actor: Actor): Promise<Result<{ deleted: number }>> {
670
780
  if (!(await permits(actor, 'emptyBin', 'bin'))) return forbidden()
671
- let deleted = 0
672
- for (const node of await repo.trashedRoots()) if (await repo.delete(node.key)) deleted++
781
+ // One change: each page deleted is announced as a delete from the bin is,
782
+ // and the emptying itself is recorded once.
783
+ const deleted = await commit(
784
+ actor,
785
+ async (tx) => {
786
+ const gone: Array<{ key: string; name: string | null }> = []
787
+ for (const node of await tx.trashedRoots())
788
+ if (await tx.delete(node.key)) gone.push({ key: node.key, name: node.text ?? null })
789
+ return gone
790
+ },
791
+ (gone): DocumentEvent[] => [
792
+ ...gone.map((node): DocumentEvent => ({ type: 'deleted', ...node })),
793
+ { type: 'binEmptied' },
794
+ ],
795
+ )
673
796
  changed()
674
- await emit({ type: 'binEmptied' }, actor)
675
- return { ok: true, deleted }
797
+ return { ok: true, deleted: deleted.length }
676
798
  },
677
799
 
678
800
  async restore(
@@ -682,7 +804,11 @@ export function createDocumentOperations(options: DocumentOperationsOptions) {
682
804
  ): Promise<Result> {
683
805
  if (!(await permits(actor, 'restore', 'bin'))) return forbidden()
684
806
  try {
685
- const result = await repo.restore(key, targetKey)
807
+ const result = await commit(
808
+ actor,
809
+ (tx) => tx.restore(key, targetKey),
810
+ (done) => (done === 'notFound' || done === 'invalid' ? [] : [{ type: 'restored', key }]),
811
+ )
686
812
  if (result === 'notFound')
687
813
  return refuse('notFound', 404, 'The document is not in the recycle bin.')
688
814
  if (result === 'invalid')
@@ -692,7 +818,6 @@ export function createDocumentOperations(options: DocumentOperationsOptions) {
692
818
  'The restore target does not exist or is in the recycle bin.',
693
819
  )
694
820
  changed()
695
- await emit({ type: 'restored', key }, actor)
696
821
  return { ok: true }
697
822
  } catch (error) {
698
823
  return refusalFrom(error, options.refusalReason)
@@ -704,7 +829,11 @@ export function createDocumentOperations(options: DocumentOperationsOptions) {
704
829
  if (!(await permits(actor, 'intoTarget', targetKey ?? 'root'))) return forbidden()
705
830
  try {
706
831
  const before = await options.redirects?.capture(key)
707
- const result = await repo.move(key, targetKey)
832
+ const result = await commit(
833
+ actor,
834
+ (tx) => tx.move(key, targetKey),
835
+ (done) => (done === 'notFound' || done === 'invalid' ? [] : [{ type: 'moved', key }]),
836
+ )
708
837
  if (result === 'notFound')
709
838
  return refuse('notFound', 404, 'The document or target could not be found.')
710
839
  if (result === 'invalid')
@@ -715,7 +844,6 @@ export function createDocumentOperations(options: DocumentOperationsOptions) {
715
844
  )
716
845
  changed()
717
846
  await options.redirects?.commit(before)
718
- await emit({ type: 'moved', key }, actor)
719
847
  return { ok: true }
720
848
  } catch (error) {
721
849
  return refusalFrom(error, options.refusalReason)
@@ -731,13 +859,15 @@ export function createDocumentOperations(options: DocumentOperationsOptions) {
731
859
  if (!(await permits(actor, 'copy', key))) return forbidden()
732
860
  if (!(await permits(actor, 'intoTarget', targetKey ?? 'root'))) return forbidden()
733
861
  try {
734
- const copied = await repo.copy(key, targetKey, {
735
- includeDescendants: copyOptions.includeDescendants,
736
- userId: await userIdOf(actor),
737
- })
862
+ const userId = await userIdOf(actor)
863
+ const copied = await commit(
864
+ actor,
865
+ (tx) =>
866
+ tx.copy(key, targetKey, { includeDescendants: copyOptions.includeDescendants, userId }),
867
+ (copyKey) => (copyKey ? [{ type: 'copied', key, copyKey }] : []),
868
+ )
738
869
  if (!copied) return refuse('notFound', 404, 'The document or target could not be found.')
739
870
  changed()
740
- await emit({ type: 'copied', key, copyKey: copied }, actor)
741
871
  return { ok: true, key: copied }
742
872
  } catch (error) {
743
873
  return refusalFrom(error, options.refusalReason)
@@ -753,7 +883,11 @@ export function createDocumentOperations(options: DocumentOperationsOptions) {
753
883
  for (const item of sorting) if (!(await permits(actor, 'sort', item.key))) return forbidden()
754
884
  try {
755
885
  const before = await options.redirects?.captureChildren(parentKey)
756
- const result = await repo.sort(parentKey, sorting)
886
+ const result = await commit(
887
+ actor,
888
+ (tx) => tx.sort(parentKey, sorting),
889
+ (done) => (done === 'notFound' ? [] : [{ type: 'sorted', parentKey }]),
890
+ )
757
891
  if (result === 'notFound')
758
892
  return refuse(
759
893
  'notFound',
@@ -762,7 +896,6 @@ export function createDocumentOperations(options: DocumentOperationsOptions) {
762
896
  )
763
897
  changed()
764
898
  await options.redirects?.commit(before)
765
- await emit({ type: 'sorted', parentKey }, actor)
766
899
  return { ok: true }
767
900
  } catch (error) {
768
901
  return refusalFrom(error, options.refusalReason)
@@ -778,27 +911,37 @@ export function createDocumentOperations(options: DocumentOperationsOptions) {
778
911
  if (!(await permits(actor, 'sort', parentKey ?? 'root'))) return forbidden()
779
912
  try {
780
913
  const before = await options.redirects?.captureChildren(parentKey)
781
- const result = await repo.sortChildrenBy(parentKey, field, direction)
914
+ const result = await commit(
915
+ actor,
916
+ (tx) => tx.sortChildrenBy(parentKey, field, direction),
917
+ (done) => (done === 'notFound' ? [] : [{ type: 'sorted', parentKey }]),
918
+ )
782
919
  if (result === 'notFound') return refuse('notFound', 404, NOT_FOUND)
783
920
  changed()
784
921
  await options.redirects?.commit(before)
785
- await emit({ type: 'sorted', parentKey }, actor)
786
922
  return { ok: true }
787
923
  } catch (error) {
788
924
  return refusalFrom(error, options.refusalReason)
789
925
  }
790
926
  },
791
927
 
792
- async rollback(actor: Actor, versionId: string): Promise<Result> {
928
+ async rollback(
929
+ actor: Actor,
930
+ versionId: string,
931
+ precondition: Precondition = {},
932
+ ): Promise<Result<{ revision?: string }>> {
793
933
  const document = await repo.atVersion(versionId)
794
934
  if (!document) return refuse('notFound', 404, 'The version could not be found.')
795
935
  if (!(await permits(actor, 'rollback', document.document.key))) return forbidden()
796
936
  try {
797
- const result = await repo.rollback(versionId)
937
+ const result = await commit(
938
+ actor,
939
+ (tx) => tx.rollback(versionId, precondition),
940
+ (done) => (done ? [{ type: 'rolledBack', key: done.key, versionId }] : []),
941
+ )
798
942
  if (!result) return refuse('notFound', 404, 'The version could not be found.')
799
943
  changed()
800
- await emit({ type: 'rolledBack', key: result.key, versionId }, actor)
801
- return { ok: true }
944
+ return { ok: true, revision: result.revision }
802
945
  } catch (error) {
803
946
  return refusalFrom(error, options.refusalReason)
804
947
  }
@@ -817,13 +960,19 @@ export function createDocumentOperations(options: DocumentOperationsOptions) {
817
960
  const node = await nodes.byKey(key)
818
961
  if (!node || node.objectType !== repo.objectType) return { status: 'notFound' }
819
962
  if (!(await permits(actor, 'domains', key))) return { status: 'forbidden' }
820
- const result = await new DomainRepository(db).setForNode(node.id, assignment)
963
+ const result = await commit(
964
+ actor,
965
+ async (_tx, txDb) => {
966
+ const set = await new DomainRepository(txDb).setForNode(node.id, assignment)
967
+ // Every node routes by hostname from its published cache, so they are all told.
968
+ if (set.status === 'ok') await repoInstruction(node.key, txDb)
969
+ return set
970
+ },
971
+ (set) => (set.status === 'ok' ? [{ type: 'domainsAssigned', key: node.key }] : []),
972
+ )
821
973
  if (result.status !== 'ok') return result
822
- // Every node routes by hostname from its published cache, so they are all told.
823
- await repoInstruction(node.key)
824
974
  changed()
825
975
  const file = await options.afterDomains?.(node, assignment)
826
- await emit({ type: 'domainsAssigned', key: node.key }, actor)
827
976
  return file ? { status: 'ok', file } : { status: 'ok' }
828
977
  },
829
978
 
@@ -837,25 +986,33 @@ export function createDocumentOperations(options: DocumentOperationsOptions) {
837
986
  if (!(await permits(actor, 'publicAccess', key))) return 'forbidden'
838
987
  if (!(await isDocument(input.loginDocumentKey))) return 'login-node-not-found'
839
988
  if (!(await isDocument(input.errorDocumentKey))) return 'error-node-not-found'
840
- const saved = await new PublicAccessRepository(db).save({
841
- nodeKey: key,
842
- loginNodeKey: input.loginDocumentKey,
843
- errorNodeKey: input.errorDocumentKey,
844
- memberGroupNames: input.memberGroupNames,
845
- memberUserNames: input.memberUserNames,
846
- })
989
+ const saved = await commit(
990
+ actor,
991
+ (_tx, txDb) =>
992
+ new PublicAccessRepository(txDb).save({
993
+ nodeKey: key,
994
+ loginNodeKey: input.loginDocumentKey,
995
+ errorNodeKey: input.errorDocumentKey,
996
+ memberGroupNames: input.memberGroupNames,
997
+ memberUserNames: input.memberUserNames,
998
+ }),
999
+ (done) => (done ? [{ type: 'publicAccessChanged', key, change }] : []),
1000
+ )
847
1001
  if (!saved) return 'content-not-found'
848
1002
  changed()
849
- await emit({ type: 'publicAccessChanged', key, change }, actor)
850
1003
  return 'ok'
851
1004
  },
852
1005
 
853
1006
  async removePublicAccess(actor: Actor, key: string): Promise<PublicAccessStatus> {
854
1007
  if (!(await isDocument(key))) return 'content-not-found'
855
1008
  if (!(await permits(actor, 'publicAccess', key))) return 'forbidden'
856
- if (!(await new PublicAccessRepository(db).remove(key))) return 'entry-not-found'
1009
+ const removed = await commit(
1010
+ actor,
1011
+ (_tx, txDb) => new PublicAccessRepository(txDb).remove(key),
1012
+ (done) => (done ? [{ type: 'publicAccessChanged', key, change: 'removed' }] : []),
1013
+ )
1014
+ if (!removed) return 'entry-not-found'
857
1015
  changed()
858
- await emit({ type: 'publicAccessChanged', key, change: 'removed' }, actor)
859
1016
  return 'ok'
860
1017
  },
861
1018
 
@@ -869,8 +1026,8 @@ export function createDocumentOperations(options: DocumentOperationsOptions) {
869
1026
  }
870
1027
 
871
1028
  /** The content cache instruction other nodes rebuild on. */
872
- async function repoInstruction(key: string): Promise<void> {
873
- await appendCacheInstruction(db, {
1029
+ async function repoInstruction(key: string, on: Db = db): Promise<void> {
1030
+ await appendCacheInstruction(on, {
874
1031
  kind: 'content',
875
1032
  payload: { key },
876
1033
  by: options.repository?.nodeId,