@crombolo/content 0.23.0 → 0.24.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/events.ts +15 -5
- package/src/operations.ts +257 -100
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crombolo/content",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.1",
|
|
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.
|
|
33
|
-
"@crombolo/data": "0.
|
|
32
|
+
"@crombolo/core": "0.24.1",
|
|
33
|
+
"@crombolo/data": "0.24.1"
|
|
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
|
-
/**
|
|
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
|
-
|
|
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 = {
|
|
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
|
|
45
|
-
for (const sink of sinks) await sink.
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
316
|
-
const updated = await
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
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
|
|
362
|
-
|
|
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
|
-
|
|
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
|
|
402
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
const
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
672
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
735
|
-
|
|
736
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
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
|
-
|
|
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(
|
|
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,
|