@byline/core 4.14.1 → 4.16.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/dist/@types/db-types.d.ts +222 -0
- package/dist/@types/site-config.d.ts +24 -0
- package/dist/codegen/index.test.node.js +1 -1
- package/dist/core.d.ts +8 -0
- package/dist/core.js +39 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +8 -11
- package/dist/scheduler/define-recurring-task.d.ts +19 -0
- package/dist/scheduler/define-recurring-task.js +20 -0
- package/dist/scheduler/index.d.ts +19 -0
- package/dist/scheduler/index.js +18 -0
- package/dist/scheduler/run-due-tasks.d.ts +39 -0
- package/dist/scheduler/run-due-tasks.js +324 -0
- package/dist/scheduler/run-due-tasks.test.node.d.ts +8 -0
- package/dist/scheduler/run-due-tasks.test.node.js +396 -0
- package/dist/scheduler/scheduled-publication-constants.d.ts +11 -0
- package/dist/scheduler/scheduled-publication-constants.js +11 -0
- package/dist/scheduler/scheduled-publication.d.ts +31 -0
- package/dist/scheduler/scheduled-publication.js +198 -0
- package/dist/scheduler/scheduled-publication.test.node.d.ts +8 -0
- package/dist/scheduler/scheduled-publication.test.node.js +109 -0
- package/dist/scheduler/scheduler-boot.test.node.d.ts +8 -0
- package/dist/scheduler/scheduler-boot.test.node.js +103 -0
- package/dist/scheduler/ticker.d.ts +34 -0
- package/dist/scheduler/ticker.js +144 -0
- package/dist/scheduler/ticker.test.node.d.ts +8 -0
- package/dist/scheduler/ticker.test.node.js +249 -0
- package/dist/scheduler/types.d.ts +241 -0
- package/dist/scheduler/types.js +8 -0
- package/dist/scheduler/validate-scheduler-config.d.ts +19 -0
- package/dist/scheduler/validate-scheduler-config.js +25 -0
- package/dist/scheduler/validate-scheduler-config.test.node.d.ts +8 -0
- package/dist/scheduler/validate-scheduler-config.test.node.js +38 -0
- package/dist/scheduler/validate-tasks.d.ts +14 -0
- package/dist/scheduler/validate-tasks.js +40 -0
- package/dist/scheduler/validate-tasks.test.node.d.ts +8 -0
- package/dist/scheduler/validate-tasks.test.node.js +49 -0
- package/dist/services/collection-bootstrap.test.node.js +2 -0
- package/dist/services/discover-counter-groups.test.node.js +2 -0
- package/dist/services/document-lifecycle/audit.d.ts +6 -0
- package/dist/services/document-lifecycle/audit.js +6 -0
- package/dist/services/document-lifecycle/copy-to-locale.js +15 -10
- package/dist/services/document-lifecycle/delete-locale.js +18 -10
- package/dist/services/document-lifecycle/delete.js +8 -0
- package/dist/services/document-lifecycle/index.d.ts +1 -0
- package/dist/services/document-lifecycle/index.js +1 -0
- package/dist/services/document-lifecycle/publish-schedule-consistency.d.ts +36 -0
- package/dist/services/document-lifecycle/publish-schedule-consistency.js +80 -0
- package/dist/services/document-lifecycle/restore.js +15 -10
- package/dist/services/document-lifecycle/scheduled-publish.d.ts +51 -0
- package/dist/services/document-lifecycle/scheduled-publish.js +351 -0
- package/dist/services/document-lifecycle/status-transition.d.ts +42 -0
- package/dist/services/document-lifecycle/status-transition.js +41 -0
- package/dist/services/document-lifecycle/status-transition.test.node.d.ts +8 -0
- package/dist/services/document-lifecycle/status-transition.test.node.js +145 -0
- package/dist/services/document-lifecycle/status.js +30 -23
- package/dist/services/document-lifecycle/tree.test.node.js +3 -0
- package/dist/services/document-lifecycle/update.js +35 -30
- package/dist/services/document-lifecycle.test.node.js +262 -2
- package/dist/services/field-upload.test.node.js +2 -0
- package/dist/services/populate.test.node.js +2 -0
- package/package.json +7 -2
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
import type { BylineLogger } from '../logger/index.js';
|
|
9
|
+
/** Lifecycle state of a registered task, as persisted by the store. */
|
|
10
|
+
export type RecurringTaskStatus = 'never_run' | 'running' | 'succeeded' | 'failed';
|
|
11
|
+
/**
|
|
12
|
+
* What a handler may tell the runner when it returns.
|
|
13
|
+
*
|
|
14
|
+
* `workRemaining` means the handler stopped on a batch budget rather than an
|
|
15
|
+
* empty queue. The runner then sets the next run to database-now instead of
|
|
16
|
+
* database-now plus the interval. This only accelerates a task whose interval
|
|
17
|
+
* exceeds the tick cadence; a task already at the 60s minimum becomes due on
|
|
18
|
+
* the next tick either way.
|
|
19
|
+
*/
|
|
20
|
+
export interface RecurringTaskResult {
|
|
21
|
+
workRemaining?: boolean;
|
|
22
|
+
}
|
|
23
|
+
/** Everything a handler is given for one execution. */
|
|
24
|
+
export interface RecurringTaskContext {
|
|
25
|
+
taskName: string;
|
|
26
|
+
/** The `next_run_at` that made this task due. Diagnostic, not a business cursor. */
|
|
27
|
+
scheduledFor: Date;
|
|
28
|
+
/** Aborted on shutdown or on lease loss. Handlers check it between batches. */
|
|
29
|
+
signal: AbortSignal;
|
|
30
|
+
logger: BylineLogger;
|
|
31
|
+
/** Renew the lease. Rejects when the lease has been lost, which aborts the run. */
|
|
32
|
+
heartbeat(): Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
export interface RecurringTaskDefinition {
|
|
35
|
+
/** Stable, code-owned, globally unique key, e.g. `analytics.rollup`. */
|
|
36
|
+
name: string;
|
|
37
|
+
/** Delay after a successful run. Minimum 60_000. */
|
|
38
|
+
intervalMs: number;
|
|
39
|
+
/** Initial lease window. Minimum 60_000. A long run renews before it expires. */
|
|
40
|
+
leaseMs: number;
|
|
41
|
+
run(context: RecurringTaskContext): Promise<RecurringTaskResult | void>;
|
|
42
|
+
}
|
|
43
|
+
/** A task successfully claimed by this instance. */
|
|
44
|
+
export interface ClaimedRecurringTask {
|
|
45
|
+
name: string;
|
|
46
|
+
/** Unique to this claim. Every later write is conditional on it. */
|
|
47
|
+
leaseToken: string;
|
|
48
|
+
scheduledFor: Date;
|
|
49
|
+
/** Database time at the moment of the claim. */
|
|
50
|
+
databaseNow: Date;
|
|
51
|
+
/**
|
|
52
|
+
* True when this claim took over a lease that had expired — i.e. a previous
|
|
53
|
+
* runner died mid-execution without recording an outcome. The runner logs a
|
|
54
|
+
* distinct `recovered-expired-lease` event for these. False for an ordinary
|
|
55
|
+
* claim of an unleased row.
|
|
56
|
+
*/
|
|
57
|
+
recoveredExpiredLease: boolean;
|
|
58
|
+
}
|
|
59
|
+
/** Read-only health row for diagnostics and admin surfaces. */
|
|
60
|
+
export interface RecurringTaskHealth {
|
|
61
|
+
name: string;
|
|
62
|
+
intervalMs: number;
|
|
63
|
+
nextRunAt: Date;
|
|
64
|
+
lastStatus: RecurringTaskStatus;
|
|
65
|
+
lastStartedAt: Date | null;
|
|
66
|
+
lastSucceededAt: Date | null;
|
|
67
|
+
lastFailedAt: Date | null;
|
|
68
|
+
lastDurationMs: number | null;
|
|
69
|
+
consecutiveFailures: number;
|
|
70
|
+
lastError: string | null;
|
|
71
|
+
/** True when a lease exists and has passed its expiry — a crashed runner. */
|
|
72
|
+
leaseExpired: boolean;
|
|
73
|
+
/** Database time when this row was read, so callers can judge staleness. */
|
|
74
|
+
databaseNow: Date;
|
|
75
|
+
}
|
|
76
|
+
/** What a definition contributes to reconciliation. */
|
|
77
|
+
export interface ReconcileTaskInput {
|
|
78
|
+
name: string;
|
|
79
|
+
intervalMs: number;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The optional scheduler capability a database adapter implements. Every method
|
|
83
|
+
* derives due-ness and expiry from database time, never from the process clock.
|
|
84
|
+
*
|
|
85
|
+
* A MySQL (or any third) adapter implementing this interface should be able to
|
|
86
|
+
* do so from the doc comments below without reading the design spec — every
|
|
87
|
+
* method states exactly which columns it reads and writes.
|
|
88
|
+
*
|
|
89
|
+
* One rule holds across every method and is not repeated below: every
|
|
90
|
+
* successful mutation — `reconcile`, `claim`, `renew`, `complete`, `fail` —
|
|
91
|
+
* also sets `updated_at` from database time, in addition to the per-method
|
|
92
|
+
* column lists that follow.
|
|
93
|
+
*/
|
|
94
|
+
export interface ISchedulerStore {
|
|
95
|
+
/**
|
|
96
|
+
* Insert rows for unknown names and update `interval_ms` for known ones,
|
|
97
|
+
* preserving health history. Must be safe to run concurrently from several
|
|
98
|
+
* instances — a deploy restarts them together.
|
|
99
|
+
*
|
|
100
|
+
* A brand-new row's `next_run_at` is database-now **plus** the task's
|
|
101
|
+
* `intervalMs` — a task never fires at module evaluation or immediately on
|
|
102
|
+
* deploy; the first execution is one interval after the row is created.
|
|
103
|
+
*
|
|
104
|
+
* For an existing, unleased row:
|
|
105
|
+
*
|
|
106
|
+
* - When the interval **decreases**, `next_run_at` is clamped to no later
|
|
107
|
+
* than database-now plus the new interval, so a task moving from a daily
|
|
108
|
+
* to a minute-grained cadence does not wait out the rest of the old day.
|
|
109
|
+
* - When the interval **increases**, an already-due or earlier-scheduled
|
|
110
|
+
* `next_run_at` is left alone — it is NOT postponed to reflect the new,
|
|
111
|
+
* longer interval. The new cadence takes effect starting from the next
|
|
112
|
+
* time the row completes successfully (see `complete` below).
|
|
113
|
+
*
|
|
114
|
+
* `interval_ms` is always updated, including on a row carrying a live
|
|
115
|
+
* lease (`lease_expires_at` in the future) — the lease protects
|
|
116
|
+
* `next_run_at` and the lease columns themselves (`lease_token`,
|
|
117
|
+
* `lease_owner`, `lease_expires_at`), which are left untouched while a
|
|
118
|
+
* lease is live, but not `interval_ms`. This matters because `complete`
|
|
119
|
+
* derives `next_run_at` from the row's persisted `interval_ms` rather than
|
|
120
|
+
* from a caller-supplied value (see `complete` below); if reconcile
|
|
121
|
+
* skipped leased rows entirely, a newly deployed cadence would not take
|
|
122
|
+
* effect until the in-flight lease released, which would partially defeat
|
|
123
|
+
* the reason `complete` stopped accepting an interval from the runner. A
|
|
124
|
+
* rolling deploy is exactly the moment both a reconcile and a live lease
|
|
125
|
+
* are likely to coincide.
|
|
126
|
+
*
|
|
127
|
+
* Rows for names no longer present in the registered task set are retained
|
|
128
|
+
* as dormant history: reconcile neither executes them nor deletes them.
|
|
129
|
+
* Pruning dormant rows is a future explicit maintenance operation, not a
|
|
130
|
+
* side effect of reconcile.
|
|
131
|
+
*/
|
|
132
|
+
reconcile(tasks: readonly ReconcileTaskInput[]): Promise<void>;
|
|
133
|
+
/**
|
|
134
|
+
* Atomically claim `name` if it is due (`next_run_at <= database now`) and
|
|
135
|
+
* either unleased or its lease has expired. Returns null when another
|
|
136
|
+
* instance won the race or the task is not yet due.
|
|
137
|
+
*
|
|
138
|
+
* On a successful claim, exactly these columns are mutated:
|
|
139
|
+
*
|
|
140
|
+
* - `lease_token` — set to a fresh, claim-unique token.
|
|
141
|
+
* - `lease_owner` — set to the caller-supplied `owner` label.
|
|
142
|
+
* - `lease_expires_at` — set to database-now plus `leaseMs`.
|
|
143
|
+
* - `last_started_at` — set to database-now.
|
|
144
|
+
* - `last_status` — set to `'running'`.
|
|
145
|
+
*
|
|
146
|
+
* `next_run_at` is deliberately left untouched by claim. The returned
|
|
147
|
+
* `scheduledFor` is that pre-claim `next_run_at` value — the due time that
|
|
148
|
+
* made the row eligible, not a new value computed at claim time. Because
|
|
149
|
+
* claim never advances `next_run_at`, a row whose runner died without
|
|
150
|
+
* calling `complete` or `fail` stays due, so another instance can reclaim
|
|
151
|
+
* it the moment `lease_expires_at` passes (`recoveredExpiredLease: true`
|
|
152
|
+
* on that claim — see `ClaimedRecurringTask`).
|
|
153
|
+
*/
|
|
154
|
+
claim(params: {
|
|
155
|
+
name: string;
|
|
156
|
+
leaseMs: number;
|
|
157
|
+
owner: string;
|
|
158
|
+
}): Promise<ClaimedRecurringTask | null>;
|
|
159
|
+
/**
|
|
160
|
+
* Extend a token-matched lease: sets `lease_expires_at` to database-now
|
|
161
|
+
* plus `leaseMs`. Every write here and below is conditioned on
|
|
162
|
+
* `lease_token = params.leaseToken` — a stale runner whose lease has since
|
|
163
|
+
* been reclaimed by another instance cannot successfully renew, complete,
|
|
164
|
+
* or fail the row. Expiry alone does not invalidate a still-matching token:
|
|
165
|
+
* a runner may renew after the deadline provided no other claimant has
|
|
166
|
+
* replaced its token. Returns `false` (never throws) only when the token does
|
|
167
|
+
* not match — i.e. the lease has actually been lost.
|
|
168
|
+
*/
|
|
169
|
+
renew(params: {
|
|
170
|
+
name: string;
|
|
171
|
+
leaseToken: string;
|
|
172
|
+
leaseMs: number;
|
|
173
|
+
}): Promise<boolean>;
|
|
174
|
+
/**
|
|
175
|
+
* Record success on a token-matched row.
|
|
176
|
+
*
|
|
177
|
+
* Fields cleared: `lease_token`, `lease_owner`, `lease_expires_at` (all set
|
|
178
|
+
* to null — the row is unleased again).
|
|
179
|
+
*
|
|
180
|
+
* Fields reset: `consecutive_failures` back to 0, `last_error` to null —
|
|
181
|
+
* a success always clears prior failure state regardless of how many
|
|
182
|
+
* failures preceded it.
|
|
183
|
+
*
|
|
184
|
+
* Fields recorded: `last_succeeded_at` and `last_duration_ms` from
|
|
185
|
+
* `durationMs`; `last_status` set to `'succeeded'`.
|
|
186
|
+
*
|
|
187
|
+
* `next_run_at` becomes database-now plus the row's **persisted**
|
|
188
|
+
* `interval_ms` — never an interval supplied by the caller. A runner that
|
|
189
|
+
* has been holding a lease across a rolling deploy may be carrying a stale
|
|
190
|
+
* cadence; reading the column instead means a newly reconciled interval
|
|
191
|
+
* always wins. When `workRemaining` is true, `next_run_at` becomes
|
|
192
|
+
* database-now instead, ignoring the interval entirely.
|
|
193
|
+
*
|
|
194
|
+
* Returns `false` (never throws) when the token does not match — the
|
|
195
|
+
* lease had already been lost.
|
|
196
|
+
*/
|
|
197
|
+
complete(params: {
|
|
198
|
+
name: string;
|
|
199
|
+
leaseToken: string;
|
|
200
|
+
durationMs: number;
|
|
201
|
+
workRemaining: boolean;
|
|
202
|
+
}): Promise<boolean>;
|
|
203
|
+
/**
|
|
204
|
+
* Record failure on a token-matched row.
|
|
205
|
+
*
|
|
206
|
+
* Fields cleared: `lease_token`, `lease_owner`, `lease_expires_at` (all set
|
|
207
|
+
* to null — the row is unleased again, so another instance may claim it
|
|
208
|
+
* once `next_run_at` is reached).
|
|
209
|
+
*
|
|
210
|
+
* Fields incremented / stored: `consecutive_failures` is incremented by
|
|
211
|
+
* one; `last_error` is stored, truncated to 2048 characters and never
|
|
212
|
+
* containing a stack trace (full stacks belong in the configured logger,
|
|
213
|
+
* not this column). `last_failed_at` and `last_duration_ms` are recorded
|
|
214
|
+
* from `durationMs`; `last_status` is set to `'failed'`.
|
|
215
|
+
*
|
|
216
|
+
* `next_run_at` becomes database-now plus a bounded backoff derived from
|
|
217
|
+
* the just-incremented `consecutive_failures`: 1, 2, 4, 8 minutes for the
|
|
218
|
+
* first four consecutive failures, then capped at 15 minutes for the fifth
|
|
219
|
+
* and every subsequent consecutive failure. A later success (`complete`)
|
|
220
|
+
* restores the configured `interval_ms` and resets this sequence — the
|
|
221
|
+
* backoff never compounds across a success.
|
|
222
|
+
*
|
|
223
|
+
* Returns `false` (never throws) when the token does not match — the
|
|
224
|
+
* lease had already been lost.
|
|
225
|
+
*/
|
|
226
|
+
fail(params: {
|
|
227
|
+
name: string;
|
|
228
|
+
leaseToken: string;
|
|
229
|
+
durationMs: number;
|
|
230
|
+
error: string;
|
|
231
|
+
}): Promise<boolean>;
|
|
232
|
+
/**
|
|
233
|
+
* Health rows for the named tasks, or all rows when `names` is omitted.
|
|
234
|
+
*
|
|
235
|
+
* `lease_owner` is a bounded, non-secret diagnostic label (typically a
|
|
236
|
+
* machine id plus process id), capped at 255 characters. Correctness never
|
|
237
|
+
* depends on it being unique — fencing is entirely a function of
|
|
238
|
+
* `lease_token`, never of `lease_owner`.
|
|
239
|
+
*/
|
|
240
|
+
health(names?: readonly string[]): Promise<RecurringTaskHealth[]>;
|
|
241
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
import type { ISchedulerStore, RecurringTaskDefinition } from './types.js';
|
|
9
|
+
/**
|
|
10
|
+
* Boot-time gate. Recurring tasks registered against an adapter that does not
|
|
11
|
+
* implement the optional scheduler capability would silently never run, so this
|
|
12
|
+
* fails loudly at `initBylineCore()` instead.
|
|
13
|
+
*/
|
|
14
|
+
export declare function validateSchedulerConfig(params: {
|
|
15
|
+
tasks?: readonly RecurringTaskDefinition[];
|
|
16
|
+
adapter: {
|
|
17
|
+
scheduler?: ISchedulerStore;
|
|
18
|
+
};
|
|
19
|
+
}): void;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
import { validateRecurringTasks } from './validate-tasks.js';
|
|
9
|
+
/**
|
|
10
|
+
* Boot-time gate. Recurring tasks registered against an adapter that does not
|
|
11
|
+
* implement the optional scheduler capability would silently never run, so this
|
|
12
|
+
* fails loudly at `initBylineCore()` instead.
|
|
13
|
+
*/
|
|
14
|
+
export function validateSchedulerConfig(params) {
|
|
15
|
+
const tasks = params.tasks ?? [];
|
|
16
|
+
if (tasks.length === 0)
|
|
17
|
+
return;
|
|
18
|
+
validateRecurringTasks(tasks);
|
|
19
|
+
if (params.adapter.scheduler == null) {
|
|
20
|
+
const names = tasks.map((t) => t.name).join(', ');
|
|
21
|
+
throw new Error(`recurring tasks are registered (${names}) but the configured database adapter does not ` +
|
|
22
|
+
'implement the scheduler capability. Use a canonical adapter (@byline/db-postgres or ' +
|
|
23
|
+
'@byline/db-mysql), or remove the tasks.');
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, it } from 'vitest';
|
|
9
|
+
import { defineRecurringTask } from './define-recurring-task.js';
|
|
10
|
+
import { validateSchedulerConfig } from './validate-scheduler-config.js';
|
|
11
|
+
const task = defineRecurringTask({
|
|
12
|
+
name: 'analytics.rollup',
|
|
13
|
+
intervalMs: 3_600_000,
|
|
14
|
+
leaseMs: 300_000,
|
|
15
|
+
run: async () => { },
|
|
16
|
+
});
|
|
17
|
+
const store = {};
|
|
18
|
+
describe('validateSchedulerConfig', () => {
|
|
19
|
+
it('passes when tasks are registered against a scheduler-capable adapter', () => {
|
|
20
|
+
expect(() => validateSchedulerConfig({ tasks: [task], adapter: { scheduler: store } })).not.toThrow();
|
|
21
|
+
});
|
|
22
|
+
it('passes when no tasks are registered and the adapter lacks the capability', () => {
|
|
23
|
+
expect(() => validateSchedulerConfig({ tasks: [], adapter: {} })).not.toThrow();
|
|
24
|
+
expect(() => validateSchedulerConfig({ adapter: {} })).not.toThrow();
|
|
25
|
+
});
|
|
26
|
+
it('fails when tasks are registered against an adapter without the capability', () => {
|
|
27
|
+
expect(() => validateSchedulerConfig({ tasks: [task], adapter: {} })).toThrow(/scheduler/i);
|
|
28
|
+
});
|
|
29
|
+
it('names the offending tasks in the failure message', () => {
|
|
30
|
+
expect(() => validateSchedulerConfig({ tasks: [task], adapter: {} })).toThrow(/analytics\.rollup/);
|
|
31
|
+
});
|
|
32
|
+
it('applies task validation as part of config validation', () => {
|
|
33
|
+
expect(() => validateSchedulerConfig({
|
|
34
|
+
tasks: [{ ...task, intervalMs: 1_000 }],
|
|
35
|
+
adapter: { scheduler: store },
|
|
36
|
+
})).toThrow(/interval/i);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
import type { RecurringTaskDefinition } from './types.js';
|
|
9
|
+
/**
|
|
10
|
+
* Boot-time validation of the registered task set. Throws on the first problem
|
|
11
|
+
* so a misconfigured deployment fails loudly at startup rather than silently
|
|
12
|
+
* never running work.
|
|
13
|
+
*/
|
|
14
|
+
export declare function validateRecurringTasks(definitions: readonly RecurringTaskDefinition[]): void;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
import { MIN_INTERVAL_MS, MIN_LEASE_MS } from './define-recurring-task.js';
|
|
9
|
+
function assertDuration(value, label, minimum, taskName) {
|
|
10
|
+
if (!Number.isSafeInteger(value)) {
|
|
11
|
+
throw new Error(`recurring task '${taskName}': ${label} must be a whole number of milliseconds ` +
|
|
12
|
+
`(received ${value})`);
|
|
13
|
+
}
|
|
14
|
+
if (value < minimum) {
|
|
15
|
+
throw new Error(`recurring task '${taskName}': ${label} must be at least ${minimum}ms (received ${value})`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Boot-time validation of the registered task set. Throws on the first problem
|
|
20
|
+
* so a misconfigured deployment fails loudly at startup rather than silently
|
|
21
|
+
* never running work.
|
|
22
|
+
*/
|
|
23
|
+
export function validateRecurringTasks(definitions) {
|
|
24
|
+
const seen = new Set();
|
|
25
|
+
for (const definition of definitions) {
|
|
26
|
+
const name = definition?.name;
|
|
27
|
+
if (typeof name !== 'string' || name.trim().length === 0) {
|
|
28
|
+
throw new Error('recurring task: name must be a non-empty string');
|
|
29
|
+
}
|
|
30
|
+
if (seen.has(name)) {
|
|
31
|
+
throw new Error(`recurring task '${name}': duplicate task name`);
|
|
32
|
+
}
|
|
33
|
+
seen.add(name);
|
|
34
|
+
assertDuration(definition.intervalMs, 'intervalMs', MIN_INTERVAL_MS, name);
|
|
35
|
+
assertDuration(definition.leaseMs, 'leaseMs', MIN_LEASE_MS, name);
|
|
36
|
+
if (typeof definition.run !== 'function') {
|
|
37
|
+
throw new Error(`recurring task '${name}': run must be a function`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, it } from 'vitest';
|
|
9
|
+
import { defineRecurringTask } from './define-recurring-task.js';
|
|
10
|
+
import { validateRecurringTasks } from './validate-tasks.js';
|
|
11
|
+
const ok = defineRecurringTask({
|
|
12
|
+
name: 'analytics.rollup',
|
|
13
|
+
intervalMs: 3_600_000,
|
|
14
|
+
leaseMs: 300_000,
|
|
15
|
+
run: async () => { },
|
|
16
|
+
});
|
|
17
|
+
describe('validateRecurringTasks', () => {
|
|
18
|
+
it('accepts a valid set', () => {
|
|
19
|
+
expect(() => validateRecurringTasks([ok])).not.toThrow();
|
|
20
|
+
});
|
|
21
|
+
it('accepts an empty set', () => {
|
|
22
|
+
expect(() => validateRecurringTasks([])).not.toThrow();
|
|
23
|
+
});
|
|
24
|
+
it('rejects duplicate names', () => {
|
|
25
|
+
expect(() => validateRecurringTasks([ok, { ...ok }])).toThrow(/duplicate/i);
|
|
26
|
+
});
|
|
27
|
+
it('rejects a blank name', () => {
|
|
28
|
+
expect(() => validateRecurringTasks([{ ...ok, name: ' ' }])).toThrow(/name/i);
|
|
29
|
+
});
|
|
30
|
+
it('rejects an interval below the 60s minimum', () => {
|
|
31
|
+
expect(() => validateRecurringTasks([{ ...ok, intervalMs: 59_999 }])).toThrow(/interval/i);
|
|
32
|
+
});
|
|
33
|
+
it('rejects a lease below the 60s minimum', () => {
|
|
34
|
+
expect(() => validateRecurringTasks([{ ...ok, leaseMs: 59_999 }])).toThrow(/lease/i);
|
|
35
|
+
});
|
|
36
|
+
it('rejects non-finite durations', () => {
|
|
37
|
+
expect(() => validateRecurringTasks([{ ...ok, intervalMs: Number.NaN }])).toThrow(/interval/i);
|
|
38
|
+
expect(() => validateRecurringTasks([{ ...ok, leaseMs: Number.POSITIVE_INFINITY }])).toThrow(/lease/i);
|
|
39
|
+
});
|
|
40
|
+
it('rejects a fractional interval', () => {
|
|
41
|
+
expect(() => validateRecurringTasks([{ ...ok, intervalMs: 60_000.5 }])).toThrow(/whole number/i);
|
|
42
|
+
});
|
|
43
|
+
it('accepts a large, valid interval (30 days)', () => {
|
|
44
|
+
expect(() => validateRecurringTasks([{ ...ok, intervalMs: 30 * 86_400_000 }])).not.toThrow();
|
|
45
|
+
});
|
|
46
|
+
it('rejects a missing run function', () => {
|
|
47
|
+
expect(() => validateRecurringTasks([{ ...ok, run: undefined }])).toThrow(/run/i);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -36,6 +36,7 @@ function createMockDb(options) {
|
|
|
36
36
|
commands: {
|
|
37
37
|
collections: { create, update, delete: vi.fn(fail) },
|
|
38
38
|
documents: {
|
|
39
|
+
publishSchedules: {},
|
|
39
40
|
createDocumentVersion: vi.fn(fail),
|
|
40
41
|
updateDocumentPath: vi.fn(fail),
|
|
41
42
|
setDocumentAvailableLocales: vi.fn(fail),
|
|
@@ -72,6 +73,7 @@ function createMockDb(options) {
|
|
|
72
73
|
getCollectionById: vi.fn(fail),
|
|
73
74
|
},
|
|
74
75
|
documents: {
|
|
76
|
+
publishSchedules: {},
|
|
75
77
|
getDocumentSystemFieldsForUpdate: vi.fn(async () => null),
|
|
76
78
|
getDocumentById: vi.fn(fail),
|
|
77
79
|
getCurrentVersionMetadata: vi.fn(fail),
|
|
@@ -23,6 +23,7 @@ function makeAdapter(options) {
|
|
|
23
23
|
commands: {
|
|
24
24
|
collections: { create: vi.fn(fail), update: vi.fn(fail), delete: vi.fn(fail) },
|
|
25
25
|
documents: {
|
|
26
|
+
publishSchedules: {},
|
|
26
27
|
createDocumentVersion: vi.fn(fail),
|
|
27
28
|
updateDocumentPath: vi.fn(fail),
|
|
28
29
|
setDocumentAvailableLocales: vi.fn(fail),
|
|
@@ -59,6 +60,7 @@ function makeAdapter(options) {
|
|
|
59
60
|
getCollectionById: vi.fn(fail),
|
|
60
61
|
},
|
|
61
62
|
documents: {
|
|
63
|
+
publishSchedules: {},
|
|
62
64
|
getDocumentSystemFieldsForUpdate: vi.fn(async () => null),
|
|
63
65
|
getDocumentById: vi.fn(fail),
|
|
64
66
|
getCurrentVersionMetadata: vi.fn(fail),
|
|
@@ -12,6 +12,12 @@ export declare const AUDIT_ACTIONS: {
|
|
|
12
12
|
readonly pathChanged: 'document.path.changed';
|
|
13
13
|
readonly localesChanged: 'document.locales.changed';
|
|
14
14
|
readonly statusChanged: 'document.status.changed';
|
|
15
|
+
readonly publishScheduled: 'document.publish.scheduled';
|
|
16
|
+
readonly publishRescheduled: 'document.publish.rescheduled';
|
|
17
|
+
readonly publishReconfirmed: 'document.publish.reconfirmed';
|
|
18
|
+
readonly publishScheduleCancelled: 'document.publish.schedule.cancelled';
|
|
19
|
+
readonly publishScheduleSuspended: 'document.publish.schedule.suspended';
|
|
20
|
+
readonly publishScheduleDiscarded: 'document.publish.schedule.discarded';
|
|
15
21
|
readonly deleted: 'document.deleted';
|
|
16
22
|
readonly treePlaced: 'document.tree.placed';
|
|
17
23
|
readonly treeReparented: 'document.tree.reparented';
|
|
@@ -21,6 +21,12 @@ export const AUDIT_ACTIONS = {
|
|
|
21
21
|
pathChanged: 'document.path.changed',
|
|
22
22
|
localesChanged: 'document.locales.changed',
|
|
23
23
|
statusChanged: 'document.status.changed',
|
|
24
|
+
publishScheduled: 'document.publish.scheduled',
|
|
25
|
+
publishRescheduled: 'document.publish.rescheduled',
|
|
26
|
+
publishReconfirmed: 'document.publish.reconfirmed',
|
|
27
|
+
publishScheduleCancelled: 'document.publish.schedule.cancelled',
|
|
28
|
+
publishScheduleSuspended: 'document.publish.schedule.suspended',
|
|
29
|
+
publishScheduleDiscarded: 'document.publish.schedule.discarded',
|
|
24
30
|
deleted: 'document.deleted',
|
|
25
31
|
treePlaced: 'document.tree.placed',
|
|
26
32
|
treeReparented: 'document.tree.reparented',
|
|
@@ -12,6 +12,7 @@ import { withLogContext } from '../../lib/logger.js';
|
|
|
12
12
|
import { getDefaultStatus } from '../../workflow/workflow.js';
|
|
13
13
|
import { actorId, applyRichTextEmbed, extractVersionId, invokeHook } from './internals.js';
|
|
14
14
|
import { mergeLocaleData } from './merge-locale-data.js';
|
|
15
|
+
import { commitContentVersionWithScheduleSuspension } from './publish-schedule-consistency.js';
|
|
15
16
|
/**
|
|
16
17
|
* Copy a document's content from one locale into another, in place on
|
|
17
18
|
* the same document.
|
|
@@ -123,17 +124,21 @@ export async function copyToLocale(ctx, params) {
|
|
|
123
124
|
// rewritten by this call).
|
|
124
125
|
const previousVersionId = targetRecord.document_version_id ?? undefined;
|
|
125
126
|
await applyRichTextEmbed(ctx, merged.data);
|
|
126
|
-
const writeResult = await
|
|
127
|
+
const writeResult = await commitContentVersionWithScheduleSuspension({
|
|
128
|
+
ctx,
|
|
127
129
|
documentId: params.documentId,
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
130
|
+
write: () => db.commands.documents.createDocumentVersion({
|
|
131
|
+
documentId: params.documentId,
|
|
132
|
+
collectionId,
|
|
133
|
+
collectionVersion: ctx.collectionVersion,
|
|
134
|
+
collectionConfig: definition,
|
|
135
|
+
action: 'copy_to_locale',
|
|
136
|
+
documentData: merged.data,
|
|
137
|
+
status: getDefaultStatus(definition),
|
|
138
|
+
locale: params.targetLocale,
|
|
139
|
+
previousVersionId,
|
|
140
|
+
createdBy: actorId(ctx),
|
|
141
|
+
}),
|
|
137
142
|
});
|
|
138
143
|
const documentVersionId = extractVersionId(writeResult.document);
|
|
139
144
|
await invokeHook(hooks?.afterUpdate, {
|
|
@@ -11,6 +11,7 @@ import { ERR_NOT_FOUND, ERR_VALIDATION } from '../../lib/errors.js';
|
|
|
11
11
|
import { withLogContext } from '../../lib/logger.js';
|
|
12
12
|
import { getDefaultStatus } from '../../workflow/workflow.js';
|
|
13
13
|
import { actorId, invokeHook } from './internals.js';
|
|
14
|
+
import { commitContentVersionWithScheduleSuspension } from './publish-schedule-consistency.js';
|
|
14
15
|
/**
|
|
15
16
|
* Remove one content locale's data from a document, in place on the same
|
|
16
17
|
* document, by writing a new immutable version that omits that locale's
|
|
@@ -86,18 +87,25 @@ export async function deleteLocale(ctx, params) {
|
|
|
86
87
|
collectionPath,
|
|
87
88
|
deleteLocale: deleteLocaleMarker,
|
|
88
89
|
});
|
|
89
|
-
const result = await
|
|
90
|
+
const result = await commitContentVersionWithScheduleSuspension({
|
|
91
|
+
ctx,
|
|
90
92
|
documentId: params.documentId,
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
93
|
+
write: async () => {
|
|
94
|
+
const deleted = await db.commands.documents.deleteDocumentLocale({
|
|
95
|
+
documentId: params.documentId,
|
|
96
|
+
locale: params.locale,
|
|
97
|
+
status: getDefaultStatus(definition),
|
|
98
|
+
createdBy: actorId(ctx),
|
|
99
|
+
});
|
|
100
|
+
if (deleted == null) {
|
|
101
|
+
throw ERR_NOT_FOUND({
|
|
102
|
+
message: 'document not found',
|
|
103
|
+
details: { documentId: params.documentId, collectionPath },
|
|
104
|
+
}).log(ctx.logger);
|
|
105
|
+
}
|
|
106
|
+
return deleted;
|
|
107
|
+
},
|
|
94
108
|
});
|
|
95
|
-
if (result == null) {
|
|
96
|
-
throw ERR_NOT_FOUND({
|
|
97
|
-
message: 'document not found',
|
|
98
|
-
details: { documentId: params.documentId, collectionPath },
|
|
99
|
-
}).log(ctx.logger);
|
|
100
|
-
}
|
|
101
109
|
await invokeHook(hooks?.afterUpdate, {
|
|
102
110
|
data: originalData,
|
|
103
111
|
originalData,
|
|
@@ -11,6 +11,7 @@ import { ERR_NOT_FOUND, ErrorCodes } from '../../lib/errors.js';
|
|
|
11
11
|
import { withLogContext } from '../../lib/logger.js';
|
|
12
12
|
import { AUDIT_ACTIONS, auditActor, requireAuditCapability, requireTreeAuditCapability, } from './audit.js';
|
|
13
13
|
import { invokeHook } from './internals.js';
|
|
14
|
+
import { appendPublishScheduleCancellationAudit, cancelPublishScheduleInTransaction, } from './publish-schedule-consistency.js';
|
|
14
15
|
import { firePromoteTreeChange, reconcileTreeOnDeleteInTransaction } from './tree.js';
|
|
15
16
|
function readErrorCode(error) {
|
|
16
17
|
try {
|
|
@@ -87,6 +88,7 @@ export async function deleteDocument(ctx, params) {
|
|
|
87
88
|
let deletedVersionCount = 0;
|
|
88
89
|
let treeResult;
|
|
89
90
|
await audit.withTransaction(async () => {
|
|
91
|
+
const cancelledSchedule = await cancelPublishScheduleInTransaction(ctx, params.documentId);
|
|
90
92
|
deletedVersionCount = await db.commands.documents.softDeleteDocument({
|
|
91
93
|
document_id: params.documentId,
|
|
92
94
|
});
|
|
@@ -100,6 +102,12 @@ export async function deleteDocument(ctx, params) {
|
|
|
100
102
|
if (treeAudit != null) {
|
|
101
103
|
treeResult = await reconcileTreeOnDeleteInTransaction(ctx, params.documentId, treeAudit);
|
|
102
104
|
}
|
|
105
|
+
await appendPublishScheduleCancellationAudit({
|
|
106
|
+
ctx,
|
|
107
|
+
audit,
|
|
108
|
+
schedule: cancelledSchedule,
|
|
109
|
+
reason: 'soft_deleted',
|
|
110
|
+
});
|
|
103
111
|
});
|
|
104
112
|
// Everything below is post-commit. Each operation and the logger get an
|
|
105
113
|
// independent attempt; none can turn the committed delete into a rejection.
|
|
@@ -28,6 +28,7 @@ export { deleteDocument } from './delete.js';
|
|
|
28
28
|
export { deleteLocale } from './delete-locale.js';
|
|
29
29
|
export { duplicateDocument } from './duplicate.js';
|
|
30
30
|
export { restoreDocumentVersion } from './restore.js';
|
|
31
|
+
export { cancelDocumentScheduledPublish, confirmDocumentScheduledPublish, getDocumentScheduledPublish, listDocumentPublishSchedules, scheduleDocumentPublish, } from './scheduled-publish.js';
|
|
31
32
|
export { changeDocumentStatus, unpublishDocument } from './status.js';
|
|
32
33
|
export { updateDocumentSystemFields } from './system-fields.js';
|
|
33
34
|
export { placeTreeNode, promoteChildrenAndRemove, removeFromTree } from './tree.js';
|
|
@@ -28,6 +28,7 @@ export { deleteDocument } from './delete.js';
|
|
|
28
28
|
export { deleteLocale } from './delete-locale.js';
|
|
29
29
|
export { duplicateDocument } from './duplicate.js';
|
|
30
30
|
export { restoreDocumentVersion } from './restore.js';
|
|
31
|
+
export { cancelDocumentScheduledPublish, confirmDocumentScheduledPublish, getDocumentScheduledPublish, listDocumentPublishSchedules, scheduleDocumentPublish, } from './scheduled-publish.js';
|
|
31
32
|
export { changeDocumentStatus, unpublishDocument } from './status.js';
|
|
32
33
|
export { updateDocumentSystemFields } from './system-fields.js';
|
|
33
34
|
export { placeTreeNode, promoteChildrenAndRemove, removeFromTree } from './tree.js';
|