@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,483 @@
1
+ /**
2
+ * In-memory Firestore fake for unit tests. Records every created batch and every
3
+ * direct (non-batched) write so tests can assert the atomicity contract: all
4
+ * writes of an operation enlisted into one batch, committed exactly once, and
5
+ * nothing applied when the commit fails.
6
+ *
7
+ * Test files mock the firebase module with the shared holder so file ordering
8
+ * does not matter:
9
+ * mock.module('~/system/firebase', () => ({ db: fakeDb }))
10
+ */
11
+ import { AsyncLocalStorage } from 'node:async_hooks'
12
+ import { FieldValue, type Firestore } from 'firebase-admin/firestore'
13
+
14
+ type Doc = Record<string, unknown>
15
+
16
+ export type FakeSnapshot = { exists: boolean; id: string; data: () => Doc | undefined }
17
+
18
+ export type FakeRef = {
19
+ /** The collection holding this document. Collections are flat, never nested. */
20
+ collectionPath: string
21
+ id: string
22
+ get: () => Promise<FakeSnapshot>
23
+ set: (data: Doc, options?: { merge?: boolean }) => Promise<void>
24
+ update: (data: Doc) => Promise<void>
25
+ delete: () => Promise<void>
26
+ }
27
+
28
+ export type BatchOp =
29
+ | { type: 'set'; ref: FakeRef; data: Doc }
30
+ | { type: 'update'; ref: FakeRef; data: Doc }
31
+ | { type: 'merge'; ref: FakeRef; data: Doc }
32
+ | { type: 'delete'; ref: FakeRef }
33
+
34
+ export type FakeBatch = {
35
+ ops: BatchOp[]
36
+ commits: number
37
+ set: (ref: FakeRef, data: Doc, options?: { merge?: boolean }) => FakeBatch
38
+ update: (ref: FakeRef, data: Doc) => FakeBatch
39
+ delete: (ref: FakeRef) => FakeBatch
40
+ commit: () => Promise<void>
41
+ }
42
+
43
+ export type FakeTransaction = {
44
+ get: (ref: FakeRef) => Promise<FakeSnapshot>
45
+ set: (ref: FakeRef, data: Doc) => FakeTransaction
46
+ delete: (ref: FakeRef) => FakeTransaction
47
+ }
48
+
49
+ export type DirectWrite = { type: 'set' | 'update' | 'delete'; collection: string; id: string }
50
+
51
+ type FakeQuery = {
52
+ where: (field: string, op: string, value: unknown) => FakeQuery
53
+ orderBy: (field: string, direction?: 'asc' | 'desc') => FakeQuery
54
+ limit: (count: number) => FakeQuery
55
+ offset: (count: number) => FakeQuery
56
+ startAfter: (cursor: FakeSnapshot) => FakeQuery
57
+ get: () => Promise<{ docs: Array<{ data: () => Doc; ref: FakeRef }> }>
58
+ count: () => { get: () => Promise<{ data: () => { count: number } }> }
59
+ }
60
+
61
+ type FakeCollection = {
62
+ withConverter: (converter: unknown) => FakeCollection
63
+ doc: (id?: string) => FakeRef
64
+ add: (data: Doc) => Promise<FakeRef>
65
+ where: FakeQuery['where']
66
+ orderBy: FakeQuery['orderBy']
67
+ limit: FakeQuery['limit']
68
+ get: FakeQuery['get']
69
+ count: FakeQuery['count']
70
+ }
71
+
72
+ export const createFakeFirestore = () => {
73
+ const store = new Map<string, Map<string, Doc>>()
74
+ const batches: FakeBatch[] = []
75
+ const directWrites: DirectWrite[] = []
76
+ // The writes each committed transaction applied, in order — lets a test prove a
77
+ // read-modify-write went through one transaction rather than a bare set.
78
+ const transactions: BatchOp[][] = []
79
+ let commitError: Error | undefined
80
+ let generatedIds = 0
81
+ let docReads = 0
82
+ let queryReads = 0
83
+ // Documents a query returned: what Firestore actually bills a query for.
84
+ let queriedDocs = 0
85
+ // Handed to the next query instead of an answer, once.
86
+ let queryError: Error | undefined
87
+ // How long a query takes per document it returns, as a real one takes longer
88
+ // the more it decodes: zero answers at once, which leaves no query in flight
89
+ // for requests sent together to share.
90
+ let queryMsPerDocument = 0
91
+
92
+ const docsOf = (collection: string) => {
93
+ const existing = store.get(collection)
94
+ if (existing) return existing
95
+ const created = new Map<string, Doc>()
96
+ store.set(collection, created)
97
+ return created
98
+ }
99
+
100
+ // Resolve a document write the way Firestore does: FieldValue.increment
101
+ // sentinels add to the stored number (`operand` is the transform's runtime
102
+ // field), nested maps recurse, and a merge keeps whatever the write does not
103
+ // name. Only direct ref.set supports this — no production code sends
104
+ // transforms through a batch or a transaction.
105
+ const resolveWrite = (existing: Doc | undefined, data: Doc, merge: boolean): Doc => {
106
+ const result: Doc = merge ? { ...(existing ?? {}) } : {}
107
+ for (const [key, value] of Object.entries(data)) {
108
+ const current = merge ? existing?.[key] : undefined
109
+ if (value instanceof FieldValue && 'operand' in value) {
110
+ const operand = (value as unknown as { operand: number }).operand
111
+ result[key] = (typeof current === 'number' ? current : 0) + operand
112
+ } else if (
113
+ value &&
114
+ typeof value === 'object' &&
115
+ !(value instanceof Date) &&
116
+ !Array.isArray(value)
117
+ ) {
118
+ result[key] = resolveWrite(current as Doc | undefined, value as Doc, merge)
119
+ } else {
120
+ result[key] = value
121
+ }
122
+ }
123
+ return result
124
+ }
125
+
126
+ // Firestore rejects `undefined` anywhere in a document, including inside an
127
+ // array. The fake used to accept it, which let a real bug pass a green test:
128
+ // a series catalogue with one unnumbered volume wrote fine here and was
129
+ // refused in production. Rejecting it makes the fake honest.
130
+ const rejectUndefined = (value: unknown, path: string): void => {
131
+ if (value === undefined) {
132
+ throw new Error(`Cannot use "undefined" as a Firestore value (found at ${path})`)
133
+ }
134
+ if (Array.isArray(value)) {
135
+ for (const [index, entry] of value.entries()) rejectUndefined(entry, `${path}[${index}]`)
136
+ return
137
+ }
138
+ if (value === null || typeof value !== 'object' || value instanceof Date) return
139
+ if (value instanceof FieldValue) return
140
+ for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
141
+ rejectUndefined(entry, path ? `${path}.${key}` : key)
142
+ }
143
+ }
144
+
145
+ const makeRef = (collection: string, id: string): FakeRef => ({
146
+ collectionPath: collection,
147
+ id,
148
+ get: async () => {
149
+ docReads += 1
150
+ const doc = docsOf(collection).get(id)
151
+ return { exists: doc !== undefined, id, data: () => doc }
152
+ },
153
+ set: async (data, options) => {
154
+ rejectUndefined(data, `${collection}/${id}`)
155
+ directWrites.push({ type: 'set', collection, id })
156
+ docsOf(collection).set(
157
+ id,
158
+ resolveWrite(docsOf(collection).get(id), data, options?.merge === true),
159
+ )
160
+ },
161
+ // Like Firestore's update: merges into an existing document, and refuses to
162
+ // create one. A caller racing with a deletion must fail, not resurrect a
163
+ // ghost document holding only the field it wrote.
164
+ update: async (data) => {
165
+ const existing = docsOf(collection).get(id)
166
+ if (existing === undefined) {
167
+ throw new Error(`NOT_FOUND: no document to update at ${collection}/${id}`)
168
+ }
169
+ directWrites.push({ type: 'update', collection, id })
170
+ docsOf(collection).set(id, resolveWrite(existing, data, true))
171
+ },
172
+ delete: async () => {
173
+ directWrites.push({ type: 'delete', collection, id })
174
+ docsOf(collection).delete(id)
175
+ },
176
+ })
177
+
178
+ const sortValue = (value: unknown) =>
179
+ value instanceof Date ? value.getTime() : (value as number | string)
180
+
181
+ type Filter = [field: string, op: string, value: unknown]
182
+ type QueryState = {
183
+ filters: Filter[]
184
+ orders: { field: string; direction: 'asc' | 'desc' }[]
185
+ limit?: number
186
+ offset?: number
187
+ startAfter?: { id: string; data: Doc | undefined }
188
+ }
189
+
190
+ // A dotted field reaches into a map, as `where('series.id', ...)` does in Firestore.
191
+ const valueAt = (data: Doc, field: string): unknown =>
192
+ field
193
+ .split('.')
194
+ .reduce<unknown>(
195
+ (value, key) =>
196
+ value !== null && typeof value === 'object'
197
+ ? (value as Record<string, unknown>)[key]
198
+ : undefined,
199
+ data,
200
+ )
201
+
202
+ // Only the operators production code actually uses — fail loudly otherwise.
203
+ const matchesFilter = (data: Doc, [field, op, value]: Filter) => {
204
+ const held = valueAt(data, field)
205
+ if (op === '==') return held === value
206
+ if (op === '!=') return held !== undefined && held !== value
207
+ if (op === '>=')
208
+ // Firestore leaves out a document missing the field, as it does on order.
209
+ return held !== undefined && sortValue(held) >= sortValue(value)
210
+ if (op === 'in') return Array.isArray(value) && value.includes(held)
211
+ if (op === 'array-contains') return Array.isArray(held) && held.includes(value)
212
+ if (op === 'array-contains-any')
213
+ return (
214
+ Array.isArray(held) && Array.isArray(value) && value.some((wanted) => held.includes(wanted))
215
+ )
216
+ throw new Error(
217
+ `fake-firestore only supports '==', '!=', '>=', 'in', 'array-contains' and ` +
218
+ `'array-contains-any' queries, got '${op}'`,
219
+ )
220
+ }
221
+
222
+ const makeQuery = (collection: string, state: QueryState): FakeQuery => ({
223
+ where: (field, op, value) =>
224
+ makeQuery(collection, { ...state, filters: [...state.filters, [field, op, value]] }),
225
+ orderBy: (field, direction = 'asc') =>
226
+ makeQuery(collection, { ...state, orders: [...state.orders, { field, direction }] }),
227
+ limit: (count) => makeQuery(collection, { ...state, limit: count }),
228
+ offset: (count) => makeQuery(collection, { ...state, offset: count }),
229
+ startAfter: (cursor) =>
230
+ makeQuery(collection, { ...state, startAfter: { id: cursor.id, data: cursor.data() } }),
231
+ get: async () => {
232
+ if (queryError) {
233
+ const error = queryError
234
+ queryError = undefined
235
+ throw error
236
+ }
237
+ queryReads += 1
238
+ let matching = [...docsOf(collection).entries()].filter(([, data]) =>
239
+ state.filters.every((filter) => matchesFilter(data, filter)),
240
+ )
241
+ if (state.orders.length > 0) {
242
+ const { orders } = state
243
+ // Firestore leaves out a document missing an ordered field — mirror it,
244
+ // so a record a migration forgot is as invisible here as in production.
245
+ matching = matching.filter(([, data]) => orders.every(({ field }) => field in data))
246
+ const compared = (left: unknown, right: unknown) => {
247
+ const [a, b] = [sortValue(left), sortValue(right)]
248
+ return a < b ? -1 : a > b ? 1 : 0
249
+ }
250
+ // Firestore uses the document id as an implicit tie-break, in the
251
+ // direction of the last ordering — mirror it.
252
+ const last = orders.at(-1)?.direction ?? 'asc'
253
+ const inOrder = ([idA, a]: [string, Doc], [idB, b]: [string, Doc]) => {
254
+ for (const { field, direction } of orders) {
255
+ const comparison = compared(a[field], b[field])
256
+ if (comparison !== 0) return direction === 'desc' ? -comparison : comparison
257
+ }
258
+ const tie = compared(idA, idB)
259
+ return last === 'desc' ? -tie : tie
260
+ }
261
+ matching.sort(inOrder)
262
+ // A snapshot cursor positions by the values it holds, as Firestore's
263
+ // does: the cursor need not match the filters, nor still be stored.
264
+ const cursor = state.startAfter
265
+ if (cursor?.data)
266
+ matching = matching.filter((row) => inOrder(row, [cursor.id, cursor.data as Doc]) > 0)
267
+ } else if (state.startAfter) {
268
+ const cursorIndex = matching.findIndex(([id]) => id === state.startAfter?.id)
269
+ if (cursorIndex >= 0) matching = matching.slice(cursorIndex + 1)
270
+ }
271
+ if (state.offset !== undefined) matching = matching.slice(state.offset)
272
+ if (state.limit !== undefined) matching = matching.slice(0, state.limit)
273
+ queriedDocs += matching.length
274
+ if (queryMsPerDocument > 0) await Bun.sleep(queryMsPerDocument * matching.length)
275
+ return {
276
+ docs: matching.map(([id, data]) => ({ data: () => data, ref: makeRef(collection, id) })),
277
+ }
278
+ },
279
+ // Aggregation count: like Firestore, one billed query round-trip, no documents.
280
+ count: () => ({
281
+ get: async () => {
282
+ queryReads += 1
283
+ const matching = [...docsOf(collection).values()].filter((data) =>
284
+ state.filters.every((filter) => matchesFilter(data, filter)),
285
+ )
286
+ return { data: () => ({ count: matching.length }) }
287
+ },
288
+ }),
289
+ })
290
+
291
+ const makeCollection = (name: string): FakeCollection => ({
292
+ withConverter: () => makeCollection(name),
293
+ doc: (id) => makeRef(name, id ?? `generated-${++generatedIds}`),
294
+ add: async (data) => {
295
+ const ref = makeRef(name, `generated-${++generatedIds}`)
296
+ await ref.set(data)
297
+ return ref
298
+ },
299
+ where: (field, op, value) => makeQuery(name, { filters: [[field, op, value]], orders: [] }),
300
+ orderBy: (field, direction) =>
301
+ makeQuery(name, { filters: [], orders: [] }).orderBy(field, direction),
302
+ limit: (count) => makeQuery(name, { filters: [], orders: [] }).limit(count),
303
+ get: () => makeQuery(name, { filters: [], orders: [] }).get(),
304
+ count: () => makeQuery(name, { filters: [], orders: [] }).count(),
305
+ })
306
+
307
+ const makeBatch = (): FakeBatch => {
308
+ const ops: BatchOp[] = []
309
+ const batch: FakeBatch = {
310
+ ops,
311
+ commits: 0,
312
+ // A merge set is recorded as an update that may create the document, which is
313
+ // what Firestore does with `{ merge: true }`.
314
+ set: (ref, data, options) => {
315
+ ops.push(options?.merge ? { type: 'merge', ref, data } : { type: 'set', ref, data })
316
+ return batch
317
+ },
318
+ update: (ref, data) => {
319
+ ops.push({ type: 'update', ref, data })
320
+ return batch
321
+ },
322
+ delete: (ref) => {
323
+ ops.push({ type: 'delete', ref })
324
+ return batch
325
+ },
326
+ commit: async () => {
327
+ if (commitError) throw commitError
328
+ // Firestore refuses a batch of more than 500 writes outright.
329
+ if (ops.length > 500)
330
+ throw new Error(`INVALID_ARGUMENT: ${ops.length} writes in one batch, 500 at most`)
331
+ for (const op of ops) {
332
+ if (op.type === 'set') docsOf(op.ref.collectionPath).set(op.ref.id, op.data)
333
+ else if (op.type === 'merge') {
334
+ const existing = docsOf(op.ref.collectionPath).get(op.ref.id)
335
+ docsOf(op.ref.collectionPath).set(op.ref.id, resolveWrite(existing, op.data, true))
336
+ } else if (op.type === 'update') {
337
+ const existing = docsOf(op.ref.collectionPath).get(op.ref.id)
338
+ if (existing === undefined) {
339
+ throw new Error(
340
+ `NOT_FOUND: no document to update at ${op.ref.collectionPath}/${op.ref.id}`,
341
+ )
342
+ }
343
+ docsOf(op.ref.collectionPath).set(op.ref.id, resolveWrite(existing, op.data, true))
344
+ } else docsOf(op.ref.collectionPath).delete(op.ref.id)
345
+ }
346
+ batch.commits += 1
347
+ },
348
+ }
349
+ batches.push(batch)
350
+ return batch
351
+ }
352
+
353
+ // Transactions run one after another rather than concurrently. Real Firestore
354
+ // aborts and retries the loser of a contended write; serialising the bodies
355
+ // reaches the same end state and makes a concurrency test deterministic instead
356
+ // of retry-dependent.
357
+ let transactionQueue: Promise<unknown> = Promise.resolve()
358
+
359
+ const runTransaction = <T>(run: (tx: FakeTransaction) => Promise<T>): Promise<T> => {
360
+ const result = transactionQueue.then(async () => {
361
+ const writes: BatchOp[] = []
362
+ const tx: FakeTransaction = {
363
+ get: async (ref) => {
364
+ docReads += 1
365
+ const doc = docsOf(ref.collectionPath).get(ref.id)
366
+ return { exists: doc !== undefined, id: ref.id, data: () => doc }
367
+ },
368
+ set: (ref, data) => {
369
+ writes.push({ type: 'set', ref, data })
370
+ return tx
371
+ },
372
+ delete: (ref) => {
373
+ writes.push({ type: 'delete', ref })
374
+ return tx
375
+ },
376
+ }
377
+ const value = await run(tx)
378
+ // Applied only once the body succeeded — a throw leaves the store untouched.
379
+ for (const op of writes) {
380
+ if (op.type === 'set') docsOf(op.ref.collectionPath).set(op.ref.id, op.data)
381
+ else docsOf(op.ref.collectionPath).delete(op.ref.id)
382
+ }
383
+ transactions.push(writes)
384
+ return value
385
+ })
386
+ // The queue must keep flowing even when a transaction throws, or every later
387
+ // one would inherit the rejection.
388
+ transactionQueue = result.catch(() => undefined)
389
+ return result
390
+ }
391
+
392
+ const getAll = async (...refs: FakeRef[]) => {
393
+ docReads += refs.length
394
+ return refs.map((ref) => {
395
+ const doc = docsOf(ref.collectionPath).get(ref.id)
396
+ return { exists: doc !== undefined, id: ref.id, data: () => doc }
397
+ })
398
+ }
399
+
400
+ return {
401
+ db: {
402
+ collection: makeCollection,
403
+ batch: makeBatch,
404
+ getAll,
405
+ runTransaction,
406
+ } as unknown as Firestore,
407
+ seed: (collection: string, id: string, data: Doc) => {
408
+ docsOf(collection).set(id, { ...data })
409
+ },
410
+ snapshot: (collection: string) => new Map(docsOf(collection)),
411
+ /** One stored document, or null. Subcollections are addressed by their full
412
+ * path, e.g. `users/reader-1/books`. */
413
+ data: (collection: string, id: string) => docsOf(collection).get(id) ?? null,
414
+ batches,
415
+ directWrites,
416
+ transactions,
417
+ // Firestore round-trips (document gets + query gets) — lets tests assert read budgets
418
+ get reads() {
419
+ return docReads + queryReads
420
+ },
421
+ // Keyed document gets only (ref.get + getAll) — one read per document fetched
422
+ get docReads() {
423
+ return docReads
424
+ },
425
+ // Collection query gets only — a scan counts 1 whatever the number of docs returned,
426
+ // so asserting queryReads === 0 is the proof that a path never scans a collection
427
+ get queryReads() {
428
+ return queryReads
429
+ },
430
+ get queriedDocs() {
431
+ return queriedDocs
432
+ },
433
+ failCommitsWith: (error: Error) => {
434
+ commitError = error
435
+ },
436
+ failNextQueryWith: (error: Error) => {
437
+ queryError = error
438
+ },
439
+ answerQueriesAfter: (msPerDocument: number) => {
440
+ queryMsPerDocument = msPerDocument
441
+ },
442
+ }
443
+ }
444
+
445
+ export type FakeFirestore = ReturnType<typeof createFakeFirestore>
446
+
447
+ const holder = { current: createFakeFirestore() }
448
+
449
+ export const resetFakeFirestore = () => {
450
+ holder.current = createFakeFirestore()
451
+ startFakeRequest()
452
+ return holder.current
453
+ }
454
+
455
+ /** Give the next calls a fresh, stable request context so memoizedPerRequest()
456
+ * caches within it (mirroring one HTTP request). Each test starts one; a test
457
+ * that spans two requests — a screen read, then a mutation — starts the second. */
458
+ export const startFakeRequest = () => {
459
+ const context: Record<string, unknown> = {}
460
+ ;(globalThis as unknown as { useEvent: () => unknown }).useEvent = () => ({ context })
461
+ }
462
+
463
+ /** Run each call as its own HTTP request, all at once, as the app sends the
464
+ * sections of a screen: each gets a request context of its own, as
465
+ * `startFakeRequest` gives one, for as long as it runs. */
466
+ export const inConcurrentFakeRequests = async <T>(
467
+ calls: readonly (() => Promise<T>)[],
468
+ ): Promise<T[]> => {
469
+ const requests = new AsyncLocalStorage<Record<string, unknown>>()
470
+ const global = globalThis as unknown as { useEvent: () => unknown }
471
+ const outside = global.useEvent
472
+ global.useEvent = () => {
473
+ const context = requests.getStore()
474
+ return context ? { context } : outside()
475
+ }
476
+ try {
477
+ return await Promise.all(calls.map((call) => requests.run({}, call)))
478
+ } finally {
479
+ global.useEvent = outside
480
+ }
481
+ }
482
+
483
+ export const fakeDb = () => holder.current.db
@@ -0,0 +1,13 @@
1
+ import type { ApolloServer } from '@apollo/server'
2
+ import type { GraphQLContext } from '~/domain/shared/graphql/builder'
3
+
4
+ let instance: ApolloServer<GraphQLContext> | null = null
5
+
6
+ export const setApollo = (apollo: ApolloServer<GraphQLContext>) => {
7
+ instance = apollo
8
+ }
9
+
10
+ export const useApollo = () => {
11
+ if (!instance) throw new Error('Apollo Server not initialized — plugin not loaded yet')
12
+ return instance
13
+ }
@@ -0,0 +1,5 @@
1
+ /** Whether a fetch failed because its `AbortSignal.timeout` ran out, rather than
2
+ * because the host could not be reached or answered badly. The platform throws a
3
+ * `DOMException` named `TimeoutError` for it, with no subclass to test against. */
4
+ export const isTimeout = (error: unknown): boolean =>
5
+ error instanceof DOMException && error.name === 'TimeoutError'
@@ -0,0 +1,91 @@
1
+ import type {
2
+ DocumentData,
3
+ DocumentReference,
4
+ FirestoreDataConverter,
5
+ QueryDocumentSnapshot,
6
+ Transaction,
7
+ WriteBatch,
8
+ } from 'firebase-admin/firestore'
9
+ import { chunk } from 'lodash-es'
10
+ import { db } from '~/system/firebase'
11
+
12
+ // Generic Firestore converter that preserves type information when reading
13
+ // documents and recursively turns Timestamp instances back into JS Date.
14
+ // Pattern from https://github.com/moifort/price-it back/src/utils/firestore.ts
15
+ export const genericDataConverter = <T extends DocumentData>(): FirestoreDataConverter<T> => ({
16
+ toFirestore: (data: T) => data,
17
+ fromFirestore: (snapshot: QueryDocumentSnapshot) => toDate(snapshot.data()) as T,
18
+ })
19
+
20
+ const toDate = (value: unknown): unknown => {
21
+ if (!value || typeof value !== 'object') return value
22
+ const obj = value as Record<string, unknown>
23
+ for (const key of Object.keys(obj)) {
24
+ const v = obj[key] as { toDate?: () => Date } | unknown
25
+ if (v && typeof v === 'object' && typeof (v as { toDate?: unknown }).toDate === 'function') {
26
+ obj[key] = (v as { toDate: () => Date }).toDate()
27
+ } else if (v && typeof v === 'object') {
28
+ toDate(v)
29
+ }
30
+ }
31
+ return obj
32
+ }
33
+
34
+ // Firestore rejects `undefined` field values outright. An absent domain field is
35
+ // meant to disappear from the document, which is exactly what dropping the key
36
+ // does on a full `set`.
37
+ //
38
+ // Recursive, because the shallow version was not enough: a series catalogue is
39
+ // an array of volumes, and a volume with no number (a novella) or no year (one
40
+ // not out yet) carries `undefined` INSIDE the array. Firestore then rejects the
41
+ // whole document, so one unnumbered volume silently cost an entire saga its
42
+ // catalogue.
43
+ export const withoutAbsentFields = <T extends DocumentData>(data: T): T => pruneUndefined(data) as T
44
+
45
+ const pruneUndefined = (value: unknown): unknown => {
46
+ if (Array.isArray(value)) return value.map(pruneUndefined)
47
+ // Dates and other class instances are values, not maps: recursing into them
48
+ // would flatten a Timestamp into a plain object.
49
+ if (value === null || typeof value !== 'object' || value instanceof Date) return value
50
+ return Object.fromEntries(
51
+ Object.entries(value as Record<string, unknown>)
52
+ .filter(([, entry]) => entry !== undefined)
53
+ .map(([key, entry]) => [key, pruneUndefined(entry)]),
54
+ )
55
+ }
56
+
57
+ // Firestore batches accept at most 500 operations.
58
+ const BATCH_LIMIT = 400
59
+
60
+ // Persist many records with bounded write concurrency — individual sets, not a
61
+ // batch (the row count on an import/restore can exceed the 500-op batch cap).
62
+ export const bulkSave = async <T>(rows: T[], save: (row: T) => Promise<unknown>): Promise<void> => {
63
+ for (const slice of chunk(rows, 50)) await Promise.all(slice.map((row) => save(row)))
64
+ }
65
+
66
+ export const deleteInBatches = async (refs: DocumentReference[]): Promise<void> => {
67
+ for (const slice of chunk(refs, BATCH_LIMIT)) {
68
+ const batch = db().batch()
69
+ for (const ref of slice) batch.delete(ref)
70
+ await batch.commit()
71
+ }
72
+ }
73
+
74
+ // Runs `enlist` against a fresh WriteBatch and commits it once: either every
75
+ // enlisted write lands or none does. Reads inside `enlist` see pre-batch state —
76
+ // batched writes are invisible until commit. Firestore caps a batch at 500
77
+ // writes; callers enlist a handful of documents, far below the cap.
78
+ export const atomically = async <T>(enlist: (batch: WriteBatch) => Promise<T>): Promise<T> => {
79
+ const batch = db().batch()
80
+ const result = await enlist(batch)
81
+ await batch.commit()
82
+ return result
83
+ }
84
+
85
+ // Read-then-write in one go, with the read protected against a concurrent writer.
86
+ // A batch cannot do this: its reads see pre-batch state, so two callers
87
+ // incrementing the same counter would both read the same value and one increment
88
+ // would be lost. Firestore retries the body on contention, so it must stay pure
89
+ // beyond its own reads and writes.
90
+ export const transactionally = <T>(run: (tx: Transaction) => Promise<T>): Promise<T> =>
91
+ db().runTransaction(run)
@@ -0,0 +1,36 @@
1
+ // Boundary converter: GraphQL nullability → domain "absent key" convention.
2
+ //
3
+ // Pothos optional input fields are typed as `T | null | undefined`; at runtime
4
+ // graphql-js omits absent fields entirely, so the only variant to erase is an
5
+ // explicit `null` sent by the client. Domain types use `T?` (a key is present
6
+ // with a value, or not present at all) and Firestore rejects `undefined`
7
+ // values — dropping the null KEYS (not nulling them) keeps both invariants.
8
+ //
9
+ // Not recursive: every mutation input in the schema is flat.
10
+ type StripNulls<T> = { [K in keyof T]: Exclude<T[K], null> }
11
+
12
+ export const stripNulls = <T extends Record<string, unknown>>(obj: T): StripNulls<T> => {
13
+ const out: Record<string, unknown> = {}
14
+ for (const key of Object.keys(obj)) {
15
+ const value = (obj as Record<string, unknown>)[key]
16
+ if (value !== null) out[key] = value
17
+ }
18
+ return out as StripNulls<T>
19
+ }
20
+
21
+ /** Runs a branded constructor over a value nobody vouches for — a model reading a
22
+ * cover, a field of somebody else's API — and drops it if it does not validate.
23
+ *
24
+ * The whole point of the brands is that a hallucinated ISBN or a page count of 0
25
+ * never reaches the database, and a single bad field must not sink an otherwise
26
+ * good record. */
27
+ export const optionally = <T>(value: unknown, construct: (value: unknown) => T): T | undefined => {
28
+ if (value === null || value === undefined || value === '') return undefined
29
+ try {
30
+ return construct(value)
31
+ } catch {
32
+ return undefined
33
+ }
34
+ }
35
+
36
+ export const isPresent = <T>(value: T | undefined): value is T => value !== undefined
@@ -0,0 +1,43 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { stripNulls } from './input'
3
+
4
+ // Mirrors how Pothos types optional input fields: `T | null | undefined`.
5
+ type FakeInput = {
6
+ name: string
7
+ vintage?: number | null
8
+ notes?: string | null
9
+ price?: number | null
10
+ favorite?: boolean | null
11
+ }
12
+
13
+ describe('stripNulls', () => {
14
+ it('drops null keys entirely instead of keeping them', () => {
15
+ const input: FakeInput = { name: 'Margaux', vintage: null }
16
+ const out = stripNulls(input)
17
+ expect(out).toEqual({ name: 'Margaux' })
18
+ expect('vintage' in out).toBe(false)
19
+ })
20
+
21
+ it('keeps falsy but present values (0, empty string, false)', () => {
22
+ const input: FakeInput = { name: 'Chinon', price: 0, notes: '', favorite: false }
23
+ expect(stripNulls(input)).toEqual({ name: 'Chinon', price: 0, notes: '', favorite: false })
24
+ })
25
+
26
+ it('never introduces a null value: every remaining key holds a real value', () => {
27
+ const input: FakeInput = { name: 'Cornas', vintage: 2019, notes: null, price: null }
28
+ const out = stripNulls(input)
29
+ expect(Object.values(out).every((value) => value !== null)).toBe(true)
30
+ expect(out).toEqual({ name: 'Cornas', vintage: 2019 })
31
+ })
32
+
33
+ it('leaves objects without nulls untouched', () => {
34
+ const input: FakeInput = { name: 'Chinon', vintage: 2020 }
35
+ expect(stripNulls(input)).toEqual({ name: 'Chinon', vintage: 2020 })
36
+ })
37
+
38
+ it('is not recursive: nested values pass through as-is', () => {
39
+ // Every mutation input in the schema is flat; nested objects pass through.
40
+ const nested = { meta: { inner: null } as { inner: string | null } }
41
+ expect(stripNulls(nested)).toEqual({ meta: { inner: null } })
42
+ })
43
+ })