@byline/core 4.14.0 → 4.15.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.
Files changed (62) hide show
  1. package/dist/@types/db-types.d.ts +222 -0
  2. package/dist/@types/site-config.d.ts +24 -0
  3. package/dist/codegen/index.test.node.js +1 -1
  4. package/dist/core.d.ts +8 -0
  5. package/dist/core.js +39 -0
  6. package/dist/index.d.ts +2 -0
  7. package/dist/index.js +8 -11
  8. package/dist/scheduler/define-recurring-task.d.ts +19 -0
  9. package/dist/scheduler/define-recurring-task.js +20 -0
  10. package/dist/scheduler/index.d.ts +19 -0
  11. package/dist/scheduler/index.js +18 -0
  12. package/dist/scheduler/run-due-tasks.d.ts +39 -0
  13. package/dist/scheduler/run-due-tasks.js +324 -0
  14. package/dist/scheduler/run-due-tasks.test.node.d.ts +8 -0
  15. package/dist/scheduler/run-due-tasks.test.node.js +396 -0
  16. package/dist/scheduler/scheduled-publication-constants.d.ts +11 -0
  17. package/dist/scheduler/scheduled-publication-constants.js +11 -0
  18. package/dist/scheduler/scheduled-publication.d.ts +31 -0
  19. package/dist/scheduler/scheduled-publication.js +198 -0
  20. package/dist/scheduler/scheduled-publication.test.node.d.ts +8 -0
  21. package/dist/scheduler/scheduled-publication.test.node.js +109 -0
  22. package/dist/scheduler/scheduler-boot.test.node.d.ts +8 -0
  23. package/dist/scheduler/scheduler-boot.test.node.js +103 -0
  24. package/dist/scheduler/ticker.d.ts +34 -0
  25. package/dist/scheduler/ticker.js +144 -0
  26. package/dist/scheduler/ticker.test.node.d.ts +8 -0
  27. package/dist/scheduler/ticker.test.node.js +249 -0
  28. package/dist/scheduler/types.d.ts +241 -0
  29. package/dist/scheduler/types.js +8 -0
  30. package/dist/scheduler/validate-scheduler-config.d.ts +19 -0
  31. package/dist/scheduler/validate-scheduler-config.js +25 -0
  32. package/dist/scheduler/validate-scheduler-config.test.node.d.ts +8 -0
  33. package/dist/scheduler/validate-scheduler-config.test.node.js +38 -0
  34. package/dist/scheduler/validate-tasks.d.ts +14 -0
  35. package/dist/scheduler/validate-tasks.js +40 -0
  36. package/dist/scheduler/validate-tasks.test.node.d.ts +8 -0
  37. package/dist/scheduler/validate-tasks.test.node.js +49 -0
  38. package/dist/services/collection-bootstrap.test.node.js +2 -0
  39. package/dist/services/discover-counter-groups.test.node.js +2 -0
  40. package/dist/services/document-lifecycle/audit.d.ts +6 -0
  41. package/dist/services/document-lifecycle/audit.js +6 -0
  42. package/dist/services/document-lifecycle/copy-to-locale.js +15 -10
  43. package/dist/services/document-lifecycle/delete-locale.js +18 -10
  44. package/dist/services/document-lifecycle/delete.js +8 -0
  45. package/dist/services/document-lifecycle/index.d.ts +1 -0
  46. package/dist/services/document-lifecycle/index.js +1 -0
  47. package/dist/services/document-lifecycle/publish-schedule-consistency.d.ts +36 -0
  48. package/dist/services/document-lifecycle/publish-schedule-consistency.js +80 -0
  49. package/dist/services/document-lifecycle/restore.js +15 -10
  50. package/dist/services/document-lifecycle/scheduled-publish.d.ts +51 -0
  51. package/dist/services/document-lifecycle/scheduled-publish.js +351 -0
  52. package/dist/services/document-lifecycle/status-transition.d.ts +42 -0
  53. package/dist/services/document-lifecycle/status-transition.js +41 -0
  54. package/dist/services/document-lifecycle/status-transition.test.node.d.ts +8 -0
  55. package/dist/services/document-lifecycle/status-transition.test.node.js +145 -0
  56. package/dist/services/document-lifecycle/status.js +30 -23
  57. package/dist/services/document-lifecycle/tree.test.node.js +3 -0
  58. package/dist/services/document-lifecycle/update.js +35 -30
  59. package/dist/services/document-lifecycle.test.node.js +262 -2
  60. package/dist/services/field-upload.test.node.js +2 -0
  61. package/dist/services/populate.test.node.js +2 -0
  62. 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 db.commands.documents.createDocumentVersion({
127
+ const writeResult = await commitContentVersionWithScheduleSuspension({
128
+ ctx,
127
129
  documentId: params.documentId,
128
- collectionId,
129
- collectionVersion: ctx.collectionVersion,
130
- collectionConfig: definition,
131
- action: 'copy_to_locale',
132
- documentData: merged.data,
133
- status: getDefaultStatus(definition),
134
- locale: params.targetLocale,
135
- previousVersionId,
136
- createdBy: actorId(ctx),
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 db.commands.documents.deleteDocumentLocale({
90
+ const result = await commitContentVersionWithScheduleSuspension({
91
+ ctx,
90
92
  documentId: params.documentId,
91
- locale: params.locale,
92
- status: getDefaultStatus(definition),
93
- createdBy: actorId(ctx),
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';