@moifort/stack 0.0.0-stage → 0.1.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 (263) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +26 -2
  3. package/bin/stack.ts +109 -0
  4. package/hooks/shutdown-simulators-after-push.sh +19 -0
  5. package/package.json +40 -4
  6. package/routines/daily-health.md +78 -0
  7. package/routines/deprecations.md +23 -0
  8. package/routines/sentry-autofix.md +76 -0
  9. package/routines/stack-upgrade.md +45 -0
  10. package/rules/00-collaboration.md +81 -0
  11. package/rules/01-workflow.md +81 -0
  12. package/rules/02-commits.md +71 -0
  13. package/rules/03-code-review.md +25 -0
  14. package/rules/04-push.md +75 -0
  15. package/rules/05-release.md +40 -0
  16. package/rules/06-changelog.md +48 -0
  17. package/rules/07-appstore.md +77 -0
  18. package/rules/08-copy.md +52 -0
  19. package/rules/09-backend.md +132 -0
  20. package/rules/10-ios.md +206 -0
  21. package/rules/11-ops.md +114 -0
  22. package/rules/12-ci.md +32 -0
  23. package/rules/13-ai.md +71 -0
  24. package/rules/14-infra.md +90 -0
  25. package/rules/15-tooling.md +81 -0
  26. package/rules/16-routines.md +69 -0
  27. package/rules/17-landing.md +76 -0
  28. package/skills/appstore/SKILL.md +53 -0
  29. package/skills/domain/SKILL.md +136 -0
  30. package/skills/graphql/SKILL.md +83 -0
  31. package/skills/ios-feature/SKILL.md +94 -0
  32. package/skills/stack-install/SKILL.md +35 -0
  33. package/skills/stack-upgrade/SKILL.md +27 -0
  34. package/src/catalogue.ts +33 -0
  35. package/src/config.ts +20 -0
  36. package/src/init.ts +63 -0
  37. package/src/migrations.ts +72 -0
  38. package/src/migrations.unit.test.ts +44 -0
  39. package/src/package.ts +8 -0
  40. package/src/render.ts +13 -0
  41. package/src/render.unit.test.ts +17 -0
  42. package/src/sync.int.test.ts +103 -0
  43. package/src/sync.ts +161 -0
  44. package/src/templates.ts +121 -0
  45. package/src/validate-content.ts +65 -0
  46. package/templates/backend/.env.example +10 -0
  47. package/templates/backend/.github/workflows/deploy.yml +151 -0
  48. package/templates/backend/.github/workflows/schema.yml +59 -0
  49. package/templates/backend/.github/workflows/test-feat.yml +40 -0
  50. package/templates/backend/.github/workflows/test-int.yml +40 -0
  51. package/templates/backend/.github/workflows/test-unit.yml +64 -0
  52. package/templates/backend/docs/api-evolution.md +67 -0
  53. package/templates/backend/firebase.json +21 -0
  54. package/templates/backend/firestore.indexes.json +4 -0
  55. package/templates/backend/firestore.rules +11 -0
  56. package/templates/backend/infra/auth.tf +54 -0
  57. package/templates/backend/infra/backend.tf +6 -0
  58. package/templates/backend/infra/cicd.tf +63 -0
  59. package/templates/backend/infra/firebase.tf +26 -0
  60. package/templates/backend/infra/firestore.tf +53 -0
  61. package/templates/backend/infra/function.tf +171 -0
  62. package/templates/backend/infra/main.tf +26 -0
  63. package/templates/backend/infra/observer.tf +18 -0
  64. package/templates/backend/infra/outputs.tf +30 -0
  65. package/templates/backend/infra/project.tf +64 -0
  66. package/templates/backend/infra/scheduler.tf +28 -0
  67. package/templates/backend/infra/secrets.tf +44 -0
  68. package/templates/backend/infra/state.tf +13 -0
  69. package/templates/backend/infra/terraform.tfvars.example +16 -0
  70. package/templates/backend/infra/variables.tf +104 -0
  71. package/templates/backend/infra/versions.tf +30 -0
  72. package/templates/backend/nitro.config.ts +40 -0
  73. package/templates/backend/package.json +51 -0
  74. package/templates/backend/scripts/deprecations/inspector-rule.ts +29 -0
  75. package/templates/backend/scripts/deprecations/status.ts +50 -0
  76. package/templates/backend/scripts/deprecations/status.unit.test.ts +57 -0
  77. package/templates/backend/scripts/deprecations/usage.ts +79 -0
  78. package/templates/backend/scripts/deprecations/usage.unit.test.ts +64 -0
  79. package/templates/backend/scripts/deprecations.ts +224 -0
  80. package/templates/backend/scripts/export-graphql-schema.ts +11 -0
  81. package/templates/backend/scripts/generate-changelog-asset.ts +27 -0
  82. package/templates/backend/scripts/install-terraform.sh +61 -0
  83. package/templates/backend/scripts/tf +11 -0
  84. package/templates/backend/server/domain/changelog/business-rules.ts +60 -0
  85. package/templates/backend/server/domain/changelog/business-rules.unit.test.ts +88 -0
  86. package/templates/backend/server/domain/changelog/infrastructure/graphql/queries.ts +17 -0
  87. package/templates/backend/server/domain/changelog/infrastructure/graphql/types.ts +23 -0
  88. package/templates/backend/server/domain/changelog/primitives.ts +19 -0
  89. package/templates/backend/server/domain/changelog/query.ts +13 -0
  90. package/templates/backend/server/domain/changelog/query.unit.test.ts +19 -0
  91. package/templates/backend/server/domain/changelog/types.ts +9 -0
  92. package/templates/backend/server/domain/shared/graphql/builder.ts +52 -0
  93. package/templates/backend/server/domain/shared/graphql/errors.ts +19 -0
  94. package/templates/backend/server/domain/shared/graphql/scalars.ts +51 -0
  95. package/templates/backend/server/domain/shared/graphql/schema.ts +21 -0
  96. package/templates/backend/server/domain/shared/language.ts +26 -0
  97. package/templates/backend/server/domain/shared/page.ts +25 -0
  98. package/templates/backend/server/domain/shared/page.unit.test.ts +51 -0
  99. package/templates/backend/server/domain/shared/primitives.ts +50 -0
  100. package/templates/backend/server/domain/shared/primitives.unit.test.ts +25 -0
  101. package/templates/backend/server/domain/shared/types.ts +14 -0
  102. package/templates/backend/server/domain/user/command.ts +16 -0
  103. package/templates/backend/server/domain/user/infrastructure/graphql/inputs.ts +12 -0
  104. package/templates/backend/server/domain/user/infrastructure/graphql/mutations.ts +40 -0
  105. package/templates/backend/server/domain/user/infrastructure/graphql/queries.ts +13 -0
  106. package/templates/backend/server/domain/user/infrastructure/graphql/types.ts +35 -0
  107. package/templates/backend/server/domain/user/infrastructure/repository.ts +59 -0
  108. package/templates/backend/server/domain/user/mutations.feat.test.ts +42 -0
  109. package/templates/backend/server/domain/user/query.int.test.ts +49 -0
  110. package/templates/backend/server/domain/user/query.ts +34 -0
  111. package/templates/backend/server/domain/user/types.ts +20 -0
  112. package/templates/backend/server/domain/user/use-case.ts +18 -0
  113. package/templates/backend/server/middleware/auth.ts +54 -0
  114. package/templates/backend/server/plugins/00-sentry.ts +74 -0
  115. package/templates/backend/server/plugins/01-request-log.ts +28 -0
  116. package/templates/backend/server/plugins/02-graphql.ts +69 -0
  117. package/templates/backend/server/plugins/03-boot-time.ts +21 -0
  118. package/templates/backend/server/routes/admin/migrate.post.ts +9 -0
  119. package/templates/backend/server/routes/app-config.get.ts +15 -0
  120. package/templates/backend/server/routes/graphql.ts +52 -0
  121. package/templates/backend/server/system/app-support.ts +17 -0
  122. package/templates/backend/server/system/config/index.ts +18 -0
  123. package/templates/backend/server/system/config/primitives.ts +20 -0
  124. package/templates/backend/server/system/config/primitives.unit.test.ts +13 -0
  125. package/templates/backend/server/system/config/types.ts +8 -0
  126. package/templates/backend/server/system/firebase.ts +19 -0
  127. package/templates/backend/server/system/identity.ts +18 -0
  128. package/templates/backend/server/system/logger.ts +119 -0
  129. package/templates/backend/server/system/logger.unit.test.ts +109 -0
  130. package/templates/backend/server/system/migration/migrations/index.ts +6 -0
  131. package/templates/backend/server/system/migration/primitives.ts +18 -0
  132. package/templates/backend/server/system/migration/runner.ts +52 -0
  133. package/templates/backend/server/system/migration/types.ts +16 -0
  134. package/templates/backend/server/system/read-meter.ts +100 -0
  135. package/templates/backend/server/system/read-meter.unit.test.ts +97 -0
  136. package/templates/backend/server/system/request-cache.ts +139 -0
  137. package/templates/backend/server/system/request-cache.unit.test.ts +166 -0
  138. package/templates/backend/server/system/secret-box.ts +49 -0
  139. package/templates/backend/server/system/secret-box.unit.test.ts +46 -0
  140. package/templates/backend/server/test/bdd.ts +12 -0
  141. package/templates/backend/server/test/fake-firestore.ts +483 -0
  142. package/templates/backend/server/utils/apollo.ts +13 -0
  143. package/templates/backend/server/utils/fetch.ts +5 -0
  144. package/templates/backend/server/utils/firestore.ts +91 -0
  145. package/templates/backend/server/utils/input.ts +36 -0
  146. package/templates/backend/server/utils/input.unit.test.ts +43 -0
  147. package/templates/backend/server/utils/slug.ts +21 -0
  148. package/templates/backend/tsconfig.json +16 -0
  149. package/templates/base/.github/workflows/renovate.yml +17 -0
  150. package/templates/base/CHANGELOG.fr.md +3 -0
  151. package/templates/base/CHANGELOG.md +7 -0
  152. package/templates/base/CLAUDE.md +34 -0
  153. package/templates/base/README.md +12 -0
  154. package/templates/base/biome.json +47 -0
  155. package/templates/base/bunfig.toml +2 -0
  156. package/templates/base/docs/adr/0001-built-on-the-polyforms-stack.md +21 -0
  157. package/templates/base/docs/data-recovery.md +116 -0
  158. package/templates/base/docs/ideas.md +4 -0
  159. package/templates/base/project-context.md +43 -0
  160. package/templates/base/renovate.json +37 -0
  161. package/templates/ios/.github/workflows/build-ios.yml +168 -0
  162. package/templates/ios/.github/workflows/ios-upload.yml +308 -0
  163. package/templates/ios/.github/workflows/release-ios.yml +178 -0
  164. package/templates/ios/.github/workflows/testflight-ios.yml +57 -0
  165. package/templates/ios/infra/Gemfile +5 -0
  166. package/templates/ios/infra/fastlane/Appfile +1 -0
  167. package/templates/ios/infra/fastlane/Fastfile +54 -0
  168. package/templates/ios/infra/fastlane/metadata/copyright.txt +1 -0
  169. package/templates/ios/infra/fastlane/metadata/en-US/description.txt +0 -0
  170. package/templates/ios/infra/fastlane/metadata/en-US/keywords.txt +0 -0
  171. package/templates/ios/infra/fastlane/metadata/en-US/name.txt +1 -0
  172. package/templates/ios/infra/fastlane/metadata/en-US/privacy_url.txt +1 -0
  173. package/templates/ios/infra/fastlane/metadata/en-US/promotional_text.txt +0 -0
  174. package/templates/ios/infra/fastlane/metadata/en-US/subtitle.txt +1 -0
  175. package/templates/ios/infra/fastlane/metadata/en-US/support_url.txt +1 -0
  176. package/templates/ios/infra/fastlane/metadata/fr-FR/description.txt +0 -0
  177. package/templates/ios/infra/fastlane/metadata/fr-FR/keywords.txt +0 -0
  178. package/templates/ios/infra/fastlane/metadata/fr-FR/name.txt +1 -0
  179. package/templates/ios/infra/fastlane/metadata/fr-FR/privacy_url.txt +1 -0
  180. package/templates/ios/infra/fastlane/metadata/fr-FR/promotional_text.txt +0 -0
  181. package/templates/ios/infra/fastlane/metadata/fr-FR/subtitle.txt +1 -0
  182. package/templates/ios/infra/fastlane/metadata/fr-FR/support_url.txt +1 -0
  183. package/templates/ios/infra/fastlane/metadata/primary_category.txt +1 -0
  184. package/templates/ios/infra/ios.tf +28 -0
  185. package/templates/ios/ios/ExportOptions.plist +26 -0
  186. package/templates/ios/ios/apollo-codegen-config.json +16 -0
  187. package/templates/ios/ios/project.yml +94 -0
  188. package/templates/ios/ios/{{APP_NAME}}/App.swift +46 -0
  189. package/templates/ios/ios/{{APP_NAME}}/Assets.xcassets/AppIcon.appiconset/AppIcon.png +0 -0
  190. package/templates/ios/ios/{{APP_NAME}}/Assets.xcassets/AppIcon.appiconset/Contents.json +14 -0
  191. package/templates/ios/ios/{{APP_NAME}}/Assets.xcassets/Contents.json +6 -0
  192. package/templates/ios/ios/{{APP_NAME}}/Assets.xcassets/LaunchBackground.colorset/Contents.json +38 -0
  193. package/templates/ios/ios/{{APP_NAME}}/ContentView.swift +17 -0
  194. package/templates/ios/ios/{{APP_NAME}}/Features/Auth/AppleNonce.swift +33 -0
  195. package/templates/ios/ios/{{APP_NAME}}/Features/Auth/AuthRoot.swift +74 -0
  196. package/templates/ios/ios/{{APP_NAME}}/Features/Auth/AuthSession.swift +86 -0
  197. package/templates/ios/ios/{{APP_NAME}}/Features/Auth/LoginScreen.swift +83 -0
  198. package/templates/ios/ios/{{APP_NAME}}/Features/Home/HomeScreen.swift +21 -0
  199. package/templates/ios/ios/{{APP_NAME}}/Features/Home/components/pages/HomePage.swift +40 -0
  200. package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/AccountEnvironment.swift +7 -0
  201. package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/GraphQL/Onboarding.graphql +16 -0
  202. package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/OnboardingAPI.swift +28 -0
  203. package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/OnboardingClient.swift +26 -0
  204. package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/OnboardingGate.swift +50 -0
  205. package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/OnboardingScreen.swift +69 -0
  206. package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/components/pages/FirstNamePage.swift +66 -0
  207. package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/components/pages/WelcomePage.swift +37 -0
  208. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/ChangelogScreen.swift +25 -0
  209. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/ChangelogViewModel.swift +27 -0
  210. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/GraphQL/Settings.graphql +11 -0
  211. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/ProfileSettingsScreen.swift +30 -0
  212. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/ProfileSettingsViewModel.swift +51 -0
  213. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/SettingsAPI.swift +37 -0
  214. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/SettingsClient.swift +40 -0
  215. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/SettingsHomeScreen.swift +39 -0
  216. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/organisms/DeleteAccountButton.swift +30 -0
  217. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/organisms/SettingsRow.swift +56 -0
  218. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/organisms/SignOutButton.swift +23 -0
  219. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/pages/ChangelogPage.swift +59 -0
  220. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/pages/FeedbackSheet.swift +118 -0
  221. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/pages/ProfileSettingsPage.swift +90 -0
  222. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/pages/SettingsHomePage.swift +70 -0
  223. package/templates/ios/ios/{{APP_NAME}}/Info.plist +14 -0
  224. package/templates/ios/ios/{{APP_NAME}}/Localizable.xcstrings +1030 -0
  225. package/templates/ios/ios/{{APP_NAME}}/PrivacyInfo.xcprivacy +122 -0
  226. package/templates/ios/ios/{{APP_NAME}}/Shared/APIClient.swift +23 -0
  227. package/templates/ios/ios/{{APP_NAME}}/Shared/AcceptLanguageInterceptor.swift +20 -0
  228. package/templates/ios/ios/{{APP_NAME}}/Shared/Components/atoms/AsyncButton.swift +73 -0
  229. package/templates/ios/ios/{{APP_NAME}}/Shared/Components/atoms/AsyncToolbarButton.swift +69 -0
  230. package/templates/ios/ios/{{APP_NAME}}/Shared/Components/atoms/ToolbarIconButton.swift +46 -0
  231. package/templates/ios/ios/{{APP_NAME}}/Shared/Components/molecules/EmptyState.swift +132 -0
  232. package/templates/ios/ios/{{APP_NAME}}/Shared/Components/molecules/LabeledInfoRow.swift +29 -0
  233. package/templates/ios/ios/{{APP_NAME}}/Shared/Components/styles/CaptionLabelStyle.swift +45 -0
  234. package/templates/ios/ios/{{APP_NAME}}/Shared/Components/styles/Copyable.swift +32 -0
  235. package/templates/ios/ios/{{APP_NAME}}/Shared/Components/styles/EdgeToEdgeSeparator.swift +14 -0
  236. package/templates/ios/ios/{{APP_NAME}}/Shared/Components/styles/RowLabelStyle.swift +58 -0
  237. package/templates/ios/ios/{{APP_NAME}}/Shared/CursorPage.swift +26 -0
  238. package/templates/ios/ios/{{APP_NAME}}/Shared/DataChanges.swift +19 -0
  239. package/templates/ios/ios/{{APP_NAME}}/Shared/ErrorPresenter.swift +62 -0
  240. package/templates/ios/ios/{{APP_NAME}}/Shared/ErrorReporting.swift +35 -0
  241. package/templates/ios/ios/{{APP_NAME}}/Shared/FeedbackReporting.swift +42 -0
  242. package/templates/ios/ios/{{APP_NAME}}/Shared/FirebaseTokenInterceptor.swift +49 -0
  243. package/templates/ios/ios/{{APP_NAME}}/Shared/GraphQLClient.swift +54 -0
  244. package/templates/ios/ios/{{APP_NAME}}/Shared/GraphQLHelpers.swift +128 -0
  245. package/templates/ios/ios/{{APP_NAME}}/Shared/GraphQLLoggingInterceptor.swift +28 -0
  246. package/templates/ios/ios/{{APP_NAME}}/Shared/LoadGate.swift +30 -0
  247. package/templates/ios/ios/{{APP_NAME}}/Shared/Secrets.swift.example +9 -0
  248. package/templates/ios/ios/{{APP_NAME}}/Shared/Services/AppSupportGate.swift +50 -0
  249. package/templates/ios/ios/{{APP_NAME}}/Shared/Shell/StartupLoadingView.swift +23 -0
  250. package/templates/ios/ios/{{APP_NAME}}/Shared/Shell/UpdateRequiredView.swift +24 -0
  251. package/templates/ios/ios/{{APP_NAME}}/Shared/Showcase.swift +13 -0
  252. package/templates/ios/ios/{{APP_NAME}}/Shared/SnapshotCache.swift +78 -0
  253. package/templates/ios/ios/{{APP_NAME}}/Shared/UITestEnvironment.swift +70 -0
  254. package/templates/ios/ios/{{APP_NAME}}/{{APP_NAME}}.entitlements +10 -0
  255. package/templates/ios/ios/{{APP_NAME}}Tests/Onboarding/OnboardingGateTests.swift +38 -0
  256. package/templates/ios/ios/{{APP_NAME}}Tests/Settings/SettingsViewModelTests.swift +63 -0
  257. package/templates/ios/ios/{{APP_NAME}}Tests/Support/TestDoubles.swift +35 -0
  258. package/templates/ios/package.merge.json +10 -0
  259. package/templates/ios/scripts/apollo-codegen.ts +31 -0
  260. package/templates/ios/scripts/check-ios-components.ts +72 -0
  261. package/templates/ios/scripts/check-strings.ts +103 -0
  262. package/templates/ios/scripts/install-device.sh +29 -0
  263. package/templates/ios/scripts/testflight.ts +213 -0
