@jantstack/adonis-authz 2.4.0-alpha.1 → 2.4.0-alpha.10

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 (158) hide show
  1. package/README.md +496 -29
  2. package/build/commands/authz_catalog_diff.d.ts.map +1 -1
  3. package/build/commands/authz_catalog_diff.js +0 -1
  4. package/build/commands/authz_catalog_diff.js.map +1 -1
  5. package/build/commands/authz_catalog_prune_orphans.d.ts.map +1 -1
  6. package/build/commands/authz_catalog_prune_orphans.js +0 -1
  7. package/build/commands/authz_catalog_prune_orphans.js.map +1 -1
  8. package/build/commands/authz_freeze.d.ts.map +1 -1
  9. package/build/commands/authz_freeze.js +1 -2
  10. package/build/commands/authz_freeze.js.map +1 -1
  11. package/build/commands/authz_reconcile.d.ts +15 -6
  12. package/build/commands/authz_reconcile.d.ts.map +1 -1
  13. package/build/commands/authz_reconcile.js +143 -20
  14. package/build/commands/authz_reconcile.js.map +1 -1
  15. package/build/commands/authz_relations_reconcile.d.ts +72 -1
  16. package/build/commands/authz_relations_reconcile.d.ts.map +1 -1
  17. package/build/commands/authz_relations_reconcile.js +234 -21
  18. package/build/commands/authz_relations_reconcile.js.map +1 -1
  19. package/build/commands/authz_scopes_relay.d.ts +50 -0
  20. package/build/commands/authz_scopes_relay.d.ts.map +1 -1
  21. package/build/commands/authz_scopes_relay.js +213 -10
  22. package/build/commands/authz_scopes_relay.js.map +1 -1
  23. package/build/commands/authz_scopes_requeue.d.ts +80 -0
  24. package/build/commands/authz_scopes_requeue.d.ts.map +1 -0
  25. package/build/commands/authz_scopes_requeue.js +230 -0
  26. package/build/commands/authz_scopes_requeue.js.map +1 -0
  27. package/build/commands/authz_unfreeze.d.ts +12 -4
  28. package/build/commands/authz_unfreeze.d.ts.map +1 -1
  29. package/build/commands/authz_unfreeze.js +74 -9
  30. package/build/commands/authz_unfreeze.js.map +1 -1
  31. package/build/commands/main.d.ts +16 -13
  32. package/build/commands/main.d.ts.map +1 -1
  33. package/build/commands/main.js +59 -11
  34. package/build/commands/main.js.map +1 -1
  35. package/build/commands/openfga_provision.d.ts +49 -3
  36. package/build/commands/openfga_provision.d.ts.map +1 -1
  37. package/build/commands/openfga_provision.js +72 -7
  38. package/build/commands/openfga_provision.js.map +1 -1
  39. package/build/index.d.ts +15 -5
  40. package/build/index.d.ts.map +1 -1
  41. package/build/index.js +16 -3
  42. package/build/index.js.map +1 -1
  43. package/build/providers/authz_provider.d.ts +7 -2
  44. package/build/providers/authz_provider.d.ts.map +1 -1
  45. package/build/providers/authz_provider.js +20 -3
  46. package/build/providers/authz_provider.js.map +1 -1
  47. package/build/src/define_config.d.ts +106 -1
  48. package/build/src/define_config.d.ts.map +1 -1
  49. package/build/src/define_config.js +11 -0
  50. package/build/src/define_config.js.map +1 -1
  51. package/build/src/drivers/database_driver.d.ts +53 -14
  52. package/build/src/drivers/database_driver.d.ts.map +1 -1
  53. package/build/src/drivers/database_driver.js +104 -32
  54. package/build/src/drivers/database_driver.js.map +1 -1
  55. package/build/src/drivers/database_relations_driver.d.ts +74 -9
  56. package/build/src/drivers/database_relations_driver.d.ts.map +1 -1
  57. package/build/src/drivers/database_relations_driver.js +391 -93
  58. package/build/src/drivers/database_relations_driver.js.map +1 -1
  59. package/build/src/drivers/openfga_driver.d.ts +71 -10
  60. package/build/src/drivers/openfga_driver.d.ts.map +1 -1
  61. package/build/src/drivers/openfga_driver.js +195 -16
  62. package/build/src/drivers/openfga_driver.js.map +1 -1
  63. package/build/src/drivers/openfga_facts.d.ts +15 -0
  64. package/build/src/drivers/openfga_facts.d.ts.map +1 -1
  65. package/build/src/drivers/openfga_facts.js +25 -2
  66. package/build/src/drivers/openfga_facts.js.map +1 -1
  67. package/build/src/drivers/openfga_relations_driver.d.ts +72 -6
  68. package/build/src/drivers/openfga_relations_driver.d.ts.map +1 -1
  69. package/build/src/drivers/openfga_relations_driver.js +308 -50
  70. package/build/src/drivers/openfga_relations_driver.js.map +1 -1
  71. package/build/src/errors.d.ts +175 -4
  72. package/build/src/errors.d.ts.map +1 -1
  73. package/build/src/errors.js +219 -3
  74. package/build/src/errors.js.map +1 -1
  75. package/build/src/freeze.d.ts +100 -20
  76. package/build/src/freeze.d.ts.map +1 -1
  77. package/build/src/freeze.js +159 -25
  78. package/build/src/freeze.js.map +1 -1
  79. package/build/src/manager.d.ts +239 -7
  80. package/build/src/manager.d.ts.map +1 -1
  81. package/build/src/manager.js +1192 -148
  82. package/build/src/manager.js.map +1 -1
  83. package/build/src/models/authz_assignment.d.ts +2 -2
  84. package/build/src/models/authz_assignment.d.ts.map +1 -1
  85. package/build/src/models/authz_deny.d.ts +2 -2
  86. package/build/src/models/authz_deny.d.ts.map +1 -1
  87. package/build/src/models/authz_permission.d.ts +2 -2
  88. package/build/src/models/authz_permission.d.ts.map +1 -1
  89. package/build/src/models/authz_role.d.ts +2 -2
  90. package/build/src/models/authz_role.d.ts.map +1 -1
  91. package/build/src/models/authz_role_permission.d.ts +2 -2
  92. package/build/src/models/authz_role_permission.d.ts.map +1 -1
  93. package/build/src/relation_partition_trigger.d.ts.map +1 -1
  94. package/build/src/relation_partition_trigger.js +11 -3
  95. package/build/src/relation_partition_trigger.js.map +1 -1
  96. package/build/src/relations/define_relations_config.d.ts +51 -0
  97. package/build/src/relations/define_relations_config.d.ts.map +1 -1
  98. package/build/src/relations/define_relations_config.js +68 -1
  99. package/build/src/relations/define_relations_config.js.map +1 -1
  100. package/build/src/relations/manager.d.ts +106 -6
  101. package/build/src/relations/manager.d.ts.map +1 -1
  102. package/build/src/relations/manager.js +368 -30
  103. package/build/src/relations/manager.js.map +1 -1
  104. package/build/src/relations/reconcile.d.ts +81 -6
  105. package/build/src/relations/reconcile.d.ts.map +1 -1
  106. package/build/src/relations/reconcile.js +233 -77
  107. package/build/src/relations/reconcile.js.map +1 -1
  108. package/build/src/scope_outbox.d.ts +54 -0
  109. package/build/src/scope_outbox.d.ts.map +1 -1
  110. package/build/src/scope_outbox.js +274 -33
  111. package/build/src/scope_outbox.js.map +1 -1
  112. package/build/src/shared/backend_guard.d.ts +28 -0
  113. package/build/src/shared/backend_guard.d.ts.map +1 -1
  114. package/build/src/shared/backend_guard.js +52 -0
  115. package/build/src/shared/backend_guard.js.map +1 -1
  116. package/build/src/shared/sql_expiry.d.ts +2 -0
  117. package/build/src/shared/sql_expiry.d.ts.map +1 -1
  118. package/build/src/shared/sql_expiry.js +4 -0
  119. package/build/src/shared/sql_expiry.js.map +1 -1
  120. package/build/src/shared/transaction_guard.d.ts +53 -0
  121. package/build/src/shared/transaction_guard.d.ts.map +1 -0
  122. package/build/src/shared/transaction_guard.js +74 -0
  123. package/build/src/shared/transaction_guard.js.map +1 -0
  124. package/build/src/sql_descendants.d.ts +8 -0
  125. package/build/src/sql_descendants.d.ts.map +1 -1
  126. package/build/src/sql_descendants.js.map +1 -1
  127. package/build/src/testing/contract.d.ts +55 -4
  128. package/build/src/testing/contract.d.ts.map +1 -1
  129. package/build/src/testing/contract.js +244 -9
  130. package/build/src/testing/contract.js.map +1 -1
  131. package/build/src/testing/main.d.ts +6 -4
  132. package/build/src/testing/main.d.ts.map +1 -1
  133. package/build/src/testing/main.js +3 -2
  134. package/build/src/testing/main.js.map +1 -1
  135. package/build/src/testing/migration_contract.d.ts +9 -0
  136. package/build/src/testing/migration_contract.d.ts.map +1 -1
  137. package/build/src/testing/migration_contract.js +5 -2
  138. package/build/src/testing/migration_contract.js.map +1 -1
  139. package/build/src/testing/relations_contract.d.ts +23 -1
  140. package/build/src/testing/relations_contract.d.ts.map +1 -1
  141. package/build/src/testing/relations_contract.js +603 -19
  142. package/build/src/testing/relations_contract.js.map +1 -1
  143. package/build/src/testing/relations_reconcile_contract.d.ts.map +1 -1
  144. package/build/src/testing/relations_reconcile_contract.js +49 -1
  145. package/build/src/testing/relations_reconcile_contract.js.map +1 -1
  146. package/build/src/testing/scope_outbox_contract.d.ts +98 -0
  147. package/build/src/testing/scope_outbox_contract.d.ts.map +1 -0
  148. package/build/src/testing/scope_outbox_contract.js +677 -0
  149. package/build/src/testing/scope_outbox_contract.js.map +1 -0
  150. package/build/src/traits/has_uuid.d.ts +2 -2
  151. package/build/src/traits/has_uuid.d.ts.map +1 -1
  152. package/build/src/types.d.ts +708 -38
  153. package/build/src/types.d.ts.map +1 -1
  154. package/build/src/types.js +55 -0
  155. package/build/src/types.js.map +1 -1
  156. package/build/stubs/config/authorization.stub +45 -1
  157. package/build/stubs/migration.stub +20 -10
  158. package/package.json +1 -1
