velocious 1.0.596 → 1.0.598

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 (124) hide show
  1. package/README.md +18 -2
  2. package/build/background-jobs/main.js +42 -32
  3. package/build/background-jobs/pooled-runner-broker-identity.js +22 -4
  4. package/build/background-jobs/store.js +383 -9
  5. package/build/background-jobs/types.js +1 -0
  6. package/build/cli/commands/db/tenants/migrations/pending.js +45 -0
  7. package/build/configuration-types.js +3 -0
  8. package/build/configuration.js +5 -2
  9. package/build/database/drivers/sqlite/base.js +3 -3
  10. package/build/database/tenants/migration-pending-inspector.js +77 -0
  11. package/build/environment-handlers/base.js +8 -0
  12. package/build/environment-handlers/node.js +14 -0
  13. package/build/mailer/backends/resend-smtp.js +121 -0
  14. package/build/mailer/base.js +52 -22
  15. package/build/mailer/delivery-operation-store.js +152 -0
  16. package/build/mailer/delivery-operation.js +205 -0
  17. package/build/mailer/delivery.js +9 -4
  18. package/build/mailer/index.js +5 -1
  19. package/build/mailer.js +6 -1
  20. package/build/src/background-jobs/main.d.ts +15 -10
  21. package/build/src/background-jobs/main.d.ts.map +1 -1
  22. package/build/src/background-jobs/main.js +42 -34
  23. package/build/src/background-jobs/pooled-runner-broker-identity.d.ts +10 -1
  24. package/build/src/background-jobs/pooled-runner-broker-identity.d.ts.map +1 -1
  25. package/build/src/background-jobs/pooled-runner-broker-identity.js +23 -5
  26. package/build/src/background-jobs/store.d.ts +154 -0
  27. package/build/src/background-jobs/store.d.ts.map +1 -1
  28. package/build/src/background-jobs/store.js +350 -10
  29. package/build/src/background-jobs/types.d.ts +5 -0
  30. package/build/src/background-jobs/types.d.ts.map +1 -1
  31. package/build/src/background-jobs/types.js +2 -1
  32. package/build/src/cli/commands/db/tenants/migrations/pending.d.ts +15 -0
  33. package/build/src/cli/commands/db/tenants/migrations/pending.d.ts.map +1 -0
  34. package/build/src/cli/commands/db/tenants/migrations/pending.js +40 -0
  35. package/build/src/configuration-types.d.ts +20 -0
  36. package/build/src/configuration-types.d.ts.map +1 -1
  37. package/build/src/configuration-types.js +4 -1
  38. package/build/src/configuration.d.ts +2 -2
  39. package/build/src/configuration.d.ts.map +1 -1
  40. package/build/src/configuration.js +6 -3
  41. package/build/src/database/drivers/sqlite/base.js +4 -4
  42. package/build/src/database/tenants/migration-pending-inspector.d.ts +38 -0
  43. package/build/src/database/tenants/migration-pending-inspector.d.ts.map +1 -0
  44. package/build/src/database/tenants/migration-pending-inspector.js +70 -0
  45. package/build/src/environment-handlers/base.d.ts +10 -0
  46. package/build/src/environment-handlers/base.d.ts.map +1 -1
  47. package/build/src/environment-handlers/base.js +8 -1
  48. package/build/src/environment-handlers/node.d.ts +11 -0
  49. package/build/src/environment-handlers/node.d.ts.map +1 -1
  50. package/build/src/environment-handlers/node.js +14 -1
  51. package/build/src/mailer/backends/resend-smtp.d.ts +43 -0
  52. package/build/src/mailer/backends/resend-smtp.d.ts.map +1 -0
  53. package/build/src/mailer/backends/resend-smtp.js +106 -0
  54. package/build/src/mailer/base.d.ts +9 -2
  55. package/build/src/mailer/base.d.ts.map +1 -1
  56. package/build/src/mailer/base.js +41 -19
  57. package/build/src/mailer/delivery-operation-store.d.ts +52 -0
  58. package/build/src/mailer/delivery-operation-store.d.ts.map +1 -0
  59. package/build/src/mailer/delivery-operation-store.js +130 -0
  60. package/build/src/mailer/delivery-operation.d.ts +73 -0
  61. package/build/src/mailer/delivery-operation.d.ts.map +1 -0
  62. package/build/src/mailer/delivery-operation.js +185 -0
  63. package/build/src/mailer/delivery.d.ts +4 -2
  64. package/build/src/mailer/delivery.d.ts.map +1 -1
  65. package/build/src/mailer/delivery.js +9 -5
  66. package/build/src/mailer/index.d.ts +24 -1
  67. package/build/src/mailer/index.d.ts.map +1 -1
  68. package/build/src/mailer/index.js +6 -2
  69. package/build/src/mailer.d.ts +11 -13
  70. package/build/src/mailer.d.ts.map +1 -1
  71. package/build/src/mailer.js +7 -2
  72. package/build/src/sync/local-mutation-log.d.ts +5 -5
  73. package/build/src/sync/local-mutation-log.d.ts.map +1 -1
  74. package/build/src/sync/local-mutation-log.js +34 -10
  75. package/build/src/sync/stable-json.d.ts +1 -8
  76. package/build/src/sync/stable-json.d.ts.map +1 -1
  77. package/build/src/sync/stable-json.js +2 -27
  78. package/build/src/sync/sync-envelope-replay-service.d.ts +3 -2
  79. package/build/src/sync/sync-envelope-replay-service.d.ts.map +1 -1
  80. package/build/src/sync/sync-envelope-replay-service.js +12 -8
  81. package/build/src/testing/factory/node/definition-reload-policy.d.ts +97 -0
  82. package/build/src/testing/factory/node/definition-reload-policy.d.ts.map +1 -0
  83. package/build/src/testing/factory/node/definition-reload-policy.js +133 -0
  84. package/build/src/testing/factory/node/load-definitions.d.ts +4 -1
  85. package/build/src/testing/factory/node/load-definitions.d.ts.map +1 -1
  86. package/build/src/testing/factory/node/load-definitions.js +27 -4
  87. package/build/src/testing/test-runner.js +7 -7
  88. package/build/src/utils/stable-json.d.ts +7 -0
  89. package/build/src/utils/stable-json.d.ts.map +1 -0
  90. package/build/src/utils/stable-json.js +25 -0
  91. package/build/sync/local-mutation-log.js +37 -6
  92. package/build/sync/stable-json.js +1 -28
  93. package/build/sync/sync-envelope-replay-service.js +11 -7
  94. package/build/testing/factory/node/definition-reload-policy.js +149 -0
  95. package/build/testing/factory/node/load-definitions.js +29 -5
  96. package/build/testing/test-runner.js +6 -6
  97. package/build/tsconfig.tsbuildinfo +1 -1
  98. package/build/utils/stable-json.js +26 -0
  99. package/package.json +2 -1
  100. package/src/background-jobs/main.js +42 -32
  101. package/src/background-jobs/pooled-runner-broker-identity.js +22 -4
  102. package/src/background-jobs/store.js +383 -9
  103. package/src/background-jobs/types.js +1 -0
  104. package/src/cli/commands/db/tenants/migrations/pending.js +45 -0
  105. package/src/configuration-types.js +3 -0
  106. package/src/configuration.js +5 -2
  107. package/src/database/drivers/sqlite/base.js +3 -3
  108. package/src/database/tenants/migration-pending-inspector.js +77 -0
  109. package/src/environment-handlers/base.js +8 -0
  110. package/src/environment-handlers/node.js +14 -0
  111. package/src/mailer/backends/resend-smtp.js +121 -0
  112. package/src/mailer/base.js +52 -22
  113. package/src/mailer/delivery-operation-store.js +152 -0
  114. package/src/mailer/delivery-operation.js +205 -0
  115. package/src/mailer/delivery.js +9 -4
  116. package/src/mailer/index.js +5 -1
  117. package/src/mailer.js +6 -1
  118. package/src/sync/local-mutation-log.js +37 -6
  119. package/src/sync/stable-json.js +1 -28
  120. package/src/sync/sync-envelope-replay-service.js +11 -7
  121. package/src/testing/factory/node/definition-reload-policy.js +149 -0
  122. package/src/testing/factory/node/load-definitions.js +29 -5
  123. package/src/testing/test-runner.js +6 -6
  124. package/src/utils/stable-json.js +26 -0
