payment-kit 1.29.1 → 1.29.3

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 (343) hide show
  1. package/api/dev.ts +41 -2
  2. package/api/hono.d.ts +42 -0
  3. package/api/node-sqlite.d.ts +12 -0
  4. package/api/src/bootstrap.ts +47 -0
  5. package/api/src/crons/base.ts +3 -3
  6. package/api/src/crons/currency.ts +1 -1
  7. package/api/src/crons/index.ts +41 -37
  8. package/api/src/crons/metering-subscription-detection.ts +1 -1
  9. package/api/src/crons/overdue-detection.ts +2 -2
  10. package/api/src/crons/retry-pending-events.ts +6 -0
  11. package/api/src/crons/tenant-fanout.ts +82 -0
  12. package/api/src/host-node/did-connect-runtime-node.ts +33 -0
  13. package/api/src/host-node/serve-static-arc.ts +68 -0
  14. package/api/src/host-node/serve-static.ts +41 -0
  15. package/api/src/index.ts +22 -161
  16. package/api/src/integrations/app-store/client.ts +3 -4
  17. package/api/src/integrations/app-store/handlers/subscription.ts +7 -7
  18. package/api/src/integrations/app-store/signed-data-verifier.ts +3 -2
  19. package/api/src/integrations/arcblock/token.ts +21 -7
  20. package/api/src/integrations/google-play/handlers/subscription.ts +6 -6
  21. package/api/src/integrations/google-play/handlers/voided.ts +2 -2
  22. package/api/src/integrations/google-play/verify.ts +3 -2
  23. package/api/src/integrations/iap-reconcile.ts +3 -5
  24. package/api/src/integrations/stripe/handlers/invoice.ts +2 -2
  25. package/api/src/integrations/stripe/handlers/subscription.ts +3 -3
  26. package/api/src/libs/archive/query.ts +19 -0
  27. package/api/src/libs/audit.ts +61 -4
  28. package/api/src/libs/auth.ts +247 -47
  29. package/api/src/libs/context.ts +89 -1
  30. package/api/src/libs/currency.ts +2 -2
  31. package/api/src/libs/dayjs.ts +8 -2
  32. package/api/src/libs/did-connect/runtime-did-connect-js.ts +88 -0
  33. package/api/src/libs/did-connect/tenant-identity.ts +221 -0
  34. package/api/src/libs/drivers/auth-storage.ts +118 -0
  35. package/api/src/libs/drivers/cron.ts +264 -0
  36. package/api/src/libs/drivers/db.ts +170 -0
  37. package/api/src/libs/drivers/identity.ts +142 -0
  38. package/api/src/libs/drivers/index.ts +40 -0
  39. package/api/src/libs/drivers/locks.ts +226 -0
  40. package/api/src/libs/drivers/migrate-runner.ts +70 -0
  41. package/api/src/libs/drivers/queue.ts +104 -0
  42. package/api/src/libs/drivers/secrets.ts +194 -0
  43. package/api/src/libs/env.ts +170 -54
  44. package/api/src/libs/exchange-rate/service.ts +7 -6
  45. package/api/src/libs/http-fetch-adapter.ts +60 -0
  46. package/api/src/libs/invoice.ts +1 -1
  47. package/api/src/libs/lock.ts +51 -47
  48. package/api/src/libs/logger.ts +48 -8
  49. package/api/src/libs/notification/index.ts +1 -1
  50. package/api/src/libs/notification/template/customer-credit-low-balance.ts +2 -1
  51. package/api/src/libs/notification/template/customer-revenue-succeeded.ts +1 -1
  52. package/api/src/libs/notification/template/customer-reward-succeeded.ts +1 -1
  53. package/api/src/libs/overdraft-protection.ts +1 -1
  54. package/api/src/libs/payout.ts +1 -1
  55. package/api/src/libs/queue/index.ts +271 -52
  56. package/api/src/libs/queue/runtime.ts +175 -0
  57. package/api/src/libs/resource.ts +3 -3
  58. package/api/src/libs/secrets.ts +38 -0
  59. package/api/src/libs/session.ts +3 -2
  60. package/api/src/libs/subscription.ts +5 -5
  61. package/api/src/libs/tenant.ts +92 -0
  62. package/api/src/libs/url.ts +3 -3
  63. package/api/src/libs/util.ts +21 -13
  64. package/api/src/middlewares/hono/cdn.ts +63 -0
  65. package/api/src/middlewares/hono/context.ts +80 -0
  66. package/api/src/middlewares/hono/csrf.ts +83 -0
  67. package/api/src/middlewares/hono/fallback.ts +194 -0
  68. package/api/src/middlewares/hono/pipeline.ts +73 -0
  69. package/api/src/middlewares/hono/resource-mount.ts +42 -0
  70. package/api/src/middlewares/hono/resource.ts +63 -0
  71. package/api/src/middlewares/hono/security.ts +209 -0
  72. package/api/src/middlewares/hono/session.ts +114 -0
  73. package/api/src/middlewares/hono/xss.ts +61 -0
  74. package/api/src/queues/auto-recharge.ts +12 -10
  75. package/api/src/queues/checkout-session.ts +38 -21
  76. package/api/src/queues/credit-consume.ts +40 -36
  77. package/api/src/queues/credit-grant.ts +25 -18
  78. package/api/src/queues/credit-reconciliation.ts +7 -5
  79. package/api/src/queues/discount-status.ts +9 -6
  80. package/api/src/queues/event.ts +41 -11
  81. package/api/src/queues/exchange-rate-health.ts +49 -30
  82. package/api/src/queues/invoice.ts +18 -15
  83. package/api/src/queues/notification.ts +14 -7
  84. package/api/src/queues/payment.ts +64 -37
  85. package/api/src/queues/payout.ts +37 -21
  86. package/api/src/queues/refund.ts +36 -18
  87. package/api/src/queues/subscription.ts +83 -53
  88. package/api/src/queues/token-transfer.ts +15 -10
  89. package/api/src/queues/usage-record.ts +8 -5
  90. package/api/src/queues/vendors/commission.ts +7 -5
  91. package/api/src/queues/vendors/fulfillment-coordinator.ts +17 -13
  92. package/api/src/queues/vendors/fulfillment.ts +4 -2
  93. package/api/src/queues/vendors/return-processor.ts +5 -3
  94. package/api/src/queues/vendors/return-scanner.ts +5 -4
  95. package/api/src/queues/vendors/status-check.ts +10 -7
  96. package/api/src/queues/webhook.ts +60 -32
  97. package/api/src/routes/connect/shared.ts +1 -2
  98. package/api/src/routes/connect/subscribe.ts +3 -3
  99. package/api/src/routes/{archive.ts → hono/archive.ts} +69 -64
  100. package/api/src/routes/{auto-recharge-configs.ts → hono/auto-recharge-configs.ts} +39 -28
  101. package/api/src/routes/{checkout-sessions.ts → hono/checkout-sessions.ts} +790 -923
  102. package/api/src/routes/{coupons.ts → hono/coupons.ts} +93 -76
  103. package/api/src/routes/{credit-grants.ts → hono/credit-grants.ts} +140 -126
  104. package/api/src/routes/hono/credit-tokens.ts +43 -0
  105. package/api/src/routes/{credit-transactions.ts → hono/credit-transactions.ts} +37 -29
  106. package/api/src/routes/{customers.ts → hono/customers.ts} +199 -224
  107. package/api/src/routes/{donations.ts → hono/donations.ts} +41 -32
  108. package/api/src/routes/{entitlements.ts → hono/entitlements.ts} +28 -25
  109. package/api/src/routes/{events.ts → hono/events.ts} +107 -71
  110. package/api/src/routes/{exchange-rate-providers.ts → hono/exchange-rate-providers.ts} +138 -126
  111. package/api/src/routes/hono/exchange-rates.ts +77 -0
  112. package/api/src/routes/hono/index.ts +115 -0
  113. package/api/src/routes/{integrations → hono/integrations}/app-store.ts +68 -48
  114. package/api/src/routes/{integrations → hono/integrations}/google-play.ts +78 -58
  115. package/api/src/routes/hono/integrations/stripe.ts +74 -0
  116. package/api/src/routes/{invoices.ts → hono/invoices.ts} +253 -244
  117. package/api/src/routes/{meter-events.ts → hono/meter-events.ts} +120 -110
  118. package/api/src/routes/hono/meters.ts +288 -0
  119. package/api/src/routes/hono/passports.ts +73 -0
  120. package/api/src/routes/{payment-currencies.ts → hono/payment-currencies.ts} +219 -197
  121. package/api/src/routes/{payment-intents.ts → hono/payment-intents.ts} +136 -132
  122. package/api/src/routes/{payment-links.ts → hono/payment-links.ts} +145 -128
  123. package/api/src/routes/{payment-methods.ts → hono/payment-methods.ts} +125 -93
  124. package/api/src/routes/{payment-stats.ts → hono/payment-stats.ts} +30 -25
  125. package/api/src/routes/{payouts.ts → hono/payouts.ts} +55 -47
  126. package/api/src/routes/{prices.ts → hono/prices.ts} +265 -242
  127. package/api/src/routes/{pricing-table.ts → hono/pricing-table.ts} +94 -87
  128. package/api/src/routes/{products.ts → hono/products.ts} +172 -159
  129. package/api/src/routes/{promotion-codes.ts → hono/promotion-codes.ts} +207 -185
  130. package/api/src/routes/hono/redirect.ts +24 -0
  131. package/api/src/routes/{refunds.ts → hono/refunds.ts} +98 -83
  132. package/api/src/routes/{settings.ts → hono/settings.ts} +64 -55
  133. package/api/src/routes/{subscription-items.ts → hono/subscription-items.ts} +64 -57
  134. package/api/src/routes/{subscriptions.ts → hono/subscriptions.ts} +475 -528
  135. package/api/src/routes/{tax-rates.ts → hono/tax-rates.ts} +71 -70
  136. package/api/src/routes/hono/tool.ts +69 -0
  137. package/api/src/routes/{usage-records.ts → hono/usage-records.ts} +47 -42
  138. package/api/src/routes/{vendor.ts → hono/vendor.ts} +315 -167
  139. package/api/src/routes/{webhook-attempts.ts → hono/webhook-attempts.ts} +17 -13
  140. package/api/src/routes/hono/webhook-endpoints.ts +126 -0
  141. package/api/src/service.ts +814 -0
  142. package/api/src/store/migrations/20230911-seeding.ts +2 -1
  143. package/api/src/store/migrations/20260609-remove-did-space-jobs.ts +23 -0
  144. package/api/src/store/migrations/20260610-tenant-columns.ts +40 -0
  145. package/api/src/store/migrations/20260611-tenant-backfill.ts +33 -0
  146. package/api/src/store/models/auto-recharge-config.ts +22 -10
  147. package/api/src/store/models/checkout-session.ts +15 -14
  148. package/api/src/store/models/coupon.ts +29 -20
  149. package/api/src/store/models/credit-grant.ts +38 -29
  150. package/api/src/store/models/credit-transaction.ts +32 -21
  151. package/api/src/store/models/customer.ts +19 -17
  152. package/api/src/store/models/discount.ts +11 -2
  153. package/api/src/store/models/entitlement-grant.ts +21 -9
  154. package/api/src/store/models/entitlement-product.ts +21 -9
  155. package/api/src/store/models/entitlement.ts +19 -10
  156. package/api/src/store/models/event.ts +18 -9
  157. package/api/src/store/models/exchange-rate-provider.ts +17 -4
  158. package/api/src/store/models/invoice-item.ts +18 -9
  159. package/api/src/store/models/invoice.ts +16 -8
  160. package/api/src/store/models/meter-event.ts +27 -9
  161. package/api/src/store/models/meter.ts +31 -22
  162. package/api/src/store/models/payment-currency.ts +25 -8
  163. package/api/src/store/models/payment-intent.ts +15 -6
  164. package/api/src/store/models/payment-link.ts +15 -6
  165. package/api/src/store/models/payment-method.ts +38 -22
  166. package/api/src/store/models/payment-stat.ts +18 -9
  167. package/api/src/store/models/payout.ts +15 -6
  168. package/api/src/store/models/price-quote.ts +17 -8
  169. package/api/src/store/models/price.ts +24 -12
  170. package/api/src/store/models/pricing-table.ts +29 -20
  171. package/api/src/store/models/product-vendor.ts +20 -10
  172. package/api/src/store/models/product.ts +15 -6
  173. package/api/src/store/models/promotion-code.ts +14 -6
  174. package/api/src/store/models/refund.ts +15 -6
  175. package/api/src/store/models/revenue-snapshot.ts +21 -9
  176. package/api/src/store/models/setting.ts +18 -9
  177. package/api/src/store/models/setup-intent.ts +36 -27
  178. package/api/src/store/models/subscription-item.ts +21 -9
  179. package/api/src/store/models/subscription-schedule.ts +21 -9
  180. package/api/src/store/models/subscription.ts +21 -10
  181. package/api/src/store/models/tax-rate.ts +29 -21
  182. package/api/src/store/models/usage-record.ts +11 -2
  183. package/api/src/store/models/webhook-attempt.ts +18 -9
  184. package/api/src/store/models/webhook-endpoint.ts +18 -9
  185. package/api/src/store/scoped-core.ts +55 -0
  186. package/api/src/store/scoped.ts +247 -0
  187. package/api/src/store/sequelize.ts +82 -23
  188. package/api/src/store/sql-migrations.ts +20 -0
  189. package/api/src/store/tenant-backfill.ts +260 -0
  190. package/api/src/store/tenant-model.ts +124 -0
  191. package/api/src/store/tenant-tables.ts +50 -0
  192. package/api/tests/bootstrap/bootstrap.spec.ts +162 -0
  193. package/api/tests/crons/tenant-fanout.spec.ts +158 -0
  194. package/api/tests/embedded/embedded-multi-mode-d3.spec.ts +257 -0
  195. package/api/tests/fixtures/bare-query-violation.ts +13 -0
  196. package/api/tests/fixtures/core-env-violation.ts +10 -0
  197. package/api/tests/fixtures/host-read-violation.ts +19 -0
  198. package/api/tests/fixtures/tenants.ts +4 -0
  199. package/api/tests/integrations/iap-tenant.spec.ts +284 -0
  200. package/api/tests/libs/archive-query.spec.ts +26 -0
  201. package/api/tests/libs/audit-tenant.spec.ts +153 -0
  202. package/api/tests/libs/context.spec.ts +204 -0
  203. package/api/tests/libs/core-config.spec.ts +115 -0
  204. package/api/tests/libs/cron-driver-d2.spec.ts +237 -0
  205. package/api/tests/libs/crons-conservation-d2.spec.ts +52 -0
  206. package/api/tests/libs/did-connect-runtime-js.spec.ts +98 -0
  207. package/api/tests/libs/did-connect-tenant-identity.spec.ts +159 -0
  208. package/api/tests/libs/lock-tenant.spec.ts +66 -0
  209. package/api/tests/libs/scoped.spec.ts +222 -0
  210. package/api/tests/libs/secrets-facade.spec.ts +52 -0
  211. package/api/tests/libs/service-host.spec.ts +37 -0
  212. package/api/tests/libs/tenancy-slot-authority.spec.ts +209 -0
  213. package/api/tests/libs/tenant-middleware.spec.ts +42 -0
  214. package/api/tests/libs/tenant-scanner.spec.ts +120 -0
  215. package/api/tests/middlewares/hono/cdn.spec.ts +70 -0
  216. package/api/tests/middlewares/hono/context.spec.ts +113 -0
  217. package/api/tests/middlewares/hono/csrf.spec.ts +136 -0
  218. package/api/tests/middlewares/hono/fallback.spec.ts +67 -0
  219. package/api/tests/middlewares/hono/pipeline.spec.ts +47 -0
  220. package/api/tests/middlewares/hono/security.spec.ts +181 -0
  221. package/api/tests/middlewares/hono/session.spec.ts +42 -0
  222. package/api/tests/middlewares/hono/xss.spec.ts +81 -0
  223. package/api/tests/models/tenant-backfill.spec.ts +287 -0
  224. package/api/tests/models/tenant-columns-model.spec.ts +46 -0
  225. package/api/tests/models/tenant-columns.spec.ts +161 -0
  226. package/api/tests/queues/credit-consume-batch.spec.ts +8 -1
  227. package/api/tests/queues/credit-consume.spec.ts +8 -1
  228. package/api/tests/queues/event-tenant.spec.ts +292 -0
  229. package/api/tests/queues/exchange-rate-health-tenant-d6.spec.ts +62 -0
  230. package/api/tests/queues/queue-parity.spec.ts +249 -0
  231. package/api/tests/queues/queue-runtime-surface.spec.ts +277 -0
  232. package/api/tests/queues/queue-teardown-d2.spec.ts +127 -0
  233. package/api/tests/queues/tenant-matrix-a.spec.ts +245 -0
  234. package/api/tests/queues/tenant-matrix-b.spec.ts +168 -0
  235. package/api/tests/routes/connect/hono-attach.spec.ts +107 -0
  236. package/api/tests/service/collapse.spec.ts +96 -0
  237. package/api/tests/service/didconnect-storage-slot.spec.ts +60 -0
  238. package/api/tests/service/fail-closed-http.spec.ts +79 -0
  239. package/api/tests/service/static-arc-handler.spec.ts +101 -0
  240. package/api/tests/service/static-externalized.spec.ts +48 -0
  241. package/api/tests/store/tenant-crosscut.spec.ts +202 -0
  242. package/api/tests/store/tenant-model-spike.spec.ts +177 -0
  243. package/api/tests/store/tenant-model.spec.ts +162 -0
  244. package/api/tests/store/tenant-residual.spec.ts +196 -0
  245. package/api/third.d.ts +4 -0
  246. package/blocklet.yml +1 -1
  247. package/cloudflare/MIGRATION-RUNBOOK.md +3 -8
  248. package/cloudflare/README.md +34 -27
  249. package/cloudflare/STAGING-MIGRATION-GUIDE.md +3 -15
  250. package/cloudflare/build.ts +33 -13
  251. package/cloudflare/cf-adapter.ts +419 -0
  252. package/cloudflare/did-connect-runtime.ts +96 -0
  253. package/cloudflare/did-connect-token-storage.ts +151 -0
  254. package/cloudflare/esbuild-cf-config.cjs +407 -0
  255. package/cloudflare/migrations/0006_tenant_columns.sql +46 -0
  256. package/cloudflare/migrations/0007_tenant_backfill_indexes.sql +65 -0
  257. package/cloudflare/migrations/0008_schema_parity.sql +16 -0
  258. package/cloudflare/migrations/0009_remove_did_space_jobs.sql +5 -0
  259. package/cloudflare/queue-runtime-mode.ts +13 -0
  260. package/cloudflare/run-build.js +33 -403
  261. package/cloudflare/scripts/cf-package-import-probe.mjs +90 -0
  262. package/cloudflare/scripts/didconnect-mock-smoke.mjs +140 -0
  263. package/cloudflare/shims/blocklet-sdk/asset-host-transformer.ts +20 -0
  264. package/cloudflare/shims/blocklet-sdk/config.ts +8 -1
  265. package/cloudflare/shims/blocklet-sdk/login.ts +12 -0
  266. package/cloudflare/shims/blocklet-sdk/service-api.ts +14 -0
  267. package/cloudflare/shims/blocklet-sdk/session.ts +4 -2
  268. package/cloudflare/shims/blocklet-sdk/util-constants.ts +8 -0
  269. package/cloudflare/shims/blocklet-sdk/wallet-authenticator.ts +16 -1
  270. package/cloudflare/shims/blocklet-sdk/wallet-handler.ts +18 -3
  271. package/cloudflare/shims/cron.ts +38 -158
  272. package/cloudflare/shims/events.ts +124 -0
  273. package/cloudflare/shims/fastq.ts +15 -1
  274. package/cloudflare/shims/nedb-storage.ts +16 -8
  275. package/cloudflare/shims/xss.ts +8 -0
  276. package/cloudflare/tenant-middleware.ts +36 -0
  277. package/cloudflare/tests/cf-adapter.spec.ts +244 -0
  278. package/cloudflare/tests/did-connect-token-storage.spec.ts +105 -0
  279. package/cloudflare/tests/tenant-middleware.spec.ts +160 -0
  280. package/cloudflare/tests/worker-handler-gate.spec.ts +69 -0
  281. package/cloudflare/vite.config.ts +53 -45
  282. package/cloudflare/worker.ts +261 -448
  283. package/cloudflare/wrangler.json +0 -6
  284. package/cloudflare/wrangler.jsonc +0 -6
  285. package/cloudflare/wrangler.local-e2e.jsonc +25 -0
  286. package/cloudflare/wrangler.staging.json +0 -6
  287. package/jest.config.js +3 -1
  288. package/package.json +33 -38
  289. package/scripts/bootstrap-inject.ts +166 -0
  290. package/scripts/core-env-whitelist.json +1 -0
  291. package/scripts/e2e-12b-runtime.ts +149 -0
  292. package/scripts/e2e-core-config.ts +125 -0
  293. package/scripts/e2e-d1-tenancy.ts +116 -0
  294. package/scripts/e2e-d2-cron-queue.ts +139 -0
  295. package/scripts/e2e-d3-embedded-multi.ts +171 -0
  296. package/scripts/e2e-hono-s2.ts +125 -0
  297. package/scripts/e2e-hono-s3e.ts +135 -0
  298. package/scripts/e2e-hono-s4.ts +114 -0
  299. package/scripts/e2e-migration-contract.ts +100 -0
  300. package/scripts/e2e-s0.ts +61 -0
  301. package/scripts/e2e-s1.ts +107 -0
  302. package/scripts/e2e-s2.ts +178 -0
  303. package/scripts/e2e-s3.ts +110 -0
  304. package/scripts/e2e-s4.ts +191 -0
  305. package/scripts/e2e-s5.ts +139 -0
  306. package/scripts/e2e-s6.ts +127 -0
  307. package/scripts/e2e-tenant-model.ts +119 -0
  308. package/scripts/e2e-tenant-worker.ts +199 -0
  309. package/scripts/gen-sql-migrations.js +46 -0
  310. package/scripts/phase8-codemod.js +219 -0
  311. package/scripts/phase9a-env-getters-codemod.js +82 -0
  312. package/scripts/scan-core-env.js +109 -0
  313. package/scripts/scan-tenant-queries.js +235 -0
  314. package/scripts/schema-drift-guard.ts +210 -0
  315. package/scripts/tenant-scan-whitelist.json +1 -0
  316. package/src/app.tsx +2 -1
  317. package/src/env.d.ts +13 -1
  318. package/src/libs/service-host.ts +13 -0
  319. package/tsconfig.json +1 -1
  320. package/vite.arc.config.ts +159 -0
  321. package/api/src/libs/did-space.ts +0 -235
  322. package/api/src/libs/middleware.ts +0 -50
  323. package/api/src/libs/security.ts +0 -192
  324. package/api/src/queues/space.ts +0 -662
  325. package/api/src/routes/credit-tokens.ts +0 -38
  326. package/api/src/routes/exchange-rates.ts +0 -87
  327. package/api/src/routes/index.ts +0 -142
  328. package/api/src/routes/integrations/stripe.ts +0 -61
  329. package/api/src/routes/meters.ts +0 -274
  330. package/api/src/routes/passports.ts +0 -68
  331. package/api/src/routes/redirect.ts +0 -20
  332. package/api/src/routes/tool.ts +0 -65
  333. package/api/src/routes/webhook-endpoints.ts +0 -126
  334. package/api/tests/routes/credit-grants.spec.ts +0 -1261
  335. package/cloudflare/did-connect-auth.ts +0 -527
  336. package/cloudflare/shims/did-space-js.ts +0 -17
  337. package/cloudflare/shims/did-space.ts +0 -11
  338. package/cloudflare/shims/express-compat/index.ts +0 -80
  339. package/cloudflare/shims/express-compat/types.ts +0 -41
  340. package/cloudflare/shims/lock.ts +0 -115
  341. package/cloudflare/shims/queue.ts +0 -611
  342. package/cloudflare/tests/shims/queue-delayed-persist.spec.ts +0 -87
  343. package/cloudflare/tests/shims/queue-scheduled.spec.ts +0 -186
