@antelopejs/dms 0.15.0 → 0.16.0

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 (354) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +36 -26
  3. package/bin/control/base.mjs +9 -4
  4. package/bin/control/browser-client.mjs +24 -5
  5. package/bin/control/browser-wire.mjs +41 -0
  6. package/bin/control/browser-wire.test.mjs +96 -0
  7. package/bin/control/browser.mjs +78 -27
  8. package/bin/control/commands-stack.mjs +58 -8
  9. package/bin/control/commands-verify.mjs +47 -1
  10. package/bin/control/doctor.mjs +49 -4
  11. package/bin/control/failure.mjs +9 -0
  12. package/bin/control/journey.mjs +163 -30
  13. package/bin/control/journey.test.mjs +61 -0
  14. package/bin/control/json.mjs +229 -0
  15. package/bin/control/json.test.mjs +130 -0
  16. package/bin/control/mail.mjs +57 -7
  17. package/bin/control/mail.test.mjs +55 -0
  18. package/bin/control/mongo.mjs +205 -27
  19. package/bin/control/mongo.test.mjs +298 -0
  20. package/bin/control/options.mjs +33 -0
  21. package/bin/control/processes.mjs +24 -2
  22. package/bin/control/renderer.mjs +39 -0
  23. package/bin/control/renderer.test.mjs +324 -0
  24. package/bin/control/run-state.mjs +119 -6
  25. package/bin/control/run-state.test.mjs +171 -0
  26. package/bin/control/session.mjs +31 -14
  27. package/bin/control/stack.mjs +96 -24
  28. package/bin/control/tab.mjs +117 -18
  29. package/bin/control/verify/behaviour.mjs +44 -6
  30. package/bin/control/verify/behaviour.test.mjs +130 -22
  31. package/bin/control/verify/decode.mjs +220 -0
  32. package/bin/control/verify/decode.test.mjs +245 -0
  33. package/bin/control/verify/doors.mjs +1 -1
  34. package/bin/control/verify/doors.test.mjs +40 -0
  35. package/bin/control/verify/emit.mjs +135 -9
  36. package/bin/control/verify/formal.mjs +61 -10
  37. package/bin/control/verify/formal.test.mjs +19 -3
  38. package/bin/control/verify/judgement.mjs +22 -3
  39. package/bin/control/verify/map.mjs +25 -5
  40. package/bin/control/verify/publish.mjs +20 -14
  41. package/bin/control/verify/seeded.mjs +65 -16
  42. package/bin/control/verify/skill.mjs +27 -6
  43. package/bin/control/verify/types.mjs +29 -0
  44. package/bin/control/verify/verdict.mjs +72 -7
  45. package/bin/dms-control.mjs +8 -28
  46. package/checks/api-error-presentation.d.mts +61 -0
  47. package/dist/attachments/access.js +1 -1
  48. package/dist/attachments/access.js.map +1 -1
  49. package/dist/attachments/fields.d.ts +2 -2
  50. package/dist/attachments/fields.js +26 -13
  51. package/dist/attachments/fields.js.map +1 -1
  52. package/dist/attachments/index.d.ts +1 -0
  53. package/dist/attachments/index.js +1 -0
  54. package/dist/attachments/index.js.map +1 -1
  55. package/dist/attachments/read.js.map +1 -1
  56. package/dist/attachments/save.d.ts +1 -1
  57. package/dist/attachments/save.js +49 -17
  58. package/dist/attachments/save.js.map +1 -1
  59. package/dist/attachments/table-view.d.ts +1 -2
  60. package/dist/attachments/table-view.js +23 -13
  61. package/dist/attachments/table-view.js.map +1 -1
  62. package/dist/automation/events.d.ts +10 -10
  63. package/dist/automation/index.js +3 -4
  64. package/dist/automation/index.js.map +1 -1
  65. package/dist/automation/send-notification.d.ts +2 -2
  66. package/dist/automation/send-notification.js +4 -6
  67. package/dist/automation/send-notification.js.map +1 -1
  68. package/dist/config/internal/state.d.ts +7 -0
  69. package/dist/config/internal/state.js +9 -0
  70. package/dist/config/internal/state.js.map +1 -1
  71. package/dist/config/startup-config.d.ts +1 -0
  72. package/dist/config/startup-config.js +11 -0
  73. package/dist/config/startup-config.js.map +1 -1
  74. package/dist/crons/cleanup-user-invites.js.map +1 -1
  75. package/dist/crons/sweep-expired-sessions.js.map +1 -1
  76. package/dist/crons/sweep-stale-exports.js.map +1 -1
  77. package/dist/db/guard-row.d.ts +2 -3
  78. package/dist/db/guard-row.js +1 -2
  79. package/dist/db/guard-row.js.map +1 -1
  80. package/dist/db/index.d.ts +1 -0
  81. package/dist/db/index.js +1 -0
  82. package/dist/db/index.js.map +1 -1
  83. package/dist/db/models/attachments.model.d.ts +7 -5
  84. package/dist/db/models/index.d.ts +1 -0
  85. package/dist/db/models/index.js +1 -0
  86. package/dist/db/models/index.js.map +1 -1
  87. package/dist/db/models/setupToken.model.d.ts +31 -0
  88. package/dist/db/models/setupToken.model.js +62 -0
  89. package/dist/db/models/setupToken.model.js.map +1 -0
  90. package/dist/db/models/signInAttempts.model.d.ts +7 -5
  91. package/dist/db/models/systemState.model.d.ts +7 -5
  92. package/dist/db/models/userEmailChanges.model.d.ts +7 -5
  93. package/dist/db/models/userKnownDevices.model.d.ts +7 -5
  94. package/dist/db/models/userNotificationPreferences.model.d.ts +7 -5
  95. package/dist/db/models/userNotifications.model.d.ts +9 -6
  96. package/dist/db/models/userNotifications.model.js.map +1 -1
  97. package/dist/db/setup-token.d.ts +2 -0
  98. package/dist/db/setup-token.js +28 -0
  99. package/dist/db/setup-token.js.map +1 -0
  100. package/dist/db/tables/index.d.ts +1 -0
  101. package/dist/db/tables/index.js +1 -0
  102. package/dist/db/tables/index.js.map +1 -1
  103. package/dist/db/tables/setupToken.table.d.ts +7 -0
  104. package/dist/db/tables/setupToken.table.js +34 -0
  105. package/dist/db/tables/setupToken.table.js.map +1 -0
  106. package/dist/dev/module-update-notifications.js.map +1 -1
  107. package/dist/hooks/internal/sign-in-monitor.js +6 -6
  108. package/dist/hooks/internal/sign-in-monitor.js.map +1 -1
  109. package/dist/implementations/dms/page/controller.d.ts +2 -2
  110. package/dist/implementations/dms/page/controller.js +3 -1
  111. package/dist/implementations/dms/page/controller.js.map +1 -1
  112. package/dist/implementations/dms/page/dynamic-menu.js +3 -2
  113. package/dist/implementations/dms/page/dynamic-menu.js.map +1 -1
  114. package/dist/implementations/dms/page/notifications.d.ts +6 -2
  115. package/dist/implementations/dms/page/notifications.js +1 -0
  116. package/dist/implementations/dms/page/notifications.js.map +1 -1
  117. package/dist/implementations/dms/page/payloads.js +6 -10
  118. package/dist/implementations/dms/page/payloads.js.map +1 -1
  119. package/dist/implementations/dms/page/preview-pages.js +6 -3
  120. package/dist/implementations/dms/page/preview-pages.js.map +1 -1
  121. package/dist/implementations/dms/page/public-layout.d.ts +2 -0
  122. package/dist/implementations/dms/page/public-layout.js +45 -0
  123. package/dist/implementations/dms/page/public-layout.js.map +1 -0
  124. package/dist/implementations/dms/page/registry.js +10 -4
  125. package/dist/implementations/dms/page/registry.js.map +1 -1
  126. package/dist/implementations/dms/permissions.js +12 -12
  127. package/dist/implementations/dms/permissions.js.map +1 -1
  128. package/dist/implementations/dms-base/bulk-selection.d.ts +3 -4
  129. package/dist/implementations/dms-base/bulk-selection.js +18 -6
  130. package/dist/implementations/dms-base/bulk-selection.js.map +1 -1
  131. package/dist/implementations/dms-base/filter-tokens.js.map +1 -1
  132. package/dist/implementations/dms-base/footer-summary.js +44 -27
  133. package/dist/implementations/dms-base/footer-summary.js.map +1 -1
  134. package/dist/implementations/dms-base/row-rule-operand.d.ts +6 -0
  135. package/dist/implementations/dms-base/row-rule-operand.js +41 -0
  136. package/dist/implementations/dms-base/row-rule-operand.js.map +1 -0
  137. package/dist/implementations/dms-base/search-route.d.ts +9 -13
  138. package/dist/implementations/dms-base/search-route.js +7 -6
  139. package/dist/implementations/dms-base/search-route.js.map +1 -1
  140. package/dist/implementations/dms-base/services.d.ts +1 -0
  141. package/dist/implementations/dms-base/services.js +1 -0
  142. package/dist/implementations/dms-base/services.js.map +1 -1
  143. package/dist/implementations/dms-base/table-view.d.ts +5 -6
  144. package/dist/implementations/dms-base/table-view.js +17 -6
  145. package/dist/implementations/dms-base/table-view.js.map +1 -1
  146. package/dist/implementations/dms-html-render/index.js +16 -6
  147. package/dist/implementations/dms-html-render/index.js.map +1 -1
  148. package/dist/implementations/dms-notifications/delivery-guard.d.ts +3 -2
  149. package/dist/implementations/dms-notifications/delivery-guard.js.map +1 -1
  150. package/dist/implementations/dms-notifications/index.js.map +1 -1
  151. package/dist/index.d.ts +0 -1
  152. package/dist/index.js +6 -2
  153. package/dist/index.js.map +1 -1
  154. package/dist/pages/public/preview/block-types.d.ts +1 -2
  155. package/dist/pages/public/preview/block-types.js.map +1 -1
  156. package/dist/pages/settings/overview/account-summary.js +4 -1
  157. package/dist/pages/settings/overview/account-summary.js.map +1 -1
  158. package/dist/pages/settings/users/account-activity-feed.js +3 -2
  159. package/dist/pages/settings/users/account-activity-feed.js.map +1 -1
  160. package/dist/pages/settings/users/account-credentials.d.ts +1 -1
  161. package/dist/pages/settings/users/account-credentials.js +1 -2
  162. package/dist/pages/settings/users/account-credentials.js.map +1 -1
  163. package/dist/pages/settings/users/account-data-store.d.ts +1 -1
  164. package/dist/pages/settings/users/account-data-store.js +1 -3
  165. package/dist/pages/settings/users/account-data-store.js.map +1 -1
  166. package/dist/pages/settings/users/current-password.js +1 -1
  167. package/dist/pages/settings/users/current-password.js.map +1 -1
  168. package/dist/pages/settings/users/email-change.d.ts +2 -2
  169. package/dist/pages/settings/users/email-change.js +2 -3
  170. package/dist/pages/settings/users/email-change.js.map +1 -1
  171. package/dist/pages/settings/users/invite-extension-routes.d.ts +2 -2
  172. package/dist/pages/settings/users/invite-extension-routes.js +11 -4
  173. package/dist/pages/settings/users/invite-extension-routes.js.map +1 -1
  174. package/dist/pages/settings/users/invites.d.ts +2 -2
  175. package/dist/pages/settings/users/member-invite-batch.d.ts +2 -2
  176. package/dist/pages/settings/users/member-invite-batch.js +7 -3
  177. package/dist/pages/settings/users/member-invite-batch.js.map +1 -1
  178. package/dist/pages/settings/users/member-roles-field.js +1 -4
  179. package/dist/pages/settings/users/member-roles-field.js.map +1 -1
  180. package/dist/pages/settings/users/members.d.ts +2 -3
  181. package/dist/pages/settings/users/members.js.map +1 -1
  182. package/dist/pages/settings/users/notifications.d.ts +2 -3
  183. package/dist/pages/settings/users/notifications.js +2 -1
  184. package/dist/pages/settings/users/notifications.js.map +1 -1
  185. package/dist/pages/settings/users/profile-helpers.d.ts +0 -1
  186. package/dist/pages/settings/users/profile-helpers.js +0 -17
  187. package/dist/pages/settings/users/profile-helpers.js.map +1 -1
  188. package/dist/pages/settings/users/profile.d.ts +3 -4
  189. package/dist/pages/settings/users/profile.js +12 -12
  190. package/dist/pages/settings/users/profile.js.map +1 -1
  191. package/dist/pages/settings/users/region.d.ts +3 -4
  192. package/dist/pages/settings/users/region.js.map +1 -1
  193. package/dist/pages/settings/users/regional-preferences.d.ts +8 -1
  194. package/dist/pages/settings/users/regional-preferences.js +10 -6
  195. package/dist/pages/settings/users/regional-preferences.js.map +1 -1
  196. package/dist/pages/settings/users/role-preview.js +3 -2
  197. package/dist/pages/settings/users/role-preview.js.map +1 -1
  198. package/dist/pages/settings/users/roles.d.ts +4 -5
  199. package/dist/pages/settings/users/roles.js.map +1 -1
  200. package/dist/pages/settings/users/security.d.ts +9 -12
  201. package/dist/pages/settings/users/security.js +11 -9
  202. package/dist/pages/settings/users/security.js.map +1 -1
  203. package/dist/pages/settings/users/two-factor-operations.d.ts +7 -7
  204. package/dist/pages/settings/users/two-factor-operations.js +5 -7
  205. package/dist/pages/settings/users/two-factor-operations.js.map +1 -1
  206. package/dist/realtime/broker.js.map +1 -1
  207. package/dist/realtime/heartbeat.js.map +1 -1
  208. package/dist/realtime/redis-broker.js.map +1 -1
  209. package/dist/realtime/sessions.js.map +1 -1
  210. package/dist/realtime/sse.js +1 -2
  211. package/dist/realtime/sse.js.map +1 -1
  212. package/dist/routes/auth/forgot-password.d.ts +1 -1
  213. package/dist/routes/auth/forgot-password.js +1 -3
  214. package/dist/routes/auth/forgot-password.js.map +1 -1
  215. package/dist/routes/auth/login.d.ts +2 -2
  216. package/dist/routes/auth/login.js +8 -4
  217. package/dist/routes/auth/login.js.map +1 -1
  218. package/dist/routes/auth/logout.d.ts +1 -1
  219. package/dist/routes/auth/logout.js +1 -2
  220. package/dist/routes/auth/logout.js.map +1 -1
  221. package/dist/routes/auth/oauth/providers.js +30 -6
  222. package/dist/routes/auth/oauth/providers.js.map +1 -1
  223. package/dist/routes/auth/oauth/state.js +8 -5
  224. package/dist/routes/auth/oauth/state.js.map +1 -1
  225. package/dist/routes/auth/oauth/token-exchange.js +8 -1
  226. package/dist/routes/auth/oauth/token-exchange.js.map +1 -1
  227. package/dist/routes/auth/password-hash-upgrade.d.ts +2 -0
  228. package/dist/routes/auth/password-hash-upgrade.js +22 -0
  229. package/dist/routes/auth/password-hash-upgrade.js.map +1 -0
  230. package/dist/routes/auth/refresh.d.ts +1 -1
  231. package/dist/routes/auth/refresh.js +1 -2
  232. package/dist/routes/auth/refresh.js.map +1 -1
  233. package/dist/routes/auth/request-2fa-email.d.ts +1 -1
  234. package/dist/routes/auth/request-2fa-email.js +1 -2
  235. package/dist/routes/auth/request-2fa-email.js.map +1 -1
  236. package/dist/routes/auth/reset-password.d.ts +1 -1
  237. package/dist/routes/auth/reset-password.js +1 -3
  238. package/dist/routes/auth/reset-password.js.map +1 -1
  239. package/dist/routes/auth/session-handoff.d.ts +1 -1
  240. package/dist/routes/auth/session-handoff.js +1 -2
  241. package/dist/routes/auth/session-handoff.js.map +1 -1
  242. package/dist/routes/auth/signup.d.ts +1 -1
  243. package/dist/routes/auth/signup.js +1 -2
  244. package/dist/routes/auth/signup.js.map +1 -1
  245. package/dist/routes/auth/switch-tenant.d.ts +2 -3
  246. package/dist/routes/auth/switch-tenant.js +3 -2
  247. package/dist/routes/auth/switch-tenant.js.map +1 -1
  248. package/dist/routes/auth/validate-forgot-password-token.d.ts +1 -1
  249. package/dist/routes/auth/validate-forgot-password-token.js +1 -3
  250. package/dist/routes/auth/validate-forgot-password-token.js.map +1 -1
  251. package/dist/routes/auth/validate-invite-token.d.ts +1 -1
  252. package/dist/routes/auth/validate-invite-token.js +1 -3
  253. package/dist/routes/auth/validate-invite-token.js.map +1 -1
  254. package/dist/routes/auth/verify-2fa.d.ts +1 -1
  255. package/dist/routes/auth/verify-2fa.js +1 -2
  256. package/dist/routes/auth/verify-2fa.js.map +1 -1
  257. package/dist/routes/auth/verify-email.d.ts +1 -1
  258. package/dist/routes/auth/verify-email.js +1 -2
  259. package/dist/routes/auth/verify-email.js.map +1 -1
  260. package/dist/routes/auth.d.ts +14 -15
  261. package/dist/routes/auth.js +15 -13
  262. package/dist/routes/auth.js.map +1 -1
  263. package/dist/routes/authentication.d.ts +2 -0
  264. package/dist/routes/authentication.js +2 -0
  265. package/dist/routes/authentication.js.map +1 -1
  266. package/dist/routes/files.d.ts +1 -2
  267. package/dist/routes/files.js.map +1 -1
  268. package/dist/routes/onboarding.d.ts +4 -3
  269. package/dist/routes/onboarding.js +20 -3
  270. package/dist/routes/onboarding.js.map +1 -1
  271. package/dist/routes/realtime.js +5 -6
  272. package/dist/routes/realtime.js.map +1 -1
  273. package/dist/routes/store.d.ts +6 -7
  274. package/dist/routes/store.js +19 -13
  275. package/dist/routes/store.js.map +1 -1
  276. package/dist/store/catalog.d.ts +13 -5
  277. package/dist/store/catalog.js +19 -9
  278. package/dist/store/catalog.js.map +1 -1
  279. package/dist/store/compatibility.d.ts +1 -0
  280. package/dist/store/compatibility.js +24 -8
  281. package/dist/store/compatibility.js.map +1 -1
  282. package/dist/store/frontend-compatibility.js +15 -3
  283. package/dist/store/frontend-compatibility.js.map +1 -1
  284. package/dist/store/interfaces.js +35 -13
  285. package/dist/store/interfaces.js.map +1 -1
  286. package/dist/store/internal/frontend/frontend-modules.js +7 -3
  287. package/dist/store/internal/frontend/frontend-modules.js.map +1 -1
  288. package/dist/store/internal/frontend/frontend-ready.d.ts +2 -1
  289. package/dist/store/internal/frontend/frontend-ready.js +6 -4
  290. package/dist/store/internal/frontend/frontend-ready.js.map +1 -1
  291. package/dist/store/internal/frontend/manifest.js +6 -7
  292. package/dist/store/internal/frontend/manifest.js.map +1 -1
  293. package/dist/store/operations.js.map +1 -1
  294. package/dist/store/registry.js.map +1 -1
  295. package/dist/store/runtime.d.ts +13 -0
  296. package/dist/store/runtime.js +9 -1
  297. package/dist/store/runtime.js.map +1 -1
  298. package/dist/utils/admin-invite-email.js +3 -1
  299. package/dist/utils/admin-invite-email.js.map +1 -1
  300. package/dist/utils/email-subjects.js +3 -1
  301. package/dist/utils/email-subjects.js.map +1 -1
  302. package/dist/utils/ensure-default-tenant.d.ts +2 -1
  303. package/dist/utils/ensure-default-tenant.js +126 -8
  304. package/dist/utils/ensure-default-tenant.js.map +1 -1
  305. package/dist/utils/export-jobs-run.js.map +1 -1
  306. package/dist/utils/export-jobs.js +3 -9
  307. package/dist/utils/export-jobs.js.map +1 -1
  308. package/dist/utils/index.d.ts +1 -0
  309. package/dist/utils/index.js +1 -0
  310. package/dist/utils/index.js.map +1 -1
  311. package/dist/utils/own-value.d.ts +1 -0
  312. package/dist/utils/own-value.js +7 -0
  313. package/dist/utils/own-value.js.map +1 -0
  314. package/dist/utils/row-action-rule-evaluator.js +31 -35
  315. package/dist/utils/row-action-rule-evaluator.js.map +1 -1
  316. package/dist/utils/sign-in-country.js +4 -2
  317. package/dist/utils/sign-in-country.js.map +1 -1
  318. package/dist/utils/tenant-export-archive.js +9 -14
  319. package/dist/utils/tenant-export-archive.js.map +1 -1
  320. package/dist/utils/user-agent.js +2 -1
  321. package/dist/utils/user-agent.js.map +1 -1
  322. package/dist/validation/auth.schema.d.ts +10 -10
  323. package/dist/validation/onboarding/register-admin.schema.d.ts +5 -0
  324. package/dist/validation/onboarding/register-admin.schema.js +3 -0
  325. package/dist/validation/onboarding/register-admin.schema.js.map +1 -1
  326. package/dist/validation/regional-preferences.schema.d.ts +2 -2
  327. package/dist/validation/role-editor.schema.d.ts +1 -1
  328. package/frontend-vue/build/backend-boundary.ts +49 -18
  329. package/frontend-vue/dms.frontend.ts +8 -12
  330. package/frontend-vue/layers/dms-auth/server/accounts.ts +44 -3
  331. package/frontend-vue/layers/dms-auth/server/actions.ts +17 -4
  332. package/frontend-vue/layers/dms-auth/server/oauth.ts +42 -4
  333. package/frontend-vue/layers/dms-core/app/types/watch.ts +1 -2
  334. package/frontend-vue/layers/dms-core/i18n/locales/core-en-GB.json +1 -0
  335. package/frontend-vue/layers/dms-core/i18n/locales/core-fr-FR.json +1 -0
  336. package/frontend-vue/layers/dms-core/shared/types/app-config.ts +5 -0
  337. package/frontend-vue/layers/dms-layout/app/app.config.ts +1 -0
  338. package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardSidebar.vue +9 -9
  339. package/frontend-vue/layers/dms-layout/app/components/layout/AppLogo.vue +1 -0
  340. package/frontend-vue/layers/dms-layout/app/composables/page/useInstalledModules.ts +78 -0
  341. package/frontend-vue/layers/dms-layout/app/custom-pages/modules.vue +5 -24
  342. package/frontend-vue/layers/dms-layout/i18n/locales/layout-en-GB.json +2 -1
  343. package/frontend-vue/layers/dms-layout/i18n/locales/layout-fr-FR.json +2 -1
  344. package/frontend-vue/layers/dms-onboarding/app/components/onboarding/steps/register.vue +21 -1
  345. package/frontend-vue/layers/dms-onboarding/app/custom-pages/onboarding.vue +14 -0
  346. package/frontend-vue/layers/dms-onboarding/i18n/locales/onboarding-en-GB.json +2 -0
  347. package/frontend-vue/layers/dms-onboarding/i18n/locales/onboarding-fr-FR.json +2 -0
  348. package/frontend-vue/layers/dms-ui/app/build/composables/form/fieldValueRule.ts +57 -6
  349. package/frontend-vue/layers/dms-ui/app/build/composables/form/registerFormFunctions.ts +7 -9
  350. package/frontend-vue/layers/dms-ui/app/entries/composables/form/registerFormFunctions.ts +1 -0
  351. package/frontend-vue/lazy-plugin.ts +18 -0
  352. package/frontend-vue/package.json +2 -2
  353. package/package.json +19 -13
  354. package/skills/dms-project/SKILL.md +4 -3