@@ -0,0 +1,677 @@
1
+ /**
2
+ * **El contrato del puerto `ScopeOutbox`** (2.4.0-alpha.5 · R5; tester del
3
+ * panel relay, su recomendación nº 1) — el juez de una outbox del árbol,
4
+ * SEPARADO del runner de drivers (precedente: `runRelationsDriverContract`).
5
+ *
6
+ * import { runScopeOutboxContract } from '@jantstack/adonis-authz/testing'
7
+ *
8
+ * El puerto tiene deberes que hasta alpha.5 solo cumplía la implementación
9
+ * del paquete y que el manager solo caza en caliente en dos casos (el bucle
10
+ * de un `markApplied` no honrado y el `only` no honrado): el recibo, el orden
11
+ * por antigüedad, `after` como continuación, `markApplied` idempotente,
12
+ * `markFailed` que cuenta y no pierde la fila, el actor que viaja. Y desde
13
+ * alpha.5 cada método OPCIONAL es un **par de capacidad**: una cara `true`
14
+ * que juzga el deber y una cara `false` que juzga que el manager lo DICE (o
15
+ * que la implementación degrada hacia lo seguro), jamás un `skip`. El runner
16
+ * FALLA al registrarse si una cara no está poblada o si una capacidad `true`
17
+ * llega sin lo que hace falta para observarla.
18
+ *
19
+ * Corre contra `sqlScopeOutbox` en los cuatro motores de la suite y contra
20
+ * el doble en memoria de aquí (`makeScopeOutbox`), en sus dos formas.
21
+ */
22
+ import { v7 as uuidv7 } from 'uuid';
23
+ import { test as japaTest } from '@japa/runner';
24
+ import { AuthorizationManager } from '../manager.js';
25
+ import { memoryScopeTree, resolveChainFrom } from './scope_tree.js';
26
+ import { APP_SCOPE } from '../types.js';
27
+ export const SCOPE_OUTBOX_CAPABILITY_KEYS = [
28
+ 'transactionalEnqueue',
29
+ 'filter',
30
+ 'transient',
31
+ 'dead',
32
+ 'acquire',
33
+ 'earlierPending',
34
+ 'requeue',
35
+ 'createdAt',
36
+ ];
37
+ function keyOf(scope) {
38
+ return scope.type === 'app' ? 'app' : `${scope.type}|${scope.uuid}`;
39
+ }
40
+ function keysOf(change) {
41
+ return change.op === 'detached' ? [keyOf(change.child)] : [keyOf(change.child), keyOf(change.parent)];
42
+ }
43
+ /**
44
+ * Una outbox EN MEMORIA que implementa el puerto entero y expone SOLO lo que
45
+ * declara: con `filter: false` ignora `ids`, con `transient: false` cuenta
46
+ * siempre, con `dead: false` no aparca (y no tiene `dead`), y sin `acquire`/
47
+ * `earlierPending`/`requeue` no trae esos métodos. Es la forma de ejercer LAS
48
+ * DOS CARAS de cada par sin servidor. No escribe por ninguna transacción:
49
+ * `transactionalEnqueue` tiene que ser `false` (lanza si no).
50
+ */
51
+ export function makeScopeOutbox(options) {
52
+ const caps = options.capabilities;
53
+ if (caps.transactionalEnqueue) {
54
+ throw new Error('makeScopeOutbox: el doble en memoria no escribe por ninguna transacción; declara transactionalEnqueue: false');
55
+ }
56
+ const max = options.maxAttempts ?? 5;
57
+ const now = options.now ?? (() => new Date());
58
+ const rows = [];
59
+ const view = (index) => {
60
+ const row = rows[index];
61
+ return {
62
+ id: index + 1,
63
+ change: row.change,
64
+ attempts: row.attempts,
65
+ ...(row.actor ? { actor: row.actor } : {}),
66
+ ...(row.lastError === undefined ? {} : { lastError: row.lastError }),
67
+ // `createdAt` solo si se declara: la cara `false` juzga la AUSENCIA.
68
+ ...(caps.createdAt ? { createdAt: row.createdAt } : {}),
69
+ };
70
+ };
71
+ const parked = (index) => caps.dead && rows[index].attempts >= max;
72
+ const isPending = (index) => !rows[index].applied && !parked(index);
73
+ const indexOf = (id) => Number(id) - 1;
74
+ const outbox = {
75
+ async enqueue(change, context) {
76
+ rows.push({ change, actor: context?.actor, attempts: 0, applied: false, createdAt: now() });
77
+ return { id: rows.length };
78
+ },
79
+ async pending(limit, after, filter) {
80
+ return rows
81
+ .map((_, index) => index)
82
+ .filter(isPending)
83
+ .filter((index) => after === undefined || index + 1 > Number(after))
84
+ .filter((index) => !caps.filter || filter === undefined || filter.ids.map(String).includes(String(index + 1)))
85
+ .slice(0, limit)
86
+ .map(view);
87
+ },
88
+ async markApplied(id) {
89
+ const row = rows[indexOf(id)];
90
+ if (row)
91
+ row.applied = true;
92
+ },
93
+ async markFailed(id, error, options) {
94
+ const row = rows[indexOf(id)];
95
+ if (!row)
96
+ return;
97
+ row.lastError = String(error);
98
+ if (!(caps.transient && options?.transient === true))
99
+ row.attempts += 1;
100
+ },
101
+ };
102
+ if (caps.dead) {
103
+ outbox.dead = async (limit) => rows
104
+ .map((_, index) => index)
105
+ .filter((index) => !rows[index].applied && parked(index))
106
+ .slice(0, limit)
107
+ .map(view);
108
+ }
109
+ if (caps.acquire) {
110
+ let held = false;
111
+ outbox.acquire = async () => {
112
+ if (held)
113
+ return null;
114
+ held = true;
115
+ return { release: async () => void (held = false) };
116
+ };
117
+ }
118
+ if (caps.earlierPending) {
119
+ outbox.earlierPending = async (id, scopes) => {
120
+ const wanted = scopes.map(keyOf);
121
+ const hit = rows
122
+ .map((_, index) => index)
123
+ .filter((index) => index + 1 < Number(id) && isPending(index))
124
+ .find((index) => keysOf(rows[index].change).some((k) => wanted.includes(k)));
125
+ return hit === undefined ? null : view(hit);
126
+ };
127
+ }
128
+ if (caps.requeue) {
129
+ outbox.laterApplied = async (id, scopes, limit) => {
130
+ const wanted = scopes.map(keyOf);
131
+ return rows
132
+ .map((_, index) => index)
133
+ .filter((index) => index + 1 > Number(id) && rows[index].applied)
134
+ .filter((index) => keysOf(rows[index].change).some((k) => wanted.includes(k)))
135
+ .slice(0, limit)
136
+ .map(view);
137
+ };
138
+ outbox.requeue = async (ids) => {
139
+ if (!Array.isArray(ids) || ids.length === 0)
140
+ throw new Error('makeScopeOutbox.requeue: ids vacío');
141
+ const bad = ids.filter((id) => {
142
+ const row = rows[indexOf(id)];
143
+ return !row || row.applied || !parked(indexOf(id));
144
+ });
145
+ if (bad.length > 0)
146
+ throw new Error(`makeScopeOutbox.requeue: ${bad.join(', ')} no está(n) aparcada(s): nada se ha tocado`);
147
+ for (const id of ids)
148
+ rows[indexOf(id)].attempts = max - 1;
149
+ };
150
+ }
151
+ return outbox;
152
+ }
153
+ /* ── El runner ───────────────────────────────────────────────────────────── */
154
+ export function runScopeOutboxContract(harness) {
155
+ return registerScopeOutboxContract(harness, {
156
+ group: (title, define) => japaTest.group(title, define),
157
+ test: (title, fn) => japaTest(title, fn),
158
+ });
159
+ }
160
+ const orgScope = () => ({ type: 'organization', uuid: uuidv7() });
161
+ const unitScope = () => ({ type: 'unit', uuid: uuidv7() });
162
+ const ids = (items) => items.map((item) => String(item.id));
163
+ async function rejects(assert, run, expected, message) {
164
+ let caught = null;
165
+ try {
166
+ await run();
167
+ }
168
+ catch (error) {
169
+ caught = error;
170
+ }
171
+ assert.isNotNull(caught, `${message ?? 'la operación'}: debería haber lanzado`);
172
+ if (expected.status !== undefined)
173
+ assert.equal(caught.status, expected.status, message);
174
+ if (expected.code !== undefined)
175
+ assert.equal(caught.code, expected.code, message);
176
+ return caught;
177
+ }
178
+ /**
179
+ * El MOTOR del runner (registro de casos), separado para que el propio
180
+ * paquete pueda contar los casos (`scope_outbox_harness.spec.ts`). Devuelve
181
+ * el nº de casos registrados y las capacidades cubiertas.
182
+ */
183
+ export function registerScopeOutboxContract(harness, api) {
184
+ const caps = harness.capabilities;
185
+ for (const key of SCOPE_OUTBOX_CAPABILITY_KEYS) {
186
+ if (typeof caps[key] !== 'boolean') {
187
+ throw new Error(`[contrato ${harness.name}] la capacidad '${key}' tiene que ser un booleano (llegó ${String(caps[key])})`);
188
+ }
189
+ }
190
+ const maxAttempts = harness.limits?.maxAttempts;
191
+ if (caps.dead && !(Number.isInteger(maxAttempts) && maxAttempts >= 3)) {
192
+ throw new Error(`[contrato ${harness.name}] declara 'dead: true' sin 'limits.maxAttempts' (entero >= 3): sin saber cuándo aparca tu ` +
193
+ `outbox no hay forma de observar el aparcado (un skip disfrazado).`);
194
+ }
195
+ if (caps.requeue && !caps.dead) {
196
+ throw new Error(`[contrato ${harness.name}] declara 'requeue: true' sin 'dead: true': solo lo aparcado se reencola.`);
197
+ }
198
+ if (caps.transactionalEnqueue && typeof harness.transactions?.begin !== 'function') {
199
+ throw new Error(`[contrato ${harness.name}] declara 'transactionalEnqueue: true' sin 'transactions.begin': sin una transacción de TU ` +
200
+ `conexión no se puede observar el rollback (un skip disfrazado).`);
201
+ }
202
+ const covered = new Set();
203
+ let registered = 0;
204
+ const state = {};
205
+ /** El manager sobre la outbox del caso, con un driver espía. */
206
+ function managerOver(outbox, tree = memoryScopeTree()) {
207
+ const calls = [];
208
+ const driver = {
209
+ authorize: async () => false,
210
+ onScopeAttached: async (c, p) => void calls.push(`attached ${keyOf(c)} → ${keyOf(p)}`),
211
+ onScopeMoved: async (c, p) => void calls.push(`moved ${keyOf(c)} → ${keyOf(p)}`),
212
+ onScopeDetached: async (c) => void calls.push(`detached ${keyOf(c)}`),
213
+ purgeScope: async () => { },
214
+ };
215
+ const manager = new AuthorizationManager({
216
+ default: 'spy',
217
+ drivers: { spy: () => driver },
218
+ scopes: { resolveChain: resolveChainFrom(tree), outbox },
219
+ warnOnOptInSecurity: false,
220
+ });
221
+ return { manager, driver, calls, tree };
222
+ }
223
+ const ctx = (actor) => (actor ? { actor } : {});
224
+ api.group(`scope outbox contract [${harness.name}]`, (group) => {
225
+ group.each.setup(async () => {
226
+ await harness.cleanup?.();
227
+ state.outbox = await harness.makeOutbox();
228
+ });
229
+ const test = (title, fn) => {
230
+ registered++;
231
+ return api.test(title, fn);
232
+ };
233
+ function caseFor(capability, pair) {
234
+ if (typeof pair.whenTrue !== 'function' || typeof pair.whenFalse !== 'function') {
235
+ throw new Error(`[contrato ${harness.name}] la capacidad '${capability}' no declara sus dos caras (whenTrue Y whenFalse): ` +
236
+ `una cara ausente es un skip disfrazado.`);
237
+ }
238
+ const before = registered;
239
+ (caps[capability] ? pair.whenTrue : pair.whenFalse)();
240
+ if (registered > before)
241
+ covered.add(capability);
242
+ }
243
+ /* ── Núcleo: los deberes que todo puerto tiene ── */
244
+ test('O-01 · recibo: `enqueue` devuelve `{ id }` (o void) y ese id es EXACTAMENTE el que `pending()` devuelve', async ({ assert }) => {
245
+ const outbox = state.outbox;
246
+ const receipt = await outbox.enqueue({ op: 'attached', child: unitScope(), parent: orgScope() }, ctx());
247
+ const [row] = await outbox.pending(10);
248
+ assert.isDefined(row, 'lo encolado está pendiente');
249
+ if (receipt !== undefined && receipt !== null) {
250
+ assert.isTrue(typeof receipt === 'object' && ('id' in receipt), 'el recibo es { id }');
251
+ assert.equal(String(receipt.id), String(row.id), 'el MISMO id que pending(): sin eso no hay only');
252
+ }
253
+ });
254
+ test('O-02 · `pending` da los MÁS ANTIGUOS primero, respeta `limit`, y devuelve la identidad intacta (la raíz como padre, `detached` sin padre, el actor)', async ({ assert, }) => {
255
+ const outbox = state.outbox;
256
+ const org = orgScope();
257
+ const unit = unitScope();
258
+ const actor = { type: 'users', uuid: uuidv7() };
259
+ await outbox.enqueue({ op: 'attached', child: org, parent: APP_SCOPE }, ctx(actor));
260
+ await outbox.enqueue({ op: 'moved', child: unit, parent: org }, ctx());
261
+ await outbox.enqueue({ op: 'detached', child: unit }, ctx());
262
+ const all = await outbox.pending(10);
263
+ assert.deepEqual(all.map((p) => p.change), [
264
+ { op: 'attached', child: org, parent: APP_SCOPE },
265
+ { op: 'moved', child: unit, parent: org },
266
+ { op: 'detached', child: unit },
267
+ ], 'en orden de encolado y con la identidad tal cual');
268
+ assert.deepEqual(all[0].actor, actor, 'el actor viaja');
269
+ assert.isUndefined(all[1].actor, 'y sin actor no se inventa');
270
+ assert.equal(all[0].attempts ?? 0, 0);
271
+ assert.deepEqual(ids(await outbox.pending(2)), ids(all).slice(0, 2), '`limit` acota por delante');
272
+ assert.isTrue(new Set(ids(all)).size === 3, 'ids distintos');
273
+ });
274
+ test('O-03 · `after` continúa DESPUÉS de un id: lo saltado no vuelve a salir el primero (3b-2h · 🔴 2)', async ({ assert }) => {
275
+ const outbox = state.outbox;
276
+ await outbox.enqueue({ op: 'detached', child: unitScope() }, ctx());
277
+ await outbox.enqueue({ op: 'detached', child: unitScope() }, ctx());
278
+ await outbox.enqueue({ op: 'detached', child: unitScope() }, ctx());
279
+ const [a, b, c] = await outbox.pending(10);
280
+ assert.deepEqual(ids(await outbox.pending(10, a.id)), [String(b.id), String(c.id)]);
281
+ assert.deepEqual(ids(await outbox.pending(1, a.id)), [String(b.id)]);
282
+ assert.deepEqual(await outbox.pending(10, c.id), []);
283
+ });
284
+ test('O-04 · `markApplied` saca la entrada de `pending()` y es idempotente; lo encolado después sigue saliendo', async ({ assert }) => {
285
+ const outbox = state.outbox;
286
+ await outbox.enqueue({ op: 'detached', child: unitScope() }, ctx());
287
+ const [first] = await outbox.pending(10);
288
+ await outbox.markApplied(first.id);
289
+ assert.deepEqual(await outbox.pending(10), []);
290
+ await outbox.markApplied(first.id);
291
+ assert.deepEqual(await outbox.pending(10), [], 'la segunda vez no revive nada ni lanza');
292
+ await outbox.enqueue({ op: 'detached', child: unitScope() }, ctx());
293
+ const rest = await outbox.pending(10);
294
+ assert.lengthOf(rest, 1);
295
+ assert.notEqual(String(rest[0].id), String(first.id));
296
+ });
297
+ test('O-05 · `markFailed` DEJA la entrada pendiente, cuenta el intento y guarda la causa (a la vista en `pending()`)', async ({ assert }) => {
298
+ const outbox = state.outbox;
299
+ await outbox.enqueue({ op: 'detached', child: unitScope() }, ctx());
300
+ const [row] = await outbox.pending(10);
301
+ await outbox.markFailed(row.id, 'el padre no existe');
302
+ const [once] = await outbox.pending(10);
303
+ assert.equal(String(once.id), String(row.id), 'sigue pendiente');
304
+ assert.equal(once.attempts, 1);
305
+ assert.equal(once.lastError, 'el padre no existe');
306
+ await outbox.markFailed(row.id, 'sigue sin existir');
307
+ const [twice] = await outbox.pending(10);
308
+ assert.equal(twice.attempts, 2);
309
+ assert.equal(twice.lastError, 'sigue sin existir', 'la ÚLTIMA causa');
310
+ });
311
+ test('O-06 · el manager la drena: `relayScopeChanges()` aplica en orden, marca, y la pasada siguiente no vuelve a aplicar nada', async ({ assert, }) => {
312
+ const { manager, calls, tree } = managerOver(state.outbox);
313
+ const org = orgScope();
314
+ const unit = unitScope();
315
+ await tree.attach(org, APP_SCOPE);
316
+ await tree.attach(unit, org);
317
+ await manager.scopes.attached(org, APP_SCOPE);
318
+ await manager.scopes.attached(unit, org);
319
+ assert.deepEqual(calls, [], 'con outbox el driver no ve nada al notificar');
320
+ const first = await manager.relayScopeChanges();
321
+ assert.lengthOf(first.applied, 2);
322
+ assert.deepEqual(calls, [`attached ${keyOf(org)} → app`, `attached ${keyOf(unit)} → ${keyOf(org)}`]);
323
+ assert.isFalse(first.remaining);
324
+ const second = await manager.relayScopeChanges();
325
+ assert.deepEqual(second.applied, []);
326
+ assert.lengthOf(calls, 2, 'nada se aplica dos veces');
327
+ });
328
+ test('O-07 · un fallo APLAZA lo que comparte clave y deja pasar el resto; la pasada siguiente retoma desde la cola (no desde memoria)', async ({ assert, }) => {
329
+ const { manager, driver, calls, tree } = managerOver(state.outbox);
330
+ const org = orgScope();
331
+ const unit = unitScope();
332
+ // Independiente de verdad: no comparte NINGUNA clave (ni la raíz) con lo que falla.
333
+ const other = unitScope();
334
+ await tree.attach(org, APP_SCOPE);
335
+ await tree.attach(unit, org);
336
+ await manager.scopes.attached(org, APP_SCOPE);
337
+ await manager.scopes.attached(unit, org);
338
+ await manager.scopes.detached(other);
339
+ driver.onScopeAttached = async (c, p) => {
340
+ if (keyOf(c) === keyOf(org))
341
+ throw new Error('el store no lo acepta');
342
+ calls.push(`attached ${keyOf(c)} → ${keyOf(p)}`);
343
+ };
344
+ const report = await manager.relayScopeChanges();
345
+ assert.deepEqual(report.failures.map((f) => keyOf(f.change.child)), [keyOf(org)]);
346
+ assert.deepEqual(report.deferred.map((d) => keyOf(d.change.child)), [keyOf(unit)], 'lo que depende, aplazado');
347
+ assert.deepEqual(report.applied.map((a) => keyOf(a.change.child)), [keyOf(other)], 'lo independiente, aplicado');
348
+ assert.deepEqual(calls, [`detached ${keyOf(other)}`]);
349
+ assert.isTrue(report.remaining);
350
+ const [head] = await state.outbox.pending(10);
351
+ assert.equal(keyOf(head.change.child), keyOf(org), 'la fallida sigue la primera en la COLA, con su intento');
352
+ assert.isAtLeast(head.attempts ?? 0, 1);
353
+ });
354
+ test('O-07b · la raíz `app` NO es clave de orden: dos `attached` bajo `app` no se contaminan (el primero falla, el segundo se aplica); bajo el MISMO padre no raíz, sí (2.4.0-alpha.8)', async ({ assert, }) => {
355
+ // La raíz no se mueve, no se purga y nunca es hijo: dos aristas bajo
356
+ // ella conmutan, así que un fallo en una no dice nada de la otra. Hasta
357
+ // alpha.8 `[hijo, padre]` incluía `app` y toda alta de tenant esperaba a
358
+ // la anterior (medido en un consumidor: el registro serializado).
359
+ const { manager, driver, calls, tree } = managerOver(state.outbox);
360
+ const orgA = orgScope();
361
+ const orgB = orgScope();
362
+ const unitA = unitScope();
363
+ const unitB = unitScope();
364
+ await tree.attach(orgA, APP_SCOPE);
365
+ await tree.attach(orgB, APP_SCOPE);
366
+ await tree.attach(unitA, orgB);
367
+ await tree.attach(unitB, orgB);
368
+ await manager.scopes.attached(orgA, APP_SCOPE);
369
+ await manager.scopes.attached(orgB, APP_SCOPE);
370
+ await manager.scopes.attached(unitA, orgB);
371
+ await manager.scopes.attached(unitB, orgB);
372
+ driver.onScopeAttached = async (c, p) => {
373
+ if (keyOf(c) === keyOf(orgA) || keyOf(c) === keyOf(unitA))
374
+ throw new Error('el store no lo acepta');
375
+ calls.push(`attached ${keyOf(c)} → ${keyOf(p)}`);
376
+ };
377
+ const report = await manager.relayScopeChanges();
378
+ assert.deepEqual(report.failures.map((f) => keyOf(f.change.child)), [keyOf(orgA), keyOf(unitA)]);
379
+ assert.deepEqual(report.applied.map((a) => keyOf(a.change.child)), [keyOf(orgB)], 'bajo la raíz, el fallo de orgA no aplaza a orgB');
380
+ assert.deepEqual(report.deferred.map((d) => keyOf(d.change.child)), [keyOf(unitB)], 'bajo un padre NO raíz (orgB), el fallo de unitA sí aplaza a unitB: comparten la clave del padre');
381
+ assert.deepEqual(calls, [`attached ${keyOf(orgB)} → app`]);
382
+ });
383
+ test('O-08 · `pending()` no ESCONDE nada: lo encolado por dos «tenants» sale entero, en orden de id — el orden del relay se apoya en esto (auditor alpha.5 · 🟡 3)', async ({ assert, }) => {
384
+ const outbox = state.outbox;
385
+ const tenantA = orgScope();
386
+ const tenantB = orgScope();
387
+ const unitA = unitScope();
388
+ const unitB = unitScope();
389
+ const wanted = [
390
+ { op: 'attached', child: tenantA, parent: APP_SCOPE },
391
+ { op: 'attached', child: tenantB, parent: APP_SCOPE },
392
+ { op: 'attached', child: unitA, parent: tenantA },
393
+ { op: 'attached', child: unitB, parent: tenantB },
394
+ { op: 'moved', child: unitA, parent: tenantB },
395
+ { op: 'detached', child: unitB },
396
+ ];
397
+ for (const change of wanted)
398
+ await outbox.enqueue(change, ctx());
399
+ const all = await outbox.pending(100);
400
+ assert.deepEqual(all.map((p) => p.change), wanted, 'las seis, de los dos tenants, en orden de encolado');
401
+ const first = await outbox.pending(4);
402
+ const rest = await outbox.pending(100, first[first.length - 1].id);
403
+ assert.deepEqual([...first, ...rest].map((p) => p.change), wanted, 'y paginando con `after` también salen todas');
404
+ });
405
+ /* ── Los pares de capacidad ── */
406
+ caseFor('transactionalEnqueue', {
407
+ whenTrue: () => test('transactionalEnqueue:true · `enqueue` por la transacción del llamante: ROLLBACK ⇒ nada pendiente; COMMIT ⇒ pendiente', async ({ assert, }) => {
408
+ const outbox = state.outbox;
409
+ const rolled = await harness.transactions.begin();
410
+ await outbox.enqueue({ op: 'detached', child: unitScope() }, { transaction: rolled.client });
411
+ await rolled.rollback();
412
+ assert.deepEqual(await outbox.pending(10), [], 'el rollback se lleva la intención: es TODO el mecanismo');
413
+ const committed = await harness.transactions.begin();
414
+ const unit = unitScope();
415
+ await outbox.enqueue({ op: 'detached', child: unit }, { transaction: committed.client });
416
+ await committed.commit();
417
+ assert.deepEqual((await outbox.pending(10)).map((p) => p.change), [{ op: 'detached', child: unit }]);
418
+ }),
419
+ whenFalse: () => test('transactionalEnqueue:false · encolar SIN transacción funciona (y la mitigación del rollback queda apagada: dilo en tu README)', async ({ assert, }) => {
420
+ const outbox = state.outbox;
421
+ const unit = unitScope();
422
+ await outbox.enqueue({ op: 'detached', child: unit }, {});
423
+ assert.deepEqual((await outbox.pending(10)).map((p) => p.change), [{ op: 'detached', child: unit }]);
424
+ }),
425
+ });
426
+ caseFor('filter', {
427
+ whenTrue: () => test('filter:true · `pending(limit, after, { ids })` devuelve SOLO esos ids, en orden, y con `after`; el manager aplica solo lo pedido', async ({ assert, }) => {
428
+ const outbox = state.outbox;
429
+ const { manager, calls } = managerOver(outbox);
430
+ // Tres entradas INDEPENDIENTES (sin clave común, ni la raíz): lo pedido no depende de nada.
431
+ const [a, b, c] = [unitScope(), unitScope(), unitScope()];
432
+ const ia = (await outbox.enqueue({ op: 'detached', child: a }, ctx())).id;
433
+ const ib = (await outbox.enqueue({ op: 'detached', child: b }, ctx())).id;
434
+ const ic = (await outbox.enqueue({ op: 'detached', child: c }, ctx())).id;
435
+ assert.deepEqual(ids(await outbox.pending(10, undefined, { ids: [ic, ia] })), [String(ia), String(ic)], 'en orden de id, no del array');
436
+ assert.deepEqual(ids(await outbox.pending(10, ia, { ids: [ic, ia] })), [String(ic)], '`after` sigue valiendo');
437
+ const report = await manager.relayScopeChanges({ only: [ib] });
438
+ assert.deepEqual(report.applied.map((x) => String(x.id)), [String(ib)]);
439
+ assert.deepEqual(calls, [`detached ${keyOf(b)}`]);
440
+ assert.isTrue(report.remaining, 'las otras dos siguen ahí (global)');
441
+ }),
442
+ whenFalse: () => test('filter:false · una outbox que IGNORA `ids` no aplica el backlog ajeno en silencio: `relayScopeChanges({ only })` es 500 E_AUTHZ_CONFIG con cero llamadas', async ({ assert, }) => {
443
+ const { manager, calls } = managerOver(state.outbox);
444
+ await state.outbox.enqueue({ op: 'detached', child: unitScope() }, ctx());
445
+ const ib = (await state.outbox.enqueue({ op: 'detached', child: unitScope() }, ctx())).id;
446
+ const error = await rejects(assert, () => manager.relayScopeChanges({ only: [ib] }), { status: 500, code: 'E_AUTHZ_CONFIG' });
447
+ assert.include(error.message, 'pending(limit, after, { ids })');
448
+ assert.deepEqual(calls, []);
449
+ }),
450
+ });
451
+ caseFor('transient', {
452
+ whenTrue: () => test('transient:true · `markFailed(id, error, { transient: true })` escribe la causa y NO cuenta el intento; sin `transient`, cuenta', async ({ assert, }) => {
453
+ const outbox = state.outbox;
454
+ await outbox.enqueue({ op: 'detached', child: unitScope() }, ctx());
455
+ const [row] = await outbox.pending(10);
456
+ await outbox.markFailed(row.id, 'el store no responde', { transient: true });
457
+ const [after] = await outbox.pending(10);
458
+ assert.equal(after.attempts, 0, 'una caída del store no dice nada de la fila');
459
+ assert.equal(after.lastError, 'el store no responde', 'pero nada se vuelve silencioso');
460
+ await outbox.markFailed(row.id, 'el padre no existe');
461
+ assert.equal((await outbox.pending(10))[0].attempts, 1);
462
+ }),
463
+ whenFalse: () => test('transient:false · una outbox que ignora el tercer parámetro cuenta el intento igual (lo de alpha.4: degrada hacia lo seguro)', async ({ assert, }) => {
464
+ const outbox = state.outbox;
465
+ await outbox.enqueue({ op: 'detached', child: unitScope() }, ctx());
466
+ const [row] = await outbox.pending(10);
467
+ await outbox.markFailed(row.id, 'el store no responde', { transient: true });
468
+ assert.equal((await outbox.pending(10))[0].attempts, 1);
469
+ }),
470
+ });
471
+ caseFor('dead', {
472
+ whenTrue: () => test('dead:true · tras `maxAttempts` fallos la entrada sale de `pending()` y entra en `dead()` con sus intentos y su causa; el relay la reporta y tapona su clave (alpha.5 · R2a)', async ({ assert, }) => {
473
+ const outbox = state.outbox;
474
+ const { manager, calls, tree } = managerOver(outbox);
475
+ const org = orgScope();
476
+ const unit = unitScope();
477
+ await tree.attach(org, APP_SCOPE);
478
+ await tree.attach(unit, org);
479
+ const { queued: [id] } = await manager.scopes.moved(unit, org);
480
+ for (let i = 0; i < maxAttempts; i++)
481
+ await outbox.markFailed(id, `fallo ${i + 1}`);
482
+ assert.deepEqual(await outbox.pending(10), [], 'agotados los intentos, deja de ofrecerse');
483
+ const dead = await outbox.dead(10);
484
+ assert.deepEqual(ids(dead), [String(id)]);
485
+ assert.equal(dead[0].attempts, maxAttempts);
486
+ assert.equal(dead[0].lastError, `fallo ${maxAttempts}`);
487
+ assert.deepEqual(ids(await outbox.dead(1)), [String(id)], '`limit` acota');
488
+ // Y el manager: reportada, y el attached que nombra su clave se APLAZA (parked:<id>).
489
+ const child = unitScope();
490
+ await tree.attach(child, unit);
491
+ await manager.scopes.attached(child, unit);
492
+ const report = await manager.relayScopeChanges();
493
+ assert.deepEqual(ids(report.dead), [String(id)]);
494
+ assert.deepEqual(report.entries.map((e) => [e.status, e.reason ?? null]), [['deferred', `parked:${id}`], ['dead', `fallo ${maxAttempts}`]]);
495
+ assert.deepEqual(calls, []);
496
+ }),
497
+ whenFalse: () => test('dead:false · sin `dead()` nada se aparca: tras N fallos la entrada sigue en `pending()` y el reporte dice `dead: []`', async ({ assert, }) => {
498
+ const outbox = state.outbox;
499
+ assert.isUndefined(outbox.dead);
500
+ await outbox.enqueue({ op: 'detached', child: unitScope() }, ctx());
501
+ const [row] = await outbox.pending(10);
502
+ for (let i = 0; i < 6; i++)
503
+ await outbox.markFailed(row.id, 'irreparable');
504
+ assert.deepEqual(ids(await outbox.pending(10)), [String(row.id)], 'sigue pendiente para siempre: es tu decisión, dila');
505
+ const { manager } = managerOver(outbox);
506
+ assert.deepEqual((await manager.relayScopeChanges({ dryRun: true })).dead, []);
507
+ }),
508
+ });
509
+ caseFor('acquire', {
510
+ whenTrue: () => test('acquire:true · el lease es EXCLUSIVO y SOSTENIDO: un segundo `acquire()` es null mientras dura, y entra al soltarlo; dos pasadas a la vez ⇒ una `busy`', async ({ assert, }) => {
511
+ const outbox = state.outbox;
512
+ const lease = await outbox.acquire();
513
+ assert.isNotNull(lease, 'la primera lo toma');
514
+ assert.isNull(await outbox.acquire(), 'la segunda no: nada de TTL');
515
+ await lease.release();
516
+ const again = await outbox.acquire();
517
+ assert.isNotNull(again, 'soltado, entra');
518
+ await again.release();
519
+ // Y por el manager: la pasada rezagada no aplica nada y lo dice.
520
+ const { manager, driver, calls, tree } = managerOver(outbox);
521
+ const org = orgScope();
522
+ await tree.attach(org, APP_SCOPE);
523
+ await manager.scopes.attached(org, APP_SCOPE);
524
+ driver.onScopeAttached = async (c, p) => {
525
+ await new Promise((r) => setTimeout(r, 60));
526
+ calls.push(`attached ${keyOf(c)} → ${keyOf(p)}`);
527
+ };
528
+ const [first, second] = await Promise.all([
529
+ manager.relayScopeChanges(),
530
+ new Promise((r) => setTimeout(r, 10)).then(() => manager.relayScopeChanges()),
531
+ ]);
532
+ assert.isFalse(first.busy);
533
+ assert.isTrue(second.busy);
534
+ assert.lengthOf(calls, 1, 'el cambio se aplicó UNA vez');
535
+ }),
536
+ whenFalse: () => test('acquire:false · sin lease el relay corre igual y NUNCA dice `busy` (y el README te obliga a correrlo de uno en uno)', async ({ assert, }) => {
537
+ const outbox = state.outbox;
538
+ assert.isUndefined(outbox.acquire);
539
+ const { manager, tree } = managerOver(outbox);
540
+ const org = orgScope();
541
+ await tree.attach(org, APP_SCOPE);
542
+ await manager.scopes.attached(org, APP_SCOPE);
543
+ const report = await manager.relayScopeChanges();
544
+ assert.isFalse(report.busy);
545
+ assert.lengthOf(report.applied, 1);
546
+ }),
547
+ });
548
+ caseFor('earlierPending', {
549
+ whenTrue: () => test('earlierPending:true · la pendiente más antigua ANTERIOR que nombra alguno de los scopes (hijo, padre o la raíz); ignora aplicadas y posteriores', async ({ assert, }) => {
550
+ const outbox = state.outbox;
551
+ const org = orgScope();
552
+ const unit = unitScope();
553
+ const child = unitScope();
554
+ const other = unitScope();
555
+ const a = (await outbox.enqueue({ op: 'attached', child: org, parent: APP_SCOPE }, ctx())).id;
556
+ const b = (await outbox.enqueue({ op: 'attached', child: unit, parent: org }, ctx())).id;
557
+ const c = (await outbox.enqueue({ op: 'attached', child, parent: unit }, ctx())).id;
558
+ const d = (await outbox.enqueue({ op: 'detached', child: other }, ctx())).id;
559
+ assert.equal(String((await outbox.earlierPending(c, [child, unit])).id), String(b), 'por hijo');
560
+ assert.equal(String((await outbox.earlierPending(b, [unit, org])).id), String(a), 'por padre');
561
+ assert.equal(String((await outbox.earlierPending(d, [APP_SCOPE])).id), String(a), 'la raíz');
562
+ assert.isNull(await outbox.earlierPending(d, [other]), 'nada anterior nombra `other`');
563
+ assert.isNull(await outbox.earlierPending(a, [org, APP_SCOPE]), 'la primera no tiene anterior');
564
+ await outbox.markApplied(a);
565
+ assert.isNull(await outbox.earlierPending(b, [unit, org]), 'una APLICADA ya no bloquea');
566
+ }),
567
+ whenFalse: () => test('earlierPending:false · sin sonda el manager pagina `pending()` y el veredicto de `only` es el MISMO: la dependiente se aplaza (`depends-on:<id>`), cero llamadas', async ({ assert, }) => {
568
+ const outbox = state.outbox;
569
+ assert.isUndefined(outbox.earlierPending);
570
+ const { manager, calls, tree } = managerOver(outbox);
571
+ const org = orgScope();
572
+ const unit = unitScope();
573
+ await tree.attach(org, APP_SCOPE);
574
+ await tree.attach(unit, org);
575
+ const { queued: [movedId] } = await manager.scopes.moved(unit, org);
576
+ const child = unitScope();
577
+ await tree.attach(child, unit);
578
+ const { queued: [attachedId] } = await manager.scopes.attached(child, unit);
579
+ if (caps.filter) {
580
+ const report = await manager.relayScopeChanges({ only: [attachedId] });
581
+ assert.deepEqual(report.applied, []);
582
+ assert.deepEqual(report.entries.map((e) => [String(e.id), e.status, e.reason]), [[String(attachedId), 'deferred', `depends-on:${movedId}`]]);
583
+ }
584
+ else {
585
+ // Sin filtro en el puerto tampoco hay `only`: el manager corta ANTES de sondear nada (par `filter`).
586
+ await rejects(assert, () => manager.relayScopeChanges({ only: [attachedId] }), { status: 500, code: 'E_AUTHZ_CONFIG' });
587
+ }
588
+ assert.deepEqual(calls, [], 'jamás fuera de orden');
589
+ }),
590
+ });
591
+ caseFor('requeue', {
592
+ whenTrue: () => test('requeue:true · `requeue` devuelve una aparcada a `pending()` con `attempts = maxAttempts − 1` (todo o nada: un id no aparcado lanza sin tocar nada); `laterApplied` da las aplicadas posteriores que comparten clave; el manager se niega si las hay (E_AUTHZ_REQUEUE_REFUSED)', async ({ assert, }) => {
593
+ const outbox = state.outbox;
594
+ const orgB = orgScope();
595
+ const orgC = orgScope();
596
+ const unit = unitScope();
597
+ const old = (await outbox.enqueue({ op: 'moved', child: unit, parent: orgB }, ctx())).id;
598
+ for (let i = 0; i < maxAttempts; i++)
599
+ await outbox.markFailed(old, 'el store no respondía');
600
+ const current = (await outbox.enqueue({ op: 'moved', child: unit, parent: orgC }, ctx())).id;
601
+ await outbox.markApplied(current);
602
+ // Una posterior PENDIENTE que comparte `unit`: no es «aplicada», no cuenta en la sonda.
603
+ const live = (await outbox.enqueue({ op: 'moved', child: unit, parent: orgScope() }, ctx())).id;
604
+ assert.deepEqual(ids(await outbox.dead(10)), [String(old)]);
605
+ // laterApplied: SOLO la posterior APLICADA que comparte `unit`; la pendiente no; nada posterior a `current`.
606
+ assert.deepEqual(ids(await outbox.laterApplied(old, [unit, orgB], 10)), [String(current)]);
607
+ assert.deepEqual(await outbox.laterApplied(old, [orgScope()], 10), []);
608
+ assert.deepEqual(await outbox.laterApplied(current, [unit], 10), [], 'la pendiente posterior no es una aplicada');
609
+ // Todo o nada: un id vivo o aplicado lanza y la aparcada sigue aparcada.
610
+ await rejects(assert, () => outbox.requeue([old, live]), {}, 'lote mixto');
611
+ await rejects(assert, () => outbox.requeue([current]), {}, 'ya aplicada');
612
+ assert.deepEqual(ids(await outbox.dead(10)), [String(old)], 'nada tocado');
613
+ // El manager: con una aplicada posterior que comparte clave, se NIEGA; nada cambia.
614
+ const { manager, calls } = managerOver(outbox);
615
+ const refused = await rejects(assert, () => manager.requeueScopeChanges([old]), { status: 500, code: 'E_AUTHZ_REQUEUE_REFUSED' });
616
+ assert.equal(String(refused.conflicts[0].applied[0].id), String(current));
617
+ assert.deepEqual(ids(await outbox.dead(10)), [String(old)]);
618
+ // Y el puerto solo: vuelve con attempts = max − 1; un fallo más y vuelve a aparcarse.
619
+ await outbox.requeue([old]);
620
+ const [back] = await outbox.pending(10);
621
+ assert.equal(String(back.id), String(old));
622
+ assert.equal(back.attempts, maxAttempts - 1);
623
+ assert.deepEqual(await outbox.dead(10), []);
624
+ await outbox.markFailed(old, 'sigue rota');
625
+ assert.deepEqual(ids(await outbox.dead(10)), [String(old)], 'UNA pasada y vuelve a dead()');
626
+ assert.deepEqual(calls, []);
627
+ }),
628
+ whenFalse: () => test('requeue:false · sin `requeue`/`laterApplied` el manager lo DICE: `requeueScopeChanges` es 500 E_AUTHZ_UNSUPPORTED nombrando el método', async ({ assert, }) => {
629
+ const outbox = state.outbox;
630
+ assert.isUndefined(outbox.requeue);
631
+ assert.isUndefined(outbox.laterApplied);
632
+ const { manager } = managerOver(outbox);
633
+ const error = await rejects(assert, () => manager.requeueScopeChanges([1]), { status: 500, code: 'E_AUTHZ_UNSUPPORTED' });
634
+ assert.match(error.message, /dead|requeue|laterApplied/);
635
+ }),
636
+ });
637
+ caseFor('createdAt', {
638
+ whenTrue: () => test('createdAt:true · cada entrada de `pending()` lleva `createdAt` (Date, no decreciente en orden de encolado, el instante del encolado); el relay publica la EDAD de la cabeza global (`queue.oldestPendingAt` = ese sello, `pendingAgeMs` ≥ 0), también bajo `only` (2.4.0-alpha.8)', async ({ assert, }) => {
639
+ const outbox = state.outbox;
640
+ const before = Date.now();
641
+ const unit = unitScope();
642
+ const other = unitScope();
643
+ await outbox.enqueue({ op: 'detached', child: unit }, ctx());
644
+ await outbox.enqueue({ op: 'detached', child: other }, ctx());
645
+ const after = Date.now();
646
+ const [first, second] = await outbox.pending(10);
647
+ assert.instanceOf(first.createdAt, Date, 'el sello de la primera');
648
+ assert.instanceOf(second.createdAt, Date, 'el sello de la segunda');
649
+ // Tolerancia de 2 s: un motor puede guardar el sello a segundos (MySQL TIMESTAMP(0) redondea).
650
+ assert.isAtLeast(first.createdAt.getTime(), before - 2_000, 'no antes del encolado');
651
+ assert.isAtMost(first.createdAt.getTime(), after + 2_000, 'no después');
652
+ assert.isAtLeast(second.createdAt.getTime(), first.createdAt.getTime(), 'no decreciente');
653
+ const { manager } = managerOver(outbox);
654
+ const dry = await manager.relayScopeChanges({ dryRun: true, only: [second.id] });
655
+ assert.equal(dry.queue.oldestPendingAt?.getTime(), first.createdAt.getTime(), 'la cabeza GLOBAL, no la pedida');
656
+ assert.isNumber(dry.queue.pendingAgeMs);
657
+ assert.isAtLeast(dry.queue.pendingAgeMs, 0, 'nunca negativa');
658
+ const pass = await manager.relayScopeChanges({ limit: 1 });
659
+ assert.equal(pass.queue.oldestPendingAt?.getTime(), second.createdAt.getTime(), 'tras aplicar la primera, la cabeza es la segunda');
660
+ }),
661
+ whenFalse: () => test('createdAt:false · las entradas NO llevan `createdAt` y el relay dice `queue: { oldestPendingAt: null, pendingAgeMs: null }` (jamás 0 ni «ahora»), con la pasada en verde', async ({ assert, }) => {
662
+ const outbox = state.outbox;
663
+ await outbox.enqueue({ op: 'detached', child: unitScope() }, ctx());
664
+ await outbox.enqueue({ op: 'detached', child: unitScope() }, ctx());
665
+ const [first] = await outbox.pending(10);
666
+ assert.isUndefined(first.createdAt, 'ausente, no `null` ni una fecha inventada');
667
+ const { manager, calls } = managerOver(outbox);
668
+ const pass = await manager.relayScopeChanges({ limit: 1 });
669
+ assert.lengthOf(calls, 1);
670
+ assert.isTrue(pass.remaining, 'queda una pendiente… sin fecha');
671
+ assert.deepEqual(pass.queue, { oldestPendingAt: null, pendingAgeMs: null });
672
+ }),
673
+ });
674
+ });
675
+ return { registered, covered };
676
+ }
677
+ //# sourceMappingURL=scope_outbox_contract.js.map