@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
package/rules/10-ios.md
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# iOS
|
|
2
|
+
|
|
3
|
+
SwiftUI, Swift 6 strict concurrency, Apollo iOS for typed operations generated into
|
|
4
|
+
`Generated/GraphQL/` (never edited), Firebase Auth with Sign in with Apple, Sentry. Xcode file
|
|
5
|
+
system synchronised groups: a new file is picked up without touching the project. Long form in the
|
|
6
|
+
`ios-feature` skill.
|
|
7
|
+
|
|
8
|
+
## The one rule
|
|
9
|
+
|
|
10
|
+
**A view is either pure or connected, and its name says which.**
|
|
11
|
+
|
|
12
|
+
- **Pure**: receives values and actions, draws them. No API call, no fetching `.task`, no
|
|
13
|
+
`.shared` singleton, no notification centre. Renders in a `#Preview` from fixtures alone.
|
|
14
|
+
Everything under `components/` and `Shared/Components/` is pure, and
|
|
15
|
+
`bun run ios:components` fails the build when it is not.
|
|
16
|
+
- **Connected**: the `{Feature}Screen` at the root of a feature. It owns the ViewModel, the
|
|
17
|
+
tasks, the change notices, the navigation and the sheets, and hands values and actions to its
|
|
18
|
+
`Page`. It draws nothing of its own.
|
|
19
|
+
|
|
20
|
+
## Layout
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
Shared/Components/{atoms,molecules,organisms,styles}/ design system, pure
|
|
24
|
+
Shared/Platform/ UIKit wrappers: camera, mail composer, web view
|
|
25
|
+
Shared/Shell/ the app around the tabs: launch curtain, update-required screen
|
|
26
|
+
Features/{Domain}/
|
|
27
|
+
{Feature}Screen.swift connected
|
|
28
|
+
{Feature}ViewModel.swift state and actions, init(client:)
|
|
29
|
+
{Domain}Client.swift struct of async closures, .live and .preview
|
|
30
|
+
{Domain}API.swift GraphQL operations to app models, the only file that sees Apollo types
|
|
31
|
+
{Domain}Models.swift Sendable app models
|
|
32
|
+
{Domain}Fixtures.swift preview and test data
|
|
33
|
+
components/{organisms,pages}/
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
- Domains follow the business, not the tabs. A component another domain needs is imported, not
|
|
37
|
+
copied.
|
|
38
|
+
- Atoms and molecules live only in `Shared/`; a domain holds organisms and pages. A component
|
|
39
|
+
small enough to be an atom carries no business meaning.
|
|
40
|
+
- Naming carries the category: `Screen` connected, `Page` pure whole screen, `Section`, `Card`,
|
|
41
|
+
`Strip`, `Row`, `Tile` for organisms and molecules. A file named `…View` says nothing; new
|
|
42
|
+
files never use it, except the UIKit wrappers named after what they wrap.
|
|
43
|
+
- One component per file, named after it. A private helper only that file uses may stay.
|
|
44
|
+
|
|
45
|
+
## Screen, page, action
|
|
46
|
+
|
|
47
|
+
- **One `onAction` per page, over an `enum`.** The screen's `switch` is exhaustive: an action
|
|
48
|
+
added to the page and forgotten in the screen does not compile. A page with a dozen optional
|
|
49
|
+
closures compiles with one left unwired. An organism with one or two actions takes plain
|
|
50
|
+
closures.
|
|
51
|
+
- An action the page waits on is `async`, awaited through `AsyncButton` / `AsyncToolbarButton`,
|
|
52
|
+
which draw the progress and disable the control. A page whose actions only open or switch takes
|
|
53
|
+
a plain closure.
|
|
54
|
+
- Navigation state (which sheet, which destination) lives in the screen; the page only says what
|
|
55
|
+
was tapped.
|
|
56
|
+
- Leaves take primitives (`String`, `Int?`, `Date?`, `Bool`, a logic-free enum, closures), never a
|
|
57
|
+
domain struct; an organism may take a domain model and is the mapping boundary. Beyond about
|
|
58
|
+
five parameters, a nested `Item` struct.
|
|
59
|
+
|
|
60
|
+
## ViewModel and client
|
|
61
|
+
|
|
62
|
+
- The client is a **struct of `@Sendable` closures** with `.live` and `.preview`, not a protocol:
|
|
63
|
+
a test builds the client it needs inline, overriding one closure.
|
|
64
|
+
- The ViewModel is `@MainActor @Observable`, state `private(set)`, errors through the shared
|
|
65
|
+
`reportError()`, never a generated type in sight.
|
|
66
|
+
- Business logic leaves the views: "look it up, then add it" exists once, as a client call or a
|
|
67
|
+
domain function; formatting a line is a pure function on the model.
|
|
68
|
+
- No singleton read in a component: app services are read by the screen and passed down, or
|
|
69
|
+
injected through the environment.
|
|
70
|
+
|
|
71
|
+
## Loading and failures
|
|
72
|
+
|
|
73
|
+
Every network call shows that it runs, except a refresh over content already on screen. A list
|
|
74
|
+
with nothing yet shows a `ProgressView` in its body, never full screen over the chrome. A button
|
|
75
|
+
that triggers a mutation shows its progress and is disabled while it runs.
|
|
76
|
+
|
|
77
|
+
- Over content already on screen, a failure says nothing: the rows stay. Replacing a good list
|
|
78
|
+
with an empty one reads as data loss. The error still goes to Sentry.
|
|
79
|
+
- Over an empty screen, `EmptyState.failure(title, message, retry)`.
|
|
80
|
+
- Every screen that loads is pullable: refresh is the retry.
|
|
81
|
+
- A cancellation is not a failure: check it and return.
|
|
82
|
+
|
|
83
|
+
## Snapshots
|
|
84
|
+
|
|
85
|
+
**Only for a screen that changes slowly and that the person recognises** (a library, a list of
|
|
86
|
+
series): it keeps what it showed last on disk and reopens on it. Never for a screen whose state
|
|
87
|
+
must be exact at opening (a quota, a subscription, an inventory count, a cart): a stale snapshot
|
|
88
|
+
there misleads. When it applies: read synchronously in the ViewModel's `init`, written after each
|
|
89
|
+
successful load off the main actor, one file per view and filter, **never more than the first
|
|
90
|
+
page**, versioned (bump when the type changes shape; an older file is ignored), cleared at
|
|
91
|
+
sign-out and account deletion. The refresh that follows animates rows into place. Images are
|
|
92
|
+
cached by what they are, not by their signed URL. No normalised client cache beyond this.
|
|
93
|
+
|
|
94
|
+
## Pagination
|
|
95
|
+
|
|
96
|
+
Long lists load by **cursor, never offset**: an offset shifts under a list that changed between
|
|
97
|
+
two pages. Page size is sized to the screen, not the dataset; the server caps a page. Prefetch
|
|
98
|
+
before the end, well below the page size. A load-more row closes the list while there is more,
|
|
99
|
+
with a retry once a page failed. **A page asked before a reload is never appended to the list that
|
|
100
|
+
replaced it**, and an edit deep in a list never shrinks it. The mechanisms Shiori uses for that
|
|
101
|
+
(generation token, reload keeping depth, patching a named row) are in the `ios-feature` skill as
|
|
102
|
+
a pattern to copy, not a requirement.
|
|
103
|
+
|
|
104
|
+
## Change notices
|
|
105
|
+
|
|
106
|
+
Every mutation goes through one helper that posts a single data-changed notice once the write
|
|
107
|
+
landed, carrying what changed when it is one row. Only screens listen; a page never subscribes.
|
|
108
|
+
Coming back to a tab asks nothing once the server has answered.
|
|
109
|
+
|
|
110
|
+
## Previews and tests
|
|
111
|
+
|
|
112
|
+
Every view has a `#Preview`: atoms, molecules, organisms, pages, one per state that matters.
|
|
113
|
+
Fixtures sit beside their type as `static let preview` or in `{Domain}Fixtures.swift`; tests reuse
|
|
114
|
+
them. A view that resists previewing is a design smell: pull the pure part out. Previews check
|
|
115
|
+
how a view looks; unit tests check what the app decides: ViewModels with an inline fake client
|
|
116
|
+
(state after a load, after a failure over rows and over nothing, a stale page dropped, a row
|
|
117
|
+
patched), and pure functions on models. Never a test of a SwiftUI `body`. A cached ViewModel takes
|
|
118
|
+
a no-cache option in tests. The unit test target runs in CI on every push.
|
|
119
|
+
|
|
120
|
+
## Design conventions
|
|
121
|
+
|
|
122
|
+
- Screens showing the same kind of object compose the same shared parts (the read-only header,
|
|
123
|
+
labelled info rows, the status banner); only known fields are drawn; never a bespoke layout.
|
|
124
|
+
- An upcoming or announced date is orange, an available one green. A destructive button's icon is
|
|
125
|
+
red too (a row label style keeps it accent blue unless coloured).
|
|
126
|
+
- Icons beside a title and a description sit at the **top** of the row, never centred.
|
|
127
|
+
- **No hint text under sections**: no footer explaining a picker. A message that matters in one
|
|
128
|
+
state is a row inside the section, shown only in that state.
|
|
129
|
+
- Toolbar actions are icon-only, titles kept for VoiceOver; cancel on the left, confirm on the
|
|
130
|
+
right.
|
|
131
|
+
- A tappable row answers on its whole width: a `.plain` button whose label carries
|
|
132
|
+
`.contentShape(.rect)`, set in the row view. Never `onTapGesture` in place of a button.
|
|
133
|
+
- Native components, no custom re-skins.
|
|
134
|
+
- **Accessibility**: every icon without text carries a label; every component is checked once
|
|
135
|
+
at the largest Dynamic Type size (text wraps, nothing is clipped, nothing overlaps); colours
|
|
136
|
+
never carry a meaning alone (orange and green dates also say it in words).
|
|
137
|
+
|
|
138
|
+
## Screen patterns
|
|
139
|
+
|
|
140
|
+
The shapes every app repeats, so they are not re-explained per project. Code in the
|
|
141
|
+
`ios-feature` skill and in the shared component package.
|
|
142
|
+
|
|
143
|
+
**A list row.** Image or icon on the left; the text block **top-aligned** beside it: title, one
|
|
144
|
+
secondary line, at most one tag; a status tag in the top corner when the list is not already
|
|
145
|
+
sectioned by it. The whole row opens the detail page. The one frequent action is a swipe action;
|
|
146
|
+
the context menu mirrors the detail page's menu. A row never carries more than three pieces of
|
|
147
|
+
information below its title: the rest belongs to the detail page.
|
|
148
|
+
|
|
149
|
+
**A detail page (fiche).** Opened from a row as a sheet, or pushed when it is the tab's own
|
|
150
|
+
object. From top to bottom: a header section (image, title, one line under it, the state), the
|
|
151
|
+
facts as labelled rows with an icon (long press copies the value), the long text, the actions
|
|
152
|
+
that belong to the object. **The toolbar**: close (`xmark`) on the left; on the right, the one
|
|
153
|
+
action the person takes over and over as its own icon (a favourite heart, a check), then a `…`
|
|
154
|
+
menu with the rest, edit first, delete last and red. A single fact is edited **inline in its
|
|
155
|
+
row** (tap, type, done), never through a form; the full edit form is one sheet from the menu.
|
|
156
|
+
Only known fields are drawn: an absent fact has no row, not an empty one.
|
|
157
|
+
|
|
158
|
+
**A tab's main screen.** Large title, icon-only actions on the right (add, filter, settings),
|
|
159
|
+
search attached to the list when there is one, sections with plain headers, the empty state
|
|
160
|
+
drawn in the list body with the one action that fills it.
|
|
161
|
+
|
|
162
|
+
**A form or a sheet that asks something.** Cancel (`xmark`) left, confirm (`checkmark`) right as
|
|
163
|
+
an `AsyncToolbarButton`; the first field focused on appear; a picker shows its current value in
|
|
164
|
+
its row; destructive confirmation as a dialog, never a toggle.
|
|
165
|
+
|
|
166
|
+
## Shared component package
|
|
167
|
+
|
|
168
|
+
The pure atoms, molecules and styles that carry no business meaning live in the shared Swift
|
|
169
|
+
package `PolyformsUI`, versioned by tag and tracked by Renovate like any dependency:
|
|
170
|
+
`AsyncButton`, `AsyncToolbarButton`, `ToolbarIconButton`, `EmptyState`, `LabeledInfoRow`,
|
|
171
|
+
`LoadMoreRow`, `MenuPicker`, `SegmentedBar`, `TagList`, `RowChip`, `WidgetCard`, star ratings,
|
|
172
|
+
the row and caption label styles, `Copyable`, the edge-to-edge separator, the AI glow. Each ships
|
|
173
|
+
with its preview, and the package carries a catalogue app showing every component in every
|
|
174
|
+
state. An app's `Shared/Components/` holds only what is specific to that app; a component that
|
|
175
|
+
turns out generic moves up to the package with a version bump. Organisms (a book row, a cover
|
|
176
|
+
tile) stay in their app: they carry the product.
|
|
177
|
+
|
|
178
|
+
## Build and capture
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
bun run ios:build
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
- Never clear the build cache to unblock a broken build; diagnose the cause.
|
|
185
|
+
- A build into a fresh derived data path needs `SWIFT_ENABLE_EXPLICIT_MODULES=NO` when Sentry's
|
|
186
|
+
import fails on CoreTelephony. Use a derived data path unique to the session for screenshot
|
|
187
|
+
builds: parallel sessions share the simulators and `/tmp`.
|
|
188
|
+
- `DEVELOPER_DIR` is required when `xcode-select` points at the Command Line Tools.
|
|
189
|
+
- A fresh worktree lacks the gitignored generated client, secrets file, Firebase plist and
|
|
190
|
+
package resolution: copy them from the main checkout.
|
|
191
|
+
- Apollo codegen is the CLI bundled in the package checkout under derived data, not on PATH; after
|
|
192
|
+
a `.graphql` change, run it before any build or install. Loop until the namespace file exists.
|
|
193
|
+
|
|
194
|
+
## Physical device
|
|
195
|
+
|
|
196
|
+
The install on the person's phone closes a task that touched the app, once the screenshot of the
|
|
197
|
+
change is approved, never before:
|
|
198
|
+
|
|
199
|
+
- **App only**: install right away, before any push. The phone talks to production, which has
|
|
200
|
+
not changed.
|
|
201
|
+
- **Backend and app**: install after the push, once the Deploy workflow is green. Installing
|
|
202
|
+
earlier ships an app against a server that does not have its changes yet.
|
|
203
|
+
|
|
204
|
+
Clean tree: run the install script in place. Dirty tree with another session's work: build HEAD
|
|
205
|
+
from a worktree. Run the API codegen first after a schema change. Relay the raw build and install
|
|
206
|
+
output; no success claim without it.
|
package/rules/11-ops.md
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# Operations
|
|
2
|
+
|
|
3
|
+
## Deployment goes through CI only
|
|
4
|
+
|
|
5
|
+
Never `terraform apply`, never a backend deploy, never a migration from the local machine. The
|
|
6
|
+
Deploy workflow on a push to `main` is the only path to production: build, `terraform
|
|
7
|
+
apply`, then `POST /admin/migrate`. If production looks stale, `gh run list --workflow deploy.yml`
|
|
8
|
+
first; fix the pipeline, never the machine.
|
|
9
|
+
|
|
10
|
+
## Migrations
|
|
11
|
+
|
|
12
|
+
Forward-only, sequential, no rollback, in `server/system/migration/migrations/`, registered in
|
|
13
|
+
its index, tracked in a `migration-meta` collection, triggered by `POST /admin/migrate` from the
|
|
14
|
+
Deploy workflow right after `terraform apply`. After a push that adds one, read that step's log
|
|
15
|
+
line (`{"outcome":"migrated","from":7,"to":8,"applied":1}`) instead of telling anyone to run it.
|
|
16
|
+
|
|
17
|
+
Between the push and the end of the deploy, a local server runs new code against unmigrated data:
|
|
18
|
+
errors in that window are expected.
|
|
19
|
+
|
|
20
|
+
**Migrate when** renaming a field, changing its structure, changing enum values, removing stale
|
|
21
|
+
data. **No migration** for a new optional field, a new collection, or changed query logic. Every
|
|
22
|
+
migration has its `.int.test.ts`.
|
|
23
|
+
|
|
24
|
+
## Indexes
|
|
25
|
+
|
|
26
|
+
Indexes follow queries: when a query changes, add the indexes it needs and drop the dead ones.
|
|
27
|
+
Only the necessary: a sort done in memory after the read needs none. The index file is the source
|
|
28
|
+
of truth read by Terraform, not a console action.
|
|
29
|
+
|
|
30
|
+
## API evolution
|
|
31
|
+
|
|
32
|
+
The GraphQL schema is not versioned: installed builds keep asking for what they were built
|
|
33
|
+
against, for weeks after a release. So the schema only grows, and what the app stops using leaves
|
|
34
|
+
in two steps.
|
|
35
|
+
|
|
36
|
+
1. **Deprecate, in the commit where the app stops asking.** The operations stop selecting the
|
|
37
|
+
field or argument; the server marks it with a `deprecationReason` that names the version and
|
|
38
|
+
the date it was deprecated and what replaces it (`"since 1.3 (2026-10-21): use coverSources"`).
|
|
39
|
+
The resolver keeps working. `bun run schema:check` in CI refuses any breaking change that
|
|
40
|
+
was not deprecated first.
|
|
41
|
+
2. **Remove, once no build on sale asks for it.** `bun run deprecations` finds, for each
|
|
42
|
+
deprecated element, the first build whose operations no longer select it and compares it
|
|
43
|
+
with what the store sells: removable when a build without it has been on sale for **fourteen
|
|
44
|
+
days**, in grace until then, waiting while the store still sells a build that asks for it.
|
|
45
|
+
One commit removes the removable elements (schema, resolver, the code and tests only they
|
|
46
|
+
used) and raises the minimum supported build to the floor the script proposes, which sends
|
|
47
|
+
older builds to the update screen at their next launch. Several releases in one day change
|
|
48
|
+
nothing: the fourteen days count from the release that stopped asking, not from the next one.
|
|
49
|
+
|
|
50
|
+
The removal runs as a **scheduled routine** (monthly by default, see
|
|
51
|
+
[16-routines.md](16-routines.md)), and may be run by hand at a release. The comparison key is the
|
|
52
|
+
build number, never the marketing version. The gate fails open: if the configuration is
|
|
53
|
+
unreachable, the app stays usable. A deploy whose floor is above the build on sale fails: readers
|
|
54
|
+
must never be blocked with no update to install.
|
|
55
|
+
|
|
56
|
+
| Change | Breaks installed builds | What to do |
|
|
57
|
+
|---|---|---|
|
|
58
|
+
| Added field, query, mutation, optional argument, enum value | No | Nothing: an unknown enum value is read as a neutral one by the app |
|
|
59
|
+
| Field or argument the app stops using | No | Deprecate it in the same commit |
|
|
60
|
+
| Removal, rename, type change, argument made required, non-null made nullable | Yes | Deprecate first; the routine removes it fourteen days after a build without it is on sale |
|
|
61
|
+
|
|
62
|
+
## Scale to zero
|
|
63
|
+
|
|
64
|
+
Never a warm instance (`min_instance_count`), whatever the p95 says. Scaling to zero is the point;
|
|
65
|
+
cold starts are absorbed by the launch screen, and when traffic grows the users keep instances
|
|
66
|
+
warm. A p95 driven by cold starts is a figure to report, not an action item.
|
|
67
|
+
|
|
68
|
+
## Monitoring and Sentry
|
|
69
|
+
|
|
70
|
+
One Sentry project per side (`<app>-node`, `<app>-ios`), org `polyforms`. Triage of an issue:
|
|
71
|
+
|
|
72
|
+
1. Load the token from the local environment, never print or commit it.
|
|
73
|
+
2. Read the issue and its latest event through the API.
|
|
74
|
+
3. Walk the stack to the domain code, fix the cause, not the symptom.
|
|
75
|
+
4. For a serious functional bug, write the failing test before the fix.
|
|
76
|
+
5. Verify locally, commit.
|
|
77
|
+
6. Mark the issue `resolvedInNextRelease` (never plain `resolved`, so a regression is flagged)
|
|
78
|
+
only after the push **and** a green pipeline.
|
|
79
|
+
|
|
80
|
+
Source maps upload is part of the release workflow, never asked again.
|
|
81
|
+
|
|
82
|
+
## Backups and recovery
|
|
83
|
+
|
|
84
|
+
The `(default)` database has delete protection and seven-day point-in-time recovery. Until the
|
|
85
|
+
app has paying users, there is no backup outside it; from the first paying user, a weekly
|
|
86
|
+
managed export of the database to a bucket of the project (`gcloud firestore export`, run by
|
|
87
|
+
Cloud Scheduler, 90-day lifecycle on the bucket) is provisioned by Terraform: a deletion found on
|
|
88
|
+
the eighth day is then still recoverable. When data is lost (deleted documents, a failed
|
|
89
|
+
migration, a deleted database or project) follow the project's `docs/data-recovery.md` before
|
|
90
|
+
acting, and never restore the whole database over live writes.
|
|
91
|
+
|
|
92
|
+
## Secrets
|
|
93
|
+
|
|
94
|
+
A secret lives in the gitignored local `.env`, with `.env.example` beside it, and in the CI's
|
|
95
|
+
secret store. The chain to production: GitHub secret, Terraform variable, Secret Manager, function
|
|
96
|
+
environment. Every place a token is replicated is listed in `project-context.md`; a rotation that
|
|
97
|
+
forgets one is found by an outage. A local API key is distinct from production's; keys are
|
|
98
|
+
compared by fingerprint (`printf '%s' "$k" | shasum`), never displayed.
|
|
99
|
+
|
|
100
|
+
## Dependencies
|
|
101
|
+
|
|
102
|
+
Renovate, self-hosted in a nightly workflow: `config:recommended`, non-major updates grouped in
|
|
103
|
+
one pull request automerged when CI is green, weekly lockfile maintenance. Every
|
|
104
|
+
`allowedVersions` bound carries its reason in `description` (a peer dependency the deploy's
|
|
105
|
+
stricter package manager rejects, a framework not yet on the new major). A strict install
|
|
106
|
+
replayed on the build artefact catches what the permissive local install let through.
|
|
107
|
+
|
|
108
|
+
**Majors are never automerged.** The upgrade routine ([16-routines.md](16-routines.md)) takes
|
|
109
|
+
every open major pull request each morning: it reads the dependency's changelog and migration
|
|
110
|
+
guide, applies the code changes the new major requires, deploys the branch to the project's
|
|
111
|
+
staging environment ([14-infra.md](14-infra.md#staging)) to prove the build and the deploy, and
|
|
112
|
+
merges when CI and the staging deploy are green; otherwise it leaves the pull request open with a
|
|
113
|
+
comment saying what blocks. The package of this stack follows the same path with its own label,
|
|
114
|
+
`stack`.
|
package/rules/12-ci.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Continuous integration
|
|
2
|
+
|
|
3
|
+
One workflow per concern, so a red run names its cause and a change touches one file. Templates
|
|
4
|
+
in `templates/.github/workflows/`.
|
|
5
|
+
|
|
6
|
+
| Workflow | Trigger | What it does |
|
|
7
|
+
|---|---|---|
|
|
8
|
+
| `test-unit`, `test-int`, `test-feat` | push, pull request | One test family each, run separately on purpose |
|
|
9
|
+
| `schema` | push, pull request | Schema compatibility against `main`: a removal without a prior deprecation fails |
|
|
10
|
+
| `build-ios` | push touching `ios/**` or the schema | Builds the app, runs the unit test target, the strings catalogue check and the pure-components check; on `main`, also produces the signed archive and keeps it as a short-lived artifact |
|
|
11
|
+
| `deploy` | push to `main`; pull request labelled `staging` | Build, `terraform apply`, migrations; to production from the branch, to staging from a labelled pull request; `paths-ignore` for prose |
|
|
12
|
+
| `testflight-ios` | nightly, manual | The nightly: takes the archive `build-ios` produced for the latest green commit of `main` (no second build), uploads it once a night when `main` moved since the last upload, to the internal group, then to the external group through Beta App Review, under the version under development read from the changelog |
|
|
13
|
+
| `release-ios` | tag `ios-v<version>` | Version guard against the changelog, panels upload, submission, source maps |
|
|
14
|
+
| `pages` | push touching `site/` | Publishes the site |
|
|
15
|
+
| `renovate` | nightly, manual | Self-hosted Renovate with its own token |
|
|
16
|
+
|
|
17
|
+
## Rules
|
|
18
|
+
|
|
19
|
+
- The build number is the run number. The marketing version is read from the project.
|
|
20
|
+
- `paths-ignore` on deploy, schema and the tests for `site/**`, `docs/**`, `screenshots/**`,
|
|
21
|
+
`README.md`, `CLAUDE.md`; never for the served changelogs.
|
|
22
|
+
- A workflow authenticates to Google Cloud through Workload Identity Federation, never a key file.
|
|
23
|
+
- Secrets are repository secrets; configuration that is not secret (a billing table, an analytics
|
|
24
|
+
property) is a repository variable.
|
|
25
|
+
- The iOS archive signs manually with the certificate and profile stored as secrets; the store API
|
|
26
|
+
key uploads and submits.
|
|
27
|
+
- **One build per commit.** The app is compiled once per commit of `main`, by
|
|
28
|
+
`build-ios`, as the archive; the nightly uploads that artifact and runs no macOS job of its
|
|
29
|
+
own. Only the release workflow archives again, on its tag. (Shiori still builds twice today:
|
|
30
|
+
this is a migration of the package.)
|
|
31
|
+
- A failing lint or test in CI is a process failure: it should have been caught before the push
|
|
32
|
+
([04-push.md](04-push.md)).
|
package/rules/13-ai.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# AI and paid calls
|
|
2
|
+
|
|
3
|
+
## State the cost before building
|
|
4
|
+
|
|
5
|
+
Any feature that calls a paid API states its cost in its design document and in its commit
|
|
6
|
+
message before it is built: how many calls the first run makes, how many per day or month after,
|
|
7
|
+
multiplied out (rows × formats × languages × searches per call) and priced. A background pass is
|
|
8
|
+
sized to what a reader is shown, never to a whole dataset: looking up every winner since 1953 to
|
|
9
|
+
show a dozen books cost 526 calls and 25 euros in four hours.
|
|
10
|
+
|
|
11
|
+
## Gemini
|
|
12
|
+
|
|
13
|
+
- A grounded call bills about **four searches**, not one; the logged search count undercounts
|
|
14
|
+
them. Searches are billed from the first one on a project that has spent its monthly free
|
|
15
|
+
allowance (5,000 per GCP project).
|
|
16
|
+
- One API key per GCP project, on the project's own prepaid billing account, carried GitHub
|
|
17
|
+
secret → Terraform → Secret Manager. Never a key shared between apps: one app's burst cuts
|
|
18
|
+
every other.
|
|
19
|
+
- A local key, distinct from production's, set for every Claude session on the machine; before
|
|
20
|
+
blaming production for a bill, compare fingerprints.
|
|
21
|
+
- Two limits, two errors: 402 when the prepaid balance is spent, 429 when the monthly cap is
|
|
22
|
+
reached. The logged error says which.
|
|
23
|
+
- The invoice is the truth: read spend from the billing export in BigQuery, grouped by day and
|
|
24
|
+
SKU; the grounded-search SKU is the one that costs. Structured logs (`jsonPayload.message`)
|
|
25
|
+
with the step and the search count let a spike be attributed to a device and an hour.
|
|
26
|
+
- Past the free searches, grounded steps switch to Claude Haiku with web search capped at two
|
|
27
|
+
searches per call; a monthly counter decides.
|
|
28
|
+
- Image generation for store panels: prototype at 1K or 2K, 4K only for a kept scene; the model
|
|
29
|
+
draws dividing lines when asked for several panels in one image.
|
|
30
|
+
|
|
31
|
+
## Choosing a model
|
|
32
|
+
|
|
33
|
+
The method is the rule; the choices are a dated table, revisited at every major version of this
|
|
34
|
+
package and whenever a provider changes its prices or models.
|
|
35
|
+
|
|
36
|
+
- **A quality test before any change of model**, upgrade included, and before adopting a new
|
|
37
|
+
one: the project keeps a bench script that runs the real task on about thirty real items
|
|
38
|
+
(`scripts/bench-*.ts`), compares the candidates on quality and on cost per item, and the result
|
|
39
|
+
goes into the commit message. A blind test by the person decides when quality is a matter of
|
|
40
|
+
taste (recommendations, copy).
|
|
41
|
+
- **The cheapest model that gives the same quality wins.** At every change, the next cheaper
|
|
42
|
+
model is in the bench; a more expensive one is adopted only when the bench shows the quality
|
|
43
|
+
the cheaper one does not reach.
|
|
44
|
+
- A source that is free (a national library's catalogue) is benched against the paid model
|
|
45
|
+
before the paid model is kept.
|
|
46
|
+
|
|
47
|
+
Choices as of 2026-10-21:
|
|
48
|
+
|
|
49
|
+
| Need | Model | Why |
|
|
50
|
+
|---|---|---|
|
|
51
|
+
| Structured extraction from a photo or a page | Gemini Flash-Lite | Cheapest with vision, good enough for fields |
|
|
52
|
+
| Edition data with web grounding | Gemini grounded, then Claude Haiku + web search past the free tier | Cost of searches |
|
|
53
|
+
| Editorial recommendation from the person's own data | Claude Sonnet | Blind test won against Haiku, Opus and Gemini Pro; about 0.035 euro per generation |
|
|
54
|
+
| Choosing a month's picks, text only | Claude Sonnet | Same |
|
|
55
|
+
|
|
56
|
+
Google Books and Open Library are not edition sources (503 on most calls, wrong ISBNs, no French
|
|
57
|
+
editions); BnF's SRU serves French named books for free.
|
|
58
|
+
|
|
59
|
+
## A cap in production
|
|
60
|
+
|
|
61
|
+
Estimating before building does not protect from a loop or a burst. Every paid call, whether a
|
|
62
|
+
background pass or a user action triggers it, goes through a counter with a **daily cap per
|
|
63
|
+
project and per step** (Shiori's release watch: 30 a day); past the cap the step logs an `error`
|
|
64
|
+
and stops instead of continuing, and the daily health routine reports the counter. A user-facing
|
|
65
|
+
action is metered per user as well (a monthly quota on the free plan, an anti-abuse ceiling on the
|
|
66
|
+
paid one).
|
|
67
|
+
|
|
68
|
+
## Keys in the app
|
|
69
|
+
|
|
70
|
+
The app never holds an AI key: every call goes through the backend, which meters it (a free
|
|
71
|
+
monthly quota per user, an anti-abuse ceiling on the paid plan the app never shows).
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# Infrastructure
|
|
2
|
+
|
|
3
|
+
Terraform in `infra/`, one GCP project per app named `<app>-polyforms`, region `europe-west3`,
|
|
4
|
+
Firestore `eur3`. Templates in `templates/infra/`.
|
|
5
|
+
|
|
6
|
+
| File | What it provisions |
|
|
7
|
+
|---|---|
|
|
8
|
+
| `project.tf`, `versions.tf`, `state.tf`, `backend.tf` | The project, the providers, the remote state bucket |
|
|
9
|
+
| `firebase.tf`, `auth.tf` | The Firebase app, Sign in with Apple |
|
|
10
|
+
| `firestore.tf` | The database with delete protection and point-in-time recovery, the indexes |
|
|
11
|
+
| `function.tf` | The Cloud Function gen 2 (Node 22) serving the Nitro build, scale to zero |
|
|
12
|
+
| `secrets.tf`, `variables.tf` | Secret Manager entries fed by Terraform variables from GitHub secrets |
|
|
13
|
+
| `cicd.tf` | Workload Identity Federation for the GitHub workflows |
|
|
14
|
+
| `scheduler.tf` | Cloud Scheduler jobs calling the backend's scheduled routes |
|
|
15
|
+
| `storage.tf` | Buckets for uploads, signed URLs |
|
|
16
|
+
| `ios.tf` | Resources the app needs (APNs key, bundle configuration) |
|
|
17
|
+
| `observer.tf` | A read-only service account for unattended production reads |
|
|
18
|
+
| `outputs.tf` | What the workflows and the app read back |
|
|
19
|
+
|
|
20
|
+
## Staging
|
|
21
|
+
|
|
22
|
+
Every app has a second GCP project, `<app>-staging-polyforms`, provisioned by the same Terraform
|
|
23
|
+
(a workspace, same files, smaller quotas), scale to zero so it costs nothing idle. It is the
|
|
24
|
+
production-like environment where anything is proven before production: a pull request labelled
|
|
25
|
+
`staging` (every major dependency bump, every stack upgrade, anything touching infrastructure) is
|
|
26
|
+
deployed there by the Deploy workflow; the end-to-end scenarios and any test that needs a real
|
|
27
|
+
stack run against it; a seeded dataset and its own Sentry environment; the app points at it
|
|
28
|
+
through its server-URL override. Nothing in staging is ever production data.
|
|
29
|
+
|
|
30
|
+
It exists from the moment an app has real users to protect, and in any case before the first
|
|
31
|
+
paid plan: with a handful of readers, production is the staging. Bazar is built with it from the
|
|
32
|
+
start.
|
|
33
|
+
|
|
34
|
+
## Bootstrap: one line creates a project
|
|
35
|
+
|
|
36
|
+
`bunx @moifort/stack bootstrap <app>` runs once from the person's machine and leaves a project
|
|
37
|
+
that deploys itself. It is a command of the package's CLI (TypeScript on Bun, like the rest), not
|
|
38
|
+
a shell script: it reads the keychain, drives `terraform` (installed once on the machine, see
|
|
39
|
+
[15-tooling.md](15-tooling.md)), `gh` and `git`, and reports. Two Terraform roots, because two
|
|
40
|
+
kinds of credentials:
|
|
41
|
+
|
|
42
|
+
| Root | Runs | Credentials | Creates |
|
|
43
|
+
|---|---|---|---|
|
|
44
|
+
| `infra/org/` | From the machine, at bootstrap and when the organisation-level setup changes | The person's organisation tokens, read from the macOS keychain for the duration of the run | The GitHub repository (settings, Pages, labels, branch rules), its secrets and variables, the Sentry projects (`<app>-node`, `<app>-ios`) with their DSNs written straight into the GitHub secrets, the Renovate installation, the GCP project and the state bucket |
|
|
45
|
+
| `infra/` | By the Deploy workflow, through Workload Identity Federation | None on the machine | Everything in the table above |
|
|
46
|
+
|
|
47
|
+
- **Keychain, not files.** The organisation tokens (GitHub, Sentry, App Store Connect) live in
|
|
48
|
+
the macOS keychain under fixed item names (`polyforms.github`, `polyforms.sentry`,
|
|
49
|
+
`polyforms.asc`), read with `security find-generic-password -w` into the environment of the
|
|
50
|
+
bootstrap process only, never written to a file, never printed, never committed. Each token is
|
|
51
|
+
fine-grained and scoped to the organisation, with an expiry, and its creation date is in the
|
|
52
|
+
person's `project-context.md`.
|
|
53
|
+
- **The CI never holds a personal token.** What a workflow needs at run time is a repository
|
|
54
|
+
secret Terraform wrote (a DSN, a store API key) or a token it mints itself: Renovate runs
|
|
55
|
+
with a GitHub App installed on the organisation (the workflow mints a short-lived token from
|
|
56
|
+
the app's private key, stored once as an organisation secret), not with a personal token
|
|
57
|
+
that expires.
|
|
58
|
+
- **Replayable.** Every step of the bootstrap checks whether it is already done and skips it,
|
|
59
|
+
so a run that failed halfway is simply run again. It never deploys the backend itself: it
|
|
60
|
+
ends by pushing the first commit and letting the Deploy workflow do the first deploy.
|
|
61
|
+
- **What it cannot do, it lists.** The bootstrap ends with a numbered list of the steps that
|
|
62
|
+
need the person: the App Store Connect app record and the Sign in with Apple key (no API),
|
|
63
|
+
the Apple developer agreement, the DNS of the project's domain, the GA4 link when the API
|
|
64
|
+
refuses it. Each item says where to click and what value to paste back.
|
|
65
|
+
- `bunx @moifort/stack teardown <app>` destroys both roots, after a typed confirmation of the
|
|
66
|
+
app's name.
|
|
67
|
+
|
|
68
|
+
## Rules
|
|
69
|
+
|
|
70
|
+
- The Terraform state is remote and never committed. `terraform.tfvars` is gitignored, with its
|
|
71
|
+
`.example`.
|
|
72
|
+
- Secrets reach the function through Secret Manager only; the workflow never writes a key into
|
|
73
|
+
an environment file.
|
|
74
|
+
- **One observer account for Claude, across every project** of the organisation
|
|
75
|
+
(`claude-observer`, in the neutral `polyforms-billing` project below), granted the read roles
|
|
76
|
+
(logging, monitoring, scheduler, functions, run, Firestore viewer, BigQuery job user and data
|
|
77
|
+
viewer) on each app project by that project's Terraform. Writes answer permission denied,
|
|
78
|
+
checked. It lives in its own `gcloud` configuration (`CLOUDSDK_CONFIG`) so the person's
|
|
79
|
+
account is untouched and routines do not depend on their login, which expires. Its key is on
|
|
80
|
+
the machine only, outside every repository.
|
|
81
|
+
- **A neutral `polyforms-billing` GCP project** holds the billing export dataset and the
|
|
82
|
+
observer account, so that deleting an app never takes the invoice or the reader of every
|
|
83
|
+
other app with it. Each project's runtime account gets `dataViewer` on the dataset through SQL
|
|
84
|
+
`GRANT`, the IAM binding command being allowlisted. (Today the export lives in Vinarium's
|
|
85
|
+
project: moving it is a migration of this package.)
|
|
86
|
+
- Analytics: the Firebase project is linked to a GA4 property at creation, or sessions go
|
|
87
|
+
nowhere; the property id is a repository variable.
|
|
88
|
+
- Never a warm instance, see [11-ops.md](11-ops.md#scale-to-zero).
|
|
89
|
+
- APNs keys and Sign in with Apple keys live in `infra/`, gitignored; they are not the store API
|
|
90
|
+
key.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Tooling on the development machine
|
|
2
|
+
|
|
3
|
+
## What a machine needs
|
|
4
|
+
|
|
5
|
+
Installed once; the bootstrap checks they are there and names what is missing.
|
|
6
|
+
|
|
7
|
+
| Tool | Install | Used for |
|
|
8
|
+
|---|---|---|
|
|
9
|
+
| Bun | `curl -fsSL https://bun.sh/install \| bash` | Backend, scripts, this package's CLI |
|
|
10
|
+
| Xcode, latest final | Mac App Store | The app; `sudo xcode-select -s /Applications/Xcode.app/Contents/Developer` once, the person's to run |
|
|
11
|
+
| `gh` | `brew install gh`, then `gh auth login` | GitHub: runs, pull requests, secrets |
|
|
12
|
+
| `gcloud` | `brew install --cask google-cloud-sdk` | Production reads through the observer configuration |
|
|
13
|
+
| Terraform | `brew install terraform` | The bootstrap and local plans |
|
|
14
|
+
| `apollo-ios-cli` | Bundled in the app's Swift package checkout, never a global install | GraphQL codegen for the app |
|
|
15
|
+
| JDK and Firebase CLI | Only with the `e2e` stack | The emulators |
|
|
16
|
+
|
|
17
|
+
## Runtime
|
|
18
|
+
|
|
19
|
+
Always `bun` / `bunx`, never `npm` / `npx`. Biome for formatting and linting: spaces, single
|
|
20
|
+
quotes, no semicolons, line width 100.
|
|
21
|
+
|
|
22
|
+
## Xcode and simulators
|
|
23
|
+
|
|
24
|
+
- Build with the latest final Xcode. `DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer`
|
|
25
|
+
is required for `xcodebuild`, `simctl` and `xcstringstool` while `xcode-select` points at the
|
|
26
|
+
Command Line Tools; switching it needs the person's password.
|
|
27
|
+
- **Always the simulator runtime already on the machine.** The reference device is the iPhone of
|
|
28
|
+
the runtime `xcrun simctl list runtimes` shows installed; a build or a capture targets that
|
|
29
|
+
one. Never download another runtime or platform without asking: each is several gigabytes.
|
|
30
|
+
When one is needed: only the version matching the installed SDK, from the command line
|
|
31
|
+
(`xcodebuild -downloadPlatform <name>`), never through Xcode's UI, one download at a time
|
|
32
|
+
(`pgrep -f downloadPlatform` first; several block each other on "Preparing to download").
|
|
33
|
+
Progress is the size of `/Library/Developer/CoreSimulator/Volumes`, not the program's messages.
|
|
34
|
+
- A download stuck on "Preparing to download" with no byte transferred is the network first:
|
|
35
|
+
macOS suspends system asset downloads on a connection it considers cellular, an iPhone
|
|
36
|
+
hotspot included, silently. Check the interface and the Wi-Fi name before anything else; the
|
|
37
|
+
daemon is not broken.
|
|
38
|
+
- **One simulator at a time on the machine.** Every session and every project uses the same
|
|
39
|
+
reference device, booted on demand if it is not, and never shut down by a session: the push
|
|
40
|
+
hook shuts it, with Simulator.app. A session installs its own build into it right before each
|
|
41
|
+
launch, from a derived data path unique to the session, so two sessions sharing the device
|
|
42
|
+
never launch each other's build. Two booted simulators is the exception that needs a reason
|
|
43
|
+
(a capture on a second size).
|
|
44
|
+
- `simctl io <udid> screenshot` works when the simulator tool's own capture fails.
|
|
45
|
+
|
|
46
|
+
## Browser, and doing things end to end
|
|
47
|
+
|
|
48
|
+
Always the built-in browser of the Claude app, never Claude in Chrome, even when asked to "go to
|
|
49
|
+
Chrome". When the person says "go to the browser and click here", Claude goes and clicks: it
|
|
50
|
+
drives the browser, fills the forms, runs the commands, and goes to the end of the task. When a
|
|
51
|
+
page needs a sign-in, it asks the person to sign in there, then continues. Some console actions
|
|
52
|
+
have no API (a price schedule, privacy answers, a subscription submission): they are done in that
|
|
53
|
+
browser, signed in as the person.
|
|
54
|
+
|
|
55
|
+
The only things that wait for a go, said in one message with what will be done and why:
|
|
56
|
+
**reading production data** and **anything that changes production** (a deploy, a console
|
|
57
|
+
setting, a store submission, a purchase). Everything else (code, commands, builds, the person's
|
|
58
|
+
own sites and consoles in read mode, GitHub, CI) is done without asking.
|
|
59
|
+
|
|
60
|
+
## Git and GitHub
|
|
61
|
+
|
|
62
|
+
- `gh` authenticates with a keyring token; a push changing workflow files may need SSH
|
|
63
|
+
([04-push.md](04-push.md#workflow-files)).
|
|
64
|
+
- Worktrees for parallel sessions, with the gitignored files copied in
|
|
65
|
+
([02-commits.md](02-commits.md#several-sessions-on-one-project)).
|
|
66
|
+
- The App Store Connect API key for scripts lives in `~/.appstoreconnect/private_keys/`, shared
|
|
67
|
+
by every app of the team; its id and issuer are in each project's `project-context.md`.
|
|
68
|
+
|
|
69
|
+
## Remote control
|
|
70
|
+
|
|
71
|
+
Driving a project from the Claude app on a phone is a CLI process per project
|
|
72
|
+
(`claude remote-control --name <app> --permission-mode auto --spawn same-dir`), with its own
|
|
73
|
+
`claude auth login`, relaunched after a reboot. Check it with `pgrep -fl "claude remote-control"`.
|
|
74
|
+
|
|
75
|
+
## Reading production
|
|
76
|
+
|
|
77
|
+
Unattended reads (logs, metrics, billing, Firestore) go through the **Claude observer account**,
|
|
78
|
+
one for every Polyforms project ([14-infra.md](14-infra.md)), in its own `gcloud` configuration;
|
|
79
|
+
`gcloud auth login` is the person's and expires. In an interactive session, a production read
|
|
80
|
+
is announced and waits for the go; a routine the person configured has its go in its prompt.
|
|
81
|
+
Firestore's REST API needs `Content-Type: application/json` or answers a misleading 400.
|