package/src/mailer.js CHANGED
@@ -1,6 +1,10 @@
1
1
  // @ts-check
2
2
 
3
- /** @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}} MailerDeliveryPayload */
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("../configuration-types.js").FrontendModelSyncJsonValue>} [syncResult] - Backend replay/result metadata.
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("../configuration-types.js").FrontendModelSyncJsonValue>} [args.syncResult] - Result metadata.
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) record.syncResult = cloneJsonObject(syncResult, "syncResult")
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) normalizedRecord.syncResult = cloneJsonObject(record.syncResult, "syncResult")
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
  }
@@ -1,28 +1 @@
1
- // @ts-check
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. The full model attribute hash is never
845
- * exposed.
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] = normalizeConflictValue(await resourceAttribute.method.call(resourceAttribute.resource, existingRecord))
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] = normalizeConflictValue(await attributeMethod.call(existingRecord))
897
+ serializedAttributes[affectedField] = await attributeMethod.call(existingRecord)
897
898
  } else {
898
- serializedAttributes[affectedField] = normalizeConflictValue(existingRecord.readAttribute(attributeName))
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 an authoritative conflict value for JSON transport and deterministic comparison.
1170
- * @param {ReturnType<typeof JSON.parse>} value - Raw value from a database record.
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,149 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * Default maximum number of cache-busted factory definition import attempts a
5
+ * single Node process may perform. Chosen conservatively from the
6
+ * `factory-esm-reload-retention` benchmark evidence: each cache-busted import
7
+ * retains roughly 6 KB of heap in Node's ESM module map, so the default bounds
8
+ * retained definition modules to a few tens of MB before the owning process
9
+ * must be recycled. This module is intentionally Node-only; browser-safe factory
10
+ * code must never import it.
11
+ */
12
+ export const DEFAULT_DEFINITION_RELOAD_BUDGET = 4096
13
+
14
+ /**
15
+ * Rejected when code tries to configure the process-global reload budget more
16
+ * than once or after any valid cache-busted import reservation was attempted.
17
+ * Retained ESM modules and their accounting live for the process lifetime, so
18
+ * changing the budget can never begin a new in-process policy epoch.
19
+ */
20
+ export class DefinitionReloadConfigurationError extends Error {
21
+ /**
22
+ * Creates the error.
23
+ * @param {object} args - Details.
24
+ * @param {number} args.current - Cache-busted imports already reserved.
25
+ * @param {number} args.budget - Active process-global import budget.
26
+ * @param {number} args.requestedBudget - Rejected replacement budget.
27
+ * @param {boolean} args.configured - Whether an explicit budget was already configured.
28
+ */
29
+ constructor({budget, configured, current, requestedBudget}) {
30
+ const reason = configured
31
+ ? "the process-global definition reload budget was already configured"
32
+ : "a definition reload reservation was already attempted"
33
+
34
+ super(`Cannot configure definition reload budget to ${requestedBudget}: ${reason} (current=${current}, budget=${budget}). Configuration is allowed exactly once before the first reservation; only process exit resets retained-module accounting.`)
35
+ this.name = "DefinitionReloadConfigurationError"
36
+ this.budget = budget
37
+ this.configured = configured
38
+ this.current = current
39
+ this.requestedBudget = requestedBudget
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Rejected when a reload would push the process over its cache-busted import
45
+ * budget. The rejection happens synchronously before any registry reset or
46
+ * import, so the currently loaded registry stays usable. Node never evicts
47
+ * retained ESM module instances, so only recycling/restarting the owning Node
48
+ * process reclaims the memory and refreshes edited dependency modules.
49
+ */
50
+ export class DefinitionRecycleRequiredError extends Error {
51
+ /**
52
+ * Creates the error.
53
+ * @param {object} args - Details.
54
+ * @param {number} args.current - Cache-busted import attempts already reserved in this process.
55
+ * @param {number} args.budget - Process-global import budget.
56
+ * @param {number} args.requested - Import attempts the rejected reload needed.
57
+ */
58
+ constructor({current, budget, requested}) {
59
+ super(`Factory definition reload import budget exhausted (current=${current}, budget=${budget}, requested=${requested}). Recycle or restart the owning Node process: every reload imports a fresh cache-busted module instance and Node never evicts them, so process recycling is the only reclamation boundary.`)
60
+ this.name = "DefinitionRecycleRequiredError"
61
+ this.current = current
62
+ this.budget = budget
63
+ this.requested = requested
64
+ }
65
+ }
66
+
67
+ /** @type {number} - The single process-global import budget. */
68
+ let importBudget = DEFAULT_DEFINITION_RELOAD_BUDGET
69
+
70
+ /** @type {number} - Cache-busted import attempts reserved so far across every registry and target. */
71
+ let reservedImports = 0
72
+
73
+ /** @type {boolean} - Whether the process-global budget was explicitly configured. */
74
+ let budgetConfigured = false
75
+
76
+ /** @type {boolean} - Whether any valid complete reload batch reservation was attempted. */
77
+ let reservationStarted = false
78
+
79
+ /**
80
+ * Returns the process-global cache-busted import budget.
81
+ * @returns {number} - The budget.
82
+ */
83
+ export function getDefinitionReloadBudget() {
84
+ return importBudget
85
+ }
86
+
87
+ /**
88
+ * Reads the cache-busted import attempts reserved so far in this process,
89
+ * across every registry and target. Combined with {@link getDefinitionReloadBudget}
90
+ * this is the deterministic process-global census for the recycle policy.
91
+ * @returns {number} - Reserved count.
92
+ */
93
+ export function peekDefinitionReloadBudget() {
94
+ return reservedImports
95
+ }
96
+
97
+ /**
98
+ * Configures the one process-global import budget exactly once and only before
99
+ * the first valid reservation attempt. There is exactly one budget for the whole
100
+ * process, so no combination of registries or targets can create independent
101
+ * budgets that defeat the global limit. Retained-import accounting is never
102
+ * reset in-process.
103
+ * @param {number} budget - New budget.
104
+ * @returns {void}
105
+ */
106
+ export function setDefinitionReloadBudget(budget) {
107
+ if (!Number.isInteger(budget) || budget < 1) {
108
+ throw new TypeError(`Definition reload budget must be a positive integer, got ${JSON.stringify(budget)}`)
109
+ }
110
+
111
+ if (budgetConfigured || reservationStarted) {
112
+ throw new DefinitionReloadConfigurationError({
113
+ budget: importBudget,
114
+ configured: budgetConfigured,
115
+ current: reservedImports,
116
+ requestedBudget: budget
117
+ })
118
+ }
119
+
120
+ importBudget = budget
121
+ budgetConfigured = true
122
+ }
123
+
124
+ /**
125
+ * Preflights and reserves a whole reload batch synchronously. Malformed counts
126
+ * are rejected before configuration is sealed or accounting changes. Every valid
127
+ * request, including zero and a rejected over-budget request, seals configuration
128
+ * before capacity is evaluated. Throws {@link DefinitionRecycleRequiredError}
129
+ * when the requested batch would push the process over its budget. The check and
130
+ * reservation run in one synchronous step, so concurrent reloads cannot race past
131
+ * the budget. The reservation is deliberately conservative: it covers every
132
+ * import attempt in the batch, so a mid-batch import failure still counts its
133
+ * attempts as retained modules.
134
+ * @param {number} requested - Cache-busted import attempts the reload will perform.
135
+ * @returns {void}
136
+ */
137
+ export function reserveDefinitionReloadBudget(requested) {
138
+ if (!Number.isInteger(requested) || requested < 0) {
139
+ throw new TypeError(`Definition reload reservation must be a non-negative integer, got ${typeof requested} ${String(requested)}`)
140
+ }
141
+
142
+ reservationStarted = true
143
+
144
+ if (reservedImports + requested > importBudget) {
145
+ throw new DefinitionRecycleRequiredError({current: reservedImports, budget: importBudget, requested})
146
+ }
147
+
148
+ reservedImports += requested
149
+ }
@@ -1,8 +1,9 @@
1
1
  // @ts-check
2
2
 
3
- import {pathToFileURL} from "node:url"
4
- import {readdir, stat} from "node:fs/promises"
3
+ import { pathToFileURL } from "node:url"
4
+ import { readdir, stat } from "node:fs/promises"
5
5
  import path from "node:path"
6
+ import { reserveDefinitionReloadBudget } from "./definition-reload-policy.js"
6
7
 
7
8
  /**
8
9
  * Monotonic cache-busting counter shared by reloads. Kept module-local so a reload
@@ -68,6 +69,26 @@ async function resolveFiles(target) {
68
69
  export async function loadDefinitions(registry, target, {reload = false} = {}) {
69
70
  const files = await resolveFiles(target)
70
71
 
72
+ return await loadResolvedDefinitionFiles({files, registry, reload})
73
+ }
74
+
75
+ /**
76
+ * Loads definition files that have already been resolved into a deterministic
77
+ * sorted list. When `reload` is set, the whole batch is preflighted and reserved
78
+ * against the process-global import budget before any registry reset or import
79
+ * attempt, so a rejected reload never mutates the registry.
80
+ * @param {object} args - Options object.
81
+ * @param {string[]} args.files - Resolved, sorted definition file paths.
82
+ * @param {import("../factory-registry.js").default} args.registry - Registry to define into.
83
+ * @param {boolean} args.reload - Whether to cache-bust the imports.
84
+ * @param {boolean} [args.reset] - Whether to reset the registry first.
85
+ * @returns {Promise<string[]>} - The loaded file paths, in load order.
86
+ */
87
+ async function loadResolvedDefinitionFiles({files, registry, reload, reset = false}) {
88
+ if (reload) reserveDefinitionReloadBudget(files.length)
89
+
90
+ if (reset) registry.reset()
91
+
71
92
  for (const file of files) {
72
93
  let href = pathToFileURL(file).href
73
94
 
@@ -91,13 +112,16 @@ export async function loadDefinitions(registry, target, {reload = false} = {}) {
91
112
  /**
92
113
  * Fully reloads definitions: resets the registry (dropping every factory, trait,
93
114
  * sequence, callback and default) and re-imports the target files with cache
94
- * busting so edited definitions take effect.
115
+ * busting so edited definitions take effect. The resolved batch is preflighted
116
+ * against the process-global import budget before the reset, and a
117
+ * `DefinitionRecycleRequiredError` is raised before any mutation when the batch
118
+ * would exceed the budget.
95
119
  * @param {import("../factory-registry.js").default} registry - Registry to reload.
96
120
  * @param {string | string[]} target - File path, directory, or list of paths.
97
121
  * @returns {Promise<string[]>} - The reloaded file paths, in load order.
98
122
  */
99
123
  export async function reloadDefinitions(registry, target) {
100
- registry.reset()
124
+ const files = await resolveFiles(target)
101
125
 
102
- return await loadDefinitions(registry, target, {reload: true})
126
+ return await loadResolvedDefinitionFiles({files, registry, reload: true, reset: true})
103
127
  }
@@ -1058,12 +1058,12 @@ export default class TestRunner {
1058
1058
  // back). Releasing the lease after each lifecycle also runs the pool's
1059
1059
  // session cleanup before another test can reuse the connection.
1060
1060
  await this.getConfiguration().ensureConnections({name: `Test: ${testDescription}`}, async () => {
1061
- // Register dynamic candidates before application hooks so transaction
1062
- // state changes made during a hook are visible to a request dispatched
1063
- // by that same callback.
1064
- if (testArgs.type == "request") {
1065
- testSharedConnectionRegistrations = this.activateTestSharedConnections()
1066
- }
1061
+ // Register dynamic candidates before hooks so transaction state changes
1062
+ // made during a hook are immediately visible to any in-process work.
1063
+ // Long-lived services such as a background-jobs main can dispatch DB
1064
+ // work between a transaction-starting hook and broker activation just
1065
+ // as an HTTP request can dispatch work from inside the hook itself.
1066
+ testSharedConnectionRegistrations = this.activateTestSharedConnections()
1067
1067
 
1068
1068
  try {
1069
1069
  try {
@@ -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
+ }