velocious 1.0.595 → 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 +21 -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/base.js +135 -1
- package/build/database/drivers/mysql/index.js +92 -0
- 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/base.d.ts +21 -0
- package/build/src/database/drivers/base.d.ts.map +1 -1
- package/build/src/database/drivers/base.js +131 -2
- package/build/src/database/drivers/mysql/index.d.ts +22 -0
- package/build/src/database/drivers/mysql/index.d.ts.map +1 -1
- package/build/src/database/drivers/mysql/index.js +91 -1
- 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/base.js +135 -1
- package/src/database/drivers/mysql/index.js +92 -0
- 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
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import BackgroundJobsStore from "../background-jobs/store.js"
|
|
4
|
+
import VelociousError from "../velocious-error.js"
|
|
5
|
+
import {
|
|
6
|
+
MAIL_DELIVERY_OPERATIONS_TABLE,
|
|
7
|
+
mailDeliveryOperationKey,
|
|
8
|
+
validateAttemptPayload
|
|
9
|
+
} from "./delivery-operation.js"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Durable first-attempt and retention guard for required mail operations.
|
|
13
|
+
*/
|
|
14
|
+
export default class MailerDeliveryOperationStore {
|
|
15
|
+
/**
|
|
16
|
+
* Creates a durable mail-operation state guard.
|
|
17
|
+
* @param {object} args - Store input.
|
|
18
|
+
* @param {import("../configuration.js").default} args.configuration - Active configuration.
|
|
19
|
+
* @param {() => number} [args.clock] - Explicit clock dependency.
|
|
20
|
+
*/
|
|
21
|
+
constructor({configuration, clock = () => Date.now()}) {
|
|
22
|
+
this.backgroundJobsStore = new BackgroundJobsStore({configuration})
|
|
23
|
+
this.clock = clock
|
|
24
|
+
this.configuration = configuration
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Atomically starts the provider retention clock once and rejects expired attempts.
|
|
29
|
+
* @param {object} args - Attempt input.
|
|
30
|
+
* @param {import("./index.js").MailerDeliveryIdempotencyCapability} args.capability - Current backend capability.
|
|
31
|
+
* @param {import("./index.js").MailerDeliveryPayload} args.payload - Persisted payload.
|
|
32
|
+
* @returns {Promise<{expiresAtMs: number, firstAttemptStartedAtMs: number}>} - Attempt window.
|
|
33
|
+
*/
|
|
34
|
+
async beginAttempt({capability, payload}) {
|
|
35
|
+
const operation = validateAttemptPayload({capability, payload})
|
|
36
|
+
const nowMs = this.clock()
|
|
37
|
+
|
|
38
|
+
if (!Number.isSafeInteger(nowMs) || nowMs < 0) throw new Error("Mailer delivery operation clock must return a non-negative safe integer")
|
|
39
|
+
|
|
40
|
+
const databaseIdentifier = this.backgroundJobsStore.getDatabaseIdentifier()
|
|
41
|
+
|
|
42
|
+
return await this.configuration.ensureConnections({
|
|
43
|
+
databaseIdentifiers: [databaseIdentifier],
|
|
44
|
+
name: "Mailer delivery operation attempt"
|
|
45
|
+
}, async (dbs) => {
|
|
46
|
+
const db = dbs[databaseIdentifier]
|
|
47
|
+
|
|
48
|
+
if (db.insideTransaction()) {
|
|
49
|
+
throw VelociousError.safe("Required mail delivery cannot start inside an existing database transaction.", {
|
|
50
|
+
code: "mail-delivery-idempotency-transaction-active"
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
await this.backgroundJobsStore.ensureSchema(db)
|
|
55
|
+
|
|
56
|
+
return await db.transaction(async () => {
|
|
57
|
+
const operationKey = mailDeliveryOperationKey(operation.id)
|
|
58
|
+
|
|
59
|
+
// A value-preserving structured update obtains the row's write lock on
|
|
60
|
+
// every driver before we inspect or initialize the one-shot timestamp.
|
|
61
|
+
await db.update({
|
|
62
|
+
tableName: MAIL_DELIVERY_OPERATIONS_TABLE,
|
|
63
|
+
data: {operation_key: operationKey},
|
|
64
|
+
conditions: {operation_key: operationKey}
|
|
65
|
+
})
|
|
66
|
+
let row = await this._operationRow(db, operation.id)
|
|
67
|
+
|
|
68
|
+
if (!row) throw new Error("Required mail delivery operation is not durably registered")
|
|
69
|
+
this._validateRow({operation, row})
|
|
70
|
+
|
|
71
|
+
let firstAttemptStartedAtMs = numberOrNull(row.first_attempt_started_at_ms)
|
|
72
|
+
|
|
73
|
+
if (firstAttemptStartedAtMs === null) {
|
|
74
|
+
await db.update({
|
|
75
|
+
tableName: MAIL_DELIVERY_OPERATIONS_TABLE,
|
|
76
|
+
data: {first_attempt_started_at_ms: nowMs},
|
|
77
|
+
conditions: {operation_key: operationKey}
|
|
78
|
+
})
|
|
79
|
+
row = await this._operationRow(db, operation.id)
|
|
80
|
+
firstAttemptStartedAtMs = row ? numberOrNull(row.first_attempt_started_at_ms) : null
|
|
81
|
+
|
|
82
|
+
if (firstAttemptStartedAtMs === null) throw new Error("Failed to durably start mail delivery provider retention")
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const expiresAtMs = firstAttemptStartedAtMs + operation.providerRetentionMs
|
|
86
|
+
|
|
87
|
+
if (nowMs >= expiresAtMs) {
|
|
88
|
+
throw VelociousError.safe("The required mail delivery idempotency window expired before another attempt.", {
|
|
89
|
+
code: "mail-delivery-idempotency-expired",
|
|
90
|
+
details: {
|
|
91
|
+
expiresAtMs,
|
|
92
|
+
firstAttemptStartedAtMs,
|
|
93
|
+
operationId: operation.id,
|
|
94
|
+
providerKind: operation.providerKind
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return {expiresAtMs, firstAttemptStartedAtMs}
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Loads one operation by its fixed-size digest key.
|
|
106
|
+
* @param {import("../database/drivers/base.js").default} db - Database connection.
|
|
107
|
+
* @param {string} operationId - Operation id.
|
|
108
|
+
* @returns {Promise<Record<string, ReturnType<typeof JSON.parse>> | null>} - Row or null.
|
|
109
|
+
*/
|
|
110
|
+
async _operationRow(db, operationId) {
|
|
111
|
+
const rows = await db
|
|
112
|
+
.newQuery()
|
|
113
|
+
.from(MAIL_DELIVERY_OPERATIONS_TABLE)
|
|
114
|
+
.where({operation_key: mailDeliveryOperationKey(operationId)})
|
|
115
|
+
.limit(1)
|
|
116
|
+
.results()
|
|
117
|
+
|
|
118
|
+
return rows[0] ? /** @type {Record<string, ReturnType<typeof JSON.parse>>} */ (rows[0]) : null
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Validates immutable durable fields without including mail content in failures.
|
|
123
|
+
* @param {object} args - Validation input.
|
|
124
|
+
* @param {import("./index.js").MailerDeliveryOperation} args.operation - Persisted payload operation.
|
|
125
|
+
* @param {Record<string, ReturnType<typeof JSON.parse>>} args.row - Durable operation row.
|
|
126
|
+
* @returns {void}
|
|
127
|
+
*/
|
|
128
|
+
_validateRow({operation, row}) {
|
|
129
|
+
const matches = String(row.operation_id) === operation.id
|
|
130
|
+
&& String(row.payload_digest) === operation.payloadDigest
|
|
131
|
+
&& String(row.provider_kind) === operation.providerKind
|
|
132
|
+
&& numberOrNull(row.provider_retention_ms) === operation.providerRetentionMs
|
|
133
|
+
|
|
134
|
+
if (!matches) {
|
|
135
|
+
throw VelociousError.safe("The durable mail delivery operation does not match the persisted payload or provider.", {
|
|
136
|
+
code: "mail-delivery-idempotency-conflict"
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Normalizes a database integer.
|
|
144
|
+
* @param {ReturnType<typeof JSON.parse>} value - Database value.
|
|
145
|
+
* @returns {number | null} - Number or null.
|
|
146
|
+
*/
|
|
147
|
+
function numberOrNull(value) {
|
|
148
|
+
if (value === null || value === undefined || value === "") return null
|
|
149
|
+
const number = Number(value)
|
|
150
|
+
|
|
151
|
+
return Number.isNaN(number) ? null : number
|
|
152
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import {createHash} from "crypto"
|
|
4
|
+
import VelociousError from "../velocious-error.js"
|
|
5
|
+
import stableJsonStringify from "../utils/stable-json.js"
|
|
6
|
+
|
|
7
|
+
export const MAIL_DELIVERY_JOB_NAME = "MailDeliveryJob"
|
|
8
|
+
export const MAIL_DELIVERY_OPERATIONS_TABLE = "mailer_delivery_operations"
|
|
9
|
+
const PAYLOAD_DIGEST_FORMAT = "velocious-mail-delivery-payload-v1"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Reads and validates a backend's provider idempotency capability.
|
|
13
|
+
* @param {object} args - Capability input.
|
|
14
|
+
* @param {import("../configuration-types.js").MailerBackend | undefined} args.backend - Configured backend.
|
|
15
|
+
* @param {import("./index.js").MailerDeliveryOperationRequest | import("./index.js").MailerDeliveryOperation} args.deliveryOperation - Required operation.
|
|
16
|
+
* @param {import("./index.js").MailerDeliveryPayload} args.payload - Rendered or persisted payload.
|
|
17
|
+
* @returns {import("./index.js").MailerDeliveryIdempotencyCapability} - Capability.
|
|
18
|
+
*/
|
|
19
|
+
export function requireDeliveryIdempotencyCapability({backend, deliveryOperation, payload}) {
|
|
20
|
+
if (!backend || typeof backend.deliveryIdempotencyCapability !== "function") {
|
|
21
|
+
throw VelociousError.safe("The configured mailer backend does not support required provider idempotency.", {
|
|
22
|
+
code: "mail-delivery-idempotency-unsupported"
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const capability = backend.deliveryIdempotencyCapability()
|
|
27
|
+
|
|
28
|
+
if (!capability || typeof capability.providerKind !== "string" || capability.providerKind.length === 0) {
|
|
29
|
+
throw new Error("Mailer backend delivery idempotency capability requires a non-empty providerKind")
|
|
30
|
+
}
|
|
31
|
+
if (!Number.isSafeInteger(capability.retentionMs) || capability.retentionMs <= 0) {
|
|
32
|
+
throw new Error("Mailer backend delivery idempotency capability requires a positive safe-integer retentionMs")
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (typeof backend.validateDeliveryOperation === "function") {
|
|
36
|
+
backend.validateDeliveryOperation({deliveryOperation, payload})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return capability
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Normalizes one public required operation into immutable payload metadata.
|
|
44
|
+
* @param {object} args - Preparation input.
|
|
45
|
+
* @param {import("./index.js").MailerDeliveryIdempotencyCapability} args.capability - Backend capability.
|
|
46
|
+
* @param {import("./index.js").MailerDeliveryOperationRequest} args.deliveryOperation - Public operation request.
|
|
47
|
+
* @param {import("./index.js").MailerDeliveryPayload} args.payload - Rendered payload.
|
|
48
|
+
* @returns {import("./index.js").MailerDeliveryPayload} - Payload with immutable operation metadata.
|
|
49
|
+
*/
|
|
50
|
+
export function prepareRequiredDeliveryPayload({capability, deliveryOperation, payload}) {
|
|
51
|
+
validateDeliveryOperationRequest(deliveryOperation)
|
|
52
|
+
const payloadDigest = mailDeliveryPayloadDigest({operationId: deliveryOperation.id, payload})
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
...payload,
|
|
56
|
+
deliveryOperation: {
|
|
57
|
+
id: deliveryOperation.id,
|
|
58
|
+
idempotency: "required",
|
|
59
|
+
payloadDigest,
|
|
60
|
+
providerKind: capability.providerKind,
|
|
61
|
+
providerRetentionMs: capability.retentionMs
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Builds the versioned digest for every recipient-visible/provider-relevant payload field.
|
|
68
|
+
* @param {object} args - Digest input.
|
|
69
|
+
* @param {string} args.operationId - Stable operation id.
|
|
70
|
+
* @param {import("./index.js").MailerDeliveryPayload} args.payload - Rendered payload.
|
|
71
|
+
* @returns {string} - Versioned SHA-256 digest.
|
|
72
|
+
*/
|
|
73
|
+
export function mailDeliveryPayloadDigest({operationId, payload}) {
|
|
74
|
+
const canonicalPayload = {
|
|
75
|
+
action: payload.action,
|
|
76
|
+
bcc: payload.bcc ?? null,
|
|
77
|
+
cc: payload.cc ?? null,
|
|
78
|
+
format: PAYLOAD_DIGEST_FORMAT,
|
|
79
|
+
from: payload.from ?? null,
|
|
80
|
+
headers: canonicalHeaders(payload.headers),
|
|
81
|
+
html: payload.html,
|
|
82
|
+
mailer: payload.mailer,
|
|
83
|
+
operationId,
|
|
84
|
+
replyTo: payload.replyTo ?? null,
|
|
85
|
+
subject: payload.subject,
|
|
86
|
+
to: payload.to
|
|
87
|
+
}
|
|
88
|
+
const digest = createHash("sha256").update(stableJsonStringify(canonicalPayload)).digest("hex")
|
|
89
|
+
|
|
90
|
+
return `sha256:v1:${digest}`
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Extracts validated persisted operation metadata from a payload.
|
|
95
|
+
* @param {import("./index.js").MailerDeliveryPayload} payload - Mail payload.
|
|
96
|
+
* @returns {import("./index.js").MailerDeliveryOperation | null} - Operation or null.
|
|
97
|
+
*/
|
|
98
|
+
export function deliveryOperationFromPayload(payload) {
|
|
99
|
+
const operation = payload.deliveryOperation
|
|
100
|
+
|
|
101
|
+
if (!operation) return null
|
|
102
|
+
if (operation.idempotency !== "required") throw new Error("Persisted mail delivery operation idempotency must be required")
|
|
103
|
+
if (typeof operation.id !== "string" || operation.id.length === 0) throw new Error("Persisted mail delivery operation requires an id")
|
|
104
|
+
if (typeof operation.payloadDigest !== "string" || !operation.payloadDigest.startsWith("sha256:v1:")) throw new Error("Persisted mail delivery operation requires a versioned payload digest")
|
|
105
|
+
if (typeof operation.providerKind !== "string" || operation.providerKind.length === 0) throw new Error("Persisted mail delivery operation requires a provider kind")
|
|
106
|
+
if (!Number.isSafeInteger(operation.providerRetentionMs) || operation.providerRetentionMs <= 0) throw new Error("Persisted mail delivery operation requires a positive retention")
|
|
107
|
+
|
|
108
|
+
return operation
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Extracts a built-in mail operation from generic job arguments.
|
|
113
|
+
* @param {string} jobName - Job class name.
|
|
114
|
+
* @param {Array<ReturnType<typeof JSON.parse>>} args - Job arguments.
|
|
115
|
+
* @returns {{operation: import("./index.js").MailerDeliveryOperation, payload: import("./index.js").MailerDeliveryPayload} | null} - Mail operation input.
|
|
116
|
+
*/
|
|
117
|
+
export function mailDeliveryOperationForJob(jobName, args) {
|
|
118
|
+
if (jobName !== MAIL_DELIVERY_JOB_NAME || !args[0] || typeof args[0] !== "object" || Array.isArray(args[0])) return null
|
|
119
|
+
|
|
120
|
+
const payload = /** @type {import("./index.js").MailerDeliveryPayload} */ (args[0])
|
|
121
|
+
const operation = deliveryOperationFromPayload(payload)
|
|
122
|
+
|
|
123
|
+
return operation ? {operation, payload} : null
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Fixed-size primary key for a potentially long operation id.
|
|
128
|
+
* @param {string} operationId - Operation id.
|
|
129
|
+
* @returns {string} - SHA-256 operation key.
|
|
130
|
+
*/
|
|
131
|
+
export function mailDeliveryOperationKey(operationId) {
|
|
132
|
+
return createHash("sha256").update(`velocious-mail-delivery-operation:${operationId}`).digest("hex")
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Validates provider compatibility and payload integrity before an attempt.
|
|
137
|
+
* @param {object} args - Validation input.
|
|
138
|
+
* @param {import("./index.js").MailerDeliveryIdempotencyCapability} args.capability - Current backend capability.
|
|
139
|
+
* @param {import("./index.js").MailerDeliveryPayload} args.payload - Persisted payload.
|
|
140
|
+
* @returns {import("./index.js").MailerDeliveryOperation} - Persisted operation.
|
|
141
|
+
*/
|
|
142
|
+
export function validateAttemptPayload({capability, payload}) {
|
|
143
|
+
const operation = deliveryOperationFromPayload(payload)
|
|
144
|
+
|
|
145
|
+
if (!operation) throw new Error("Expected a persisted mail delivery operation")
|
|
146
|
+
if (operation.providerKind !== capability.providerKind || operation.providerRetentionMs !== capability.retentionMs) {
|
|
147
|
+
throw VelociousError.safe("The configured mailer backend no longer matches the required delivery operation provider.", {
|
|
148
|
+
code: "mail-delivery-idempotency-provider-mismatch"
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const currentDigest = mailDeliveryPayloadDigest({operationId: operation.id, payload})
|
|
153
|
+
|
|
154
|
+
if (currentDigest !== operation.payloadDigest) {
|
|
155
|
+
throw VelociousError.safe("The persisted mail delivery payload digest does not match its required operation.", {
|
|
156
|
+
code: "mail-delivery-idempotency-payload-mismatch"
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return operation
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Validates the public operation shape without accepting future semantics silently.
|
|
165
|
+
* @param {import("./index.js").MailerDeliveryOperationRequest} deliveryOperation - Public operation.
|
|
166
|
+
* @returns {void}
|
|
167
|
+
*/
|
|
168
|
+
function validateDeliveryOperationRequest(deliveryOperation) {
|
|
169
|
+
if (!deliveryOperation || typeof deliveryOperation !== "object" || Array.isArray(deliveryOperation)) {
|
|
170
|
+
throw VelociousError.safe("deliveryOperation must be an object.", {code: "mail-delivery-operation-invalid"})
|
|
171
|
+
}
|
|
172
|
+
if (typeof deliveryOperation.id !== "string" || deliveryOperation.id.length === 0) {
|
|
173
|
+
throw VelociousError.safe("deliveryOperation.id must be a non-empty string.", {code: "mail-delivery-operation-invalid"})
|
|
174
|
+
}
|
|
175
|
+
if (deliveryOperation.idempotency !== "required") {
|
|
176
|
+
throw VelociousError.safe('deliveryOperation.idempotency must be "required".', {code: "mail-delivery-operation-invalid"})
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const keys = Object.keys(deliveryOperation)
|
|
180
|
+
|
|
181
|
+
if (keys.some((key) => key !== "id" && key !== "idempotency")) {
|
|
182
|
+
throw VelociousError.safe("deliveryOperation contains unsupported fields.", {code: "mail-delivery-operation-invalid"})
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Canonicalizes case-insensitive custom headers without exposing values.
|
|
188
|
+
* @param {Record<string, string> | undefined} headers - Custom headers.
|
|
189
|
+
* @returns {Array<[string, string]>} - Sorted header pairs.
|
|
190
|
+
*/
|
|
191
|
+
function canonicalHeaders(headers) {
|
|
192
|
+
if (!headers) return []
|
|
193
|
+
|
|
194
|
+
const pairs = Object.entries(headers).map(([name, value]) => [name.toLowerCase(), value])
|
|
195
|
+
const names = new Set()
|
|
196
|
+
|
|
197
|
+
for (const [name] of pairs) {
|
|
198
|
+
if (names.has(name)) {
|
|
199
|
+
throw VelociousError.safe("Mail headers contain duplicate case-insensitive names.", {code: "mail-delivery-headers-invalid"})
|
|
200
|
+
}
|
|
201
|
+
names.add(name)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return /** @type {Array<[string, string]>} */ (pairs.sort(([left], [right]) => left.localeCompare(right)))
|
|
205
|
+
}
|
package/src/mailer/delivery.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
|
+
import restArgsError from "../utils/rest-args-error.js"
|
|
4
|
+
|
|
3
5
|
/**
|
|
4
6
|
* Represents a prepared mail delivery.
|
|
5
7
|
*/
|
|
@@ -52,19 +54,22 @@ export default class MailerDelivery {
|
|
|
52
54
|
|
|
53
55
|
/**
|
|
54
56
|
* Runs deliver later.
|
|
57
|
+
* @param {import("./index.js").MailerDeliveryLaterOptions} [options] - Delivery execution options.
|
|
55
58
|
* @returns {Promise<string | import("./index.js").MailerDeliveryPayload | null>} - Job id or payload in test mode.
|
|
56
59
|
*/
|
|
57
|
-
async deliverLater() {
|
|
60
|
+
async deliverLater({deliveryOperation, ...restArgs} = {}) {
|
|
61
|
+
restArgsError(restArgs)
|
|
58
62
|
const payload = await this.buildPayload()
|
|
59
63
|
|
|
60
|
-
return await this.mailer._enqueuePayload(payload)
|
|
64
|
+
return await this.mailer._enqueuePayload(payload, {deliveryOperation})
|
|
61
65
|
}
|
|
62
66
|
|
|
63
67
|
/**
|
|
64
68
|
* Runs deliver laver.
|
|
69
|
+
* @param {import("./index.js").MailerDeliveryLaterOptions} [options] - Delivery execution options.
|
|
65
70
|
* @returns {Promise<string | import("./index.js").MailerDeliveryPayload | null>} - Job id or payload in test mode.
|
|
66
71
|
*/
|
|
67
|
-
async deliverLaver() {
|
|
68
|
-
return await this.deliverLater()
|
|
72
|
+
async deliverLaver(options) {
|
|
73
|
+
return await this.deliverLater(options)
|
|
69
74
|
}
|
|
70
75
|
}
|
package/src/mailer/index.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
|
-
/** @typedef {{
|
|
3
|
+
/** @typedef {{id: string, idempotency: "required"}} MailerDeliveryOperationRequest */
|
|
4
|
+
/** @typedef {{id: string, idempotency: "required", payloadDigest: string, providerKind: string, providerRetentionMs: number}} MailerDeliveryOperation */
|
|
5
|
+
/** @typedef {{providerKind: string, retentionMs: number}} MailerDeliveryIdempotencyCapability */
|
|
6
|
+
/** @typedef {{deliveryOperation?: MailerDeliveryOperationRequest}} MailerDeliveryLaterOptions */
|
|
7
|
+
/** @typedef {{to: ReturnType<typeof JSON.parse>, subject: string, from?: ReturnType<typeof JSON.parse>, cc?: ReturnType<typeof JSON.parse>, bcc?: ReturnType<typeof JSON.parse>, replyTo?: ReturnType<typeof JSON.parse>, headers?: Record<string, string>, html: string, mailer: string, action: string, deliveryOperation?: MailerDeliveryOperation}} MailerDeliveryPayload */
|
|
4
8
|
|
|
5
9
|
import {
|
|
6
10
|
clearDeliveries,
|
package/src/mailer.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
|
-
/** @typedef {
|
|
3
|
+
/** @typedef {import("./mailer/index.js").MailerDeliveryOperationRequest} MailerDeliveryOperationRequest */
|
|
4
|
+
/** @typedef {import("./mailer/index.js").MailerDeliveryOperation} MailerDeliveryOperation */
|
|
5
|
+
/** @typedef {import("./mailer/index.js").MailerDeliveryIdempotencyCapability} MailerDeliveryIdempotencyCapability */
|
|
6
|
+
/** @typedef {import("./mailer/index.js").MailerDeliveryLaterOptions} MailerDeliveryLaterOptions */
|
|
7
|
+
/** @typedef {import("./mailer/index.js").MailerDeliveryPayload} MailerDeliveryPayload */
|
|
4
8
|
|
|
5
9
|
export {
|
|
6
10
|
VelociousMailerBase,
|
|
@@ -12,4 +16,5 @@ export {
|
|
|
12
16
|
setDeliveryHandler
|
|
13
17
|
} from "./mailer/index.js"
|
|
14
18
|
export {default as SmtpMailerBackend} from "./mailer/backends/smtp.js"
|
|
19
|
+
export {default as ResendSmtpMailerBackend} from "./mailer/backends/resend-smtp.js"
|
|
15
20
|
export {default} from "./mailer/index.js"
|
|
@@ -37,11 +37,12 @@
|
|
|
37
37
|
* @property {import("./device-identity.js").SignedSyncMutation} [signedMutation] - Original signed mutation envelope, retained for peer-forwarded mutations.
|
|
38
38
|
* @property {number} sequence - Monotonic local sequence.
|
|
39
39
|
* @property {LocalMutationStatus} status - Local replay/apply status.
|
|
40
|
-
* @property {Record<string, import("../
|
|
40
|
+
* @property {Record<string, import("../frontend-models/base.js").FrontendModelTransportValue>} [syncResult] - Backend replay/result metadata. Stored as transport markers so Date (and other typed) values survive the durable JSON round trip and are restored on readback.
|
|
41
41
|
* @property {string} updatedAt - ISO timestamp when the record was last changed.
|
|
42
42
|
*/
|
|
43
43
|
// @ts-check
|
|
44
44
|
|
|
45
|
+
import {deserializeFrontendModelTransportValue, serializeFrontendModelTransportValue} from "../frontend-models/transport-serialization.js"
|
|
45
46
|
import stableJsonStringify from "./stable-json.js"
|
|
46
47
|
|
|
47
48
|
const DEFAULT_STORAGE_KEY = "velocious.sync.localMutationLog"
|
|
@@ -126,7 +127,7 @@ export default class LocalMutationLog {
|
|
|
126
127
|
* @param {object} args - Arguments.
|
|
127
128
|
* @param {string} args.id - Record id.
|
|
128
129
|
* @param {LocalMutationStatus} args.status - New status.
|
|
129
|
-
* @param {Record<string, import("../
|
|
130
|
+
* @param {Record<string, import("../frontend-models/base.js").FrontendModelTransportValue>} [args.syncResult] - Result metadata (may carry transport-restored typed values).
|
|
130
131
|
* @returns {Promise<LocalMutationLogRecord>} - Updated record.
|
|
131
132
|
*/
|
|
132
133
|
async updateStatus({id, status, syncResult}) {
|
|
@@ -140,11 +141,16 @@ export default class LocalMutationLog {
|
|
|
140
141
|
const record = normalizeRecord(rawRecord)
|
|
141
142
|
|
|
142
143
|
record.status = /** @type {LocalMutationStatus} */ (status)
|
|
143
|
-
if (syncResult !== undefined)
|
|
144
|
+
if (syncResult !== undefined) {
|
|
145
|
+
// Encode transport-restored typed values (e.g. Date attributes in a
|
|
146
|
+
// conflict serverModel) as markers before the JSON clone so the
|
|
147
|
+
// durable persistence round trip cannot stringify them.
|
|
148
|
+
record.syncResult = cloneJsonObject(serializeFrontendModelTransportValue(syncResult), "syncResult")
|
|
149
|
+
}
|
|
144
150
|
record.updatedAt = this.currentTimestamp()
|
|
145
151
|
await this.storage.updateRecord(this.storageKey, cloneRecord(record))
|
|
146
152
|
|
|
147
|
-
return cloneRecord(record)
|
|
153
|
+
return restoreSyncResultTypes(cloneRecord(record))
|
|
148
154
|
})
|
|
149
155
|
}
|
|
150
156
|
|
|
@@ -168,7 +174,7 @@ export default class LocalMutationLog {
|
|
|
168
174
|
record.updatedAt = this.currentTimestamp()
|
|
169
175
|
await this.storage.updateRecord(this.storageKey, cloneRecord(record))
|
|
170
176
|
|
|
171
|
-
return cloneRecord(record)
|
|
177
|
+
return restoreSyncResultTypes(cloneRecord(record))
|
|
172
178
|
})
|
|
173
179
|
}
|
|
174
180
|
|
|
@@ -270,6 +276,25 @@ function normalizeRecordList(records) {
|
|
|
270
276
|
.map(normalizeRecord)
|
|
271
277
|
.sort((left, right) => left.sequence - right.sequence)
|
|
272
278
|
.map((record) => cloneRecord(record))
|
|
279
|
+
.map(restoreSyncResultTypes)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Restores transport-restored typed values in a record's syncResult after the
|
|
284
|
+
* final JSON clone, so callers see Date (and other typed) values on durable
|
|
285
|
+
* readback instead of marker-encoded ISO strings.
|
|
286
|
+
* @param {LocalMutationLogRecord} record - Cloned record.
|
|
287
|
+
* @returns {LocalMutationLogRecord} - Record with restored syncResult types.
|
|
288
|
+
*/
|
|
289
|
+
function restoreSyncResultTypes(record) {
|
|
290
|
+
if (record.syncResult === undefined) return record
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
...record,
|
|
294
|
+
syncResult: /** @type {Record<string, import("../frontend-models/base.js").FrontendModelTransportValue>} */ (
|
|
295
|
+
deserializeFrontendModelTransportValue(record.syncResult)
|
|
296
|
+
)
|
|
297
|
+
}
|
|
273
298
|
}
|
|
274
299
|
|
|
275
300
|
/**
|
|
@@ -328,7 +353,13 @@ function normalizeRecord(value) {
|
|
|
328
353
|
if (record.signedMutation !== undefined) {
|
|
329
354
|
normalizedRecord.signedMutation = /** @type {import("./device-identity.js").SignedSyncMutation} */ (cloneJsonObject(record.signedMutation, "signedMutation"))
|
|
330
355
|
}
|
|
331
|
-
if (record.syncResult !== undefined)
|
|
356
|
+
if (record.syncResult !== undefined) {
|
|
357
|
+
// Persisted syncResult is marker-encoded so it survives the JSON clone;
|
|
358
|
+
// typed values are restored by restoreSyncResultTypes at the read boundary.
|
|
359
|
+
normalizedRecord.syncResult = /** @type {Record<string, import("../frontend-models/base.js").FrontendModelTransportValue>} */ (
|
|
360
|
+
cloneJsonObject(record.syncResult, "syncResult")
|
|
361
|
+
)
|
|
362
|
+
}
|
|
332
363
|
|
|
333
364
|
return normalizedRecord
|
|
334
365
|
}
|
package/src/sync/stable-json.js
CHANGED
|
@@ -1,28 +1 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Serializes a JSON-compatible value with recursively sorted object keys, so
|
|
5
|
-
* equal values always produce byte-identical strings (used for sync scope and
|
|
6
|
-
* change-feed identity comparisons).
|
|
7
|
-
* @param {ReturnType<typeof JSON.parse>} value - JSON-compatible value.
|
|
8
|
-
* @returns {string} - Stable JSON string.
|
|
9
|
-
*/
|
|
10
|
-
export default function stableJsonStringify(value) {
|
|
11
|
-
return JSON.stringify(stableJsonValue(value))
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Produces a recursively key-sorted JSON value.
|
|
16
|
-
* @param {ReturnType<typeof JSON.parse>} value - JSON-compatible value.
|
|
17
|
-
* @returns {ReturnType<typeof JSON.parse>} - Stable JSON-compatible value.
|
|
18
|
-
*/
|
|
19
|
-
function stableJsonValue(value) {
|
|
20
|
-
if (Array.isArray(value)) return value.map((item) => stableJsonValue(item))
|
|
21
|
-
if (!value || typeof value !== "object") return value
|
|
22
|
-
|
|
23
|
-
return Object.keys(value).sort().reduce((memo, key) => {
|
|
24
|
-
memo[key] = stableJsonValue(value[key])
|
|
25
|
-
|
|
26
|
-
return memo
|
|
27
|
-
}, /** @type {Record<string, ReturnType<typeof JSON.parse>>} */ ({}))
|
|
28
|
-
}
|
|
1
|
+
export {default} from "../utils/stable-json.js"
|
|
@@ -841,8 +841,9 @@ export default class SyncEnvelopeReplayService {
|
|
|
841
841
|
* Projects affected mutation fields through the resource's readable
|
|
842
842
|
* attribute contract. Writable-but-hidden fields are omitted, while custom
|
|
843
843
|
* `<attribute>Attribute(model)` serializers and model accessors remain the
|
|
844
|
-
* source of frontend-visible values
|
|
845
|
-
*
|
|
844
|
+
* source of frontend-visible values (Date values are kept raw so the normal
|
|
845
|
+
* frontend-model transport serializer can emit its date marker). The full
|
|
846
|
+
* model attribute hash is never exposed.
|
|
846
847
|
* @param {object} args - Projection args.
|
|
847
848
|
* @param {Record<string, ReturnType<typeof JSON.parse>>} args.attributes - Permitted affected mutation attributes.
|
|
848
849
|
* @param {import("../database/record/index.js").default} args.existingRecord - Authorized server record.
|
|
@@ -885,7 +886,7 @@ export default class SyncEnvelopeReplayService {
|
|
|
885
886
|
const resourceAttribute = resource.resourceMethod(`${attributeName}Attribute`)
|
|
886
887
|
|
|
887
888
|
if (resourceAttribute) {
|
|
888
|
-
serializedAttributes[affectedField] =
|
|
889
|
+
serializedAttributes[affectedField] = await resourceAttribute.method.call(resourceAttribute.resource, existingRecord)
|
|
889
890
|
continue
|
|
890
891
|
}
|
|
891
892
|
|
|
@@ -893,9 +894,9 @@ export default class SyncEnvelopeReplayService {
|
|
|
893
894
|
const attributeMethod = recordMethods[attributeName]
|
|
894
895
|
|
|
895
896
|
if (typeof attributeMethod === "function") {
|
|
896
|
-
serializedAttributes[affectedField] =
|
|
897
|
+
serializedAttributes[affectedField] = await attributeMethod.call(existingRecord)
|
|
897
898
|
} else {
|
|
898
|
-
serializedAttributes[affectedField] =
|
|
899
|
+
serializedAttributes[affectedField] = existingRecord.readAttribute(attributeName)
|
|
899
900
|
}
|
|
900
901
|
}
|
|
901
902
|
|
|
@@ -1166,8 +1167,11 @@ export function syncReplayConflictLockName({resourceId, resourceType}) {
|
|
|
1166
1167
|
}
|
|
1167
1168
|
|
|
1168
1169
|
/**
|
|
1169
|
-
* Normalizes
|
|
1170
|
-
*
|
|
1170
|
+
* Normalizes a version value for deterministic comparison and transport.
|
|
1171
|
+
* Only version values participate in stable-JSON comparison against client
|
|
1172
|
+
* `baseVersion` strings; resource serializer/accessor results must stay raw so
|
|
1173
|
+
* the frontend-model transport serializer can retain Date markers.
|
|
1174
|
+
* @param {ReturnType<typeof JSON.parse>} value - Raw version value from a database record.
|
|
1171
1175
|
* @returns {ReturnType<typeof JSON.parse>} - Normalized value (Date values become ISO strings).
|
|
1172
1176
|
*/
|
|
1173
1177
|
function normalizeConflictValue(value) {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Serializes a JSON-compatible value with recursively sorted object keys.
|
|
5
|
+
* @param {ReturnType<typeof JSON.parse>} value - JSON-compatible value.
|
|
6
|
+
* @returns {string} - Stable JSON string.
|
|
7
|
+
*/
|
|
8
|
+
export default function stableJsonStringify(value) {
|
|
9
|
+
return JSON.stringify(stableJsonValue(value))
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Produces a recursively key-sorted JSON value.
|
|
14
|
+
* @param {ReturnType<typeof JSON.parse>} value - JSON-compatible value.
|
|
15
|
+
* @returns {ReturnType<typeof JSON.parse>} - Stable JSON-compatible value.
|
|
16
|
+
*/
|
|
17
|
+
function stableJsonValue(value) {
|
|
18
|
+
if (Array.isArray(value)) return value.map((item) => stableJsonValue(item))
|
|
19
|
+
if (!value || typeof value !== "object") return value
|
|
20
|
+
|
|
21
|
+
return Object.keys(value).sort().reduce((memo, key) => {
|
|
22
|
+
memo[key] = stableJsonValue(value[key])
|
|
23
|
+
|
|
24
|
+
return memo
|
|
25
|
+
}, /** @type {Record<string, ReturnType<typeof JSON.parse>>} */ ({}))
|
|
26
|
+
}
|