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