@@ -0,0 +1,139 @@
1
+ // Per-request memoization: a domain query called several times within the same
2
+ // HTTP request reads once. The cache lives on the H3 event context, so it is
3
+ // scoped to the current request and discarded when the request ends — no
4
+ // cross-request state, no staleness. Requires `experimental.asyncContext`.
5
+ //
6
+ // Caveat: the cache is held for the whole request, so a flow that reads, writes,
7
+ // then reads again would see pre-write state. Every repository that memoizes a
8
+ // read therefore evicts or revises it on write: the app sends a whole screen in
9
+ // one document, and a mutation document can carry several writes in a row (the
10
+ // book sheet corrects, then rates), each of which must see the one before.
11
+ //
12
+ // Shared in flight: the app opens on about ten requests at once, and each of
13
+ // them used to scan the same library — some 230 documents a request, decoded ten
14
+ // times over by the one instance serving them all. A read asked with
15
+ // `shareInFlight` is shared by the requests asking the same key while it is in
16
+ // flight, and forgotten the moment it answers: nothing is kept between requests.
17
+ //
18
+ // Sharing must not cost a reader their own writes. A request sent once a
19
+ // mutation answered has to see it, as a read of its own would, but a read in
20
+ // flight may have started before that mutation committed — on another instance,
21
+ // where nothing tells. So a request joins only a read started after it arrived,
22
+ // or less than `JOINABLE_BEFORE_ARRIVAL_MS` before: shorter than any round trip
23
+ // from a phone, so the read started after anything the phone saw committed. On
24
+ // this instance a write also forgets the read in flight under its key, and the
25
+ // request that wrote never joins one: it reads on its own, as it always did.
26
+
27
+ const JOINABLE_BEFORE_ARRIVAL_MS = 10
28
+
29
+ type RequestContext = {
30
+ _queryCache?: Record<string, unknown>
31
+ _writtenKeys?: Set<string>
32
+ /** When this request first used the cache: no earlier than it arrived. */
33
+ _arrivedAt?: number
34
+ }
35
+
36
+ const contextOrUndefined = (): RequestContext | undefined => {
37
+ try {
38
+ return useEvent().context as RequestContext
39
+ } catch {
40
+ // No request context (e.g. migration scripts, tests).
41
+ return undefined
42
+ }
43
+ }
44
+
45
+ /** The reads in flight that concurrent requests share, by key. */
46
+ const inFlight = new Map<string, { read: Promise<unknown>; startedAt: number }>()
47
+
48
+ const sharedInFlight = <T>(key: string, read: () => Promise<T>, arrivedAt: number): Promise<T> => {
49
+ const pending = inFlight.get(key)
50
+ if (pending && pending.startedAt >= arrivedAt - JOINABLE_BEFORE_ARRIVAL_MS)
51
+ return pending.read as Promise<T>
52
+ const started = read().finally(() => {
53
+ if (inFlight.get(key)?.read === started) inFlight.delete(key)
54
+ })
55
+ inFlight.set(key, { read: started, startedAt: performance.now() })
56
+ return started
57
+ }
58
+
59
+ export const memoizedPerRequest = <T>(
60
+ key: string,
61
+ fn: () => T,
62
+ options: { shareInFlight?: boolean } = {},
63
+ ): T => {
64
+ const context = contextOrUndefined()
65
+ // No request context — run uncached.
66
+ if (!context) return fn()
67
+ context._arrivedAt ??= performance.now()
68
+ if (!context._queryCache) context._queryCache = {}
69
+ const cache = context._queryCache as Record<string, T>
70
+ if (!(key in cache))
71
+ cache[key] =
72
+ options.shareInFlight && !context._writtenKeys?.has(key)
73
+ ? (sharedInFlight(key, fn as () => Promise<unknown>, context._arrivedAt) as T)
74
+ : fn()
75
+ return cache[key]
76
+ }
77
+
78
+ /** A write under the key: the read in flight may hold what stood before it,
79
+ * so no request joins it any more, and this one never joins one again. */
80
+ const markWritten = (key: string) => {
81
+ inFlight.delete(key)
82
+ const context = contextOrUndefined()
83
+ if (!context) return
84
+ if (!context._writtenKeys) context._writtenKeys = new Set()
85
+ context._writtenKeys.add(key)
86
+ }
87
+
88
+ export const isInRequestCache = (key: string): boolean => {
89
+ try {
90
+ const event = useEvent()
91
+ return Boolean(
92
+ event.context._queryCache && key in (event.context._queryCache as Record<string, unknown>),
93
+ )
94
+ } catch {
95
+ return false
96
+ }
97
+ }
98
+
99
+ export const evictFromRequestCache = (key: string) => {
100
+ markWritten(key)
101
+ const context = contextOrUndefined()
102
+ if (context?._queryCache) delete context._queryCache[key]
103
+ }
104
+
105
+ /** Remember what a direct write just stored, so the rest of the request reads
106
+ * it without asking Firestore again. Only for a write already applied: a
107
+ * batched one is not visible until its commit, and must evict instead. */
108
+ export const rememberInRequestCache = <T>(key: string, value: T) => {
109
+ evictFromRequestCache(key)
110
+ memoizedPerRequest(key, () => value)
111
+ }
112
+
113
+ /** Apply a write to a memoized value rather than dropping it, so the rest of the
114
+ * request reads what was written without paying the read again. A no-op when
115
+ * nothing is memoized under the key. */
116
+ export const reviseInRequestCache = <T>(key: string, revise: (value: T) => T) => {
117
+ markWritten(key)
118
+ const cache = contextOrUndefined()?._queryCache as Record<string, T> | undefined
119
+ if (cache && key in cache) cache[key] = revise(cache[key])
120
+ }
121
+
122
+ /** Run `fn` with a cache of its own, dropped when it returns. For a request that
123
+ * walks many accounts in turn — the nightly sync — which would otherwise hold
124
+ * every library it passed over until the very end. */
125
+ export const withRequestCacheScope = async <T>(fn: () => Promise<T>): Promise<T> => {
126
+ let event: ReturnType<typeof useEvent>
127
+ try {
128
+ event = useEvent()
129
+ } catch {
130
+ return fn()
131
+ }
132
+ const outer = event.context._queryCache
133
+ event.context._queryCache = {}
134
+ try {
135
+ return await fn()
136
+ } finally {
137
+ event.context._queryCache = outer
138
+ }
139
+ }
@@ -0,0 +1,166 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+ import { AsyncLocalStorage } from 'node:async_hooks'
3
+ import {
4
+ evictFromRequestCache,
5
+ isInRequestCache,
6
+ memoizedPerRequest,
7
+ reviseInRequestCache,
8
+ } from './request-cache'
9
+
10
+ // The request cache reads the current H3 event via the auto-imported useEvent().
11
+ // In unit tests there is no request, so we stub a fake event on the global.
12
+ const withRequest = (context: Record<string, unknown> = {}) => {
13
+ ;(globalThis as unknown as { useEvent: () => unknown }).useEvent = () => ({ context })
14
+ }
15
+
16
+ describe('memoizedPerRequest', () => {
17
+ afterEach(() => {
18
+ delete (globalThis as unknown as { useEvent?: unknown }).useEvent
19
+ })
20
+
21
+ test('runs the factory once and reuses the value within a request', () => {
22
+ withRequest()
23
+ let calls = 0
24
+ const read = () => ++calls
25
+ expect(memoizedPerRequest('k', read)).toBe(1)
26
+ expect(memoizedPerRequest('k', read)).toBe(1)
27
+ expect(calls).toBe(1)
28
+ })
29
+
30
+ test('caches the promise so concurrent reads dedupe', async () => {
31
+ withRequest()
32
+ let calls = 0
33
+ const read = () => {
34
+ calls++
35
+ return Promise.resolve('value')
36
+ }
37
+ const [a, b] = await Promise.all([memoizedPerRequest('p', read), memoizedPerRequest('p', read)])
38
+ expect(a).toBe('value')
39
+ expect(b).toBe('value')
40
+ expect(calls).toBe(1)
41
+ })
42
+
43
+ test('different keys are cached independently', () => {
44
+ withRequest()
45
+ let calls = 0
46
+ memoizedPerRequest('a', () => ++calls)
47
+ memoizedPerRequest('b', () => ++calls)
48
+ expect(calls).toBe(2)
49
+ })
50
+
51
+ test('isInRequestCache and evictFromRequestCache track entries', () => {
52
+ withRequest()
53
+ memoizedPerRequest('k', () => 1)
54
+ expect(isInRequestCache('k')).toBe(true)
55
+ evictFromRequestCache('k')
56
+ expect(isInRequestCache('k')).toBe(false)
57
+ })
58
+
59
+ test('runs uncached when there is no request context', () => {
60
+ let calls = 0
61
+ memoizedPerRequest('k', () => ++calls)
62
+ memoizedPerRequest('k', () => ++calls)
63
+ expect(calls).toBe(2)
64
+ expect(isInRequestCache('k')).toBe(false)
65
+ })
66
+ })
67
+
68
+ describe('memoizedPerRequest shared in flight', () => {
69
+ // Each call runs as its own request, as the app's requests sent together do.
70
+ const requests = new AsyncLocalStorage<Record<string, unknown>>()
71
+ const inRequest = <T>(call: () => Promise<T>) => requests.run({}, call)
72
+
73
+ beforeEach(() => {
74
+ ;(globalThis as unknown as { useEvent: () => unknown }).useEvent = () => {
75
+ const context = requests.getStore()
76
+ if (!context) throw new Error('no request')
77
+ return { context }
78
+ }
79
+ })
80
+
81
+ afterEach(() => {
82
+ delete (globalThis as unknown as { useEvent?: unknown }).useEvent
83
+ })
84
+
85
+ /** A read that answers when `answer` is called, counting how often it ran. */
86
+ const pendingRead = () => {
87
+ const read = { calls: 0, answer: (_value: string) => {} }
88
+ const run = () => {
89
+ read.calls++
90
+ return new Promise<string>((resolve) => {
91
+ read.answer = resolve
92
+ })
93
+ }
94
+ return { read, run }
95
+ }
96
+
97
+ const sharing = { shareInFlight: true }
98
+
99
+ test('requests sent together share one read while it is in flight', async () => {
100
+ const { read, run } = pendingRead()
101
+ const first = inRequest(() => memoizedPerRequest('library', run, sharing))
102
+ const second = inRequest(() => memoizedPerRequest('library', run, sharing))
103
+ read.answer('shelf')
104
+ expect(await Promise.all([first, second])).toEqual(['shelf', 'shelf'])
105
+ expect(read.calls).toBe(1)
106
+ })
107
+
108
+ test('a read that answered is not kept for the next request', async () => {
109
+ let calls = 0
110
+ const run = async () => `shelf ${++calls}`
111
+ expect(await inRequest(() => memoizedPerRequest('library', run, sharing))).toBe('shelf 1')
112
+ expect(await inRequest(() => memoizedPerRequest('library', run, sharing))).toBe('shelf 2')
113
+ })
114
+
115
+ test('a request arriving well after a read started reads on its own', async () => {
116
+ const { read, run } = pendingRead()
117
+ const early = inRequest(() => memoizedPerRequest('library', run, sharing))
118
+ await Bun.sleep(30)
119
+ const late = inRequest(() => memoizedPerRequest('library', async () => 'its own', sharing))
120
+ read.answer('started early')
121
+ expect(await Promise.all([early, late])).toEqual(['started early', 'its own'])
122
+ })
123
+
124
+ test('a write forgets the read in flight for the requests after it', async () => {
125
+ const { read, run } = pendingRead()
126
+ const before = inRequest(() => memoizedPerRequest('library', run, sharing))
127
+ await inRequest(async () => evictFromRequestCache('library'))
128
+ const after = inRequest(() => memoizedPerRequest('library', async () => 'fresh', sharing))
129
+ read.answer('stale')
130
+ expect(await Promise.all([before, after])).toEqual(['stale', 'fresh'])
131
+ })
132
+
133
+ test('a request that wrote never joins a read started before its write', async () => {
134
+ const { read, run } = pendingRead()
135
+ const before = inRequest(() => memoizedPerRequest('library', run, sharing))
136
+ const writer = inRequest(async () => {
137
+ reviseInRequestCache('library', (shelf: string) => shelf)
138
+ return memoizedPerRequest('library', async () => 'after the write', sharing)
139
+ })
140
+ read.answer('before the write')
141
+ expect(await Promise.all([before, writer])).toEqual(['before the write', 'after the write'])
142
+ })
143
+
144
+ test('an evicted key is read again by that request on its own', async () => {
145
+ const { read, run } = pendingRead()
146
+ const other = inRequest(() => memoizedPerRequest('library', run, sharing))
147
+ const writer = inRequest(async () => {
148
+ evictFromRequestCache('library')
149
+ return memoizedPerRequest('library', async () => 'fresh', sharing)
150
+ })
151
+ read.answer('stale')
152
+ expect(await Promise.all([other, writer])).toEqual(['stale', 'fresh'])
153
+ expect(read.calls).toBe(1)
154
+ })
155
+
156
+ test('nothing is shared without a request context', async () => {
157
+ delete (globalThis as unknown as { useEvent?: unknown }).useEvent
158
+ let calls = 0
159
+ const run = async () => ++calls
160
+ await Promise.all([
161
+ memoizedPerRequest('library', run, sharing),
162
+ memoizedPerRequest('library', run, sharing),
163
+ ])
164
+ expect(calls).toBe(2)
165
+ })
166
+ })
@@ -0,0 +1,49 @@
1
+ import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
2
+
3
+ /** Authenticated encryption for the few secrets {{APP_NAME}} has to keep on behalf of a
4
+ * reader rather than about them — today, the Amazon device credentials an Audible
5
+ * connection is made of.
6
+ *
7
+ * A Firestore export is a plausible way for those to leak, and a leaked refresh
8
+ * token plus device key is a standing grant on somebody's Amazon account. They
9
+ * are therefore never written in the clear: the key lives in Secret Manager and
10
+ * reaches the function as an environment variable, so reading the database is not
11
+ * enough to use what it holds.
12
+ *
13
+ * AES-256-GCM rather than CBC: the tag makes a tampered payload fail to open
14
+ * instead of decrypting into garbage the caller then has to recognize. */
15
+
16
+ const ALGORITHM = 'aes-256-gcm'
17
+ const KEY_BYTES = 32
18
+ const IV_BYTES = 12
19
+ /** Versioned so a future key rotation can tell the two formats apart rather than
20
+ * guessing from the field's shape. */
21
+ const FORMAT = 'v1'
22
+
23
+ export const encryptionKeyOf = (base64Key: string): Buffer => {
24
+ const key = Buffer.from(base64Key, 'base64')
25
+ if (key.length !== KEY_BYTES)
26
+ throw new Error(`encryption key must be ${KEY_BYTES} bytes, base64-encoded`)
27
+ return key
28
+ }
29
+
30
+ export const seal = (plaintext: string, key: Buffer): string => {
31
+ const iv = randomBytes(IV_BYTES)
32
+ const cipher = createCipheriv(ALGORITHM, key, iv)
33
+ const sealed = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
34
+ return [FORMAT, iv, cipher.getAuthTag(), sealed]
35
+ .map((part) => (typeof part === 'string' ? part : part.toString('base64url')))
36
+ .join('.')
37
+ }
38
+
39
+ export const unseal = (sealed: string, key: Buffer): string => {
40
+ const [format, iv, tag, payload] = sealed.split('.')
41
+ if (format !== FORMAT || !iv || !tag || !payload)
42
+ throw new Error('sealed value is not in the expected format')
43
+ const decipher = createDecipheriv(ALGORITHM, key, Buffer.from(iv, 'base64url'))
44
+ decipher.setAuthTag(Buffer.from(tag, 'base64url'))
45
+ return Buffer.concat([
46
+ decipher.update(Buffer.from(payload, 'base64url')),
47
+ decipher.final(),
48
+ ]).toString('utf8')
49
+ }
@@ -0,0 +1,46 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { randomBytes } from 'node:crypto'
3
+ import { encryptionKeyOf, seal, unseal } from '~/system/secret-box'
4
+
5
+ const key = randomBytes(32)
6
+
7
+ describe('sealing a secret', () => {
8
+ test('reads back exactly what was sealed', () => {
9
+ const secret = JSON.stringify({ refreshToken: 'Atnr|…', serial: 'ABC123' })
10
+
11
+ expect(unseal(seal(secret, key), key)).toBe(secret)
12
+ })
13
+
14
+ // Same plaintext twice must not produce the same ciphertext, or a Firestore
15
+ // export would show at a glance which accounts share a value.
16
+ test('seals the same value differently every time', () => {
17
+ expect(seal('same', key)).not.toBe(seal('same', key))
18
+ })
19
+
20
+ test('refuses to open a payload that was tampered with', () => {
21
+ const sealed = seal('same', key)
22
+ const [format, iv, tag, payload] = sealed.split('.')
23
+
24
+ expect(() => unseal(`${format}.${iv}.${tag}.${payload}00`, key)).toThrow()
25
+ })
26
+
27
+ test('refuses to open with another key', () => {
28
+ expect(() => unseal(seal('same', key), randomBytes(32))).toThrow()
29
+ })
30
+
31
+ test('refuses a value that is not in the sealed format', () => {
32
+ expect(() => unseal('plain text', key)).toThrow('sealed value is not in the expected format')
33
+ })
34
+ })
35
+
36
+ describe('reading the key', () => {
37
+ test('accepts a base64 256-bit key', () => {
38
+ expect(encryptionKeyOf(randomBytes(32).toString('base64'))).toHaveLength(32)
39
+ })
40
+
41
+ test('refuses a key of the wrong length, rather than padding it', () => {
42
+ expect(() => encryptionKeyOf(randomBytes(16).toString('base64'))).toThrow(
43
+ 'encryption key must be 32 bytes',
44
+ )
45
+ })
46
+ })
@@ -0,0 +1,12 @@
1
+ // Static import only — `await import()` of this module hangs: the `then`
2
+ // export makes the module namespace a thenable that never resolves.
3
+ import { describe, test } from 'bun:test'
4
+
5
+ export const feature = describe
6
+ export const scenario = test
7
+
8
+ export const given = (description: string) => console.log(` Given ${description}`)
9
+ export const when = (description: string) => console.log(` When ${description}`)
10
+ // biome-ignore lint/suspicious/noThenProperty: BDD DSL function, not a thenable
11
+ export const then = (description: string) => console.log(` Then ${description}`)
12
+ export const and = (description: string) => console.log(` And ${description}`)