@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.
- package/CHANGELOG.md +18 -0
- package/README.md +26 -2
- package/bin/stack.ts +109 -0
- package/hooks/shutdown-simulators-after-push.sh +19 -0
- package/package.json +40 -4
- package/routines/daily-health.md +78 -0
- package/routines/deprecations.md +23 -0
- package/routines/sentry-autofix.md +76 -0
- package/routines/stack-upgrade.md +45 -0
- package/rules/00-collaboration.md +81 -0
- package/rules/01-workflow.md +81 -0
- package/rules/02-commits.md +71 -0
- package/rules/03-code-review.md +25 -0
- package/rules/04-push.md +75 -0
- package/rules/05-release.md +40 -0
- package/rules/06-changelog.md +48 -0
- package/rules/07-appstore.md +77 -0
- package/rules/08-copy.md +52 -0
- package/rules/09-backend.md +132 -0
- package/rules/10-ios.md +206 -0
- package/rules/11-ops.md +114 -0
- package/rules/12-ci.md +32 -0
- package/rules/13-ai.md +71 -0
- package/rules/14-infra.md +90 -0
- package/rules/15-tooling.md +81 -0
- package/rules/16-routines.md +69 -0
- package/rules/17-landing.md +76 -0
- package/skills/appstore/SKILL.md +53 -0
- package/skills/domain/SKILL.md +136 -0
- package/skills/graphql/SKILL.md +83 -0
- package/skills/ios-feature/SKILL.md +94 -0
- package/skills/stack-install/SKILL.md +35 -0
- package/skills/stack-upgrade/SKILL.md +27 -0
- package/src/catalogue.ts +33 -0
- package/src/config.ts +20 -0
- package/src/init.ts +63 -0
- package/src/migrations.ts +72 -0
- package/src/migrations.unit.test.ts +44 -0
- package/src/package.ts +8 -0
- package/src/render.ts +13 -0
- package/src/render.unit.test.ts +17 -0
- package/src/sync.int.test.ts +103 -0
- package/src/sync.ts +161 -0
- package/src/templates.ts +121 -0
- package/src/validate-content.ts +65 -0
- package/templates/backend/.env.example +10 -0
- package/templates/backend/.github/workflows/deploy.yml +151 -0
- package/templates/backend/.github/workflows/schema.yml +59 -0
- package/templates/backend/.github/workflows/test-feat.yml +40 -0
- package/templates/backend/.github/workflows/test-int.yml +40 -0
- package/templates/backend/.github/workflows/test-unit.yml +64 -0
- package/templates/backend/docs/api-evolution.md +67 -0
- package/templates/backend/firebase.json +21 -0
- package/templates/backend/firestore.indexes.json +4 -0
- package/templates/backend/firestore.rules +11 -0
- package/templates/backend/infra/auth.tf +54 -0
- package/templates/backend/infra/backend.tf +6 -0
- package/templates/backend/infra/cicd.tf +63 -0
- package/templates/backend/infra/firebase.tf +26 -0
- package/templates/backend/infra/firestore.tf +53 -0
- package/templates/backend/infra/function.tf +171 -0
- package/templates/backend/infra/main.tf +26 -0
- package/templates/backend/infra/observer.tf +18 -0
- package/templates/backend/infra/outputs.tf +30 -0
- package/templates/backend/infra/project.tf +64 -0
- package/templates/backend/infra/scheduler.tf +28 -0
- package/templates/backend/infra/secrets.tf +44 -0
- package/templates/backend/infra/state.tf +13 -0
- package/templates/backend/infra/terraform.tfvars.example +16 -0
- package/templates/backend/infra/variables.tf +104 -0
- package/templates/backend/infra/versions.tf +30 -0
- package/templates/backend/nitro.config.ts +40 -0
- package/templates/backend/package.json +51 -0
- package/templates/backend/scripts/deprecations/inspector-rule.ts +29 -0
- package/templates/backend/scripts/deprecations/status.ts +50 -0
- package/templates/backend/scripts/deprecations/status.unit.test.ts +57 -0
- package/templates/backend/scripts/deprecations/usage.ts +79 -0
- package/templates/backend/scripts/deprecations/usage.unit.test.ts +64 -0
- package/templates/backend/scripts/deprecations.ts +224 -0
- package/templates/backend/scripts/export-graphql-schema.ts +11 -0
- package/templates/backend/scripts/generate-changelog-asset.ts +27 -0
- package/templates/backend/scripts/install-terraform.sh +61 -0
- package/templates/backend/scripts/tf +11 -0
- package/templates/backend/server/domain/changelog/business-rules.ts +60 -0
- package/templates/backend/server/domain/changelog/business-rules.unit.test.ts +88 -0
- package/templates/backend/server/domain/changelog/infrastructure/graphql/queries.ts +17 -0
- package/templates/backend/server/domain/changelog/infrastructure/graphql/types.ts +23 -0
- package/templates/backend/server/domain/changelog/primitives.ts +19 -0
- package/templates/backend/server/domain/changelog/query.ts +13 -0
- package/templates/backend/server/domain/changelog/query.unit.test.ts +19 -0
- package/templates/backend/server/domain/changelog/types.ts +9 -0
- package/templates/backend/server/domain/shared/graphql/builder.ts +52 -0
- package/templates/backend/server/domain/shared/graphql/errors.ts +19 -0
- package/templates/backend/server/domain/shared/graphql/scalars.ts +51 -0
- package/templates/backend/server/domain/shared/graphql/schema.ts +21 -0
- package/templates/backend/server/domain/shared/language.ts +26 -0
- package/templates/backend/server/domain/shared/page.ts +25 -0
- package/templates/backend/server/domain/shared/page.unit.test.ts +51 -0
- package/templates/backend/server/domain/shared/primitives.ts +50 -0
- package/templates/backend/server/domain/shared/primitives.unit.test.ts +25 -0
- package/templates/backend/server/domain/shared/types.ts +14 -0
- package/templates/backend/server/domain/user/command.ts +16 -0
- package/templates/backend/server/domain/user/infrastructure/graphql/inputs.ts +12 -0
- package/templates/backend/server/domain/user/infrastructure/graphql/mutations.ts +40 -0
- package/templates/backend/server/domain/user/infrastructure/graphql/queries.ts +13 -0
- package/templates/backend/server/domain/user/infrastructure/graphql/types.ts +35 -0
- package/templates/backend/server/domain/user/infrastructure/repository.ts +59 -0
- package/templates/backend/server/domain/user/mutations.feat.test.ts +42 -0
- package/templates/backend/server/domain/user/query.int.test.ts +49 -0
- package/templates/backend/server/domain/user/query.ts +34 -0
- package/templates/backend/server/domain/user/types.ts +20 -0
- package/templates/backend/server/domain/user/use-case.ts +18 -0
- package/templates/backend/server/middleware/auth.ts +54 -0
- package/templates/backend/server/plugins/00-sentry.ts +74 -0
- package/templates/backend/server/plugins/01-request-log.ts +28 -0
- package/templates/backend/server/plugins/02-graphql.ts +69 -0
- package/templates/backend/server/plugins/03-boot-time.ts +21 -0
- package/templates/backend/server/routes/admin/migrate.post.ts +9 -0
- package/templates/backend/server/routes/app-config.get.ts +15 -0
- package/templates/backend/server/routes/graphql.ts +52 -0
- package/templates/backend/server/system/app-support.ts +17 -0
- package/templates/backend/server/system/config/index.ts +18 -0
- package/templates/backend/server/system/config/primitives.ts +20 -0
- package/templates/backend/server/system/config/primitives.unit.test.ts +13 -0
- package/templates/backend/server/system/config/types.ts +8 -0
- package/templates/backend/server/system/firebase.ts +19 -0
- package/templates/backend/server/system/identity.ts +18 -0
- package/templates/backend/server/system/logger.ts +119 -0
- package/templates/backend/server/system/logger.unit.test.ts +109 -0
- package/templates/backend/server/system/migration/migrations/index.ts +6 -0
- package/templates/backend/server/system/migration/primitives.ts +18 -0
- package/templates/backend/server/system/migration/runner.ts +52 -0
- package/templates/backend/server/system/migration/types.ts +16 -0
- package/templates/backend/server/system/read-meter.ts +100 -0
- package/templates/backend/server/system/read-meter.unit.test.ts +97 -0
- package/templates/backend/server/system/request-cache.ts +139 -0
- package/templates/backend/server/system/request-cache.unit.test.ts +166 -0
- package/templates/backend/server/system/secret-box.ts +49 -0
- package/templates/backend/server/system/secret-box.unit.test.ts +46 -0
- package/templates/backend/server/test/bdd.ts +12 -0
- package/templates/backend/server/test/fake-firestore.ts +483 -0
- package/templates/backend/server/utils/apollo.ts +13 -0
- package/templates/backend/server/utils/fetch.ts +5 -0
- package/templates/backend/server/utils/firestore.ts +91 -0
- package/templates/backend/server/utils/input.ts +36 -0
- package/templates/backend/server/utils/input.unit.test.ts +43 -0
- package/templates/backend/server/utils/slug.ts +21 -0
- package/templates/backend/tsconfig.json +16 -0
- package/templates/base/.github/workflows/renovate.yml +17 -0
- package/templates/base/CHANGELOG.fr.md +3 -0
- package/templates/base/CHANGELOG.md +7 -0
- package/templates/base/CLAUDE.md +34 -0
- package/templates/base/README.md +12 -0
- package/templates/base/biome.json +47 -0
- package/templates/base/bunfig.toml +2 -0
- package/templates/base/docs/adr/0001-built-on-the-polyforms-stack.md +21 -0
- package/templates/base/docs/data-recovery.md +116 -0
- package/templates/base/docs/ideas.md +4 -0
- package/templates/base/project-context.md +43 -0
- package/templates/base/renovate.json +37 -0
- package/templates/ios/.github/workflows/build-ios.yml +168 -0
- package/templates/ios/.github/workflows/ios-upload.yml +308 -0
- package/templates/ios/.github/workflows/release-ios.yml +178 -0
- package/templates/ios/.github/workflows/testflight-ios.yml +57 -0
- package/templates/ios/infra/Gemfile +5 -0
- package/templates/ios/infra/fastlane/Appfile +1 -0
- package/templates/ios/infra/fastlane/Fastfile +54 -0
- package/templates/ios/infra/fastlane/metadata/copyright.txt +1 -0
- package/templates/ios/infra/fastlane/metadata/en-US/description.txt +0 -0
- package/templates/ios/infra/fastlane/metadata/en-US/keywords.txt +0 -0
- package/templates/ios/infra/fastlane/metadata/en-US/name.txt +1 -0
- package/templates/ios/infra/fastlane/metadata/en-US/privacy_url.txt +1 -0
- package/templates/ios/infra/fastlane/metadata/en-US/promotional_text.txt +0 -0
- package/templates/ios/infra/fastlane/metadata/en-US/subtitle.txt +1 -0
- package/templates/ios/infra/fastlane/metadata/en-US/support_url.txt +1 -0
- package/templates/ios/infra/fastlane/metadata/fr-FR/description.txt +0 -0
- package/templates/ios/infra/fastlane/metadata/fr-FR/keywords.txt +0 -0
- package/templates/ios/infra/fastlane/metadata/fr-FR/name.txt +1 -0
- package/templates/ios/infra/fastlane/metadata/fr-FR/privacy_url.txt +1 -0
- package/templates/ios/infra/fastlane/metadata/fr-FR/promotional_text.txt +0 -0
- package/templates/ios/infra/fastlane/metadata/fr-FR/subtitle.txt +1 -0
- package/templates/ios/infra/fastlane/metadata/fr-FR/support_url.txt +1 -0
- package/templates/ios/infra/fastlane/metadata/primary_category.txt +1 -0
- package/templates/ios/infra/ios.tf +28 -0
- package/templates/ios/ios/ExportOptions.plist +26 -0
- package/templates/ios/ios/apollo-codegen-config.json +16 -0
- package/templates/ios/ios/project.yml +94 -0
- package/templates/ios/ios/{{APP_NAME}}/App.swift +46 -0
- package/templates/ios/ios/{{APP_NAME}}/Assets.xcassets/AppIcon.appiconset/AppIcon.png +0 -0
- package/templates/ios/ios/{{APP_NAME}}/Assets.xcassets/AppIcon.appiconset/Contents.json +14 -0
- package/templates/ios/ios/{{APP_NAME}}/Assets.xcassets/Contents.json +6 -0
- package/templates/ios/ios/{{APP_NAME}}/Assets.xcassets/LaunchBackground.colorset/Contents.json +38 -0
- package/templates/ios/ios/{{APP_NAME}}/ContentView.swift +17 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Auth/AppleNonce.swift +33 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Auth/AuthRoot.swift +74 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Auth/AuthSession.swift +86 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Auth/LoginScreen.swift +83 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Home/HomeScreen.swift +21 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Home/components/pages/HomePage.swift +40 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/AccountEnvironment.swift +7 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/GraphQL/Onboarding.graphql +16 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/OnboardingAPI.swift +28 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/OnboardingClient.swift +26 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/OnboardingGate.swift +50 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/OnboardingScreen.swift +69 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/components/pages/FirstNamePage.swift +66 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/components/pages/WelcomePage.swift +37 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/ChangelogScreen.swift +25 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/ChangelogViewModel.swift +27 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/GraphQL/Settings.graphql +11 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/ProfileSettingsScreen.swift +30 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/ProfileSettingsViewModel.swift +51 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/SettingsAPI.swift +37 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/SettingsClient.swift +40 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/SettingsHomeScreen.swift +39 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/organisms/DeleteAccountButton.swift +30 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/organisms/SettingsRow.swift +56 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/organisms/SignOutButton.swift +23 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/pages/ChangelogPage.swift +59 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/pages/FeedbackSheet.swift +118 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/pages/ProfileSettingsPage.swift +90 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/pages/SettingsHomePage.swift +70 -0
- package/templates/ios/ios/{{APP_NAME}}/Info.plist +14 -0
- package/templates/ios/ios/{{APP_NAME}}/Localizable.xcstrings +1030 -0
- package/templates/ios/ios/{{APP_NAME}}/PrivacyInfo.xcprivacy +122 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/APIClient.swift +23 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/AcceptLanguageInterceptor.swift +20 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Components/atoms/AsyncButton.swift +73 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Components/atoms/AsyncToolbarButton.swift +69 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Components/atoms/ToolbarIconButton.swift +46 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Components/molecules/EmptyState.swift +132 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Components/molecules/LabeledInfoRow.swift +29 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Components/styles/CaptionLabelStyle.swift +45 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Components/styles/Copyable.swift +32 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Components/styles/EdgeToEdgeSeparator.swift +14 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Components/styles/RowLabelStyle.swift +58 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/CursorPage.swift +26 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/DataChanges.swift +19 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/ErrorPresenter.swift +62 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/ErrorReporting.swift +35 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/FeedbackReporting.swift +42 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/FirebaseTokenInterceptor.swift +49 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/GraphQLClient.swift +54 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/GraphQLHelpers.swift +128 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/GraphQLLoggingInterceptor.swift +28 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/LoadGate.swift +30 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Secrets.swift.example +9 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Services/AppSupportGate.swift +50 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Shell/StartupLoadingView.swift +23 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Shell/UpdateRequiredView.swift +24 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Showcase.swift +13 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/SnapshotCache.swift +78 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/UITestEnvironment.swift +70 -0
- package/templates/ios/ios/{{APP_NAME}}/{{APP_NAME}}.entitlements +10 -0
- package/templates/ios/ios/{{APP_NAME}}Tests/Onboarding/OnboardingGateTests.swift +38 -0
- package/templates/ios/ios/{{APP_NAME}}Tests/Settings/SettingsViewModelTests.swift +63 -0
- package/templates/ios/ios/{{APP_NAME}}Tests/Support/TestDoubles.swift +35 -0
- package/templates/ios/package.merge.json +10 -0
- package/templates/ios/scripts/apollo-codegen.ts +31 -0
- package/templates/ios/scripts/check-ios-components.ts +72 -0
- package/templates/ios/scripts/check-strings.ts +103 -0
- package/templates/ios/scripts/install-device.sh +29 -0
- 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
|
+
})
|