velocious 1.0.596 → 1.0.597
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/README.md +16 -0
- package/build/background-jobs/pooled-runner-broker-identity.js +22 -4
- package/build/background-jobs/store.js +383 -9
- package/build/background-jobs/types.js +1 -0
- package/build/cli/commands/db/tenants/migrations/pending.js +45 -0
- package/build/configuration-types.js +3 -0
- package/build/configuration.js +5 -2
- package/build/database/drivers/sqlite/base.js +3 -3
- package/build/database/tenants/migration-pending-inspector.js +77 -0
- package/build/environment-handlers/base.js +8 -0
- package/build/environment-handlers/node.js +14 -0
- package/build/mailer/backends/resend-smtp.js +121 -0
- package/build/mailer/base.js +52 -22
- package/build/mailer/delivery-operation-store.js +152 -0
- package/build/mailer/delivery-operation.js +205 -0
- package/build/mailer/delivery.js +9 -4
- package/build/mailer/index.js +5 -1
- package/build/mailer.js +6 -1
- package/build/src/background-jobs/pooled-runner-broker-identity.d.ts +10 -1
- package/build/src/background-jobs/pooled-runner-broker-identity.d.ts.map +1 -1
- package/build/src/background-jobs/pooled-runner-broker-identity.js +23 -5
- package/build/src/background-jobs/store.d.ts +154 -0
- package/build/src/background-jobs/store.d.ts.map +1 -1
- package/build/src/background-jobs/store.js +350 -10
- package/build/src/background-jobs/types.d.ts +5 -0
- package/build/src/background-jobs/types.d.ts.map +1 -1
- package/build/src/background-jobs/types.js +2 -1
- package/build/src/cli/commands/db/tenants/migrations/pending.d.ts +15 -0
- package/build/src/cli/commands/db/tenants/migrations/pending.d.ts.map +1 -0
- package/build/src/cli/commands/db/tenants/migrations/pending.js +40 -0
- package/build/src/configuration-types.d.ts +20 -0
- package/build/src/configuration-types.d.ts.map +1 -1
- package/build/src/configuration-types.js +4 -1
- package/build/src/configuration.d.ts +2 -2
- package/build/src/configuration.d.ts.map +1 -1
- package/build/src/configuration.js +6 -3
- package/build/src/database/drivers/sqlite/base.js +4 -4
- package/build/src/database/tenants/migration-pending-inspector.d.ts +38 -0
- package/build/src/database/tenants/migration-pending-inspector.d.ts.map +1 -0
- package/build/src/database/tenants/migration-pending-inspector.js +70 -0
- package/build/src/environment-handlers/base.d.ts +10 -0
- package/build/src/environment-handlers/base.d.ts.map +1 -1
- package/build/src/environment-handlers/base.js +8 -1
- package/build/src/environment-handlers/node.d.ts +11 -0
- package/build/src/environment-handlers/node.d.ts.map +1 -1
- package/build/src/environment-handlers/node.js +14 -1
- package/build/src/mailer/backends/resend-smtp.d.ts +43 -0
- package/build/src/mailer/backends/resend-smtp.d.ts.map +1 -0
- package/build/src/mailer/backends/resend-smtp.js +106 -0
- package/build/src/mailer/base.d.ts +9 -2
- package/build/src/mailer/base.d.ts.map +1 -1
- package/build/src/mailer/base.js +41 -19
- package/build/src/mailer/delivery-operation-store.d.ts +52 -0
- package/build/src/mailer/delivery-operation-store.d.ts.map +1 -0
- package/build/src/mailer/delivery-operation-store.js +130 -0
- package/build/src/mailer/delivery-operation.d.ts +73 -0
- package/build/src/mailer/delivery-operation.d.ts.map +1 -0
- package/build/src/mailer/delivery-operation.js +185 -0
- package/build/src/mailer/delivery.d.ts +4 -2
- package/build/src/mailer/delivery.d.ts.map +1 -1
- package/build/src/mailer/delivery.js +9 -5
- package/build/src/mailer/index.d.ts +24 -1
- package/build/src/mailer/index.d.ts.map +1 -1
- package/build/src/mailer/index.js +6 -2
- package/build/src/mailer.d.ts +11 -13
- package/build/src/mailer.d.ts.map +1 -1
- package/build/src/mailer.js +7 -2
- package/build/src/sync/local-mutation-log.d.ts +5 -5
- package/build/src/sync/local-mutation-log.d.ts.map +1 -1
- package/build/src/sync/local-mutation-log.js +34 -10
- package/build/src/sync/stable-json.d.ts +1 -8
- package/build/src/sync/stable-json.d.ts.map +1 -1
- package/build/src/sync/stable-json.js +2 -27
- package/build/src/sync/sync-envelope-replay-service.d.ts +3 -2
- package/build/src/sync/sync-envelope-replay-service.d.ts.map +1 -1
- package/build/src/sync/sync-envelope-replay-service.js +12 -8
- package/build/src/utils/stable-json.d.ts +7 -0
- package/build/src/utils/stable-json.d.ts.map +1 -0
- package/build/src/utils/stable-json.js +25 -0
- package/build/sync/local-mutation-log.js +37 -6
- package/build/sync/stable-json.js +1 -28
- package/build/sync/sync-envelope-replay-service.js +11 -7
- package/build/tsconfig.tsbuildinfo +1 -1
- package/build/utils/stable-json.js +26 -0
- package/package.json +1 -1
- package/src/background-jobs/pooled-runner-broker-identity.js +22 -4
- package/src/background-jobs/store.js +383 -9
- package/src/background-jobs/types.js +1 -0
- package/src/cli/commands/db/tenants/migrations/pending.js +45 -0
- package/src/configuration-types.js +3 -0
- package/src/configuration.js +5 -2
- package/src/database/drivers/sqlite/base.js +3 -3
- package/src/database/tenants/migration-pending-inspector.js +77 -0
- package/src/environment-handlers/base.js +8 -0
- package/src/environment-handlers/node.js +14 -0
- package/src/mailer/backends/resend-smtp.js +121 -0
- package/src/mailer/base.js +52 -22
- package/src/mailer/delivery-operation-store.js +152 -0
- package/src/mailer/delivery-operation.js +205 -0
- package/src/mailer/delivery.js +9 -4
- package/src/mailer/index.js +5 -1
- package/src/mailer.js +6 -1
- package/src/sync/local-mutation-log.js +37 -6
- package/src/sync/stable-json.js +1 -28
- package/src/sync/sync-envelope-replay-service.js +11 -7
- package/src/utils/stable-json.js +26 -0
package/README.md
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* Declarative state machines for models, with typed event methods generated into the base model (see [docs/state-machine.md](docs/state-machine.md))
|
|
10
10
|
* Migrations for schema changes and UTC datetime storage (see [docs/database-migrations.md](docs/database-migrations.md))
|
|
11
11
|
* Tenant-selected base-model and structure generation with one immutable, fail-closed physical database context; tenant-only model metadata initializes only after that context is active (see [docs/tenant-selected-database-generation.md](docs/tenant-selected-database-generation.md))
|
|
12
|
+
* Read-only tenant migration deploy preflight with stable JSON output and fail-closed ledger reads (see [docs/tenant-migration-deploy-preflight.md](docs/tenant-migration-deploy-preflight.md))
|
|
12
13
|
* External packages (engines) that contribute data models, frontend-model resources and migrations to a consuming app (see [docs/packages.md](docs/packages.md))
|
|
13
14
|
* Optional Rampway-owned durable deployment control plane mounted through the standard routes DSL on Velocious 1.0.577 or newer (see [docs/rampway-integration.md](docs/rampway-integration.md))
|
|
14
15
|
* Controllers and views for HTTP endpoints
|
|
@@ -389,6 +390,19 @@ await new TasksMailer().newNotification(task, user).deliverNow()
|
|
|
389
390
|
await new TasksMailer().newNotification(task, user).deliverLater()
|
|
390
391
|
```
|
|
391
392
|
|
|
393
|
+
For a provider that advertises duplicate suppression, a producer can require one stable mail operation across outbox replay and native-job retries:
|
|
394
|
+
|
|
395
|
+
```js
|
|
396
|
+
await new TasksMailer().newNotification(task, user).deliverLater({
|
|
397
|
+
deliveryOperation: {
|
|
398
|
+
id: `project-command:${command.id()}`,
|
|
399
|
+
idempotency: "required"
|
|
400
|
+
}
|
|
401
|
+
})
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
Required delivery fails before enqueue on unsupported backends, rejects the same id with changed rendered content, and fails closed after the provider retention window. Direct required `deliverPayload()` calls also fail before provider I/O when the background-jobs database connection is already inside a caller-owned transaction, because an outer rollback could erase the first-attempt marker. Generic SMTP remains at-least-once. Velocious includes a dedicated `ResendSmtpMailerBackend` for Resend's 24-hour `Resend-Idempotency-Key` contract; see [Mailers](docs/mailers.md#provider-backed-idempotent-background-delivery) for setup, expiry, reconciliation, and non-exactly-once guarantees.
|
|
405
|
+
|
|
392
406
|
Build the rendered payload without sending when the app needs to store an audit copy or hand delivery to its own transport:
|
|
393
407
|
|
|
394
408
|
```js
|
|
@@ -2399,6 +2413,8 @@ A queued owner is atomically cancelled during replacement/cancellation. A `previ
|
|
|
2399
2413
|
|
|
2400
2414
|
Set `deduplicateWhileQueued: true` to coalesce an enqueue onto the earliest identical queued job with the same job name, arguments, and queue when that existing job is scheduled no later than the new request. A retry backed off into the future does not suppress a new immediate enqueue, while repeated immediate triggers and equal or later schedules still coalesce.
|
|
2401
2415
|
|
|
2416
|
+
Use `options: {idempotencyKey}` when producer replay must converge on the original durable job across every state and even after terminal-job pruning. Ownership is scoped to the resolved job class name, resolved queue, and key; reusing that scope with changed canonical arguments or behavior-affecting options fails. This is distinct from queued-only deduplication, and ownership rows are intentionally retained until a future explicit reconciliation/deletion policy. See [durable idempotent enqueue](docs/background-jobs.md#durable-idempotent-enqueue).
|
|
2417
|
+
|
|
2402
2418
|
Select a non-default runtime explicitly with `options: {executionMode: "inline" | "forked" | "spawned"}`.
|
|
2403
2419
|
|
|
2404
2420
|
Inline jobs share the worker process and run concurrently up to `maxConcurrentInlineJobs`, so a single slow inline job no longer blocks the queue. A single worker can also override the configured cap explicitly:
|
|
@@ -23,16 +23,18 @@ export default class PooledRunnerBrokerIdentity {
|
|
|
23
23
|
/**
|
|
24
24
|
* Prepares one identity, sharing an in-flight same-identity rotation.
|
|
25
25
|
* @param {import("../testing/shared-transaction-proxy-driver.js").SharedTransactionBrokerJobConfig} config - Dispatch configuration.
|
|
26
|
+
* @param {boolean} [admissionReserved] - Whether the caller already reserved its active-user slot.
|
|
26
27
|
* @returns {Promise<void>} - Resolves after stale connections close.
|
|
27
28
|
*/
|
|
28
|
-
async prepare(config) {
|
|
29
|
+
async prepare(config, admissionReserved = false) {
|
|
29
30
|
const identity = JSON.stringify(config)
|
|
30
31
|
if (this.pending) {
|
|
31
32
|
if (this.pending.identity !== identity) throw new Error("Pooled runner cannot mix shared transaction broker capabilities concurrently")
|
|
32
33
|
return await this.pending.promise
|
|
33
34
|
}
|
|
34
35
|
if (this.activeIdentity === identity) return
|
|
35
|
-
|
|
36
|
+
const otherActiveUsers = this.activeUsers - (admissionReserved ? 1 : 0)
|
|
37
|
+
if (otherActiveUsers > 0) throw new Error("Pooled runner cannot mix shared transaction broker capabilities concurrently")
|
|
36
38
|
if (this.activeIdentity === undefined) {
|
|
37
39
|
this.activeIdentity = identity
|
|
38
40
|
return
|
|
@@ -55,8 +57,7 @@ export default class PooledRunnerBrokerIdentity {
|
|
|
55
57
|
* @returns {Promise<T>} - Job result.
|
|
56
58
|
*/
|
|
57
59
|
async run(config, callback) {
|
|
58
|
-
await this.
|
|
59
|
-
this.activeUsers++
|
|
60
|
+
await this.admit(config)
|
|
60
61
|
try {
|
|
61
62
|
return await callback()
|
|
62
63
|
} finally {
|
|
@@ -64,6 +65,23 @@ export default class PooledRunnerBrokerIdentity {
|
|
|
64
65
|
}
|
|
65
66
|
}
|
|
66
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Atomically prepares an attempt identity and reserves its active user. Without
|
|
70
|
+
* this admission turn, another capability can rotate connections after `prepare`
|
|
71
|
+
* resolves but before `run` increments `activeUsers`.
|
|
72
|
+
* @param {import("../testing/shared-transaction-proxy-driver.js").SharedTransactionBrokerJobConfig} config - Dispatch configuration.
|
|
73
|
+
* @returns {Promise<void>} - Resolves after admission is reserved.
|
|
74
|
+
*/
|
|
75
|
+
async admit(config) {
|
|
76
|
+
this.activeUsers++
|
|
77
|
+
try {
|
|
78
|
+
await this.prepare(config, true)
|
|
79
|
+
} catch (error) {
|
|
80
|
+
this.activeUsers--
|
|
81
|
+
throw error
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
67
85
|
/**
|
|
68
86
|
* Rotates retained connection state to an identity.
|
|
69
87
|
* @param {string} identity - Target identity.
|
|
@@ -7,6 +7,12 @@ import VelociousError from "../velocious-error.js"
|
|
|
7
7
|
import BackgroundJobRecord from "./job-record.js"
|
|
8
8
|
import normalizeBackgroundJobError from "./normalize-error.js"
|
|
9
9
|
import {coordinateSharedTransactionConnection} from "../testing/shared-transaction-connection-coordinator.js"
|
|
10
|
+
import stableJsonStringify from "../utils/stable-json.js"
|
|
11
|
+
import {
|
|
12
|
+
MAIL_DELIVERY_OPERATIONS_TABLE,
|
|
13
|
+
mailDeliveryOperationForJob,
|
|
14
|
+
mailDeliveryOperationKey
|
|
15
|
+
} from "../mailer/delivery-operation.js"
|
|
10
16
|
|
|
11
17
|
/**
|
|
12
18
|
* PreparedBackgroundJob type.
|
|
@@ -37,6 +43,7 @@ const DROP_FORKED_COLUMN_MIGRATION_VERSION = "20260719000000"
|
|
|
37
43
|
const LEGACY_POOLED_HANDOFF_ID_PREFIX = "velocious-pooled:"
|
|
38
44
|
const LEGACY_POOLED_QUEUED_HANDOFF_ID = `${LEGACY_POOLED_HANDOFF_ID_PREFIX}queued`
|
|
39
45
|
const JOBS_TABLE = "background_jobs"
|
|
46
|
+
const IDEMPOTENCY_KEYS_TABLE = "background_job_idempotency_keys"
|
|
40
47
|
const SCHEDULE_KEYS_TABLE = "background_job_schedule_keys"
|
|
41
48
|
const CONCURRENCY_TABLE = "background_job_concurrency"
|
|
42
49
|
const COUNTS_REVISION_TABLE = "background_job_count_revisions"
|
|
@@ -91,7 +98,7 @@ const SORTABLE_COLUMNS = {
|
|
|
91
98
|
*/
|
|
92
99
|
const schemaApplyChains = new Map()
|
|
93
100
|
/** @type {Map<string, Promise<void>>} */
|
|
94
|
-
const
|
|
101
|
+
const transactionMutationChains = new Map()
|
|
95
102
|
|
|
96
103
|
export default class BackgroundJobsStore {
|
|
97
104
|
/**
|
|
@@ -210,6 +217,11 @@ export default class BackgroundJobsStore {
|
|
|
210
217
|
await this.ensureReady()
|
|
211
218
|
|
|
212
219
|
const preparedJob = this._prepareJob({jobName, args, options})
|
|
220
|
+
|
|
221
|
+
if (options?.idempotencyKey !== undefined) {
|
|
222
|
+
return await this._enqueueIdempotently({args: args || [], options, preparedJob})
|
|
223
|
+
}
|
|
224
|
+
|
|
213
225
|
/** @type {string} */
|
|
214
226
|
let resultJobId = preparedJob.jobId
|
|
215
227
|
|
|
@@ -243,6 +255,282 @@ export default class BackgroundJobsStore {
|
|
|
243
255
|
return resultJobId
|
|
244
256
|
}
|
|
245
257
|
|
|
258
|
+
/**
|
|
259
|
+
* Atomically owns one durable idempotency scope and creates its job exactly once.
|
|
260
|
+
* @param {object} args - Enqueue input.
|
|
261
|
+
* @param {Array<ReturnType<typeof JSON.parse>>} args.args - Job arguments.
|
|
262
|
+
* @param {import("./types.js").BackgroundJobOptions} args.options - Job options.
|
|
263
|
+
* @param {PreparedBackgroundJob} args.preparedJob - Normalized job.
|
|
264
|
+
* @returns {Promise<string>} - Stable original job id.
|
|
265
|
+
*/
|
|
266
|
+
async _enqueueIdempotently({args, options, preparedJob}) {
|
|
267
|
+
const idempotencyKey = this._normalizeIdempotencyKey(options.idempotencyKey)
|
|
268
|
+
const scopeDigest = this._idempotencyScopeDigest({idempotencyKey, jobName: preparedJob.jobName, queue: preparedJob.queue})
|
|
269
|
+
const requestDigest = this._idempotencyRequestDigest({args, options, preparedJob})
|
|
270
|
+
const ownership = {
|
|
271
|
+
created_at_ms: preparedJob.createdAtMs,
|
|
272
|
+
idempotency_key: idempotencyKey,
|
|
273
|
+
job_id: preparedJob.jobId,
|
|
274
|
+
job_name: preparedJob.jobName,
|
|
275
|
+
queue: preparedJob.queue,
|
|
276
|
+
request_digest: requestDigest,
|
|
277
|
+
scope_digest: scopeDigest
|
|
278
|
+
}
|
|
279
|
+
const mailOperationInput = mailDeliveryOperationForJob(preparedJob.jobName, args)
|
|
280
|
+
|
|
281
|
+
if (mailOperationInput && mailOperationInput.operation.id !== idempotencyKey) {
|
|
282
|
+
throw VelociousError.safe("Mail delivery operation id must equal its background job idempotency key.", {
|
|
283
|
+
code: "mail-delivery-idempotency-key-mismatch"
|
|
284
|
+
})
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Reuse ordinary enqueue transaction admission because this path changes
|
|
288
|
+
// the same durable count revision. The scope primary key remains the
|
|
289
|
+
// cross-process convergence owner.
|
|
290
|
+
return await this._withDb(async (db) => await this._idempotentEnqueueTransaction(db, async () => {
|
|
291
|
+
const existing = await this._idempotencyOwnership(db, scopeDigest)
|
|
292
|
+
|
|
293
|
+
if (existing) {
|
|
294
|
+
this._validateIdempotencyOwnership({existing, ownership})
|
|
295
|
+
await this._validateMailDeliveryOperation(db, {jobId: String(existing.job_id), mailOperationInput})
|
|
296
|
+
return String(existing.job_id)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const claimed = await this._claimIdempotencyOwnership(db, ownership)
|
|
300
|
+
|
|
301
|
+
if (!claimed.created) {
|
|
302
|
+
this._validateIdempotencyOwnership({existing: claimed.row, ownership})
|
|
303
|
+
await this._validateMailDeliveryOperation(db, {jobId: String(claimed.row.job_id), mailOperationInput})
|
|
304
|
+
return String(claimed.row.job_id)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
await this._lockCountRevision(db)
|
|
308
|
+
await this._insertPreparedJob(db, {preparedJob, scheduleKey: null})
|
|
309
|
+
await this._persistMailDeliveryOperation(db, {jobId: preparedJob.jobId, mailOperationInput, createdAtMs: preparedJob.createdAtMs})
|
|
310
|
+
await this._recordCountDelta(db, {all: 1, queued: 1})
|
|
311
|
+
|
|
312
|
+
return preparedJob.jobId
|
|
313
|
+
}))
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Serializes one physical connection locally without taking ownership away
|
|
318
|
+
* from the database uniqueness constraint shared by all processes.
|
|
319
|
+
* @template T
|
|
320
|
+
* @param {import("../database/drivers/base.js").default} db - Database connection.
|
|
321
|
+
* @param {() => Promise<T>} callback - Transaction work.
|
|
322
|
+
* @returns {Promise<T>} - Callback result.
|
|
323
|
+
*/
|
|
324
|
+
async _idempotentEnqueueTransaction(db, callback) {
|
|
325
|
+
return await this._serializedTransactionMutation(db, callback)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Inserts an ownership row, resolving only a database uniqueness race.
|
|
330
|
+
* @param {import("../database/drivers/base.js").default} db - Transaction connection.
|
|
331
|
+
* @param {Record<string, ReturnType<typeof JSON.parse>>} ownership - Ownership row.
|
|
332
|
+
* @returns {Promise<{created: boolean, row: Record<string, ReturnType<typeof JSON.parse>>}>} - Claim result.
|
|
333
|
+
*/
|
|
334
|
+
async _claimIdempotencyOwnership(db, ownership) {
|
|
335
|
+
try {
|
|
336
|
+
// The savepoint keeps PostgreSQL's outer transaction usable after a
|
|
337
|
+
// concurrent unique-key loss. The unique primary key, not a process
|
|
338
|
+
// mutex, is the cross-process convergence authority.
|
|
339
|
+
await db.transaction(async () => {
|
|
340
|
+
await db.insert({tableName: IDEMPOTENCY_KEYS_TABLE, data: ownership})
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
return {created: true, row: ownership}
|
|
344
|
+
} catch (error) {
|
|
345
|
+
const raced = await this._idempotencyOwnership(db, String(ownership.scope_digest))
|
|
346
|
+
|
|
347
|
+
if (!raced) throw error
|
|
348
|
+
return {created: false, row: raced}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Loads one durable enqueue owner.
|
|
354
|
+
* @param {import("../database/drivers/base.js").default} db - Database connection.
|
|
355
|
+
* @param {string} scopeDigest - Fixed-size scope digest.
|
|
356
|
+
* @returns {Promise<Record<string, ReturnType<typeof JSON.parse>> | null>} - Row or null.
|
|
357
|
+
*/
|
|
358
|
+
async _idempotencyOwnership(db, scopeDigest) {
|
|
359
|
+
const rows = await db.newQuery().from(IDEMPOTENCY_KEYS_TABLE).where({scope_digest: scopeDigest}).limit(1).results()
|
|
360
|
+
|
|
361
|
+
return rows[0] ? /** @type {Record<string, ReturnType<typeof JSON.parse>>} */ (rows[0]) : null
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Fails closed when a durable key is reused for a different canonical request.
|
|
366
|
+
* @param {object} args - Validation input.
|
|
367
|
+
* @param {Record<string, ReturnType<typeof JSON.parse>>} args.existing - Stored owner.
|
|
368
|
+
* @param {Record<string, ReturnType<typeof JSON.parse>>} args.ownership - Requested owner.
|
|
369
|
+
* @returns {void}
|
|
370
|
+
*/
|
|
371
|
+
_validateIdempotencyOwnership({existing, ownership}) {
|
|
372
|
+
const exactScope = String(existing.job_name) === ownership.job_name
|
|
373
|
+
&& String(existing.queue) === ownership.queue
|
|
374
|
+
&& String(existing.idempotency_key) === ownership.idempotency_key
|
|
375
|
+
|
|
376
|
+
if (!exactScope || String(existing.request_digest) !== ownership.request_digest) {
|
|
377
|
+
throw VelociousError.safe("The background job idempotency key was already used for a different request.", {
|
|
378
|
+
code: "background-job-idempotency-conflict"
|
|
379
|
+
})
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Persists the built-in mail operation in the same first-enqueue transaction.
|
|
385
|
+
* @param {import("../database/drivers/base.js").default} db - Transaction connection.
|
|
386
|
+
* @param {object} args - Operation input.
|
|
387
|
+
* @param {number} args.createdAtMs - Creation timestamp.
|
|
388
|
+
* @param {string} args.jobId - Native job id.
|
|
389
|
+
* @param {{operation: import("../mailer/index.js").MailerDeliveryOperation, payload: import("../mailer/index.js").MailerDeliveryPayload} | null} args.mailOperationInput - Mail operation.
|
|
390
|
+
* @returns {Promise<void>} - Resolves after persistence.
|
|
391
|
+
*/
|
|
392
|
+
async _persistMailDeliveryOperation(db, {createdAtMs, jobId, mailOperationInput}) {
|
|
393
|
+
if (!mailOperationInput) return
|
|
394
|
+
const {operation} = mailOperationInput
|
|
395
|
+
const operationKey = mailDeliveryOperationKey(operation.id)
|
|
396
|
+
const row = {
|
|
397
|
+
background_job_id: jobId,
|
|
398
|
+
created_at_ms: createdAtMs,
|
|
399
|
+
first_attempt_started_at_ms: null,
|
|
400
|
+
operation_id: operation.id,
|
|
401
|
+
operation_key: operationKey,
|
|
402
|
+
payload_digest: operation.payloadDigest,
|
|
403
|
+
provider_kind: operation.providerKind,
|
|
404
|
+
provider_retention_ms: operation.providerRetentionMs
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
try {
|
|
408
|
+
await db.transaction(async () => {
|
|
409
|
+
await db.insert({tableName: MAIL_DELIVERY_OPERATIONS_TABLE, data: row})
|
|
410
|
+
})
|
|
411
|
+
} catch (error) {
|
|
412
|
+
const existing = await this._mailDeliveryOperation(db, operationKey)
|
|
413
|
+
|
|
414
|
+
if (!existing) throw error
|
|
415
|
+
this._validateMailDeliveryOperationRow({existing, requested: row})
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Validates the durable mail row during an exact generic enqueue replay.
|
|
421
|
+
* @param {import("../database/drivers/base.js").default} db - Database connection.
|
|
422
|
+
* @param {object} args - Validation input.
|
|
423
|
+
* @param {string} args.jobId - Owned job id.
|
|
424
|
+
* @param {{operation: import("../mailer/index.js").MailerDeliveryOperation, payload: import("../mailer/index.js").MailerDeliveryPayload} | null} args.mailOperationInput - Mail operation.
|
|
425
|
+
* @returns {Promise<void>} - Resolves when exact.
|
|
426
|
+
*/
|
|
427
|
+
async _validateMailDeliveryOperation(db, {jobId, mailOperationInput}) {
|
|
428
|
+
if (!mailOperationInput) return
|
|
429
|
+
const {operation} = mailOperationInput
|
|
430
|
+
const existing = await this._mailDeliveryOperation(db, mailDeliveryOperationKey(operation.id))
|
|
431
|
+
|
|
432
|
+
if (!existing) {
|
|
433
|
+
throw new Error("Background job idempotency ownership is missing its durable mail delivery operation")
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
this._validateMailDeliveryOperationRow({
|
|
437
|
+
existing,
|
|
438
|
+
requested: {
|
|
439
|
+
background_job_id: jobId,
|
|
440
|
+
operation_id: operation.id,
|
|
441
|
+
payload_digest: operation.payloadDigest,
|
|
442
|
+
provider_kind: operation.providerKind,
|
|
443
|
+
provider_retention_ms: operation.providerRetentionMs
|
|
444
|
+
}
|
|
445
|
+
})
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Loads a durable mail operation.
|
|
450
|
+
* @param {import("../database/drivers/base.js").default} db - Database connection.
|
|
451
|
+
* @param {string} operationKey - Fixed-size operation key.
|
|
452
|
+
* @returns {Promise<Record<string, ReturnType<typeof JSON.parse>> | null>} - Row or null.
|
|
453
|
+
*/
|
|
454
|
+
async _mailDeliveryOperation(db, operationKey) {
|
|
455
|
+
const rows = await db.newQuery().from(MAIL_DELIVERY_OPERATIONS_TABLE).where({operation_key: operationKey}).limit(1).results()
|
|
456
|
+
|
|
457
|
+
return rows[0] ? /** @type {Record<string, ReturnType<typeof JSON.parse>>} */ (rows[0]) : null
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Compares provider-relevant durable mail operation fields.
|
|
462
|
+
* @param {object} args - Validation input.
|
|
463
|
+
* @param {Record<string, ReturnType<typeof JSON.parse>>} args.existing - Stored row.
|
|
464
|
+
* @param {Record<string, ReturnType<typeof JSON.parse>>} args.requested - Requested row.
|
|
465
|
+
* @returns {void}
|
|
466
|
+
*/
|
|
467
|
+
_validateMailDeliveryOperationRow({existing, requested}) {
|
|
468
|
+
const matches = String(existing.operation_id) === requested.operation_id
|
|
469
|
+
&& String(existing.payload_digest) === requested.payload_digest
|
|
470
|
+
&& String(existing.background_job_id) === requested.background_job_id
|
|
471
|
+
&& String(existing.provider_kind) === requested.provider_kind
|
|
472
|
+
&& this._normalizeNumber(existing.provider_retention_ms) === requested.provider_retention_ms
|
|
473
|
+
|
|
474
|
+
if (!matches) {
|
|
475
|
+
throw VelociousError.safe("The mail delivery operation was already used for a different payload or provider.", {
|
|
476
|
+
code: "mail-delivery-idempotency-conflict"
|
|
477
|
+
})
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Canonical request digest excluding generated ids and immediate enqueue time.
|
|
483
|
+
* @param {object} args - Digest input.
|
|
484
|
+
* @param {Array<ReturnType<typeof JSON.parse>>} args.args - Job arguments.
|
|
485
|
+
* @param {import("./types.js").BackgroundJobOptions} args.options - Job options.
|
|
486
|
+
* @param {PreparedBackgroundJob} args.preparedJob - Normalized job.
|
|
487
|
+
* @returns {string} - SHA-256 digest.
|
|
488
|
+
*/
|
|
489
|
+
_idempotencyRequestDigest({args, options, preparedJob}) {
|
|
490
|
+
const serialized = stableJsonStringify({
|
|
491
|
+
args,
|
|
492
|
+
concurrency: preparedJob.concurrency,
|
|
493
|
+
executionMode: preparedJob.executionMode,
|
|
494
|
+
format: "velocious-background-job-idempotency-v1",
|
|
495
|
+
jobName: preparedJob.jobName,
|
|
496
|
+
maxRetries: preparedJob.maxRetries,
|
|
497
|
+
queue: preparedJob.queue,
|
|
498
|
+
scheduledAtMs: options.scheduledAtMs === undefined ? null : preparedJob.scheduledAtMs,
|
|
499
|
+
scheduling: options.scheduledAtMs === undefined ? "immediate" : "scheduled"
|
|
500
|
+
})
|
|
501
|
+
|
|
502
|
+
return createHash("sha256").update(serialized).digest("hex")
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Fixed-size globally indexed representation of the documented scope tuple.
|
|
507
|
+
* @param {object} args - Scope input.
|
|
508
|
+
* @param {string} args.idempotencyKey - Caller key.
|
|
509
|
+
* @param {string} args.jobName - Job class name.
|
|
510
|
+
* @param {string} args.queue - Queue name.
|
|
511
|
+
* @returns {string} - SHA-256 scope digest.
|
|
512
|
+
*/
|
|
513
|
+
_idempotencyScopeDigest({idempotencyKey, jobName, queue}) {
|
|
514
|
+
return createHash("sha256")
|
|
515
|
+
.update(stableJsonStringify({format: "velocious-background-job-idempotency-scope-v1", idempotencyKey, jobName, queue}))
|
|
516
|
+
.digest("hex")
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Validates one caller key.
|
|
521
|
+
* @param {string | undefined} idempotencyKey - Caller key.
|
|
522
|
+
* @returns {string} - Valid key.
|
|
523
|
+
*/
|
|
524
|
+
_normalizeIdempotencyKey(idempotencyKey) {
|
|
525
|
+
if (typeof idempotencyKey !== "string" || idempotencyKey.length === 0) {
|
|
526
|
+
throw VelociousError.safe("Background job idempotencyKey must be a non-empty string.", {
|
|
527
|
+
code: "background-job-idempotency-key-invalid"
|
|
528
|
+
})
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
return idempotencyKey
|
|
532
|
+
}
|
|
533
|
+
|
|
246
534
|
/**
|
|
247
535
|
* Replaces the queued owner of a stable schedule key with a new one-off job.
|
|
248
536
|
* A handed-off owner is left running and reported truthfully.
|
|
@@ -974,6 +1262,8 @@ export default class BackgroundJobsStore {
|
|
|
974
1262
|
|
|
975
1263
|
await this._withDb(async (db) => await this._serializedCountMutation(db, async () => {
|
|
976
1264
|
const snapshot = await this._countSnapshotOnLockedConnection(db)
|
|
1265
|
+
if (await db.tableExists(MAIL_DELIVERY_OPERATIONS_TABLE)) await db.query(`DELETE FROM ${db.quoteTable(MAIL_DELIVERY_OPERATIONS_TABLE)}`)
|
|
1266
|
+
if (await db.tableExists(IDEMPOTENCY_KEYS_TABLE)) await db.query(`DELETE FROM ${db.quoteTable(IDEMPOTENCY_KEYS_TABLE)}`)
|
|
977
1267
|
if (await db.tableExists(SCHEDULE_KEYS_TABLE)) await db.query(`DELETE FROM ${db.quoteTable(SCHEDULE_KEYS_TABLE)}`)
|
|
978
1268
|
await db.query(`DELETE FROM ${db.quoteTable(JOBS_TABLE)}`)
|
|
979
1269
|
if (await db.tableExists(CONCURRENCY_TABLE)) await db.query(`DELETE FROM ${db.quoteTable(CONCURRENCY_TABLE)}`)
|
|
@@ -1225,6 +1515,8 @@ export default class BackgroundJobsStore {
|
|
|
1225
1515
|
// row alone, otherwise later callers fail with "no such table".
|
|
1226
1516
|
if (alreadyApplied && await db.tableExists(JOBS_TABLE)) {
|
|
1227
1517
|
await this._ensureJobsTableColumns(db)
|
|
1518
|
+
await this._ensureIdempotencyKeysTable(db)
|
|
1519
|
+
await this._ensureMailDeliveryOperationsTable(db)
|
|
1228
1520
|
await this._ensureScheduleKeysTable(db)
|
|
1229
1521
|
await this._ensureConcurrencyTable(db)
|
|
1230
1522
|
await this._ensureCountRevisionTable(db)
|
|
@@ -1235,6 +1527,8 @@ export default class BackgroundJobsStore {
|
|
|
1235
1527
|
|
|
1236
1528
|
await this._applyMigrations(db)
|
|
1237
1529
|
await this._ensureJobsTableColumns(db)
|
|
1530
|
+
await this._ensureIdempotencyKeysTable(db)
|
|
1531
|
+
await this._ensureMailDeliveryOperationsTable(db)
|
|
1238
1532
|
await this._ensureScheduleKeysTable(db)
|
|
1239
1533
|
await this._ensureConcurrencyTable(db)
|
|
1240
1534
|
await this._ensureCountRevisionTable(db)
|
|
@@ -1956,6 +2250,73 @@ export default class BackgroundJobsStore {
|
|
|
1956
2250
|
}
|
|
1957
2251
|
}
|
|
1958
2252
|
|
|
2253
|
+
/**
|
|
2254
|
+
* Ensures durable generic enqueue ownership exists independently of job rows.
|
|
2255
|
+
* @param {import("../database/drivers/base.js").default} db - Database connection.
|
|
2256
|
+
* @returns {Promise<void>} - Resolves when ready.
|
|
2257
|
+
*/
|
|
2258
|
+
async _ensureIdempotencyKeysTable(db) {
|
|
2259
|
+
if (await db.tableExists(IDEMPOTENCY_KEYS_TABLE)) return
|
|
2260
|
+
|
|
2261
|
+
const lockName = `${MIGRATION_SCOPE}:idempotency_keys_table`
|
|
2262
|
+
const acquired = await db.acquireAdvisoryLock(lockName)
|
|
2263
|
+
|
|
2264
|
+
if (!acquired) throw new Error("Failed to acquire background job idempotency-key table schema lock")
|
|
2265
|
+
|
|
2266
|
+
try {
|
|
2267
|
+
db.clearSchemaCache()
|
|
2268
|
+
if (await db.tableExists(IDEMPOTENCY_KEYS_TABLE)) return
|
|
2269
|
+
|
|
2270
|
+
const table = new TableData(IDEMPOTENCY_KEYS_TABLE, {ifNotExists: true})
|
|
2271
|
+
|
|
2272
|
+
table.string("scope_digest", {primaryKey: true})
|
|
2273
|
+
table.string("job_name", {null: false})
|
|
2274
|
+
table.string("queue", {null: false})
|
|
2275
|
+
table.text("idempotency_key", {null: false})
|
|
2276
|
+
table.string("job_id", {index: true, null: false})
|
|
2277
|
+
table.string("request_digest", {null: false})
|
|
2278
|
+
table.bigint("created_at_ms", {null: false})
|
|
2279
|
+
await db.createTable(table)
|
|
2280
|
+
db.clearSchemaCache()
|
|
2281
|
+
} finally {
|
|
2282
|
+
await db.releaseAdvisoryLock(lockName)
|
|
2283
|
+
}
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2286
|
+
/**
|
|
2287
|
+
* Ensures durable provider-backed mail operation state exists independently of jobs.
|
|
2288
|
+
* @param {import("../database/drivers/base.js").default} db - Database connection.
|
|
2289
|
+
* @returns {Promise<void>} - Resolves when ready.
|
|
2290
|
+
*/
|
|
2291
|
+
async _ensureMailDeliveryOperationsTable(db) {
|
|
2292
|
+
if (await db.tableExists(MAIL_DELIVERY_OPERATIONS_TABLE)) return
|
|
2293
|
+
|
|
2294
|
+
const lockName = `${MIGRATION_SCOPE}:mail_delivery_operations_table`
|
|
2295
|
+
const acquired = await db.acquireAdvisoryLock(lockName)
|
|
2296
|
+
|
|
2297
|
+
if (!acquired) throw new Error("Failed to acquire mail delivery operation table schema lock")
|
|
2298
|
+
|
|
2299
|
+
try {
|
|
2300
|
+
db.clearSchemaCache()
|
|
2301
|
+
if (await db.tableExists(MAIL_DELIVERY_OPERATIONS_TABLE)) return
|
|
2302
|
+
|
|
2303
|
+
const table = new TableData(MAIL_DELIVERY_OPERATIONS_TABLE, {ifNotExists: true})
|
|
2304
|
+
|
|
2305
|
+
table.string("operation_key", {primaryKey: true})
|
|
2306
|
+
table.text("operation_id", {null: false})
|
|
2307
|
+
table.string("payload_digest", {null: false})
|
|
2308
|
+
table.string("background_job_id", {index: true, null: false})
|
|
2309
|
+
table.bigint("first_attempt_started_at_ms", {null: true})
|
|
2310
|
+
table.string("provider_kind", {null: false})
|
|
2311
|
+
table.bigint("provider_retention_ms", {null: false})
|
|
2312
|
+
table.bigint("created_at_ms", {null: false})
|
|
2313
|
+
await db.createTable(table)
|
|
2314
|
+
db.clearSchemaCache()
|
|
2315
|
+
} finally {
|
|
2316
|
+
await db.releaseAdvisoryLock(lockName)
|
|
2317
|
+
}
|
|
2318
|
+
}
|
|
2319
|
+
|
|
1959
2320
|
/**
|
|
1960
2321
|
* Ensures the singleton durable count-revision row exists.
|
|
1961
2322
|
* @param {import("../database/drivers/base.js").default} db - Database connection.
|
|
@@ -2422,8 +2783,25 @@ export default class BackgroundJobsStore {
|
|
|
2422
2783
|
* @returns {Promise<T>} Callback result.
|
|
2423
2784
|
*/
|
|
2424
2785
|
async _serializedCountMutation(db, callback) {
|
|
2786
|
+
return await this._serializedTransactionMutation(db, async () => {
|
|
2787
|
+
await this._lockCountRevision(db)
|
|
2788
|
+
|
|
2789
|
+
return await callback()
|
|
2790
|
+
})
|
|
2791
|
+
}
|
|
2792
|
+
|
|
2793
|
+
/**
|
|
2794
|
+
* Serializes transactions that may share one physical connection in this
|
|
2795
|
+
* process. Cross-process ordering remains the responsibility of durable row
|
|
2796
|
+
* locks and unique constraints acquired inside the callback.
|
|
2797
|
+
* @template T
|
|
2798
|
+
* @param {import("../database/drivers/base.js").default} db - Database connection.
|
|
2799
|
+
* @param {() => Promise<T>} callback - Transaction callback.
|
|
2800
|
+
* @returns {Promise<T>} Callback result.
|
|
2801
|
+
*/
|
|
2802
|
+
async _serializedTransactionMutation(db, callback) {
|
|
2425
2803
|
const identifier = this.getDatabaseIdentifier() || "default"
|
|
2426
|
-
const previous =
|
|
2804
|
+
const previous = transactionMutationChains.get(identifier) || Promise.resolve()
|
|
2427
2805
|
let resolveRun = () => {}
|
|
2428
2806
|
/** @type {Promise<void>} */
|
|
2429
2807
|
const run = new Promise((resolve) => {
|
|
@@ -2431,18 +2809,14 @@ export default class BackgroundJobsStore {
|
|
|
2431
2809
|
})
|
|
2432
2810
|
const chain = previous.then(() => run)
|
|
2433
2811
|
|
|
2434
|
-
|
|
2812
|
+
transactionMutationChains.set(identifier, chain)
|
|
2435
2813
|
await previous
|
|
2436
2814
|
|
|
2437
2815
|
try {
|
|
2438
|
-
return await this._transactionResult(db,
|
|
2439
|
-
await this._lockCountRevision(db)
|
|
2440
|
-
|
|
2441
|
-
return await callback()
|
|
2442
|
-
})
|
|
2816
|
+
return await this._transactionResult(db, callback)
|
|
2443
2817
|
} finally {
|
|
2444
2818
|
resolveRun()
|
|
2445
|
-
if (
|
|
2819
|
+
if (transactionMutationChains.get(identifier) === chain) transactionMutationChains.delete(identifier)
|
|
2446
2820
|
}
|
|
2447
2821
|
}
|
|
2448
2822
|
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* @property {string} [concurrencyKey] - Opaque non-empty key used to share a concurrency cap. Overrides any queue-derived cap.
|
|
17
17
|
* @property {number} [maxConcurrency] - Positive integer cap; must be paired with `concurrencyKey`.
|
|
18
18
|
* @property {boolean} [deduplicateWhileQueued] - When true, skip the enqueue if an identical still-queued job (same job name, args and queue) is scheduled no later than this enqueue, returning the earliest matching job's id. A future retry does not suppress earlier work. Deduplication is independent of `concurrencyKey`, so the job keeps its normal (e.g. queue-derived) concurrency cap. Keeps an interval-scheduled recurring job (e.g. retention pruning) from piling up redundant queued rows when it runs slower than its interval or no worker is free.
|
|
19
|
+
* @property {string} [idempotencyKey] - Durable enqueue identity scoped to the resolved job class name and queue. Exact replay returns the original job id across every state and after job pruning; reuse with different canonical arguments or behavior-affecting options fails. Ownership is independent of `deduplicateWhileQueued` and is retained until an explicit future retention policy removes it.
|
|
19
20
|
* @property {number} [scheduledAtMs] - Epoch timestamp in milliseconds when the job becomes eligible for dispatch. Defaults to enqueue time.
|
|
20
21
|
*/
|
|
21
22
|
/**
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import {digg} from "diggerize"
|
|
4
|
+
import BaseCommand from "../../../../base-command.js"
|
|
5
|
+
import TenantDatabaseCommandHelper from "../../../../tenant-database-command-helper.js"
|
|
6
|
+
import TenantMigrationPendingInspector from "../../../../../database/tenants/migration-pending-inspector.js"
|
|
7
|
+
|
|
8
|
+
export default class DbTenantsMigrationsPending extends BaseCommand {
|
|
9
|
+
/**
|
|
10
|
+
* Reports aggregate tenant migration state as one machine-readable JSON object.
|
|
11
|
+
* @returns {Promise<{hasPendingMigrations: boolean, identifier: string, migrationCount: number, pendingTenantCount: number, tenantCount: number}>} - Deploy preflight result.
|
|
12
|
+
*/
|
|
13
|
+
async execute() {
|
|
14
|
+
const helper = new TenantDatabaseCommandHelper({
|
|
15
|
+
command: this,
|
|
16
|
+
identifier: this.processArgs?.[1]
|
|
17
|
+
})
|
|
18
|
+
const migrations = await this.getEnvironmentHandler().findMigrations()
|
|
19
|
+
const requireMigration = digg(this.getEnvironmentHandler(), "requireMigration")
|
|
20
|
+
const applicableMigrationVersions = []
|
|
21
|
+
|
|
22
|
+
for (const migration of migrations) {
|
|
23
|
+
if (!migration.fullPath) throw new Error(`Migration didn't have a fullPath key: ${migration.file}`)
|
|
24
|
+
|
|
25
|
+
const MigrationClass = await requireMigration(migration.fullPath)
|
|
26
|
+
|
|
27
|
+
if ((MigrationClass.getDatabaseIdentifiers() || ["default"]).includes(helper.identifier)) {
|
|
28
|
+
applicableMigrationVersions.push(migration.date)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const tenants = await helper.listTenants()
|
|
33
|
+
const inspector = new TenantMigrationPendingInspector({
|
|
34
|
+
configuration: this.getConfiguration(),
|
|
35
|
+
identifier: helper.identifier,
|
|
36
|
+
migrationVersions: applicableMigrationVersions,
|
|
37
|
+
tenants
|
|
38
|
+
})
|
|
39
|
+
const result = await inspector.inspect()
|
|
40
|
+
|
|
41
|
+
if (!this.args.testing) console.log(JSON.stringify(result))
|
|
42
|
+
|
|
43
|
+
return result
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -367,6 +367,9 @@
|
|
|
367
367
|
/**
|
|
368
368
|
* @typedef {object} MailerBackend
|
|
369
369
|
* @property {(args: {payload: import("./mailer.js").MailerDeliveryPayload, configuration: import("./configuration.js").default}) => Promise<ReturnType<typeof JSON.parse>> | ReturnType<typeof JSON.parse>} deliver - Deliver a mailer payload.
|
|
370
|
+
* @property {() => import("./mailer.js").MailerDeliveryIdempotencyCapability} [deliveryIdempotencyCapability] - Explicit provider-backed duplicate-suppression capability. Generic SMTP omits this and remains at-least-once.
|
|
371
|
+
* @property {(args: {payload: import("./mailer.js").MailerDeliveryPayload}) => import("./mailer.js").MailerDeliveryPayload} [prepareDeliveryOperationPayload] - Resolves provider defaults into the immutable payload before required-operation hashing and enqueue.
|
|
372
|
+
* @property {(args: {deliveryOperation: import("./mailer.js").MailerDeliveryOperationRequest | import("./mailer.js").MailerDeliveryOperation, payload: import("./mailer.js").MailerDeliveryPayload}) => void} [validateDeliveryOperation] - Provider-specific operation and payload validation performed before enqueue and every network attempt.
|
|
370
373
|
*/
|
|
371
374
|
|
|
372
375
|
|
package/build/configuration.js
CHANGED
|
@@ -1136,7 +1136,10 @@ export default class VelociousConfiguration {
|
|
|
1136
1136
|
throw new Error("No poolType given in database configuration")
|
|
1137
1137
|
}
|
|
1138
1138
|
|
|
1139
|
-
return
|
|
1139
|
+
return this.getEnvironmentHandler().resolveTestSharedTransactionPoolType({
|
|
1140
|
+
configuredPoolType: poolTypeClass,
|
|
1141
|
+
databaseIdentifier: identifier
|
|
1142
|
+
})
|
|
1140
1143
|
}
|
|
1141
1144
|
|
|
1142
1145
|
getDatabaseType(identifier = "default") {
|
|
@@ -1898,7 +1901,7 @@ export default class VelociousConfiguration {
|
|
|
1898
1901
|
|
|
1899
1902
|
/**
|
|
1900
1903
|
* Runs set mailer backend.
|
|
1901
|
-
* @param {import("./configuration-types.js").MailerBackend} mailerBackend - Mailer backend.
|
|
1904
|
+
* @param {import("./configuration-types.js").MailerBackend | undefined} mailerBackend - Mailer backend, or undefined to remove it.
|
|
1902
1905
|
* @returns {void} - No return value.
|
|
1903
1906
|
*/
|
|
1904
1907
|
setMailerBackend(mailerBackend) {
|
|
@@ -356,13 +356,13 @@ export default class VelociousDatabaseDriversSqliteBase extends Base {
|
|
|
356
356
|
* @returns {import("../base.js").RetryableDatabaseErrorResult} - Retry info.
|
|
357
357
|
*/
|
|
358
358
|
retryableDatabaseError(error) {
|
|
359
|
+
const databaseLocked = Boolean(error.message?.includes("database is locked"))
|
|
359
360
|
const shouldRetry = (
|
|
360
361
|
error.message?.startsWith("attempt to write a readonly database") ||
|
|
361
|
-
|
|
362
|
-
error.message?.includes("→ Caused by: Error code : database is locked")
|
|
362
|
+
databaseLocked
|
|
363
363
|
)
|
|
364
364
|
|
|
365
|
-
return {retry: shouldRetry, reconnect: false}
|
|
365
|
+
return {deadlock: databaseLocked, retry: shouldRetry, reconnect: false}
|
|
366
366
|
}
|
|
367
367
|
|
|
368
368
|
/**
|