@@ -1,16 +1,11 @@
1
1
  import { randomBytes } from "node:crypto";
2
- import {
3
- existsSync,
4
- mkdirSync,
5
- readFileSync,
6
- rmSync,
7
- writeFileSync,
8
- } from "node:fs";
2
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
9
3
  import { join, relative } from "node:path";
10
4
  import { pathToFileURL } from "node:url";
11
5
  import { withBaseBackend } from "./base.mjs";
12
6
  import { launchBrowser, newContext } from "./browser.mjs";
13
7
  import { capturedMail } from "./mail.mjs";
8
+ import { isRecord, isString, readRecordList } from "./json.mjs";
14
9
  import { saveRun } from "./run-state.mjs";
15
10
  import {
16
11
  adminCredentials,
@@ -27,6 +22,14 @@ import {
27
22
  } from "./stack.mjs";
28
23
  import { Tab } from "./tab.mjs";
29
24
 
25
+ /**
26
+ * @typedef {{ name: string, pass: boolean, detail: string }} Check
27
+ * @typedef {ReturnType<typeof capturedMail>[number]} CapturedMail
28
+ * @typedef {import("./verify/types.mjs").FeatureResult} FeatureResult
29
+ * @typedef {{ id: string, title: string, entryPoint: string, database?: string, onboard?: boolean, run: (context: JourneyContext) => Promise<void> }} Journey
30
+ * @typedef {{ name: string, folder: string, checks: Check[] }} Step
31
+ */
32
+
30
33
  const EXPECT_TIMEOUT_MS = 15_000;
31
34
  const POLL_MS = 250;
32
35
  const DETAIL_LENGTH = 300;
@@ -34,6 +37,10 @@ const UNIQUE_BYTES = 3;
34
37
 
35
38
  class JourneyAbort extends Error {}
36
39
 
40
+ /**
41
+ * @param {string} text The text
42
+ * @returns {string} The text as a folder name
43
+ */
37
44
  function slug(text) {
38
45
  return text
39
46
  .toLowerCase()
@@ -42,11 +49,22 @@ function slug(text) {
42
49
  .slice(0, 60);
43
50
  }
44
51
 
52
+ /**
53
+ * @param {string} file The file
54
+ * @param {unknown} value The value
55
+ */
45
56
  function writeJson(file, value) {
46
57
  writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
47
58
  }
48
59
 
49
- /** An equality check over JSON-serialisable values. */
60
+ /**
61
+ * An equality check over JSON-serialisable values.
62
+ *
63
+ * @param {string} name What is compared
64
+ * @param {unknown} actual The value found
65
+ * @param {unknown} expected The value wanted
66
+ * @returns {Check} The check
67
+ */
50
68
  function equal(name, actual, expected) {
51
69
  const [left, right] = [JSON.stringify(actual), JSON.stringify(expected)];
52
70
  return {
@@ -56,7 +74,13 @@ function equal(name, actual, expected) {
56
74
  };
57
75
  }
58
76
 
59
- /** A check that an API answer has `expected` as its status. */
77
+ /**
78
+ * A check that an API answer has `expected` as its status.
79
+ *
80
+ * @param {Pick<import("./session.mjs").ApiAnswer, "status" | "body">} answer The answer
81
+ * @param {number} expected The status wanted
82
+ * @returns {Check} The check
83
+ */
60
84
  function status(answer, expected) {
61
85
  const body = JSON.stringify(answer.body) ?? "";
62
86
  return {
@@ -66,7 +90,14 @@ function status(answer, expected) {
66
90
  };
67
91
  }
68
92
 
69
- /** A check that a condition holds, with what was observed. */
93
+ /**
94
+ * A check that a condition holds, with what was observed.
95
+ *
96
+ * @param {string} name What is checked
97
+ * @param {unknown} pass Whether it holds
98
+ * @param {unknown} detail What was observed
99
+ * @returns {Check} The check
100
+ */
70
101
  function holds(name, pass, detail) {
71
102
  return { name, pass: Boolean(pass), detail: String(detail) };
72
103
  }
@@ -77,6 +108,13 @@ function holds(name, pass, detail) {
77
108
  * folders under `<run>/evidence/<feature>/<journey>/`.
78
109
  */
79
110
  export class JourneyContext {
111
+ /**
112
+ * @param {import("./run-state.mjs").RunState} state The run
113
+ * @param {string} feature The feature the journey belongs to
114
+ * @param {string} journey The journey id
115
+ * @param {import("playwright-core").Browser} browser The Playwright browser
116
+ * @param {import("./verify/skill.mjs").Skill} skill The skill the journey belongs to
117
+ */
80
118
  constructor(state, feature, journey, browser, skill) {
81
119
  this.state = state;
82
120
  this.skill = skill;
@@ -84,7 +122,9 @@ export class JourneyContext {
84
122
  this.journey = journey;
85
123
  this.browser = browser;
86
124
  this.folder = join(state.evidence, feature, journey);
125
+ /** @type {Step[]} */
87
126
  this.steps = [];
127
+ /** @type {Map<string, import("playwright-core").BrowserContext>} */
88
128
  this.contexts = new Map();
89
129
  this.index = 0;
90
130
  this.startedAt = new Date().toISOString();
@@ -95,7 +135,12 @@ export class JourneyContext {
95
135
  mkdirSync(this.folder, { recursive: true });
96
136
  }
97
137
 
98
- /** A new numbered step folder. */
138
+ /**
139
+ * A new numbered step folder.
140
+ *
141
+ * @param {string} label What the step does
142
+ * @returns {string} The step folder
143
+ */
99
144
  step(label) {
100
145
  this.index += 1;
101
146
  const name = `${String(this.index).padStart(2, "0")}-${slug(label)}`;
@@ -104,7 +149,14 @@ export class JourneyContext {
104
149
  return folder;
105
150
  }
106
151
 
107
- /** Records the checks of a step folder; a failed critical step ends the journey. */
152
+ /**
153
+ * Records the checks of a step folder; a failed critical step ends the journey.
154
+ *
155
+ * @param {string} folder The step folder
156
+ * @param {{ name: string, pass: boolean, detail: string }[]} checks The checks
157
+ * @param {{ critical?: boolean }} [options] Whether a failure ends the journey
158
+ * @returns {boolean} Whether every check passed
159
+ */
108
160
  record(folder, checks, options = {}) {
109
161
  this.steps.push({
110
162
  name: relative(this.folder, folder),
@@ -124,12 +176,27 @@ export class JourneyContext {
124
176
  /**
125
177
  * Records a step of checks, which may be promises (`tab.visible(...)`).
126
178
  * With `{ critical: true }` a failure ends the journey.
179
+ *
180
+ * @param {string} label What the step verifies
181
+ * @param {(Check | Promise<Check>)[]} checks The checks, or promises of them
182
+ * @param {{ critical?: boolean }} [options] Whether a failure ends the journey
183
+ * @returns {Promise<boolean>} Whether every check passed
127
184
  */
128
185
  async verify(label, checks, options = {}) {
129
- return this.record(this.step(label), await Promise.all(checks), options);
186
+ return this.record(
187
+ this.step(label),
188
+ await Promise.all(checks.map((check) => Promise.resolve(check))),
189
+ options,
190
+ );
130
191
  }
131
192
 
132
- /** A new tab as `account` (`anon` for nobody), in that account's own browser context. */
193
+ /**
194
+ * A new tab as `account` (`anon` for nobody), in that account's own browser context.
195
+ *
196
+ * @param {string} [account] The account, or `anon`
197
+ * @param {string} [label] The tab's name in evidence
198
+ * @returns {Promise<Tab>} The tab
199
+ */
133
200
  async tab(account = "admin", label = account) {
134
201
  if (!this.contexts.has(account)) {
135
202
  this.contexts.set(account, await newContext(this.browser));
@@ -140,6 +207,10 @@ export class JourneyContext {
140
207
  /**
141
208
  * Calls the backend as `as` (an account, or `anon`; `admin` by default),
142
209
  * or with an explicit `token` and no retry, and records the exchange.
210
+ *
211
+ * @param {string} label What the call does
212
+ * @param {{ method: string, path: string, body?: unknown, as?: string, token?: string }} request What to send and as whom
213
+ * @returns {Promise<import("./session.mjs").ApiAnswer & { folder?: string }>} The answer
143
214
  */
144
215
  api(label, request) {
145
216
  if (request.token) {
@@ -155,7 +226,13 @@ export class JourneyContext {
155
226
  });
156
227
  }
157
228
 
158
- /** Sends anonymous requests at once, for races, each recorded in its own step folder. */
229
+ /**
230
+ * Sends anonymous requests at once, for races, each recorded in its own step folder.
231
+ *
232
+ * @param {string} label What the calls do
233
+ * @param {{ method: string, path: string, body?: unknown, token?: string }[]} requests What to send
234
+ * @returns {Promise<import("./session.mjs").ApiAnswer[]>} The answers
235
+ */
159
236
  concurrent(label, requests) {
160
237
  return Promise.all(
161
238
  requests.map((request, index) =>
@@ -167,18 +244,33 @@ export class JourneyContext {
167
244
  );
168
245
  }
169
246
 
170
- /** Adds an account the journey created (an invited member) so tabs and the API can act as it. */
247
+ /**
248
+ * Adds an account the journey created (an invited member) so tabs and the API can act as it.
249
+ *
250
+ * @param {string} name The account name
251
+ * @param {import("./run-state.mjs").RunState["accounts"][string]} credentials Its credentials
252
+ */
171
253
  addAccount(name, credentials) {
172
254
  this.state.accounts = { ...this.state.accounts, [name]: credentials };
173
255
  saveRun(this.state);
174
256
  }
175
257
 
176
- /** An address unique to this run and journey, which the mail sink receives. */
258
+ /**
259
+ * An address unique to this run and journey, which the mail sink receives.
260
+ *
261
+ * @param {string} name The local part
262
+ * @returns {string} The address
263
+ */
177
264
  email(name) {
178
265
  return `${name}+${this.state.id}-${slug(this.journey)}@verify.local`.toLowerCase();
179
266
  }
180
267
 
181
- /** A string unique to this journey, for names a journey creates. */
268
+ /**
269
+ * A string unique to this journey, for names a journey creates.
270
+ *
271
+ * @param {string} prefix What the string starts with
272
+ * @returns {string} The unique string
273
+ */
182
274
  unique(prefix) {
183
275
  return `${prefix} ${randomBytes(UNIQUE_BYTES).toString("hex")}`;
184
276
  }
@@ -193,6 +285,11 @@ export class JourneyContext {
193
285
  return adminCredentials(this.state);
194
286
  }
195
287
 
288
+ /** The setup token the run's backend accepts for the first registration (`DMS_SETUP_TOKEN`). */
289
+ setupToken() {
290
+ return this.state.secrets.DMS_SETUP_TOKEN;
291
+ }
292
+
196
293
  /** Creates the administrator through `POST /api/onboarding/register`, then signs it in. */
197
294
  onboard(options = {}) {
198
295
  return onboard(this.state, options);
@@ -202,6 +299,9 @@ export class JourneyContext {
202
299
  * Runs `action` against the base version's backend on the same database,
203
300
  * then the run's own backend again: data written inside `action` is data
204
301
  * the previous release wrote. Records the base commit as a step.
302
+ *
303
+ * @param {(sha: string) => Promise<void>} action What to do against the base backend
304
+ * @returns {Promise<string>} The base commit
205
305
  */
206
306
  async withBaseBackend(action) {
207
307
  const sha = await withBaseBackend(this.state, this.skill, action);
@@ -220,6 +320,10 @@ export class JourneyContext {
220
320
  /**
221
321
  * The mail the sink captured since the journey started, waiting until
222
322
  * `accept` matches one or the timeout passes; `match` is the latest match.
323
+ *
324
+ * @param {(message: CapturedMail) => boolean} accept Whether a message is the awaited one
325
+ * @param {number} [timeoutMs] How long to wait
326
+ * @returns {Promise<{ folder: string, messages: CapturedMail[], match: CapturedMail | undefined }>} What arrived
223
327
  */
224
328
  async mail(accept, timeoutMs = EXPECT_TIMEOUT_MS) {
225
329
  const deadline = Date.now() + timeoutMs;
@@ -241,6 +345,12 @@ export class JourneyContext {
241
345
  }
242
346
  }
243
347
 
348
+ /**
349
+ * @param {import("./run-state.mjs").RunState} state The run
350
+ * @param {string} feature The feature the journey belongs to
351
+ * @param {Journey} journey The journey
352
+ * @param {JourneyContext} context The journey's context
353
+ */
244
354
  async function prepare(state, feature, journey, context) {
245
355
  await ensureMail(state);
246
356
  if (journey.database !== "shared") {
@@ -278,10 +388,11 @@ async function prepare(state, feature, journey, context) {
278
388
  * false` skips the administrator), with a fresh browser, and writes its
279
389
  * result.json.
280
390
  *
281
- * @param {Record<string, any>} state The run
391
+ * @param {import("./run-state.mjs").RunState} state The run
282
392
  * @param {string} feature The feature it belongs to
283
- * @param {{ id: string, title: string, entryPoint: string, database?: string, onboard?: boolean, run: (context: JourneyContext) => Promise<void> }} journey The journey
284
- * @returns {Promise<Record<string, any>>} The result
393
+ * @param {Journey} journey The journey
394
+ * @param {import("./verify/skill.mjs").Skill} skill The skill the journey belongs to
395
+ * @returns {Promise<FeatureResult>} The result
285
396
  */
286
397
  export async function runJourney(state, feature, journey, skill) {
287
398
  const browser = await launchBrowser(resolutionFolders(state));
@@ -323,17 +434,40 @@ export async function runJourney(state, feature, journey, skill) {
323
434
  return result;
324
435
  }
325
436
 
437
+ /**
438
+ * @param {unknown} value A value a journeys module exports
439
+ * @returns {value is Journey} True for an object with the fields a journey run reads
440
+ */
441
+ function isJourney(value) {
442
+ return (
443
+ isRecord(value) &&
444
+ isString(value.id) &&
445
+ isString(value.title) &&
446
+ isString(value.entryPoint) &&
447
+ (value.database === undefined || isString(value.database)) &&
448
+ (value.onboard === undefined || typeof value.onboard === "boolean") &&
449
+ typeof value.run === "function"
450
+ );
451
+ }
452
+
326
453
  /**
327
454
  * Loads the journeys a feature's file exports (`export const journeys`).
328
455
  *
329
456
  * @param {string} file The journeys module
330
- * @returns {Promise<Record<string, any>[]>} The journeys
457
+ * @returns {Promise<Journey[]>} The journeys
331
458
  */
332
459
  export async function loadJourneys(file) {
333
460
  if (!existsSync(file)) throw new Error(`${file} does not exist`);
334
- const { journeys } = await import(pathToFileURL(file).href);
335
- if (!Array.isArray(journeys))
461
+ /** @type {unknown} */
462
+ const exported = await import(pathToFileURL(file).href);
463
+ const entries = isRecord(exported) ? exported.journeys : undefined;
464
+ if (!Array.isArray(entries))
336
465
  throw new Error(`${file} exports no journeys array`);
466
+ const journeys = entries.filter(isJourney);
467
+ if (journeys.length !== entries.length)
468
+ throw new Error(
469
+ `${file} exports a journey without an id, title, entry point or run function`,
470
+ );
337
471
  return journeys;
338
472
  }
339
473
 
@@ -341,9 +475,9 @@ export async function loadJourneys(file) {
341
475
  * Runs a feature's journeys, all or those named, and merges their results
342
476
  * into `<feature>/feature.json` so a partial run never drops earlier ones.
343
477
  *
344
- * @param {Record<string, any>} state The run
345
- * @param {{ file: string, feature: string, ids: string[], skill: Record<string, any> }} request Which journeys, and the skill they belong to
346
- * @returns {Promise<Record<string, any>[]>} The results of this run
478
+ * @param {import("./run-state.mjs").RunState} state The run
479
+ * @param {{ file: string, feature: string, ids: string[], skill: import("./verify/skill.mjs").Skill }} request Which journeys, and the skill they belong to
480
+ * @returns {Promise<FeatureResult[]>} The results of this run
347
481
  */
348
482
  export async function runJourneyFile(state, request) {
349
483
  const journeys = await loadJourneys(request.file);
@@ -359,15 +493,14 @@ export async function runJourneyFile(state, request) {
359
493
  request.ids.length > 0
360
494
  ? journeys.filter((journey) => request.ids.includes(journey.id))
361
495
  : journeys;
496
+ /** @type {FeatureResult[]} */
362
497
  const results = [];
363
498
  for (const journey of selected)
364
499
  results.push(
365
500
  await runJourney(state, request.feature, journey, request.skill),
366
501
  );
367
502
  const file = join(state.evidence, request.feature, "feature.json");
368
- const earlier = existsSync(file)
369
- ? JSON.parse(readFileSync(file, "utf8"))
370
- : [];
503
+ const earlier = existsSync(file) ? readRecordList(file) : [];
371
504
  const merged = journeys.flatMap((journey) => {
372
505
  const fresh = results.filter((result) => result.journey === journey.id);
373
506
  return fresh.length > 0
@@ -0,0 +1,61 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import test from "node:test";
6
+ import { loadJourneys } from "./journey.mjs";
7
+
8
+ const JOURNEY = '{ id: "smoke", title: "t", entryPoint: "e", async run() {} }';
9
+
10
+ /**
11
+ * @param {import("node:test").TestContext} t The test
12
+ * @param {string} source The module source
13
+ * @returns {string} The journeys module
14
+ */
15
+ function journeysModule(t, source) {
16
+ const dir = mkdtempSync(join(tmpdir(), "dms-control-journey-test-"));
17
+ t.after(() => rmSync(dir, { recursive: true, force: true }));
18
+ const file = join(dir, "journeys.mjs");
19
+ writeFileSync(file, source);
20
+ return file;
21
+ }
22
+
23
+ await test("a module exporting well-formed journeys loads them", async (t) => {
24
+ const file = journeysModule(
25
+ t,
26
+ `export const journeys = [${JOURNEY}, { id: "b", title: "t", entryPoint: "e", database: "fresh", onboard: false, async run() {} }];`,
27
+ );
28
+ const journeys = await loadJourneys(file);
29
+ assert.deepEqual(
30
+ journeys.map((journey) => journey.id),
31
+ ["smoke", "b"],
32
+ );
33
+ });
34
+
35
+ await test("a module without a journeys array is refused", async (t) => {
36
+ await assert.rejects(
37
+ loadJourneys(journeysModule(t, "export const journeys = {};")),
38
+ /exports no journeys array/,
39
+ );
40
+ await assert.rejects(
41
+ loadJourneys(journeysModule(t, "export const other = [];")),
42
+ /exports no journeys array/,
43
+ );
44
+ });
45
+
46
+ await test("a journey missing a field a run reads is refused", async (t) => {
47
+ const broken = [
48
+ '{ title: "t", entryPoint: "e", async run() {} }',
49
+ '{ id: "x", title: "t", entryPoint: "e" }',
50
+ '{ id: "x", title: "t", entryPoint: "e", database: 3, async run() {} }',
51
+ '{ id: "x", title: "t", entryPoint: "e", onboard: "yes", async run() {} }',
52
+ "null",
53
+ ];
54
+ for (const journey of broken) {
55
+ await assert.rejects(
56
+ loadJourneys(journeysModule(t, `export const journeys = [${journey}];`)),
57
+ /exports a journey without an id/,
58
+ journey,
59
+ );
60
+ }
61
+ });
@@ -0,0 +1,229 @@
1
+ import { readFileSync } from "node:fs";
2
+
3
+ /**
4
+ * Reads of JSON a program received, narrowed through checks instead of typed
5
+ * by assertion. Everything here takes `unknown` because JSON arrives
6
+ * unchecked.
7
+ */
8
+
9
+ /**
10
+ * @param {unknown} value A parsed JSON value
11
+ * @returns {value is Record<string, unknown>} True for an object that is not an array or null
12
+ */
13
+ export function isRecord(value) {
14
+ return typeof value === "object" && value !== null && !Array.isArray(value);
15
+ }
16
+
17
+ /**
18
+ * @param {unknown} value A parsed JSON value
19
+ * @returns {value is string} True for a string
20
+ */
21
+ export function isString(value) {
22
+ return typeof value === "string";
23
+ }
24
+
25
+ /**
26
+ * The string an object holds under its own key.
27
+ *
28
+ * @param {unknown} body A parsed JSON value
29
+ * @param {string} key The property
30
+ * @returns {string | undefined} The string, or undefined when `body` is no object or the property is absent or not a string
31
+ */
32
+ export function stringField(body, key) {
33
+ if (!isRecord(body)) return undefined;
34
+ /** @type {unknown} */
35
+ const value = Object.getOwnPropertyDescriptor(body, key)?.value;
36
+ return isString(value) ? value : undefined;
37
+ }
38
+
39
+ /**
40
+ * The first element of a JSON array, when it is a string.
41
+ *
42
+ * @param {unknown} body A parsed JSON value
43
+ * @returns {string | undefined} The first string, or undefined when `body` is no array or starts with anything else
44
+ */
45
+ export function firstString(body) {
46
+ if (!Array.isArray(body)) return undefined;
47
+ /** @type {unknown} */
48
+ const first = body.at(0);
49
+ return isString(first) ? first : undefined;
50
+ }
51
+
52
+ /**
53
+ * The objects a JSON file lists, kept as written.
54
+ *
55
+ * @param {string} file The JSON file
56
+ * @returns {Record<string, unknown>[]} The listed objects
57
+ * @throws When the file holds anything but a list of objects
58
+ */
59
+ export function readRecordList(file) {
60
+ /** @type {unknown} */
61
+ const stored = JSON.parse(readFileSync(file, "utf8"));
62
+ if (!Array.isArray(stored))
63
+ throw new Error(`${file} is not a list of objects`);
64
+ const records = stored.filter(isRecord);
65
+ if (records.length !== stored.length)
66
+ throw new Error(`${file} is not a list of objects`);
67
+ return records;
68
+ }
69
+
70
+ /**
71
+ * @template T
72
+ * @typedef {(value: unknown) => value is T} Guard A check that narrows a parsed JSON value to `T`
73
+ */
74
+
75
+ /**
76
+ * @param {unknown} value A parsed JSON value
77
+ * @returns {value is number} True for a number
78
+ */
79
+ export function isNumber(value) {
80
+ return typeof value === "number";
81
+ }
82
+
83
+ /**
84
+ * @param {unknown} value A parsed JSON value
85
+ * @returns {value is boolean} True for a boolean
86
+ */
87
+ export function isBoolean(value) {
88
+ return typeof value === "boolean";
89
+ }
90
+
91
+ /**
92
+ * @param {unknown} value A parsed JSON value
93
+ * @returns {value is null} True for null
94
+ */
95
+ function isNull(value) {
96
+ return value === null;
97
+ }
98
+
99
+ /**
100
+ * @template T
101
+ * @param {Guard<T>} item What each element must be
102
+ * @returns {Guard<T[]>} A guard for lists
103
+ */
104
+ export function arrayOf(item) {
105
+ /**
106
+ * @param {unknown} value A parsed JSON value
107
+ * @returns {value is T[]} True for a list whose every element passes `item`
108
+ */
109
+ return (value) => Array.isArray(value) && value.every(item);
110
+ }
111
+
112
+ /**
113
+ * @template T
114
+ * @param {Guard<T>} item What each value must be
115
+ * @returns {Guard<Record<string, T>>} A guard for objects keyed by any string
116
+ */
117
+ export function recordOf(item) {
118
+ /**
119
+ * @param {unknown} value A parsed JSON value
120
+ * @returns {value is Record<string, T>} True for an object whose every value passes `item`
121
+ */
122
+ return (value) => isRecord(value) && Object.values(value).every(item);
123
+ }
124
+
125
+ /**
126
+ * @template T
127
+ * @param {Guard<T>} guard What the value must be unless it is null
128
+ * @returns {Guard<T | null>} A guard that also accepts null
129
+ */
130
+ export function nullable(guard) {
131
+ return either(guard, isNull);
132
+ }
133
+
134
+ /**
135
+ * @template A
136
+ * @template B
137
+ * @param {Guard<A>} first One accepted form
138
+ * @param {Guard<B>} second The other accepted form
139
+ * @returns {Guard<A | B>} A guard for either form
140
+ */
141
+ export function either(first, second) {
142
+ /**
143
+ * @param {unknown} value A parsed JSON value
144
+ * @returns {value is A | B} True when one of the forms matches
145
+ */
146
+ return (value) => first(value) || second(value);
147
+ }
148
+
149
+ /**
150
+ * @template {string} Name
151
+ * @param {Name[]} names The accepted strings
152
+ * @returns {Guard<Name>} A guard for one of the names
153
+ */
154
+ export function oneOf(names) {
155
+ /**
156
+ * @param {unknown} value A parsed JSON value
157
+ * @returns {value is Name} True for one of the names
158
+ */
159
+ return (value) => isString(value) && names.some((name) => name === value);
160
+ }
161
+
162
+ /**
163
+ * @template T
164
+ * @param {new (...args: never[]) => T} constructor The class
165
+ * @returns {Guard<T>} A guard for its instances
166
+ */
167
+ export function instanceOf(constructor) {
168
+ /**
169
+ * @param {unknown} value A value
170
+ * @returns {value is T} True for an instance
171
+ */
172
+ return (value) => value instanceof constructor;
173
+ }
174
+
175
+ /**
176
+ * @param {Record<string, unknown>} record An object
177
+ * @param {string} key The property
178
+ * @returns {unknown} Its own value, undefined when it holds none
179
+ */
180
+ function ownValue(record, key) {
181
+ return Object.getOwnPropertyDescriptor(record, key)?.value;
182
+ }
183
+
184
+ /**
185
+ * @template {Record<string, Guard<unknown>>} Required
186
+ * @template {Record<string, Guard<unknown>>} Optional
187
+ * @typedef {{ [Key in keyof Required]: Required[Key] extends Guard<infer T> ? T : never } & { [Key in keyof Optional]?: Optional[Key] extends Guard<infer T> ? T : never }} Properties
188
+ */
189
+
190
+ /**
191
+ * An object with the required properties, each passing its guard, and the
192
+ * optional ones, which pass theirs when present. Other properties are not
193
+ * looked at. Only own properties count.
194
+ *
195
+ * @template {Record<string, Guard<unknown>>} Required
196
+ * @template {Record<string, Guard<unknown>>} [Optional=Record<never, never>]
197
+ * @param {Required} required The guard of each required property
198
+ * @param {Optional} [optional] The guard of each optional property
199
+ * @returns {Guard<Properties<Required, Optional>>} A guard for the object
200
+ */
201
+ export function objectOf(required, optional) {
202
+ /**
203
+ * @param {unknown} value A parsed JSON value
204
+ * @returns {value is Properties<Required, Optional>} True for an object with these properties
205
+ */
206
+ return (value) =>
207
+ isRecord(value) &&
208
+ Object.entries(required).every(([key, guard]) =>
209
+ guard(ownValue(value, key)),
210
+ ) &&
211
+ Object.entries(optional ?? {}).every(
212
+ ([key, guard]) =>
213
+ ownValue(value, key) === undefined || guard(ownValue(value, key)),
214
+ );
215
+ }
216
+
217
+ /**
218
+ * @template T
219
+ * @param {Guard<T>} guard What the value must be
220
+ * @param {unknown} value The value read
221
+ * @param {string} source Where it was read, named in the error
222
+ * @param {string} expected What the value should be, named in the error
223
+ * @returns {T} The value
224
+ * @throws When the value does not pass the guard
225
+ */
226
+ export function decode(guard, value, source, expected) {
227
+ if (guard(value)) return value;
228
+ throw new Error(`${source} is malformed: expected ${expected}`);
229
+ }