@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,17 @@
1
+ /**
2
+ * Oldest iOS build the currently deployed GraphQL schema still supports.
3
+ *
4
+ * The build number is `CFBundleVersion`, set by CI to `git rev-list --count HEAD`
5
+ * at tag time, so it grows monotonically with every commit. Builds below this
6
+ * floor are shown a blocking "update required" screen by the app (see
7
+ * `GET /app-config` and `AppSupportGate.swift`).
8
+ *
9
+ * Raised only by the removal commit of the deprecation routine, to the value
10
+ * `bun run deprecations` proposes: a build that no longer asks for what is
11
+ * removed, on the App Store for two weeks. The Deploy workflow refuses a floor
12
+ * above the build on sale — see docs/api-evolution.md.
13
+ */
14
+ export const MINIMUM_SUPPORTED_IOS_BUILD = 1
15
+
16
+ /** Direct App Store link, opened by the update screen. */
17
+ export const APP_STORE_URL = 'https://apps.apple.com/app/id{{APP_STORE_ID}}'
@@ -0,0 +1,18 @@
1
+ import { AdminToken, PublicBaseUrl, SentryDsn, SentryRelease } from '~/system/config/primitives'
2
+
3
+ /** Every value the server reads from its environment, validated once. A new
4
+ * variable is added here, in nitro.config.ts, in .env.example, in
5
+ * infra/variables.tf and in the deploy workflow, in the same commit. */
6
+ export const config = () => {
7
+ const runtimeConfig = useRuntimeConfig()
8
+ return {
9
+ adminToken: runtimeConfig.adminToken ? AdminToken(runtimeConfig.adminToken) : undefined,
10
+ sentryDsn: runtimeConfig.sentryDsn ? SentryDsn(runtimeConfig.sentryDsn) : undefined,
11
+ sentryRelease: runtimeConfig.sentryRelease
12
+ ? SentryRelease(runtimeConfig.sentryRelease)
13
+ : undefined,
14
+ devUserId: runtimeConfig.devUserId || undefined,
15
+ gcpProjectId: runtimeConfig.firebaseProjectId || undefined,
16
+ publicBaseUrl: PublicBaseUrl(runtimeConfig.publicBaseUrl),
17
+ }
18
+ }
@@ -0,0 +1,20 @@
1
+ import { make } from 'ts-brand'
2
+ import { z } from 'zod'
3
+ import type {
4
+ AdminToken as AdminTokenType,
5
+ PublicBaseUrl as PublicBaseUrlType,
6
+ SentryDsn as SentryDsnType,
7
+ SentryRelease as SentryReleaseType,
8
+ } from '~/system/config/types'
9
+
10
+ export const AdminToken = (value: unknown) => make<AdminTokenType>()(z.string().min(1).parse(value))
11
+
12
+ // Any non-empty string: Sentry.init validates the DSN itself (warns and disables
13
+ // on a bad one) rather than throwing, so a malformed value cannot take config() down.
14
+ export const SentryDsn = (value: unknown) => make<SentryDsnType>()(z.string().min(1).parse(value))
15
+
16
+ export const SentryRelease = (value: unknown) =>
17
+ make<SentryReleaseType>()(z.string().min(1).parse(value))
18
+
19
+ export const PublicBaseUrl = (value: unknown) =>
20
+ make<PublicBaseUrlType>()(z.string().url().parse(value))
@@ -0,0 +1,13 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { AdminToken, PublicBaseUrl } from '~/system/config/primitives'
3
+
4
+ describe('config primitives', () => {
5
+ test('an admin token is any non-empty string', () => {
6
+ expect(AdminToken('x') as string).toBe('x')
7
+ expect(() => AdminToken('')).toThrow()
8
+ })
9
+ test('the public base url must be a url', () => {
10
+ expect(() => PublicBaseUrl('localhost')).toThrow()
11
+ expect(PublicBaseUrl('http://127.0.0.1:3000') as string).toBe('http://127.0.0.1:3000')
12
+ })
13
+ })
@@ -0,0 +1,8 @@
1
+ import type { Brand } from 'ts-brand'
2
+
3
+ export type AdminToken = Brand<string, 'AdminToken'>
4
+ export type SentryDsn = Brand<string, 'SentryDsn'>
5
+ /** The deployed revision, as `<app>-node@<git sha>`: ties errors to source maps. */
6
+ export type SentryRelease = Brand<string, 'SentryRelease'>
7
+ /** Development only: the origin this server is reachable at from the simulator. */
8
+ export type PublicBaseUrl = Brand<string, 'PublicBaseUrl'>
@@ -0,0 +1,19 @@
1
+ import { getApps, initializeApp } from 'firebase-admin/app'
2
+ import {
3
+ DocumentReference,
4
+ Firestore,
5
+ getFirestore,
6
+ Query,
7
+ Transaction,
8
+ } from 'firebase-admin/firestore'
9
+ import { meterFirestoreReads } from '~/system/read-meter'
10
+
11
+ if (getApps().length === 0) initializeApp()
12
+
13
+ export const db = () => getFirestore()
14
+
15
+ // The client exports `AggregateQuery` as a type only; its class is taken from
16
+ // an instance, which `count()` builds without any network call.
17
+ const AggregateQuery = Object.getPrototypeOf(db().collection('_').count()).constructor
18
+
19
+ meterFirestoreReads({ Firestore, Query, AggregateQuery, Transaction, DocumentReference })
@@ -0,0 +1,18 @@
1
+ import { getAuth } from 'firebase-admin/auth'
2
+
3
+ // The auth identity provider (Firebase Auth), kept separate from the Firestore
4
+ // module so the "Firestore only through repositories" boundary stays about data.
5
+ // getAuth() resolves the default app that ~/system/firebase initializes; the user
6
+ // repository imports both, so the app is always initialized before this runs.
7
+
8
+ // Delete the Firebase Auth user — the identity behind an account deletion.
9
+ // Idempotent: an already-absent user (a retried deletion) counts as done rather
10
+ // than an error, so the account wipe can be replayed safely after a partial failure.
11
+ export const deleteAuthUser = async (uid: string): Promise<void> => {
12
+ try {
13
+ await getAuth().deleteUser(uid)
14
+ } catch (error) {
15
+ if ((error as { code?: string }).code === 'auth/user-not-found') return
16
+ throw error
17
+ }
18
+ }
@@ -0,0 +1,119 @@
1
+ import * as Sentry from '@sentry/node'
2
+ import { consola } from 'consola'
3
+
4
+ /** What a log line carries besides its message: the error that caused it, and
5
+ * whatever identifies the case — a reader, an ISBN, a saga. Kept apart from
6
+ * the message so the message stays the same from one case to the next, and
7
+ * Sentry gathers every occurrence of a problem into one issue. */
8
+ export type LogContext = { error?: unknown } & Record<string, unknown>
9
+
10
+ export type Report = (
11
+ level: 'warning' | 'error',
12
+ tag: string,
13
+ message: string,
14
+ context: LogContext,
15
+ ) => void
16
+
17
+ let reportedSinceFlush = false
18
+
19
+ /** Every warning and error goes to Sentry, not only to the logs: a problem the
20
+ * code catches and recovers from — a model call that failed, a cover lookup
21
+ * that answered 500, a dashboard left stale — is still a problem, and one
22
+ * nobody reads the logs for. An error the line carries is reported with its
23
+ * stack; a line with none is reported as a message. An error naming its own
24
+ * `fingerprint` is one problem wherever it is caught, and is grouped so. A no-op until the Sentry
25
+ * plugin initialized the SDK, so a local run reports nothing. */
26
+ const sentryReport: Report = (level, tag, message, { error, ...extra }) => {
27
+ reportedSinceFlush = true
28
+ Sentry.withScope((scope) => {
29
+ scope.setLevel(level)
30
+ scope.setTag('logger', tag)
31
+ scope.setExtras({ ...extra, message })
32
+ const fingerprint = (error as { fingerprint?: unknown } | undefined)?.fingerprint
33
+ if (Array.isArray(fingerprint)) scope.setFingerprint(fingerprint)
34
+ if (error instanceof Error) Sentry.captureException(error)
35
+ else {
36
+ if (error !== undefined) scope.setExtra('error', String(error))
37
+ Sentry.captureMessage(`[${tag}] ${message}`)
38
+ }
39
+ })
40
+ }
41
+
42
+ /** Sends what the logger reported before the instance loses its CPU: Cloud
43
+ * Functions throttles it once the response is out, and a queued event would
44
+ * then never leave. Waits on nothing when nothing was reported. */
45
+ export const flushReports = async (): Promise<void> => {
46
+ if (!reportedSinceFlush) return
47
+ reportedSinceFlush = false
48
+ await Sentry.flush(2000)
49
+ }
50
+
51
+ export type Severity = 'INFO' | 'WARNING' | 'ERROR'
52
+
53
+ const serializedError = (error: unknown) =>
54
+ error instanceof Error
55
+ ? { name: error.name, message: error.message, stack: error.stack }
56
+ : String(error)
57
+
58
+ /** One log as the single JSON line Cloud Logging parses into a structured
59
+ * entry: `severity` makes `severity>=WARNING` work, the context keys land
60
+ * under `jsonPayload` where they can be filtered and summed, and the stack
61
+ * travels inside the one entry instead of being split into one per line. */
62
+ export const structuredLine = (
63
+ severity: Severity,
64
+ tag: string,
65
+ message: string,
66
+ { error, ...context }: LogContext = {},
67
+ ): string => {
68
+ const entry = {
69
+ ...context,
70
+ ...(error === undefined ? {} : { error: serializedError(error) }),
71
+ severity,
72
+ message: `[${tag}] ${message}`,
73
+ tag,
74
+ }
75
+ try {
76
+ return JSON.stringify(entry)
77
+ } catch {
78
+ // A context that cannot be serialized (a cycle, a bigint) still logs its message.
79
+ return JSON.stringify({ severity, message: entry.message, tag })
80
+ }
81
+ }
82
+
83
+ type Write = (severity: Severity, tag: string, message: string, context?: LogContext) => void
84
+
85
+ const prettyWrite = (tag: string): Write => {
86
+ const logger = consola.withTag(tag)
87
+ return (severity, _tag, message, context) => {
88
+ const rest = context === undefined ? [] : [context]
89
+ if (severity === 'INFO') logger.info(message, ...rest)
90
+ else if (severity === 'WARNING') logger.warn(message, ...rest)
91
+ else logger.error(message, ...rest)
92
+ }
93
+ }
94
+
95
+ const structuredWrite: Write = (severity, tag, message, context) => {
96
+ process.stdout.write(`${structuredLine(severity, tag, message, context)}\n`)
97
+ }
98
+
99
+ /** A built bundle writes JSON lines for Cloud Logging; a dev server keeps
100
+ * consola's pretty output. `import.meta.dev` is compiled to `false` in a
101
+ * build and left undefined under `bun test`, which keeps the pretty output. */
102
+ const writeFor = (tag: string): Write =>
103
+ import.meta.dev === false ? structuredWrite : prettyWrite(tag)
104
+
105
+ export const createLogger = (
106
+ tag: string,
107
+ report: Report = sentryReport,
108
+ write: Write = writeFor(tag),
109
+ ) => ({
110
+ info: (message: string, context?: LogContext) => write('INFO', tag, message, context),
111
+ warn: (message: string, context?: LogContext) => {
112
+ write('WARNING', tag, message, context)
113
+ report('warning', tag, message, context ?? {})
114
+ },
115
+ error: (message: string, context?: LogContext) => {
116
+ write('ERROR', tag, message, context)
117
+ report('error', tag, message, context ?? {})
118
+ },
119
+ })
@@ -0,0 +1,109 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { createLogger, type LogContext, structuredLine } from '~/system/logger'
3
+
4
+ const recorded = () => {
5
+ const reports: { level: string; tag: string; message: string; context: LogContext }[] = []
6
+ const logger = createLogger(
7
+ 'scan',
8
+ (level, tag, message, context) => reports.push({ level, tag, message, context }),
9
+ () => {},
10
+ )
11
+ return { logger, reports }
12
+ }
13
+
14
+ describe('the logger', () => {
15
+ // A problem the code recovers from is still one somebody has to hear about.
16
+ test('reports every warning and error, with its error and context', () => {
17
+ const { logger, reports } = recorded()
18
+ const cause = new Error('Gemini returned no content')
19
+
20
+ logger.warn('catalogue failed', { error: cause, series: 'Dune' })
21
+ logger.error('scan failed')
22
+
23
+ expect(reports).toEqual([
24
+ {
25
+ level: 'warning',
26
+ tag: 'scan',
27
+ message: 'catalogue failed',
28
+ context: { error: cause, series: 'Dune' },
29
+ },
30
+ { level: 'error', tag: 'scan', message: 'scan failed', context: {} },
31
+ ])
32
+ })
33
+
34
+ test('reports nothing for information', () => {
35
+ const { logger, reports } = recorded()
36
+
37
+ logger.info('usage recorded', { tokens: 12 })
38
+
39
+ expect(reports).toEqual([])
40
+ })
41
+ })
42
+
43
+ describe('a structured log line', () => {
44
+ // Cloud Logging reads one JSON object per line: severity drives `severity>=WARNING`,
45
+ // and every context key becomes a `jsonPayload` field.
46
+ test('carries the severity, the tagged message and the context as fields', () => {
47
+ const line = structuredLine('INFO', 'scan', 'gemini usage', { step: 'cover', searches: 4 })
48
+
49
+ expect(line).not.toContain('\n')
50
+ expect(JSON.parse(line)).toEqual({
51
+ step: 'cover',
52
+ searches: 4,
53
+ severity: 'INFO',
54
+ message: '[scan] gemini usage',
55
+ tag: 'scan',
56
+ })
57
+ })
58
+
59
+ test('keeps the error and its stack in the one entry', () => {
60
+ const cause = new TypeError('fetch failed')
61
+
62
+ const entry = JSON.parse(structuredLine('ERROR', 'scan', 'scan failed', { error: cause }))
63
+
64
+ expect(entry.severity).toBe('ERROR')
65
+ expect(entry.error).toEqual({ name: 'TypeError', message: 'fetch failed', stack: cause.stack })
66
+ })
67
+
68
+ test('writes an error that is not an Error as text', () => {
69
+ const entry = JSON.parse(structuredLine('WARNING', 'scan', 'odd', { error: 404 }))
70
+
71
+ expect(entry.error).toBe('404')
72
+ })
73
+
74
+ test('never lets the context overwrite the severity or the message', () => {
75
+ const entry = JSON.parse(
76
+ structuredLine('WARNING', 'scan', 'odd', { severity: 'DEBUG', message: 'other' }),
77
+ )
78
+
79
+ expect(entry).toMatchObject({ severity: 'WARNING', message: '[scan] odd' })
80
+ })
81
+
82
+ test('still logs the message when the context cannot be serialized', () => {
83
+ const cycle: Record<string, unknown> = {}
84
+ cycle.self = cycle
85
+
86
+ expect(JSON.parse(structuredLine('INFO', 'scan', 'loop', { cycle }))).toEqual({
87
+ severity: 'INFO',
88
+ message: '[scan] loop',
89
+ tag: 'scan',
90
+ })
91
+ })
92
+ })
93
+
94
+ describe('the logger writing', () => {
95
+ test('writes every level with its severity', () => {
96
+ const written: string[] = []
97
+ const logger = createLogger(
98
+ 'scan',
99
+ () => {},
100
+ (severity, tag, message) => written.push(`${severity} ${tag} ${message}`),
101
+ )
102
+
103
+ logger.info('a')
104
+ logger.warn('b')
105
+ logger.error('c')
106
+
107
+ expect(written).toEqual(['INFO scan a', 'WARNING scan b', 'ERROR scan c'])
108
+ })
109
+ })
@@ -0,0 +1,6 @@
1
+ import type { Migration } from '~/system/migration/types'
2
+
3
+ // Forward-only, sequential, no rollback. Adding a new optional field or a new
4
+ // collection needs no migration; renaming, restructuring or removing stale data
5
+ // does. Each migration lives beside its .int.test.ts and is listed here in order.
6
+ export const migrations: Migration[] = []
@@ -0,0 +1,18 @@
1
+ import { make } from 'ts-brand'
2
+ import { z } from 'zod'
3
+ import type {
4
+ MigrationName as MigrationNameType,
5
+ MigrationVersion as MigrationVersionType,
6
+ } from '~/system/migration/types'
7
+
8
+ export const MigrationVersion = (value: unknown) => {
9
+ const validatedValue = z
10
+ .preprocess((v) => (typeof v === 'string' ? Number(v) : v), z.number().int().min(0))
11
+ .parse(value)
12
+ return make<MigrationVersionType>()(validatedValue)
13
+ }
14
+
15
+ export const MigrationName = (value: unknown) => {
16
+ const validatedValue = z.string().min(1).parse(value)
17
+ return make<MigrationNameType>()(validatedValue)
18
+ }
@@ -0,0 +1,52 @@
1
+ import { sortBy } from 'lodash-es'
2
+ import { db } from '~/system/firebase'
3
+ import { createLogger } from '~/system/logger'
4
+ import { MigrationVersion } from '~/system/migration/primitives'
5
+ import type { Migration, MigrationMeta } from '~/system/migration/types'
6
+
7
+ const log = createLogger('migration')
8
+
9
+ const META_COLLECTION = 'migration-meta'
10
+ const META_DOC_ID = 'state'
11
+
12
+ export const runMigrations = async (migrations: Migration[]) => {
13
+ const firestore = db()
14
+ const metaRef = firestore.collection(META_COLLECTION).doc(META_DOC_ID)
15
+
16
+ const snap = await metaRef.get()
17
+ const current = (snap.data() as MigrationMeta | undefined) ?? {
18
+ version: MigrationVersion(0),
19
+ appliedAt: new Date(0),
20
+ }
21
+ const fromVersion = current.version
22
+
23
+ const pending = sortBy(
24
+ migrations.filter((m) => m.version > fromVersion),
25
+ (m) => m.version,
26
+ )
27
+ if (pending.length === 0) return { outcome: 'up-to-date' as const }
28
+
29
+ for (const migration of pending) {
30
+ try {
31
+ const result = await migration.migrate({ db: firestore })
32
+ if (!result.ok)
33
+ return { outcome: 'failed' as const, version: migration.version, error: result.error }
34
+ await metaRef.set({ version: migration.version, appliedAt: new Date() })
35
+ // Only output of the runner: visible in Cloud Logging (CI reads the HTTP
36
+ // response of POST /admin/migrate instead).
37
+ log.info(
38
+ `Applied v${migration.version} "${migration.name}" (${result.transformed} transformed)`,
39
+ )
40
+ } catch (error) {
41
+ const message = error instanceof Error ? error.message : String(error)
42
+ return { outcome: 'failed' as const, version: migration.version, error: message }
43
+ }
44
+ }
45
+ const lastVersion = pending[pending.length - 1].version
46
+ return {
47
+ outcome: 'migrated' as const,
48
+ from: fromVersion,
49
+ to: lastVersion,
50
+ applied: pending.length,
51
+ }
52
+ }
@@ -0,0 +1,16 @@
1
+ import type { Firestore } from 'firebase-admin/firestore'
2
+ import type { Brand } from 'ts-brand'
3
+
4
+ export type MigrationVersion = Brand<number, 'MigrationVersion'>
5
+ export type MigrationName = Brand<string, 'MigrationName'>
6
+
7
+ export type MigrationContext = { db: Firestore }
8
+
9
+ export type Migration = {
10
+ version: MigrationVersion
11
+ name: MigrationName
12
+ migrate: (ctx: MigrationContext) => Promise<MigrationResult>
13
+ }
14
+
15
+ export type MigrationMeta = { version: MigrationVersion; appliedAt: Date }
16
+ export type MigrationResult = { ok: true; transformed: number } | { ok: false; error: string }
@@ -0,0 +1,100 @@
1
+ // Per-request count of the Firestore reads a request made, so a slow screen can
2
+ // be told apart from an expensive one. The meter lives on the H3 event context,
3
+ // like the request cache, so the requests an instance serves at once never add
4
+ // to each other's count. Requires `experimental.asyncContext`.
5
+ //
6
+ // Firestore has no read hook, so the count is taken by wrapping the client's
7
+ // public read methods once, at startup. Every read of the app goes through one
8
+ // of them: `DocumentReference.get()` is itself a `Firestore.getAll()` of one,
9
+ // and a transaction reads through its own methods, never through those.
10
+
11
+ export type ReadMeter = {
12
+ /** Documents fetched by reference: `get()`, `getAll()`, a transaction's read. */
13
+ docReads: number
14
+ /** Query round-trips, aggregations included. */
15
+ queryReads: number
16
+ /** Documents the queries returned — with `docReads`, what Firestore bills. */
17
+ queryDocs: number
18
+ }
19
+
20
+ const currentMeter = (): ReadMeter | undefined => {
21
+ try {
22
+ return useEvent().context._readMeter as ReadMeter | undefined
23
+ } catch {
24
+ // No request context (e.g. migration scripts, tests) — nothing to count into.
25
+ return undefined
26
+ }
27
+ }
28
+
29
+ /** Start counting the reads of the current request, from zero. Returns the
30
+ * meter, which keeps counting until the request ends. */
31
+ export const startReadMeter = (context: Record<string, unknown>): ReadMeter => {
32
+ const meter: ReadMeter = { docReads: 0, queryReads: 0, queryDocs: 0 }
33
+ context._readMeter = meter
34
+ return meter
35
+ }
36
+
37
+ type Read = (this: unknown, ...args: unknown[]) => Promise<unknown>
38
+ type Readable = { prototype: object }
39
+
40
+ /** What the wrapped methods read, told from their arguments and their answer. */
41
+ type Tally = (meter: ReadMeter, args: unknown[], result: unknown) => void
42
+
43
+ const sizeOf = (result: unknown) => (result as { size?: number } | undefined)?.size ?? 0
44
+
45
+ const byReference: Tally = (meter, args) => {
46
+ // `getAll` takes the references, then optionally a `{ fieldMask }`.
47
+ meter.docReads += args.filter((arg) => (arg as { path?: unknown })?.path !== undefined).length
48
+ }
49
+
50
+ const byQuery: Tally = (meter, _args, result) => {
51
+ meter.queryReads += 1
52
+ meter.queryDocs += sizeOf(result)
53
+ }
54
+
55
+ const wrapped = Symbol('readMeter')
56
+
57
+ const meter = (type: Readable, method: string, tally: Tally) => {
58
+ const prototype = type.prototype as Record<string, Read> & { [wrapped]?: Set<string> }
59
+ const read = prototype[method]
60
+ if (typeof read !== 'function') throw new Error(`Firestore has no ${method} to meter`)
61
+ if (!Object.hasOwn(prototype, wrapped)) prototype[wrapped] = new Set()
62
+ if (prototype[wrapped]?.has(method)) return
63
+ prototype[wrapped]?.add(method)
64
+ prototype[method] = function (this: unknown, ...args: unknown[]) {
65
+ // Taken before the read starts: the request context is certain here.
66
+ const current = currentMeter()
67
+ const pending = read.apply(this, args)
68
+ if (!current) return pending
69
+ return pending.then((result) => {
70
+ tally(current, args, result)
71
+ return result
72
+ })
73
+ }
74
+ }
75
+
76
+ /** The Firestore client classes whose reads are metered. */
77
+ export type FirestoreClasses = {
78
+ Firestore: Readable
79
+ Query: Readable
80
+ AggregateQuery: Readable
81
+ Transaction: Readable
82
+ DocumentReference: Readable
83
+ }
84
+
85
+ /** Wrap the client's read methods so each read adds to the current request's
86
+ * meter. Idempotent: a second call wraps nothing again. */
87
+ export const meterFirestoreReads = (classes: FirestoreClasses) => {
88
+ const { Firestore, Query, AggregateQuery, Transaction, DocumentReference } = classes
89
+ meter(Firestore, 'getAll', byReference)
90
+ meter(Query, 'get', byQuery)
91
+ meter(AggregateQuery, 'get', (current) => {
92
+ current.queryReads += 1
93
+ })
94
+ meter(Transaction, 'getAll', byReference)
95
+ meter(Transaction, 'get', (current, [target], result) => {
96
+ if (target instanceof (DocumentReference as unknown as new () => object)) current.docReads += 1
97
+ else if (target instanceof (Query as unknown as new () => object)) byQuery(current, [], result)
98
+ else current.queryReads += 1
99
+ })
100
+ }
@@ -0,0 +1,97 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
+ import { meterFirestoreReads, startReadMeter } from './read-meter'
3
+
4
+ // Stand-ins for the Firestore client classes: the meter wraps their prototypes
5
+ // exactly as it wraps the real ones.
6
+ class DocumentReference {
7
+ constructor(readonly path: string) {}
8
+ }
9
+ class Query {
10
+ constructor(readonly size: number) {}
11
+ async get() {
12
+ return { size: this.size }
13
+ }
14
+ }
15
+ class AggregateQuery {
16
+ async get() {
17
+ return { data: () => ({ count: 12 }) }
18
+ }
19
+ }
20
+ class Firestore {
21
+ async getAll(...refs: unknown[]) {
22
+ return refs
23
+ }
24
+ }
25
+ class Transaction {
26
+ async get(target: unknown) {
27
+ return target instanceof Query ? { size: target.size } : { exists: true }
28
+ }
29
+ async getAll(...refs: unknown[]) {
30
+ return refs
31
+ }
32
+ }
33
+
34
+ meterFirestoreReads({ Firestore, Query, AggregateQuery, Transaction, DocumentReference })
35
+ // A second call wraps nothing again, or every read would count twice.
36
+ meterFirestoreReads({ Firestore, Query, AggregateQuery, Transaction, DocumentReference })
37
+
38
+ // The meter reads the current H3 event via the auto-imported useEvent().
39
+ const inRequest = (context: Record<string, unknown>) => {
40
+ ;(globalThis as unknown as { useEvent: () => unknown }).useEvent = () => ({ context })
41
+ }
42
+
43
+ describe('meterFirestoreReads', () => {
44
+ afterEach(() => {
45
+ delete (globalThis as unknown as { useEvent?: unknown }).useEvent
46
+ })
47
+
48
+ test('counts documents read by reference and the documents queries return', async () => {
49
+ const context = {}
50
+ const meter = startReadMeter(context)
51
+ inRequest(context)
52
+
53
+ await new Firestore().getAll(new DocumentReference('a/1'), new DocumentReference('a/2'), {
54
+ fieldMask: ['title'],
55
+ })
56
+ await new Query(7).get()
57
+ await new Query(0).get()
58
+ await new AggregateQuery().get()
59
+
60
+ expect(meter).toEqual({ docReads: 2, queryReads: 3, queryDocs: 7 })
61
+ })
62
+
63
+ test('counts a transaction reading a document, a query and a batch', async () => {
64
+ const context = {}
65
+ const meter = startReadMeter(context)
66
+ inRequest(context)
67
+ const tx = new Transaction()
68
+
69
+ await tx.get(new DocumentReference('a/1'))
70
+ await tx.get(new Query(4))
71
+ await tx.getAll(new DocumentReference('a/2'), new DocumentReference('a/3'))
72
+
73
+ expect(meter).toEqual({ docReads: 3, queryReads: 1, queryDocs: 4 })
74
+ })
75
+
76
+ test('keeps concurrent requests apart', async () => {
77
+ const first = {}
78
+ const second = {}
79
+ const firstMeter = startReadMeter(first)
80
+ const secondMeter = startReadMeter(second)
81
+
82
+ // Each read is attributed to the request it started in, even when the two
83
+ // interleave and finish in the other order.
84
+ inRequest(first)
85
+ const slow = new Query(5).get()
86
+ inRequest(second)
87
+ const fast = new Firestore().getAll(new DocumentReference('a/1'))
88
+ await Promise.all([fast, slow])
89
+
90
+ expect(firstMeter).toEqual({ docReads: 0, queryReads: 1, queryDocs: 5 })
91
+ expect(secondMeter).toEqual({ docReads: 1, queryReads: 0, queryDocs: 0 })
92
+ })
93
+
94
+ test('reads outside a request still answer, counted nowhere', async () => {
95
+ expect(await new Query(3).get()).toEqual({ size: 3 })
96
+ })
97
+ })