@@ -0,0 +1,249 @@
1
+ // Phase 9 (W2-1b): queue engine + executor parity (W2 判据 4).
2
+ //
3
+ // The CF worker runs the SAME queue engine as Blocklet Server
4
+ // (api/src/libs/queue) — only the fastq EXECUTOR primitive is swapped (real
5
+ // fastq on Node, cloudflare/shims/fastq in the worker). This spec proves:
6
+ // Part A — the engine's delay/retry/cancel/pushAndWait semantics (embedded).
7
+ // Part B — the fastq shim is a faithful drop-in for real fastq on the exact
8
+ // operations the engine uses, so the worker behaves identically.
9
+ // Together that is the embedded↔worker queue parity the acceptance gate wants.
10
+
11
+ import fs from 'fs';
12
+ import os from 'os';
13
+ import path from 'path';
14
+ import { Sequelize } from 'sequelize';
15
+ import { SequelizeStorage, Umzug } from 'umzug';
16
+
17
+ import { withTenant, getInstanceDid } from '../../src/libs/context';
18
+
19
+ /* eslint-disable global-require, import/no-dynamic-require, require-await, no-promise-executor-return */
20
+
21
+ // the retry/settle cases do real per-attempt DB ops on a shared sqlite file;
22
+ // under full-suite parallel load they can exceed jest's 5s default, so give the
23
+ // suite a generous timeout (these are inherently I/O-bound, not hung). Phase 12b
24
+ // added queue-runtime-surface.spec.ts which contends for the same jest workers,
25
+ // so the margin is bumped to keep the settle cases from a transient timeout.
26
+ jest.setTimeout(60000);
27
+
28
+ jest.mock('../../src/libs/logger', () => ({
29
+ __esModule: true,
30
+ default: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() },
31
+ }));
32
+
33
+ const STORE_DIR = path.join(__dirname, '../../src/store');
34
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'queue-parity-'));
35
+ const sequelize = new Sequelize({ dialect: 'sqlite', storage: path.join(dir, 'test.db'), logging: false });
36
+ const umzug = new Umzug({
37
+ migrations: {
38
+ glob: ['migrations/*.ts', { cwd: STORE_DIR }],
39
+ resolve: ({ name, path: p, context }) => {
40
+ const migration = require(p!);
41
+ return {
42
+ name: name.replace(/\.ts$/, '.js'),
43
+ up: () => migration.up({ context }),
44
+ down: () => migration.down({ context }),
45
+ };
46
+ },
47
+ },
48
+ context: sequelize.getQueryInterface(),
49
+ storage: new SequelizeStorage({ sequelize }),
50
+ logger: undefined,
51
+ });
52
+
53
+ let createQueue: any;
54
+
55
+ beforeAll(async () => {
56
+ await umzug.up();
57
+ const models = require('../../src/store/models');
58
+ models.initialize(sequelize);
59
+ createQueue = require('../../src/libs/queue').default;
60
+ }, 120000);
61
+
62
+ afterAll(async () => {
63
+ await sequelize.close();
64
+ fs.rmSync(dir, { recursive: true, force: true });
65
+ });
66
+
67
+ const settle = (emitter: any): Promise<{ event: string; data: any }> =>
68
+ new Promise((resolve) => {
69
+ ['finished', 'failed', 'cancelled'].forEach((e) => emitter.on(e, (data: any) => resolve({ event: e, data })));
70
+ });
71
+
72
+ describe('Part A — Node queue engine contract semantics', () => {
73
+ beforeEach(async () => {
74
+ await sequelize.query('DELETE FROM jobs');
75
+ });
76
+
77
+ it('immediate job runs and the jobs row is cleared on finish', async () => {
78
+ const seen: any[] = [];
79
+ const q = createQueue({
80
+ name: `pa-imm-${Date.now()}`,
81
+ onJob: async (job: any) => {
82
+ seen.push(job.v);
83
+ return job.v * 2;
84
+ },
85
+ });
86
+ const ev = q.push({ job: { v: 21 } });
87
+ const { event, data } = await settle(ev);
88
+ expect(event).toBe('finished');
89
+ expect(data.result).toBe(42);
90
+ expect(seen).toEqual([21]);
91
+ const row = await q.get(ev.id);
92
+ expect(row).toBeNull(); // cleared
93
+ });
94
+
95
+ it('pushAndWait resolves with the handler result', async () => {
96
+ const q = createQueue({ name: `pa-paw-${Date.now()}`, onJob: async (job: any) => `ok:${job.v}` });
97
+ const res: any = await q.pushAndWait({ job: { v: 7 } });
98
+ expect(res.result).toBe('ok:7');
99
+ });
100
+
101
+ it('retries up to maxRetries then fails (no infinite retry)', async () => {
102
+ let attempts = 0;
103
+ const retries: number[] = [];
104
+ const q = createQueue({
105
+ name: `pa-retry-${Date.now()}`,
106
+ onJob: async () => {
107
+ attempts += 1;
108
+ throw new Error('always');
109
+ },
110
+ options: { maxRetries: 3, retryDelay: 1 },
111
+ });
112
+ q.on('retry', () => retries.push(1));
113
+ const ev = q.push({ job: { v: 1 } });
114
+ const { event } = await settle(ev);
115
+ expect(event).toBe('failed');
116
+ expect(attempts).toBe(3); // initial + 2 retries (retry_count starts at 1, fails at >= maxRetries)
117
+ expect(retries.length).toBeGreaterThanOrEqual(1);
118
+ });
119
+
120
+ it('nonRetryable error fails immediately (single attempt)', async () => {
121
+ let attempts = 0;
122
+ const q = createQueue({
123
+ name: `pa-nonretry-${Date.now()}`,
124
+ onJob: async () => {
125
+ attempts += 1;
126
+ const err: any = new Error('forged');
127
+ err.nonRetryable = true;
128
+ throw err;
129
+ },
130
+ options: { maxRetries: 5, retryDelay: 1 },
131
+ });
132
+ const ev = q.push({ job: { v: 1 } });
133
+ const { event } = await settle(ev);
134
+ expect(event).toBe('failed');
135
+ expect(attempts).toBe(1);
136
+ });
137
+
138
+ it('cancel marks the row and excludes it from recovery (the skip mechanism)', async () => {
139
+ const q = createQueue({ name: `pa-cancel-${Date.now()}`, onJob: async () => undefined });
140
+ await q.store.addJob('cj-1', { v: 1, instance_did: 'did:abt:zCANCEL' }, {});
141
+ // before cancel: recovery would pick the row up
142
+ expect((await q.store.getJobs()).some((j: any) => j.id === 'cj-1')).toBe(true);
143
+ await q.cancel('cj-1');
144
+ // after cancel: execution-time guard sees it, recovery query excludes it
145
+ expect(await q.store.isCancelled('cj-1')).toBe(true);
146
+ expect((await q.store.getJobs()).some((j: any) => j.id === 'cj-1')).toBe(false);
147
+ });
148
+
149
+ // Bad input
150
+ it('rejects an empty job', () => {
151
+ const q = createQueue({ name: `pa-empty-${Date.now()}`, onJob: async () => undefined });
152
+ expect(() => q.push({ job: undefined as any })).toThrow(/Can not queue empty job/);
153
+ });
154
+
155
+ // Data damage — same job id does not execute twice (duplicate guard)
156
+ it('a duplicate job id executes only once', async () => {
157
+ let runs = 0;
158
+ const q = createQueue({
159
+ name: `pa-dup-${Date.now()}`,
160
+ onJob: async () => {
161
+ runs += 1;
162
+ return runs;
163
+ },
164
+ });
165
+ q.push({ job: { v: 1 }, id: 'dup-1' });
166
+ q.push({ job: { v: 1 }, id: 'dup-1' }); // duplicate id — store rejects, no second execution
167
+ // Either push can win the concurrent addJob UNIQUE race; the LOSER's
168
+ // jobEvents never settles (its addJob rejects before queueJob), so awaiting
169
+ // one specific push is racy and starves under parallel load. Wait for the
170
+ // single execution to actually land instead — that is what we assert.
171
+ const deadline = Date.now() + 5000;
172
+ while (runs < 1 && Date.now() < deadline) {
173
+ // eslint-disable-next-line no-await-in-loop, no-promise-executor-return
174
+ await new Promise((r) => setTimeout(r, 20));
175
+ }
176
+ await new Promise((r) => setTimeout(r, 60));
177
+ expect(runs).toBe(1);
178
+ });
179
+
180
+ // Data leak — the handler runs under the PAYLOAD tenant, never another's
181
+ it('handler executes under the payload tenant (no cross-tenant leak)', async () => {
182
+ const seen: Record<string, string> = {};
183
+ const q = createQueue({
184
+ name: `pa-tenant-${Date.now()}`,
185
+ onJob: async (job: any) => {
186
+ seen[job.tag] = getInstanceDid();
187
+ },
188
+ });
189
+ // push under tenant A's context — payload is stamped with A
190
+ const evA = await withTenant('did:abt:zTENANTA', async () => q.push({ job: { tag: 'A' } }));
191
+ const evB = await withTenant('did:abt:zTENANTB', async () => q.push({ job: { tag: 'B' } }));
192
+ await Promise.all([settle(evA), settle(evB)]);
193
+ expect(seen.A).toBe('did:abt:zTENANTA');
194
+ expect(seen.B).toBe('did:abt:zTENANTB');
195
+ });
196
+ });
197
+
198
+ describe('Part B — fastq shim is a faithful drop-in for real fastq', () => {
199
+ // run the exact operations the engine uses against both executors
200
+ const realFastq = require('fastq');
201
+ const shimFastq = require('../../../cloudflare/shims/fastq').default;
202
+
203
+ const scenario = (fastqImpl: any) =>
204
+ new Promise<any>((resolve) => {
205
+ const order: string[] = [];
206
+ const results: any[] = [];
207
+ const q = fastqImpl(async (data: any, cb: Function) => {
208
+ order.push(data.id);
209
+ if (data.fail) {
210
+ cb(new Error(`fail:${data.id}`));
211
+ return;
212
+ }
213
+ cb(null, `done:${data.id}`);
214
+ }, 1);
215
+ let pending = 3;
216
+ const done = (tag: string) => (err: any, res: any) => {
217
+ results.push({ tag, err: err?.message ?? null, res: res ?? null });
218
+ pending -= 1;
219
+ if (pending === 0) resolve({ order, results });
220
+ };
221
+ q.push({ id: 'a' }, done('a'));
222
+ q.push({ id: 'b', fail: true }, done('b'));
223
+ q.unshift({ id: 'c' }, done('c')); // unshift jumps the queue (retry path)
224
+ });
225
+
226
+ it('produces identical execution order and results on both executors', async () => {
227
+ const real = await scenario(realFastq);
228
+ const shim = await scenario(shimFastq);
229
+ // same success/error callback shape per job
230
+ const norm = (r: any) => r.results.sort((x: any, y: any) => x.tag.localeCompare(y.tag));
231
+ expect(norm(shim)).toEqual(norm(real));
232
+ // job 'b' errored on both, 'a'/'c' succeeded on both
233
+ const byTag = (r: any) => Object.fromEntries(r.results.map((x: any) => [x.tag, x.err ? 'err' : 'ok']));
234
+ expect(byTag(shim)).toEqual(byTag(real));
235
+ expect(byTag(real)).toEqual({ a: 'ok', b: 'err', c: 'ok' });
236
+ });
237
+
238
+ it('the shim accepts the 2-arg fastq(worker, concurrency) form the engine uses', async () => {
239
+ // regression for the pre-existing "worker is not a function" bug
240
+ const ran: any[] = [];
241
+ const q = shimFastq(async (data: any, cb: Function) => {
242
+ ran.push(data.id);
243
+ cb(null, 'ok');
244
+ }, 1);
245
+ const res = await new Promise((r) => q.push({ id: 'x' }, (_e: any, v: any) => r(v)));
246
+ expect(ran).toEqual(['x']);
247
+ expect(res).toBe('ok');
248
+ });
249
+ });
@@ -0,0 +1,277 @@
1
+ // Phase 12b (W2′): core queue RUNTIME surface parity probes.
2
+ //
3
+ // These lock the contract the CF worker now drives through the service/slot
4
+ // boundary instead of cloudflare/shims/queue.ts:
5
+ // - the queue REGISTRY is non-empty under the canonical (node-engine) build
6
+ // - a due delayed job dispatches + executes EXACTLY once via dispatchDueJobs()
7
+ // - a cancelled delayed job is NOT executed by dispatchDueJobs() (no
8
+ // half-execution residue — the spec's mandatory negative case)
9
+ // - retry / nonRetryable / maxRetries match the node engine
10
+ // - the handler runs under the PAYLOAD tenant; a cross-tenant object is
11
+ // fail-closed
12
+ // - in 'workerd' mode the background loop() is disabled (a frozen isolate
13
+ // must not run a timer) and an immediate push is not lost: flushQueueWork()
14
+ // drains it before the (simulated) response
15
+ // - the queue() consumer can resolve a core handler by name (no undefined ack)
16
+ //
17
+ // These supersede the deleted cloudflare/tests/shims/queue-{scheduled,delayed-
18
+ // persist}.spec.ts: those probed the removed cloudflare/shims/queue.ts duplicate
19
+ // engine (D1 + CF-Queue-send + inline-fallback). Under the canonical node engine
20
+ // the worker never sends to CF Queue (immediate jobs run in-isolate via fastq),
21
+ // so those CF-Queue-fallback scenarios no longer exist; due-delayed dispatch is
22
+ // covered here by dispatchDueJobs().
23
+
24
+ import fs from 'fs';
25
+ import os from 'os';
26
+ import path from 'path';
27
+ import { Sequelize } from 'sequelize';
28
+ import { SequelizeStorage, Umzug } from 'umzug';
29
+
30
+ import { withTenant, getInstanceDid } from '../../src/libs/context';
31
+ import {
32
+ setQueueRuntimeMode,
33
+ getQueueHandler,
34
+ getAllQueueNames,
35
+ dispatchDueJobs,
36
+ flushQueueWork,
37
+ __test__ as runtimeTest,
38
+ } from '../../src/libs/queue/runtime';
39
+
40
+ /* eslint-disable global-require, require-await, no-promise-executor-return */
41
+
42
+ // delayed/retry cases do real per-attempt DB ops on a shared sqlite file; give
43
+ // the suite a generous timeout (I/O-bound, not hung).
44
+ jest.setTimeout(30000);
45
+
46
+ jest.mock('../../src/libs/logger', () => ({
47
+ __esModule: true,
48
+ default: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() },
49
+ }));
50
+
51
+ const STORE_DIR = path.join(__dirname, '../../src/store');
52
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'queue-runtime-'));
53
+ const sequelize = new Sequelize({ dialect: 'sqlite', storage: path.join(dir, 'test.db'), logging: false });
54
+ const umzug = new Umzug({
55
+ migrations: {
56
+ glob: ['migrations/*.ts', { cwd: STORE_DIR }],
57
+ resolve: ({ name, path: p, context }) => {
58
+ const migration = require(p!);
59
+ return {
60
+ name: name.replace(/\.ts$/, '.js'),
61
+ up: () => migration.up({ context }),
62
+ down: () => migration.down({ context }),
63
+ };
64
+ },
65
+ },
66
+ context: sequelize.getQueryInterface(),
67
+ storage: new SequelizeStorage({ sequelize }),
68
+ logger: undefined,
69
+ });
70
+
71
+ let createQueue: any;
72
+ const TENANT_A = 'did:abt:zRUNTIMEA';
73
+ const TENANT_B = 'did:abt:zRUNTIMEB';
74
+
75
+ const settle = (emitter: any): Promise<{ event: string; data: any }> =>
76
+ new Promise((resolve) => {
77
+ ['finished', 'failed', 'cancelled'].forEach((e) => emitter.on(e, (data: any) => resolve({ event: e, data })));
78
+ });
79
+
80
+ beforeAll(async () => {
81
+ await umzug.up();
82
+ const models = require('../../src/store/models');
83
+ models.initialize(sequelize);
84
+ createQueue = require('../../src/libs/queue').default;
85
+ }, 120000);
86
+
87
+ afterAll(async () => {
88
+ await sequelize.close();
89
+ fs.rmSync(dir, { recursive: true, force: true });
90
+ });
91
+
92
+ beforeEach(async () => {
93
+ await sequelize.query('DELETE FROM jobs');
94
+ setQueueRuntimeMode('node');
95
+ });
96
+
97
+ afterEach(() => {
98
+ // clears the registry + restores 'node' so each test is self-contained
99
+ runtimeTest.reset();
100
+ });
101
+
102
+ describe('registry + consumer lookup', () => {
103
+ it('createQueue registers the handle into the runtime registry (non-empty)', () => {
104
+ const name = `reg-${Date.now()}`;
105
+ const q = createQueue({ name, onJob: async () => undefined });
106
+ expect(getAllQueueNames()).toContain(name);
107
+ expect(getQueueHandler(name)).toBe(q);
108
+ });
109
+
110
+ it('the queue() consumer resolves a core handler by name and runs onJob once (no undefined ack)', async () => {
111
+ let runs = 0;
112
+ const name = `consume-${Date.now()}`;
113
+ createQueue({ name, onJob: async () => { runs += 1; return 'ok'; } });
114
+
115
+ const handle = getQueueHandler(name);
116
+ expect(handle).toBeTruthy();
117
+ const res: any = await withTenant(TENANT_A, async () =>
118
+ handle.pushAndWait({ job: { v: 1, instance_did: TENANT_A } })
119
+ );
120
+ expect(res.result).toBe('ok');
121
+ expect(runs).toBe(1);
122
+ });
123
+ });
124
+
125
+ describe('scheduled dispatch (delay) parity — host-driven dispatchDueJobs()', () => {
126
+ it("'workerd' mode disables the background loop (no auto-dispatch)", async () => {
127
+ setQueueRuntimeMode('workerd');
128
+ let ran = 0;
129
+ const name = `wd-loop-${Date.now()}`;
130
+ const q = createQueue({ name, onJob: async () => { ran += 1; }, options: { enableScheduledJob: true } });
131
+ // a due delayed row sitting in D1
132
+ await q.store.addJob('wd-1', { v: 1, instance_did: TENANT_A }, { delay: 5, will_run_at: Date.now() - 1000 });
133
+ // a node loop would have polled + run it by now; workerd must not
134
+ await new Promise((r) => setTimeout(r, 60));
135
+ expect(ran).toBe(0);
136
+ });
137
+
138
+ it('a due delayed job dispatches and executes exactly once via dispatchDueJobs()', async () => {
139
+ setQueueRuntimeMode('workerd');
140
+ let ran = 0;
141
+ const seenTenant: string[] = [];
142
+ const name = `wd-due-${Date.now()}`;
143
+ const q = createQueue({
144
+ name,
145
+ onJob: async () => { ran += 1; seenTenant.push(getInstanceDid()); },
146
+ options: { enableScheduledJob: true },
147
+ });
148
+ await q.store.addJob('due-1', { v: 1, instance_did: TENANT_A }, { delay: 5, will_run_at: Date.now() - 1000 });
149
+
150
+ const r = await dispatchDueJobs();
151
+ await flushQueueWork(); // drain the re-dispatched immediate execution
152
+
153
+ expect(r.dispatched).toBe(1);
154
+ expect(ran).toBe(1);
155
+ expect(seenTenant).toEqual([TENANT_A]); // ran under the payload tenant
156
+ // row cleared after success
157
+ expect(await q.store.getJob('due-1')).toBeNull();
158
+ });
159
+
160
+ it('a cancelled delayed job is NOT executed by dispatchDueJobs() (no half-execution residue)', async () => {
161
+ setQueueRuntimeMode('workerd');
162
+ let ran = 0;
163
+ const name = `wd-cancel-${Date.now()}`;
164
+ const q = createQueue({ name, onJob: async () => { ran += 1; }, options: { enableScheduledJob: true } });
165
+ await q.store.addJob('c-1', { v: 1, instance_did: TENANT_A }, { delay: 5, will_run_at: Date.now() - 1000 });
166
+ await q.cancel('c-1'); // marks cancelled=true BEFORE the due-dispatch tick
167
+
168
+ const r = await dispatchDueJobs();
169
+ await flushQueueWork();
170
+
171
+ expect(r.dispatched).toBe(0);
172
+ expect(ran).toBe(0);
173
+ });
174
+
175
+ it('node entry: a cancelled delayed row is excluded from due dispatch (same filter both entries use)', async () => {
176
+ setQueueRuntimeMode('node');
177
+ // no enableScheduledJob → no background loop leaks; we assert the exact
178
+ // store filter (cancelled:false) that BOTH the node loop() and the workerd
179
+ // dispatchDueJobs() select due rows with, so cancel parity is structural.
180
+ const q = createQueue({ name: `node-cancel-${Date.now()}`, onJob: async () => undefined });
181
+ await q.store.addJob('nc-1', { v: 1, instance_did: TENANT_A }, { delay: 5, will_run_at: Date.now() - 1000 });
182
+ expect((await q.store.getScheduledJobs()).some((j: any) => j.id === 'nc-1')).toBe(true);
183
+ await q.cancel('nc-1');
184
+ expect((await q.store.getScheduledJobs()).some((j: any) => j.id === 'nc-1')).toBe(false);
185
+ });
186
+ });
187
+
188
+ describe('retry / nonRetryable parity (node engine semantics)', () => {
189
+ it('retries up to maxRetries then fails (initial + retries, no infinite loop)', async () => {
190
+ let attempts = 0;
191
+ const q = createQueue({
192
+ name: `rt-retry-${Date.now()}`,
193
+ onJob: async () => { attempts += 1; throw new Error('always'); },
194
+ options: { maxRetries: 3, retryDelay: 1 },
195
+ });
196
+ const ev = q.push({ job: { v: 1, instance_did: TENANT_A } });
197
+ const { event } = await settle(ev);
198
+ expect(event).toBe('failed');
199
+ expect(attempts).toBe(3);
200
+ });
201
+
202
+ it('a nonRetryable error fails immediately (single attempt)', async () => {
203
+ let attempts = 0;
204
+ const q = createQueue({
205
+ name: `rt-nonretry-${Date.now()}`,
206
+ onJob: async () => {
207
+ attempts += 1;
208
+ const err: any = new Error('forged');
209
+ err.nonRetryable = true;
210
+ throw err;
211
+ },
212
+ options: { maxRetries: 5, retryDelay: 1 },
213
+ });
214
+ const ev = q.push({ job: { v: 1, instance_did: TENANT_A } });
215
+ const { event } = await settle(ev);
216
+ expect(event).toBe('failed');
217
+ expect(attempts).toBe(1);
218
+ });
219
+ });
220
+
221
+ describe('tenant safety', () => {
222
+ it('handler runs under the payload tenant; a cross-tenant object is fail-closed', async () => {
223
+ const { assertJobObjectTenant } = require('../../src/libs/queue');
224
+ let leaked = false;
225
+ let observedTenant = '';
226
+ const q = createQueue({
227
+ name: `tn-${Date.now()}`,
228
+ onJob: async (_job: any) => {
229
+ observedTenant = getInstanceDid();
230
+ // simulate an object loaded cross-tenant (payload says A, row says B)
231
+ assertJobObjectTenant({ instance_did: TENANT_B });
232
+ leaked = true; // must be unreachable — assert throws first
233
+ },
234
+ });
235
+ const ev = await withTenant(TENANT_A, async () => q.push({ job: { tag: 'x' } }));
236
+ const { event } = await settle(ev);
237
+ expect(observedTenant).toBe(TENANT_A);
238
+ expect(leaked).toBe(false);
239
+ expect(event).toBe('failed');
240
+ });
241
+ });
242
+
243
+ describe('workerd flush — immediate push not lost', () => {
244
+ it('flushQueueWork() drains an in-flight immediate push before the response', async () => {
245
+ setQueueRuntimeMode('workerd');
246
+ let ran = 0;
247
+ const q = createQueue({ name: `flush-${Date.now()}`, onJob: async () => { ran += 1; } });
248
+ // fire-and-forget immediate push (the worker does this inside a request)
249
+ q.push({ job: { v: 1, instance_did: TENANT_A } });
250
+ // simulate the host draining before the isolate freezes
251
+ await flushQueueWork();
252
+ expect(ran).toBe(1);
253
+ });
254
+
255
+ it('flushQueueWork() is a no-op on node (nothing tracked, returns immediately)', async () => {
256
+ setQueueRuntimeMode('node');
257
+ await expect(flushQueueWork()).resolves.toBeUndefined();
258
+ });
259
+
260
+ it('a duplicate immediate push does not hang flushQueueWork() (tracker released on addJob failure)', async () => {
261
+ setQueueRuntimeMode('workerd');
262
+ let runs = 0;
263
+ const q = createQueue({ name: `flush-dup-${Date.now()}`, onJob: async () => { runs += 1; } });
264
+ q.push({ job: { v: 1, instance_did: TENANT_A }, id: 'dup-flush' });
265
+ q.push({ job: { v: 1, instance_did: TENANT_A }, id: 'dup-flush' }); // duplicate → never enqueues
266
+ // must resolve, not hang on the never-settling duplicate
267
+ await flushQueueWork();
268
+ expect(runs).toBe(1);
269
+ });
270
+ });
271
+
272
+ describe('test harness sanity', () => {
273
+ it('runtime __test__.reset clears the registry', () => {
274
+ createQueue({ name: `sanity-${Date.now()}`, onJob: async () => undefined });
275
+ expect(runtimeTest.registrySize()).toBeGreaterThan(0);
276
+ });
277
+ });
@@ -0,0 +1,127 @@
1
+ // D2 (S3.0) — the node queue poll loop is cancelable. On a Node host the
2
+ // per-scheduled-queue loop() polls due delayed rows on a timer; lifecycle.stop()
3
+ // calls stopAllQueues() so no poll timer survives a stop / ARC_PAYMENT toggle
4
+ // (the spec's "active handles 归零"). This proves: (1) the loop self-dispatches a
5
+ // due job on node, (2) after stopAllQueues() it no longer dispatches AND its
6
+ // sleep timer is gone.
7
+
8
+ import fs from 'fs';
9
+ import os from 'os';
10
+ import path from 'path';
11
+ import { Sequelize } from 'sequelize';
12
+ import { SequelizeStorage, Umzug } from 'umzug';
13
+
14
+ import { withTenant } from '../../src/libs/context';
15
+ import { setQueueRuntimeMode, stopAllQueues, __test__ as runtimeTest } from '../../src/libs/queue/runtime';
16
+
17
+ jest.setTimeout(30000);
18
+
19
+ jest.mock('../../src/libs/logger', () => ({
20
+ __esModule: true,
21
+ default: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() },
22
+ }));
23
+
24
+ const STORE_DIR = path.join(__dirname, '../../src/store');
25
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'queue-teardown-'));
26
+ const sequelize = new Sequelize({ dialect: 'sqlite', storage: path.join(dir, 'test.db'), logging: false });
27
+ const umzug = new Umzug({
28
+ migrations: {
29
+ glob: ['migrations/*.ts', { cwd: STORE_DIR }],
30
+ resolve: ({ name, path: p, context }) => {
31
+ const migration = require(p!);
32
+ return {
33
+ name: name.replace(/\.ts$/, '.js'),
34
+ up: () => migration.up({ context }),
35
+ down: () => migration.down({ context }),
36
+ };
37
+ },
38
+ },
39
+ context: sequelize.getQueryInterface(),
40
+ storage: new SequelizeStorage({ sequelize }),
41
+ logger: undefined,
42
+ });
43
+
44
+ let createQueue: any;
45
+ const TENANT = 'did:abt:zTEARDOWNA';
46
+ const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
47
+ const countTimers = () =>
48
+ (process as any).getActiveResourcesInfo().filter((r: string) => r === 'Timeout').length;
49
+
50
+ beforeAll(async () => {
51
+ await umzug.up();
52
+ const models = require('../../src/store/models');
53
+ models.initialize(sequelize);
54
+ createQueue = require('../../src/libs/queue').default;
55
+ }, 120000);
56
+
57
+ afterAll(async () => {
58
+ await sequelize.close();
59
+ fs.rmSync(dir, { recursive: true, force: true });
60
+ });
61
+
62
+ beforeEach(async () => {
63
+ await sequelize.query('DELETE FROM jobs');
64
+ setQueueRuntimeMode('node');
65
+ });
66
+
67
+ afterEach(() => {
68
+ stopAllQueues();
69
+ runtimeTest.reset();
70
+ });
71
+
72
+ describe('D2 — node queue loop self-dispatches a due delayed job (positive)', () => {
73
+ it('a due delayed row is picked up by the node loop with no host tick', async () => {
74
+ let ran = 0;
75
+ const name = `loop-pos-${Date.now()}`;
76
+ const q = createQueue({
77
+ name,
78
+ onJob: async () => {
79
+ ran += 1;
80
+ },
81
+ options: { enableScheduledJob: true },
82
+ });
83
+ await q.store.addJob('due-1', { v: 1, instance_did: TENANT }, { delay: 5, will_run_at: Date.now() - 1000 });
84
+ await wait(1600); // loop polls every minDelay/2 = 1s in test env
85
+ expect(ran).toBeGreaterThanOrEqual(1);
86
+ });
87
+ });
88
+
89
+ describe('D2 — stopAllQueues() stops the loop and clears its timer (teardown)', () => {
90
+ it('after stopAllQueues() a due delayed row is NOT auto-dispatched', async () => {
91
+ let ran = 0;
92
+ const name = `loop-stop-${Date.now()}`;
93
+ const q = createQueue({
94
+ name,
95
+ onJob: async () => {
96
+ ran += 1;
97
+ },
98
+ options: { enableScheduledJob: true },
99
+ });
100
+ const withLoop = countTimers();
101
+ stopAllQueues(); // teardown before the first tick fires
102
+ const afterStop = countTimers();
103
+ // the loop's pending sleep timer is gone
104
+ expect(afterStop).toBeLessThan(withLoop);
105
+
106
+ await q.store.addJob('due-2', { v: 1, instance_did: TENANT }, { delay: 5, will_run_at: Date.now() - 1000 });
107
+ await wait(1600);
108
+ expect(ran).toBe(0); // loop is dead — nothing dispatched it
109
+ });
110
+
111
+ it('the queue handle exposes a stop() function', () => {
112
+ const q = createQueue({
113
+ name: `has-stop-${Date.now()}`,
114
+ onJob: async () => {},
115
+ options: { enableScheduledJob: true },
116
+ });
117
+ expect(typeof q.stop).toBe('function');
118
+ });
119
+
120
+ it('pushAndWait still works after a stop (immediate path unaffected by loop teardown)', async () => {
121
+ const name = `imm-${Date.now()}`;
122
+ const q = createQueue({ name, onJob: async () => 'ok', options: { enableScheduledJob: true } });
123
+ stopAllQueues();
124
+ const res: any = await withTenant(TENANT, async () => q.pushAndWait({ job: { v: 1, instance_did: TENANT } }));
125
+ expect(res.result).toBe('ok');
126
+ });
127
+ });