Author SHA1 Message Date
jjbrunton 5ebf57bdbf Merge branch 'staging' into task/ios-export-compliance 2026-08-30 10:07:10 +01:00
jjbrunton 089e629021 docs(agents): delegate substantive repository tasks 2026-08-30 10:06:37 +01:00
jjbrunton 58b70710db chore(ios): declare exempt encryption 2026-08-30 09:58:26 +01:00
jjbrunton d645d83c47 chore(ios): record v1.0.6 build 44 2026-08-30 01:29:55 +01:00
jjbrunton 179f912170 chore(android): record v1.0.6 build 48 2026-08-30 01:05:13 +01:00
jjbrunton a604f82110 chore(release): prepare v1.0.6 2026-08-30 00:46:46 +01:00
jjbrunton ae8e72ddb5 Merge pull request #10 from jjbrunton/staging
chore(release): complete mobile release configuration
2026-08-30 00:41:09 +01:00
jjbrunton 2e4cddd1c8 fix(mobile): restore iOS SDK 54 runtime 2026-08-30 00:29:45 +01:00
jjbrunton 440f224095 docs(release): require upload confirmation 2026-08-30 00:28:14 +01:00
jjbrunton 22871d037c chore(release): configure store submissions
Route Android uploads to an internal draft and bind iOS submissions to the verified App Store Connect app. Document explicit-path local artifact submission and preserve separate release authorization.
2026-08-30 00:13:40 +01:00
jjbrunton fd38b303f0 Merge pull request #9 from jjbrunton/staging
fix(api): keep auth sync timestamps monotonic
2026-08-29 23:57:39 +01:00
jjbrunton 266233a473 fix(api): keep auth sync timestamps monotonic
Use PostgreSQL's clock for both the initial auth sync timestamp and later identity refreshes so host clock skew cannot move the marker backwards.
2026-08-29 23:45:39 +01:00
jjbrunton 387b242aea Merge pull request #8 from jjbrunton/staging
chore(release): promote staging for v1.0.6
2026-08-29 23:34:30 +01:00
jjbrunton 2bc69c2790 feat(mobile): surface pending questions
Add a conditional Home cue for partner-answered questions and route both the card and catch-up reminders into the pending answer flow. Track prompt engagement and cover hidden, visible, CTA, and notification behavior.
2026-08-29 23:24:19 +01:00
jjbrunton 9da44217f7 feat(release): add generation workflow
Add a guarded mobile release skill that synchronizes public versions, writes on-brand notes, verifies signed iOS and Android artifacts, and tags the verified release commit.
2026-08-29 23:22:35 +01:00
jjbrunton 5f77b06044 test(coverage): enforce API and app floors
Measure untouched mobile source, add regression coverage for native media paths and account operations, and enforce ratcheting API and mobile thresholds in required CI checks.
2026-08-29 22:50:07 +01:00
jjbrunton 5c973c4ac5 perf(api): bound worker pools and add telemetry
Share one configured PostgreSQL pool across worker repositories and make API and worker connection budgets explicit.

Add privacy-safe aggregate request, query, pool, sync, and outbox telemetry, a loopback-only representative load fixture, bounded worker shutdown, and focused regression and integration coverage.

Document the database-wide budget formula and defer pg_stat_statements activation to a reviewed migration and planned restart.
2026-08-29 22:43:27 +01:00
jjbrunton a13bbc3f65 docs(agents): require published completion 2026-08-29 21:59:50 +01:00
jjbrunton 10c4ae8d84 docs(agents): require isolated implementation 2026-08-29 21:58:13 +01:00
jjbrunton ac4a05d5ed chore(staging): reconcile main ancestry 2026-08-29 21:49:46 +01:00
jjbrunton c7ee8a4851 perf(app): reduce redundant data fetching
Share bounded database pools across API repositories, consolidate sync and chat queries, and make mobile refreshes cache-aware and foreground-aware.

Add focused regression coverage, client freshness documentation, and repository agent role configuration.
2026-08-29 21:47:27 +01:00
jjbrunton 9957fa43f2 feat(agents): add Claude delegation skill
Add a repository-owned Codex skill and helper for bounded Claude Code execution, model and effort selection, resumable follow-ups, and independent acceptance checks.
2026-08-29 18:29:29 +01:00
jjbrunton 502452c9e4 Merge pull request #4 from jjbrunton/staging
chore(release): promote staging to production
2026-08-29 00:12:03 +01:00
jjbrunton e6bb368bc8 Merge pull request #7 from jjbrunton/task/production-cutover-import
feat(api): harden production data import
2026-08-29 00:08:15 +01:00
jjbrunton aedba2a480 feat(api): harden production data import
Preserve live Dokploy state while importing legacy Supabase data. Add schema parity, safe relationship reconciliation, storage quarantine, and production-shaped integration coverage.
2026-08-29 00:04:11 +01:00
jjbrunton 78900a24fb ci(staging): enable direct verification
Allow agents to run required checks manually against an exact commit before pushing it directly to staging, while retaining protected status checks.
2026-08-28 20:41:18 +01:00
jjbrunton 03443fe898 chore(staging): integrate verified changes
Combine the Android API 36 release, dares loop, RevenueCat documentation correction, and daily engagement features. Resolve migration ordering and preserve both dares expiry and streak reminder operations.
2026-08-28 20:36:05 +01:00
jjbrunton b60c788362 feat(engagement): localize limits and streaks
Use reported user timezones for daily response limits, expose couple streak state and reminders, and improve swipe and live-drawing synchronization with focused tests and documentation.
2026-08-28 20:28:31 +01:00
jjbrunton 61ec3692b7 chore(android): target API 36 with Expo 54
Upgrade the mobile native toolchain and Expo dependencies for Android 16 compatibility while preserving web and admin runtime isolation.
2026-08-28 20:28:31 +01:00
jjbrunton 1482305810 Merge remote-tracking branch 'origin/staging' into task/android-api-36 2026-08-28 20:22:16 +01:00
jjbrunton 8b37569818 Merge pull request #5 from jjbrunton/feat/dares-loop
feat(dares): ship the dares loop to mobile
2026-08-28 20:18:11 +01:00
jjbrunton 2178750d07 chore(android): bump version code to 47 2026-08-28 20:17:19 +01:00
jjbrunton 028e21e54c Merge origin/staging (Expo 54 / Android API 36) into feat/dares-loop 2026-08-28 20:12:55 +01:00
jjbrunton b24a402b4c Merge pull request #3 from jjbrunton/task/android-api-36
chore(android): target API 36 with Expo 54
2026-08-28 20:09:20 +01:00
jjbruntonandClaude Opus 5 ce0b188776 feat(dares): ship the dares loop to mobile
Completes the half-built dares feature: send a dare, respond, report done,
confirm, and get a push notification on every transition.

Backend (apps/api):
- New `dares` domain with couple-shared premium entitlement, a free weekly
  send allowance, and server-enforced actor/state rules on every transition.
- `submitted` status so the recipient reports completion and the sender
  confirms, rather than the sender guessing.
- Notifications ride the existing operations outbox via a trigger on
  `sent_dares`. Push copy is generic; store compliance forbids dare text in a
  preview and a test asserts no payload leaks it.
- Worker expiry sweep now covers `pending` as well as `active`/`submitted`;
  an invitation nobody opened previously stayed open forever.

Fixes two latent defects:
- Deleting any dare that had been sent aborted with a check violation, because
  `dare_id` is ON DELETE SET NULL while `sent_dares_dare_or_custom_check`
  required it. Dare text is now snapshotted at send time, which also keeps a
  couple's history stable across catalogue edits.
- The free send allowance was raceable: concurrent requests all read the same
  count and every one succeeded. The count and insert are now serialized on
  the sender's profile row.

Also ports `content_status` review metadata to the API catalogue, which had no
store-safety gating at all, and mirrors the review decision already enforced on
the legacy catalogue.

Mobile: dares screen (active/send/history), send sheet with duration presets,
home tile badging incoming dares, paywall on both proactive and 402 paths.

Verified: lint, typecheck, 379 unit tests, build, 71 integration tests against
local PostgreSQL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 20:03:11 +01:00
jjbrunton f35114dc77 chore(android): target API 36 with Expo 54
Upgrade the mobile runtime and tracked Android project to compile and target API 36. Align Expo modules, React Native, Node, Gradle, and Android build tooling while preserving the custom widget sources.\n\nAdapt file-system, notification, animation, test, and Metro compatibility for the SDK 54 runtime.
2026-08-28 20:01:15 +01:00
jjbruntonandClaude Opus 5 aacb4f8731 docs(subscription): correct stale Android RevenueCat status
The Platform Support section claimed Android was "not yet implemented".
Android has full native SDK support: getApiKey() selects the Android key,
com.android.vending.BILLING is declared in app.json, the paywall has no
platform gating, and the webhook passes event.store through unchanged so
PLAY_STORE events need no separate handling.

Also flag the entitlement ID mismatch between the "pro" fallback in
revenuecat.ts and the "Sauci Pro" value set in eas.json. A build profile
missing that variable yields isProUser: false with no error, which
presents as churn rather than as misconfiguration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 19:26:53 +01:00
jjbrunton 1be54175e4 Merge pull request #2 from jjbrunton/staging
chore(release): promote mobile build hardening
2026-08-28 18:44:58 +01:00
jjbrunton a7470310ae Merge pull request #1 from jjbrunton/task/mobile-release-hardening
chore(release): harden mobile build workflow
2026-08-28 18:39:14 +01:00
jjbrunton 3acadeff7c chore(release): harden mobile build workflow
Promote the reproduced release lessons into harness enforcement: keep tracked native regeneration separate from artifact builds, separate Android build and upload authorization, use stable login readiness in Maestro, and retain the iOS 43 and Android 46 version increments.
2026-08-28 18:34:00 +01:00
217 changed files with 17171 additions and 8124 deletions

No files matched your search

@@ -0,0 +1,37 @@
---
id: 2026-08-28-maestro-stable-login-readiness
date: 2026-08-28
status: promoted
scope: native Auth-shell acceptance flows
evidence: iOS simulator login-shell acceptance on current main
destination: e2e/maestro
review_after: 2027-02-28
---
## Symptom
The credential-free native smoke failed even though the current app reached the
login screen and displayed its required controls.
## Reproduction
Run `e2e/maestro/login-screen-smoke.yaml` against current main. The flow waited
for the retired tagline `Explore intimacy together`; the app displayed updated
marketing copy while `Sauci` and `Password` were present.
## Cause
Login readiness was coupled to mutable marketing copy rather than a stable
required control.
## Remediation
Wait for the password control that defines login readiness, while retaining the
brand and password assertions. Apply the same readiness condition to the
authenticated and credential-free flows.
## Evidence
Simulator read-back confirmed the current login shell exposes `Sauci` and
`Password`. The maintained Maestro flows now wait for that stable control rather
than the tagline.
@@ -0,0 +1,40 @@
---
id: 2026-08-28-mobile-release-source-integrity
date: 2026-08-28
status: promoted
scope: mobile release build and upload workflow
evidence: clean-source local EAS builds for iOS 43 and Android 46
destination: scripts/lint-harness.mjs
review_after: 2027-02-28
---
## Symptom
A build-only request initially regenerated tracked native projects. The generated
Android widget provider replaced the checked-in implementation, and the Android
release script would also have uploaded after building.
## Reproduction
Run the former mobile release scripts or run `expo prebuild` before a local EAS
build, then inspect `git diff`. The reproduced diff included the Android widget
provider, widget XML, and Xcode project rather than version metadata alone.
## Cause
The release scripts combined three independently reviewed operations: native
regeneration, artifact creation, and store upload. Sauci already tracks native
projects, and EAS skips prebuild when those directories exist.
## Remediation
Keep native regeneration explicit, make release scripts artifact-only, expose
upload separately, and lint the scripts so those responsibilities cannot be
silently recombined.
## Evidence
Corrected local EAS logs reported `PREBUILD` skipped. Both production artifacts
built successfully from the clean tracked sources; the post-build worktree
contained only the four expected build-number files. `npm run lint:harness`
enforces the release-script boundary.
+11
View File
@@ -0,0 +1,11 @@
name = "architecture_reviewer"
description = "Read-only reviewer for cross-cutting or hard-to-reverse Sauci architecture decisions."
model = "gpt-5.6-sol"
model_reasoning_effort = "high"
sandbox_mode = "read-only"
developer_instructions = """
Evaluate the delegated decision against current system boundaries, data flow,
migration path, operability, reversibility, and verification. Prefer the smallest
coherent change. Return explicit trade-offs, risks, and a recommendation with
file-backed evidence. Do not edit files or review unrelated local refactors.
"""
+12
View File
@@ -0,0 +1,12 @@
name = "database_reviewer"
description = "Read-only reviewer for Sauci migrations, RLS, authorization, and query boundaries."
model = "gpt-5.6-terra"
model_reasoning_effort = "high"
sandbox_mode = "read-only"
developer_instructions = """
Read the applicable AGENTS.md, database migration skill, and migration safety
documentation. Review only the delegated diff for migration tracking,
idempotency, authorization and tenant isolation, portability, rollback impact,
types, and integration coverage. Never mutate a database or edit files. Return
prioritized line-specific findings with concrete evidence and counterevidence.
"""
+12
View File
@@ -0,0 +1,12 @@
name = "implementation"
description = "Default executor for a bounded Sauci product or harness change after scope and acceptance criteria are known."
model = "gpt-5.6-terra"
developer_instructions = """
Read the root and nearest scoped AGENTS.md files plus the applicable repository
skill. Implement only the delegated objective and preserve unrelated work. Update
maintained contract documentation and add focused tests when the contract changes.
Run the required focused checks and return changed files, exact results,
assumptions, and blockers. Stop for missing authority or a materially ambiguous
product choice. Do not expand scope or perform external, destructive, production,
release, or provider writes unless the task explicitly authorizes that action.
"""
+11
View File
@@ -0,0 +1,11 @@
name = "repo_scout"
description = "Read-only Sauci codebase scout for locating ownership, callers, contracts, tests, and existing patterns."
model = "gpt-5.6-luna"
model_reasoning_effort = "low"
sandbox_mode = "read-only"
developer_instructions = """
Read the root and nearest scoped AGENTS.md files. Map only the requested surface.
Return evidence-backed file paths, symbols, current behavior, dependencies, tests,
and uncertainties. Do not edit files, make design decisions, or propose broad
refactors. Do not claim runtime behavior you did not observe.
"""
+12
View File
@@ -0,0 +1,12 @@
name = "security_reviewer"
description = "Read-only reviewer for auth, RLS, cryptography, secrets, payments, privileged MCP, and destructive operations."
model = "gpt-5.6-sol"
model_reasoning_effort = "high"
sandbox_mode = "read-only"
developer_instructions = """
Perform a scoped threat-boundary review of the delegated change. Trace callers
through authentication and authorization to the data sink. Prioritize exploitable
correctness and security risks, include counterevidence, and cite exact files and
lines. Do not edit files or expand into a repository-wide audit unless explicitly
requested.
"""
+11
View File
@@ -0,0 +1,11 @@
name = "test_writer"
description = "Focused Sauci regression and contract-test author for already-defined behavior."
model = "gpt-5.6-terra"
model_reasoning_effort = "medium"
developer_instructions = """
Read the applicable AGENTS.md and testing documentation. Test the intended
observable contract at the lowest useful layer while preserving real auth,
authorization, tenant, and couple boundaries. Do not weaken assertions to
accommodate a failure. Run the focused test and return files changed, exact
commands, results, and any product defect exposed by the test.
"""
+11
View File
@@ -0,0 +1,11 @@
name = "verifier"
description = "Independent Sauci behavior verifier that drives completed work and returns evidence, not fixes."
model = "gpt-5.6-terra"
model_reasoning_effort = "medium"
developer_instructions = """
Read the applicable AGENTS.md and .codex/skills/verify/SKILL.md. Independently
exercise the delegated acceptance criteria against the running non-production
product. You may write generated proof only under evidence/. Do not edit source,
tests, configuration, or acceptance criteria. Return expected behavior, observed
behavior, evidence paths, environment limitations, and a works or broken verdict.
"""
+5
View File
@@ -0,0 +1,5 @@
[agents]
enabled = true
max_concurrent_threads_per_session = 3
default_subagent_model = "gpt-5.6-terra"
default_subagent_reasoning_effort = "medium"
+73
View File
@@ -0,0 +1,73 @@
---
name: delegate-to-claude
description: Delegate a bounded coding, investigation, or review task from Codex to Claude Code, then inspect and independently assess Claude's result. Use when the user asks Codex to delegate to Claude or when a second-model execution or review pass is explicitly desired. Do not use for ordinary Codex subagents or as a substitute for Codex's final review.
---
# Delegate to Claude Code
Use Claude Code as an executor or independent reviewer while Codex remains the coordinator responsible for scope, evidence, and acceptance.
## Preconditions
1. Confirm `claude` is installed and authenticated by running `scripts/claude_delegate.sh check` from this skill directory.
2. Read the repository's applicable `AGENTS.md`, `CLAUDE.md`, and task-specific instructions before writing the work order.
3. Do not delegate secrets, provider writes, production mutations, releases, or destructive operations unless the user explicitly authorized that exact action. Claude receives no broader authority than Codex has.
## Work order
Write the task into a temporary Markdown file outside the repository unless the user requested a durable task artifact. Make it self-contained and include:
- role: executor or read-only reviewer;
- exact objective and boundaries;
- working directory and relevant files;
- repository instructions Claude must read;
- allowed side effects and forbidden actions;
- required checks and expected evidence;
- requested return format: findings or changed files, commands run, results, and blockers.
The helper adds an executor marker and tells Claude not to spawn further agents. Do not pass the user's whole conversation when a concise work order is sufficient.
## Run
From the target repository directory:
```bash
/absolute/path/to/this-skill/scripts/claude_delegate.sh run /absolute/path/to/work-order.md /absolute/path/to/result.json
```
The default permission mode is `auto`, which lets Claude decide whether tools are safe to use. Override it only when the task requires a stricter mode:
```bash
CLAUDE_DELEGATE_PERMISSION_MODE=plan scripts/claude_delegate.sh run work-order.md result.json
```
Optional environment variables:
- `CLAUDE_DELEGATE_MODEL`: Claude model alias or full model name; default `sonnet`.
- `CLAUDE_DELEGATE_EFFORT`: `low`, `medium`, `high`, `xhigh`, or `max`; default `medium`.
- `CLAUDE_DELEGATE_MAX_BUDGET_USD`: API budget ceiling when supported by the current authentication mode.
- `CLAUDE_DELEGATE_PERMISSION_MODE`: default `auto`; prefer `plan` for read-only planning/review when tools are unnecessary.
The helper loads project and local Claude settings while excluding user-level integrations by default, and disables browser/MCP integrations. This keeps delegation focused on the checked-out repository. If a task genuinely requires an external connector, handle that action in Codex under the original authorization rather than silently widening Claude's access.
Never choose `bypassPermissions` merely to avoid a permission failure. Use it only when the user explicitly requests it and the execution is inside an appropriately isolated sandbox.
For a follow-up in the same Claude session, obtain `session_id` from the JSON result and run:
```bash
scripts/claude_delegate.sh resume SESSION_ID follow-up.md follow-up-result.json
```
If a call outlives the command yield, continue waiting on that same process/session rather than launching a duplicate.
## Accept or iterate
Claude's summary is evidence to inspect, not proof. Codex must:
1. inspect the actual diff or artifacts;
2. run proportionate independent checks;
3. compare the result with the original scope and repository rules;
4. send a focused follow-up with concrete failures when iteration is needed;
5. report Claude's contribution separately from Codex's verification.
Do not claim success from Claude's self-report alone. Codex retains the decision to accept, commit, publish, deploy, or make any external change.
@@ -0,0 +1,4 @@
interface:
display_name: "Delegate to Claude Code"
short_description: "Delegate scoped work to Claude Code"
default_prompt: "Use $delegate-to-claude to delegate this scoped task to Claude Code and review the result."
@@ -0,0 +1,70 @@
#!/usr/bin/env bash
set -euo pipefail
die() {
printf 'claude_delegate: %s\n' "$*" >&2
exit 1
}
usage() {
cat >&2 <<'EOF'
usage:
claude_delegate.sh check
claude_delegate.sh run <prompt-file> [result-json]
claude_delegate.sh resume <session-id> <prompt-file> [result-json]
EOF
exit 2
}
command -v claude >/dev/null 2>&1 || die "claude CLI is not installed or not on PATH"
if [[ "${1:-}" == "check" ]]; then
claude --version
auth_status="$(claude auth status 2>/dev/null)" || die "unable to read Claude authentication status"
[[ "$auth_status" == *'"loggedIn": true'* ]] || die "Claude CLI is not authenticated"
printf 'authentication: logged in\n'
exit 0
fi
mode="${1:-}"
case "$mode" in
run)
[[ $# -ge 2 && $# -le 3 ]] || usage
prompt_file="$2"
result_file="${3:-}"
resume_id=""
;;
resume)
[[ $# -ge 3 && $# -le 4 ]] || usage
resume_id="$2"
prompt_file="$3"
result_file="${4:-}"
[[ "$resume_id" =~ ^[0-9a-fA-F-]{36}$ ]] || die "session id must be a UUID"
;;
*) usage ;;
esac
[[ -f "$prompt_file" ]] || die "prompt file does not exist: $prompt_file"
[[ -r "$prompt_file" ]] || die "prompt file is not readable: $prompt_file"
prompt="ROLE: EXECUTOR — do the work yourself; do not spawn subagents. Stay within the work order's authorization and scope. At the end, report changed files or findings, commands and checks run with results, and any blockers. WORK ORDER: $(<"$prompt_file")"
effort="${CLAUDE_DELEGATE_EFFORT:-medium}"
permission_mode="${CLAUDE_DELEGATE_PERMISSION_MODE:-auto}"
case "$effort" in low|medium|high|xhigh|max) ;; *) die "invalid effort: $effort" ;; esac
case "$permission_mode" in acceptEdits|auto|manual|dontAsk|plan|bypassPermissions) ;;
*) die "invalid permission mode: $permission_mode" ;;
esac
model="${CLAUDE_DELEGATE_MODEL:-sonnet}"
args=(--print --output-format json --model "$model" --effort "$effort" --permission-mode "$permission_mode" --setting-sources project,local --strict-mcp-config --no-chrome)
[[ -n "${CLAUDE_DELEGATE_MAX_BUDGET_USD:-}" ]] && args+=(--max-budget-usd "$CLAUDE_DELEGATE_MAX_BUDGET_USD")
[[ -n "$resume_id" ]] && args+=(--resume "$resume_id")
if [[ -n "$result_file" ]]; then
result_dir="$(dirname "$result_file")"
[[ -d "$result_dir" ]] || die "result directory does not exist: $result_dir"
claude "${args[@]}" "$prompt" | tee "$result_file"
else
claude "${args[@]}" "$prompt"
fi
+116
View File
@@ -0,0 +1,116 @@
---
name: generate-release
description: Generate a signed Sauci mobile release by bumping the public version, writing on-brand release notes, building and verifying iOS and Android binaries, committing release metadata, and tagging the release commit. Use for an actual production mobile release; use release-mobile for preparation, one-platform builds, uploads, or submission work that does not require the complete release lifecycle.
---
# Generate release
Create a reproducible release whose tag identifies the reviewed release source
and final platform build metadata. Read `apps/mobile/AGENTS.md`,
`docs/releasing.md`, and the `release-mobile` and `commit` skills before acting. Read
[references/release-notes-voice.md](references/release-notes-voice.md) before
drafting notes and [references/artifact-verification.md](references/artifact-verification.md)
before accepting a build.
A request to generate or create a release authorizes the scoped version edits,
release notes, local builds, release commits, and one local annotated tag. It
does not authorize pushing commits or tags, uploading binaries, submitting a
store release, changing signing credentials, or regenerating native projects.
## Defaults
- Build both iOS and Android production binaries unless the user narrows the
platforms.
- Increment the patch version unless the user specifies `minor`, `major`, or an
exact SemVer version.
- Use `v<version>` for the annotated Git tag.
- Write artifacts beneath ignored `dist/releases/v<version>/`; never commit
binaries, credentials, logs, or build directories.
## Preflight
Fetch the remote and inspect the worktree, branch, upstream, existing tags,
toolchains, EAS authentication, and signing availability without changing them.
Require all of the following:
- a named branch, normally `main` for a production release;
- a clean starting worktree with `HEAD` equal to its fetched upstream;
- the previous reachable `v[0-9]*` release tag and no collision for the next tag;
- consistent public versions across `app.json`, Android `versionName`, and every
iOS `MARKETING_VERSION` (`scripts/set_version.mjs --check`);
- Node 20, JDK 17 for Android, and Xcode/CocoaPods for iOS;
- the configured production API/Auth/RevenueCat environment, without printing
secret values.
Do not switch branches, discard work, repair credentials, push, or weaken branch
protection merely to pass preflight. Stop with the exact blocker. Never run
`expo prebuild` as part of this workflow.
## Prepare the release
1. Derive the next version with `scripts/set_version.mjs --bump <kind>` or set an
explicit version with `--version <x.y.z>`. The script updates the checked-in
public-version sources but deliberately leaves platform build numbers to EAS.
2. Review the complete first-parent and ordinary commit diff from the previous
reachable release tag to the starting commit. Inspect the actual product diff
for every user-facing claim; commit subjects alone are not evidence.
3. Add the new entry immediately below the title in `docs/release-notes.md`.
Describe user-visible value, meaningful fixes, privacy or reliability changes,
and only claims supported by the diff. Omit internal migrations, refactors,
CI, infrastructure, dependency churn, and implementation jargon unless the
user experiences a direct result. Use a temporary `Build numbers pending
artifact verification` line until the binaries have been inspected; never
guess or predict build numbers.
4. Run `npm run verify:full` and the native acceptance required by the
`release-mobile` skill. A missing prerequisite is a failure/limitation, not a
pass.
5. Review the scoped diff and create `chore(release): prepare v<version>`. Do not
stage unrelated paths. This clean release-preparation commit must descend
directly from the fetched upstream commit.
## Build and capture platform metadata
Build from the clean prepared source using the package scripts in
`apps/mobile/package.json`, with `EAS_LOCAL_BUILD_ARTIFACTS_DIR` set to the
versioned artifact directory. When building both platforms, build Android first,
then iOS.
After each build:
1. Inspect the worktree before doing anything else. Accept only the documented
EAS build-number changes in `app.json`, that platform's native version source,
and the iOS widget plist when applicable. Treat any implementation, resource,
dependency, or native-project regeneration as a failed build.
2. Independently inspect the binary for package/bundle identifier, public
version, platform build number, production endpoint configuration where
inspectable, and signing identity/fingerprint. Require the embedded public
version to equal the requested version.
3. Record the verified platform build number in the new release-note entry.
4. Commit only the expected metadata and release-note update as
`chore(<platform>): record v<version> build <number>`. The worktree must be
clean before starting the next platform build.
Do not accept a successful EAS exit code as proof of a valid artifact. If either
requested platform fails, do not tag a partial release.
## Tag and prove the result
Re-run focused version checks and `npm run verify:fast`. Confirm every requested
artifact exists, has a SHA-256 digest, passed independent version/signature
inspection, and was produced from the release commit chain. Require a clean
worktree, then create the annotated local tag `v<version>` at `HEAD`.
Read back the tag object and prove:
- the tag resolves to `HEAD`;
- the tag version equals all public-version sources and embedded artifacts;
- recorded build numbers equal the embedded platform build numbers;
- no binary or ignored build output entered the commit;
- the release commits descend from the fetched starting branch commit;
- for a two-platform release, the Android artifact's source commit differs from
the final tag only by the later verified iOS build metadata.
Report the branch, tag, commit, release-note summary, artifact paths, versions,
build numbers, signatures, SHA-256 digests, checks run, and any environmental
limitations. State explicitly that the tag and commits remain local unless a
separate authorized push was completed.
@@ -0,0 +1,4 @@
interface:
display_name: "Generate Release"
short_description: "Bump, build, note, and tag Sauci releases"
default_prompt: "Use $generate-release to create the next signed Sauci mobile release for iOS and Android."
@@ -0,0 +1,39 @@
# Release artifact verification
Use the strongest locally available platform tools and record the commands and
results. Never print provisioning profiles, keys, tokens, or environment values.
## Android AAB
Require a production `.aab`. Inspect its manifest with `bundletool dump manifest`
or Android SDK `apkanalyzer`, and verify:
- package `com.sauci.app`;
- `versionName` equals the release version;
- `versionCode` equals the recorded Android build number.
Verify the archive signature with `jarsigner -verify -verbose -certs`. Compare the
signing certificate SHA-1 to the production fingerprint documented in
`docs/releasing.md`; do not copy credentials into commands or reports. Calculate
the artifact SHA-256 with `shasum -a 256`.
## iOS IPA
Extract the `.ipa` into a new temporary directory created with `mktemp -d`. Read
the application `Info.plist` with `/usr/libexec/PlistBuddy` or `plutil` and verify:
- `CFBundleIdentifier` is `com.sauci.app`;
- `CFBundleShortVersionString` equals the release version;
- `CFBundleVersion` equals the recorded iOS build number.
Use `codesign -dvvv` and `codesign --verify --deep --strict` on the extracted app
to inspect and verify signing without exposing the embedded profile. Calculate the
original `.ipa` SHA-256 with `shasum -a 256`. Remove only the explicit temporary
extraction directory after inspection.
## Source correspondence
For both platforms, retain the build command, artifact path, build completion
time, and digest in the final report. Compare the post-build tracked diff with the
documented allowed version files. A binary with the right version but unexpected
source regeneration, wrong signing, or an unverifiable manifest is not accepted.
@@ -0,0 +1,51 @@
# Sauci release-note voice
Sauci sounds warm, confident, inviting, and quietly playful. It is a premium
couples app about private connection and shared curiosity: supportive without
being clinical, polished without sounding corporate, and intimate without being
graphic or presumptuous.
## Shape
Use a short opening sentence followed by three to six compact bullets. Lead with
the change that most improves a couple's experience. Combine related work into
one benefit rather than mirroring the commit list. Include a brief closing line
only when it adds warmth or useful context.
In `docs/release-notes.md`, use:
```markdown
## v1.2.3 — 29 August 2026
_iOS build 44 · Android build 48_
A little more ease, clarity, and connection in every check-in.
- **Closer conversations** — Find the prompts that feel right for both of you.
- **Smoother moments** — Chats and matches now feel quicker and more reliable.
- **More control** — Clearer choices help keep your shared space comfortable.
```
Adapt the words to the evidence; do not reuse this copy when those claims are not
present in the release. If only one platform was requested, record only its build.
## Language
- Address the couple's experience with `you`, `your partner`, `together`, or
`your shared space` where natural.
- Prefer concrete benefits: `pick up where you left off`, `find the right prompt`,
`keep conversations flowing`, `stay in control`.
- Use restrained warmth. One gentle phrase is enough; avoid forced innuendo,
breathless hype, exclamation marks, emoji, and claims that Sauci will improve or
fix a relationship.
- Prefer `new`, `smoother`, `clearer`, `more private`, and `more reliable` to
`revolutionary`, `game-changing`, `massive`, or `best ever`.
- Explain a fix as the experience restored, not the defect mechanics. Name a
security or privacy change plainly when users need to understand it.
## Evidence boundary
Release notes describe shipped behavior only. Do not claim a feature from a plan,
flagged-off path, migration, test, or commit title without confirming the runtime
path in the diff and release configuration. Do not expose internal service names,
database details, moderation logic, security-sensitive implementation, or content
that the product intentionally keeps private between partners.
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, "../../../..");
const files = {
app: path.join(repoRoot, "apps/mobile/app.json"),
android: path.join(repoRoot, "apps/mobile/android/app/build.gradle"),
ios: path.join(repoRoot, "apps/mobile/ios/Sauci.xcodeproj/project.pbxproj"),
};
const semverPattern = /^([0-9]+)\.([0-9]+)\.([0-9]+)$/;
function fail(message) {
console.error(`set_version: ${message}`);
process.exit(1);
}
function parseVersion(value, label) {
const match = semverPattern.exec(value);
if (!match) fail(`${label} must be an exact numeric SemVer version (x.y.z)`);
return match.slice(1).map(Number);
}
function readState() {
const appRaw = fs.readFileSync(files.app, "utf8");
const androidRaw = fs.readFileSync(files.android, "utf8");
const iosRaw = fs.readFileSync(files.ios, "utf8");
const appVersion = JSON.parse(appRaw).expo?.version;
const androidVersions = [...androidRaw.matchAll(/\bversionName\s+["']([^"']+)["']/g)].map(
(match) => match[1],
);
const iosVersions = [...iosRaw.matchAll(/\bMARKETING_VERSION\s*=\s*([^;]+);/g)].map(
(match) => match[1].trim(),
);
if (typeof appVersion !== "string") fail("apps/mobile/app.json has no expo.version");
if (androidVersions.length !== 1) fail("expected exactly one Android versionName");
if (iosVersions.length === 0) fail("found no iOS MARKETING_VERSION values");
const versions = [appVersion, ...androidVersions, ...iosVersions];
versions.forEach((version) => parseVersion(version, `configured version ${version}`));
if (new Set(versions).size !== 1) {
fail(`public versions disagree: ${versions.join(", ")}`);
}
return { appRaw, androidRaw, iosRaw, current: appVersion, iosCount: iosVersions.length };
}
function nextVersion(current, bump) {
const [major, minor, patch] = parseVersion(current, "current version");
if (bump === "major") return `${major + 1}.0.0`;
if (bump === "minor") return `${major}.${minor + 1}.0`;
if (bump === "patch") return `${major}.${minor}.${patch + 1}`;
fail("--bump must be patch, minor, or major");
}
const args = process.argv.slice(2);
const check = args.length === 1 && args[0] === "--check";
const bumpIndex = args.indexOf("--bump");
const versionIndex = args.indexOf("--version");
if (!check && (bumpIndex === -1) === (versionIndex === -1)) {
fail("use --check, --bump patch|minor|major, or --version x.y.z");
}
const state = readState();
if (check) {
console.log(`public version ${state.current} is consistent across app.json, Android, and ${state.iosCount} iOS settings`);
process.exit(0);
}
const desired =
bumpIndex !== -1
? nextVersion(state.current, args[bumpIndex + 1])
: (parseVersion(args[versionIndex + 1], "--version"), args[versionIndex + 1]);
if (desired === state.current) fail(`requested version is already ${state.current}`);
const currentParts = parseVersion(state.current, "current version");
const desiredParts = parseVersion(desired, "requested version");
const increases = desiredParts.some(
(part, index) => part > currentParts[index] && desiredParts.slice(0, index).every((value, prior) => value === currentParts[prior]),
);
if (!increases) fail(`requested version ${desired} must be greater than ${state.current}`);
const appRaw = state.appRaw.replace(
/(\"version\"\s*:\s*\")([^\"]+)(\")/,
`$1${desired}$3`,
);
const androidRaw = state.androidRaw.replace(
/(\bversionName\s+["'])([^"']+)(["'])/,
`$1${desired}$3`,
);
const iosRaw = state.iosRaw.replace(
/(\bMARKETING_VERSION\s*=\s*)([^;]+)(;)/g,
`$1${desired}$3`,
);
fs.writeFileSync(files.app, appRaw);
fs.writeFileSync(files.android, androidRaw);
fs.writeFileSync(files.ios, iosRaw);
console.log(`updated public version ${state.current} -> ${desired}`);
+47 -4
View File
@@ -9,7 +9,50 @@ Read `docs/releasing.md`, `apps/mobile/RELEASING.md` when present, and the mobil
scoped instructions. Confirm target platform and release profile. Run repository
verification plus a native acceptance pass before starting a release build.
Use the package scripts in `apps/mobile/package.json`; preserve platform signing
and versioning. Building or uploading to a store is an external mutation: obtain
explicit authorization for the requested target, then report the build artifact,
version/build number, upload result, and read-back state.
Before a build from the current upstream branch, fetch, require a clean worktree,
and prove `HEAD` matches its upstream. The `generate-release` skill may instead
build a clean, scoped release-preparation commit that descends directly from that
fetched upstream; between its multi-platform builds it commits only independently
verified EAS version metadata. Sauci tracks its native iOS and Android projects,
so routine release builds must package those checked-in sources. Do not run
`expo prebuild` first; native regeneration is a separate reviewed change.
Use the package scripts in `apps/mobile/package.json`; they create artifacts only.
Never infer upload or submission authority from build authorization. Store upload
uses a separately authorized command.
## Upload authorization gate
After an artifact passes verification, stop immediately before the external
upload and ask the user for confirmation. The request must identify:
- the platform, exact artifact path, bundle/package identifier, public version,
build number, signature identity, and SHA-256 digest;
- the exact destination and resulting state: Google Play internal-track draft or
App Store Connect/TestFlight processing;
- that the upload does not authorize a public rollout or App Review submission.
For iOS, inspect the current App Store Connect automatic-distribution behavior
before asking. If the build would be distributed automatically, name the exact
TestFlight groups or testers in the request and include that effect in the fresh
authorization. If the effect cannot be established, stop; changing an automatic-
distribution setting also requires separate authorization.
Even when the original request includes building, releasing, uploading, or
submitting, require this fresh confirmation once the exact verified artifact and
destination are known. End the request with: `Upload this verified artifact to
<destination> now?` A clear affirmative response authorizes one upload of that
artifact to that destination, plus only any automatic TestFlight distribution
explicitly disclosed in the request. Run `eas submit` with the explicit
`--platform`, `--profile production`, `--path`, and `--wait` arguments; never
select `--latest`. If the result is ambiguous, inspect EAS and the store before
doing anything else, do not retry, and ask again before another upload.
Apply this gate to every upload method, including EAS, Fastlane, Xcode Organizer,
and the store consoles. A later public rollout, App Review submission, or
additional TestFlight distribution requires another fresh authorization.
Preserve platform signing and local EAS versioning. After a build, verify that
tracked changes are limited to the expected version metadata, then independently
read the artifact version and signature. Report the artifact, version/build
number, upload result when authorized, and store read-back state.
+8 -2
View File
@@ -1,6 +1,7 @@
name: Verify
on:
workflow_dispatch:
pull_request:
push:
branches: [staging, main]
@@ -11,10 +12,10 @@ concurrency:
jobs:
branch-policy:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- name: Enforce promotion flow
if: github.event_name == 'pull_request'
env:
BASE_BRANCH: ${{ github.base_ref }}
HEAD_BRANCH: ${{ github.head_ref }}
@@ -31,6 +32,9 @@ jobs:
echo "Feature pull requests target staging; promotions target main" >&2
exit 1
fi
- name: Confirm direct verification
if: github.event_name != 'pull_request'
run: echo "Direct branch verification requested for $GITHUB_SHA"
static:
runs-on: ubuntu-latest
@@ -43,6 +47,8 @@ jobs:
- run: npm ci
- run: npm run learn:check
- run: npm run verify:fast
- name: Enforce API and mobile coverage floors
run: npm run test:coverage
- run: npm run build
backend-integration:
@@ -72,7 +78,7 @@ jobs:
- name: Run standalone backend integration tests
env:
DATABASE_URL: postgresql://sauci:sauci_ci_only@127.0.0.1:5432/sauci_test
run: npm run test:api:integration
run: npm run test:integration:coverage -w @sauci/api
container-contract:
runs-on: ubuntu-latest
+1 -1
View File
@@ -1 +1 @@
20.10.0
20.19.4
+20 -1
View File
@@ -17,7 +17,24 @@ MCP service in one npm/Turborepo workspace.
5. Read `apps/mobile/DESIGN.md` before mobile UI work.
6. Keep shared package exports type-only and route them through `src/index.ts`.
7. Do not commit secrets, generated evidence, local logs, or runtime state.
8. Scope work to the task. Use high-effort reviewers only for the risk triggers in
8. Implement in a task-specific git worktree created from current `staging`,
not in the shared `staging` checkout. After the work is verified, commit the
scoped changes, merge the completed task branch back into local `staging`,
push `staging`, and prove local and remote commit parity.
9. Before merging, identify every deployed app or service the change can affect
and prove the relevant compatibility contracts still hold. Preserve deployed
interfaces and supported client behavior; repository checks alone do not
prove a live change is safe.
10. Delegate every substantive repository task to at least one project agent so
detailed exploration or execution stays out of the primary context.
"Substantive" includes repository exploration, multi-file reasoning,
implementation, test execution, or contract/risk analysis. Use `repo_scout`
for read-only investigation and `implementation` for bounded changes; use
the additional roles according to `docs/agents/routing.md`. The primary
agent retains scope, acceptance, review, and the final answer. Skip
delegation only for trivial conversation, a single obvious lookup, or a
mechanical one-line edit whose coordination cost exceeds the work.
11. Use high-effort reviewers only for the risk triggers in
`docs/agents/routing.md`.
## Repository map
@@ -36,6 +53,8 @@ MCP service in one npm/Turborepo workspace.
## Workflows
- Implementation: create a task worktree from current `staging`; commit verified
work, merge it back to local `staging`, push `staging`, and verify remote parity.
- Bootstrap: `npm ci`
- Full local stack: `scripts/dev-local.sh up`
- Fast checks: `npm run verify:fast`
+2 -2
View File
@@ -44,8 +44,8 @@
"tailwind-merge": "^2.2.0"
},
"devDependencies": {
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@types/react": "~19.1.10",
"@types/react-dom": "~19.1.7",
"@vitejs/plugin-react": "^4.2.0",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
+8 -3
View File
@@ -187,7 +187,7 @@ export function AppSettingsPage() {
Daily Response Limit (Free Users)
</CardTitle>
<CardDescription>
Limit the number of questions non-premium users can answer per day (UTC 00:00-23:59)
Limit the number of questions non-premium users can answer per day, on their own local calendar day
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
@@ -203,14 +203,19 @@ export function AppSettingsPage() {
className="max-w-[200px]"
/>
<p className="text-xs text-muted-foreground">
Maximum number of questions a non-premium user can answer per UTC day.
Maximum number of questions a non-premium user can answer per day.
Premium users (via personal or partner subscription) bypass this limit.
</p>
<p className="text-xs text-muted-foreground">
Set to <strong>0</strong> to disable daily limits entirely.
</p>
<p className="text-xs text-muted-foreground">
Limit resets daily at 00:00 UTC. Editing existing answers does not count toward the limit.
Resets at midnight in the user's own timezone, as reported by their device.
Users who have not reported a timezone yet fall back to 00:00 UTC.
</p>
<p className="text-xs text-muted-foreground">
<strong>Does not count toward the limit:</strong> editing an existing answer, or
answering a question the partner had already answered (catching up is always free).
</p>
</div>
</CardContent>
+9
View File
@@ -3,6 +3,15 @@ DATABASE_URL=postgresql://sauci:sauci_local_only@127.0.0.1:54320/sauci
SUPABASE_AUTH_URL=https://your-project.supabase.co
SUPABASE_AUTH_AUDIENCE=authenticated
# Optional connection-pool bounds. The API process opens one pool and shares it
# across every repository, so these are the process-wide limits, not per-domain
# ones. The worker has its own separately bounded process pool. Raising either
# maximum raises the server connections this instance can hold; keep the total
# across instances inside the database's own limit.
# DATABASE_POOL_MAX=10
# DATABASE_POOL_IDLE_TIMEOUT_MS=30000
# DATABASE_POOL_CONNECTION_TIMEOUT_MS=10000
# Server-only hosted Auth Admin credential. Used for account deletion and for
# enriching admin user lists; it is never exposed to the browser or used as a
# Supabase database client.
+223
View File
@@ -0,0 +1,223 @@
-- Dares loop: catalogue review parity, durable dare instances, and outbox notifications.
--
-- Three concerns land together because the send path depends on all of them:
-- 1. `content_status` parity with the legacy catalogue so unreviewed dares stay hidden.
-- 2. Text snapshots on `sent_dares` so catalogue edits never rewrite a couple's history.
-- 3. Outbox rows for every status transition, which is the whole retention argument.
-- `to_regtype` resolves through the active search_path, so this creates the type in
-- the current schema rather than skipping because an unrelated schema already has it.
DO $$
BEGIN
IF to_regtype('content_review_status') IS NULL THEN
CREATE TYPE content_review_status AS ENUM ('unreviewed', 'allowed', 'archived');
END IF;
END $$;
--> statement-breakpoint
ALTER TABLE dare_packs
ADD COLUMN IF NOT EXISTS content_status content_review_status NOT NULL DEFAULT 'unreviewed',
ADD COLUMN IF NOT EXISTS content_review_reason text,
ADD COLUMN IF NOT EXISTS content_reviewed_at timestamptz,
ADD COLUMN IF NOT EXISTS content_reviewed_by uuid REFERENCES profiles(id) ON DELETE SET NULL;
--> statement-breakpoint
ALTER TABLE dares
ADD COLUMN IF NOT EXISTS content_status content_review_status NOT NULL DEFAULT 'unreviewed',
ADD COLUMN IF NOT EXISTS content_review_reason text,
ADD COLUMN IF NOT EXISTS content_reviewed_at timestamptz,
ADD COLUMN IF NOT EXISTS content_reviewed_by uuid REFERENCES profiles(id) ON DELETE SET NULL;
--> statement-breakpoint
COMMENT ON COLUMN dare_packs.content_status IS
'Store-safety review state. Only `allowed` packs are exposed to the mobile catalogue.';
--> statement-breakpoint
COMMENT ON COLUMN dares.content_status IS
'Store-safety review state. Only `allowed` dares are sendable.';
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS dare_packs_content_status_idx
ON dare_packs (content_status, is_public, sort_order);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS dares_content_status_idx
ON dares (pack_id, content_status, intensity);
--> statement-breakpoint
-- `sent_dares.dare_id` is ON DELETE SET NULL, but `sent_dares_dare_or_custom_check`
-- requires one of the two source columns to survive. Deleting a dare that had ever
-- been sent therefore aborted with a check violation. Snapshotting the text at send
-- time removes the dependency entirely and keeps history stable across content edits.
ALTER TABLE sent_dares
ADD COLUMN IF NOT EXISTS dare_text_snapshot text,
ADD COLUMN IF NOT EXISTS dare_intensity_snapshot integer,
ADD COLUMN IF NOT EXISTS submitted_at timestamptz,
ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
--> statement-breakpoint
UPDATE sent_dares sd
SET dare_text_snapshot = COALESCE(sd.custom_dare_text, d.text),
dare_intensity_snapshot = COALESCE(sd.custom_dare_intensity, d.intensity)
FROM dares d
WHERE d.id = sd.dare_id AND sd.dare_text_snapshot IS NULL;
--> statement-breakpoint
UPDATE sent_dares
SET dare_text_snapshot = custom_dare_text,
dare_intensity_snapshot = COALESCE(custom_dare_intensity, 1)
WHERE dare_text_snapshot IS NULL AND custom_dare_text IS NOT NULL;
--> statement-breakpoint
ALTER TABLE sent_dares
ALTER COLUMN dare_text_snapshot SET NOT NULL,
ALTER COLUMN dare_intensity_snapshot SET NOT NULL;
--> statement-breakpoint
ALTER TABLE sent_dares DROP CONSTRAINT IF EXISTS sent_dares_dare_or_custom_check;
--> statement-breakpoint
ALTER TABLE sent_dares DROP CONSTRAINT IF EXISTS sent_dares_intensity_snapshot_check;
--> statement-breakpoint
ALTER TABLE sent_dares
ADD CONSTRAINT sent_dares_intensity_snapshot_check
CHECK (dare_intensity_snapshot BETWEEN 1 AND 5);
--> statement-breakpoint
ALTER TABLE sent_dares DROP CONSTRAINT IF EXISTS sent_dares_participants_distinct_check;
--> statement-breakpoint
ALTER TABLE sent_dares
ADD CONSTRAINT sent_dares_participants_distinct_check
CHECK (sender_id <> recipient_id);
--> statement-breakpoint
-- `submitted` closes the loop the original design left open: the recipient performs
-- the dare, so the recipient reports it done and the sender confirms.
ALTER TABLE sent_dares DROP CONSTRAINT IF EXISTS sent_dares_status_check;
--> statement-breakpoint
ALTER TABLE sent_dares
ADD CONSTRAINT sent_dares_status_check CHECK (
status IN ('pending', 'active', 'submitted', 'completed', 'expired', 'declined', 'cancelled')
);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS sent_dares_recipient_status_idx ON sent_dares(recipient_id, status);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS sent_dares_couple_status_idx ON sent_dares(couple_id, status, sent_at DESC);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS sent_dares_sender_sent_at_idx ON sent_dares(sender_id, sent_at DESC);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS sent_dares_expiry_idx
ON sent_dares(expires_at) WHERE status IN ('pending', 'active', 'submitted');
--> statement-breakpoint
ALTER TABLE notification_preferences
ADD COLUMN IF NOT EXISTS dares_enabled boolean NOT NULL DEFAULT true;
--> statement-breakpoint
CREATE OR REPLACE FUNCTION touch_sent_dare() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END $$;
--> statement-breakpoint
DROP TRIGGER IF EXISTS sent_dares_touch ON sent_dares;
--> statement-breakpoint
CREATE TRIGGER sent_dares_touch BEFORE UPDATE ON sent_dares
FOR EACH ROW EXECUTE FUNCTION touch_sent_dare();
--> statement-breakpoint
-- Push copy is deliberately generic. Store compliance forbids dare, question,
-- response, or message text in a notification preview.
CREATE OR REPLACE FUNCTION queue_dare_operations() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
v_recipients uuid[];
v_recipient uuid;
v_title text;
v_body text;
v_event text;
BEGIN
IF current_setting('sauci.suppress_operations', true) = 'on' THEN RETURN NEW; END IF;
IF TG_OP = 'INSERT' THEN
v_event := 'sent';
v_recipients := ARRAY[NEW.recipient_id];
v_title := 'New dare';
v_body := 'Your partner sent you a dare';
ELSIF NEW.status IS DISTINCT FROM OLD.status THEN
v_event := NEW.status;
CASE NEW.status
WHEN 'active' THEN
v_recipients := ARRAY[NEW.sender_id];
v_title := 'Dare accepted';
v_body := 'Your partner accepted your dare';
WHEN 'declined' THEN
v_recipients := ARRAY[NEW.sender_id];
v_title := 'Dare declined';
v_body := 'Your partner passed on your dare';
WHEN 'submitted' THEN
v_recipients := ARRAY[NEW.sender_id];
v_title := 'Dare done';
v_body := 'Your partner says they completed your dare';
WHEN 'completed' THEN
v_recipients := ARRAY[NEW.recipient_id];
v_title := 'Dare complete';
v_body := 'Your partner confirmed your dare';
WHEN 'cancelled' THEN
v_recipients := ARRAY[NEW.recipient_id];
v_title := 'Dare cancelled';
v_body := 'Your partner cancelled the dare';
WHEN 'expired' THEN
-- Both sides lose the dare, so both sides hear about it.
v_recipients := ARRAY[NEW.sender_id, NEW.recipient_id];
v_title := 'Dare expired';
v_body := 'A dare ran out of time';
ELSE
RETURN NEW;
END CASE;
ELSE
RETURN NEW;
END IF;
FOREACH v_recipient IN ARRAY v_recipients LOOP
CONTINUE WHEN NOT EXISTS (
SELECT 1 FROM profiles p
LEFT JOIN notification_preferences np ON np.user_id = p.id
WHERE p.id = v_recipient AND COALESCE(np.dares_enabled, true) = true
);
INSERT INTO operations_outbox(kind, dedupe_key, recipient_id, payload)
VALUES (
'expo',
'dare:' || NEW.id || ':' || v_event || ':' || v_recipient,
v_recipient,
jsonb_build_object(
'title', v_title,
'body', v_body,
'data', jsonb_build_object('type', 'dare', 'event', v_event, 'sent_dare_id', NEW.id)
)
)
ON CONFLICT (dedupe_key) DO NOTHING;
END LOOP;
RETURN NEW;
END $$;
--> statement-breakpoint
DROP TRIGGER IF EXISTS operations_dare_changed ON sent_dares;
--> statement-breakpoint
CREATE TRIGGER operations_dare_changed AFTER INSERT OR UPDATE ON sent_dares
FOR EACH ROW EXECUTE FUNCTION queue_dare_operations();
--> statement-breakpoint
-- Mirror the review decision already enforced on the legacy catalogue
-- (20260828114256_enforce_allowed_catalogue): "Romantic Gestures" passed store-safety
-- review, the other seeded packs did not. Only rows still `unreviewed` are touched, so
-- this never overrides a later decision made in the admin portal.
UPDATE dare_packs
SET content_status = 'allowed',
content_review_reason = 'Reviewed as non-sexual romantic gestures'
WHERE id = 'a1b2c3d4-1111-1111-1111-111111111111'::uuid AND content_status = 'unreviewed';
--> statement-breakpoint
UPDATE dare_packs
SET content_status = 'archived',
content_review_reason = 'Pack contains suggestive, sexual, dominance, or fantasy dares'
WHERE id IN (
'a1b2c3d4-2222-2222-2222-222222222222'::uuid,
'a1b2c3d4-3333-3333-3333-333333333333'::uuid,
'a1b2c3d4-4444-4444-4444-444444444444'::uuid
) AND content_status = 'unreviewed';
--> statement-breakpoint
UPDATE dares d
SET content_status = dp.content_status,
content_review_reason = CASE
WHEN dp.content_status = 'allowed' THEN 'Reviewed with Romantic Gestures pack'
ELSE 'Archived with non-store-safe dare pack'
END
FROM dare_packs dp
WHERE d.pack_id = dp.id AND d.content_status = 'unreviewed' AND dp.content_status <> 'unreviewed';
@@ -0,0 +1,2 @@
ALTER TABLE "profiles" ADD COLUMN IF NOT EXISTS "timezone" text;--> statement-breakpoint
COMMENT ON COLUMN "profiles"."timezone" IS 'IANA zone reported by the client. NULL falls back to UTC when bucketing the daily response limit.';
@@ -0,0 +1,2 @@
-- The daily response limit counts a user's responses within their current local day.
CREATE INDEX IF NOT EXISTS "responses_user_created_idx" ON "responses" ("user_id", "created_at" DESC);
@@ -0,0 +1,15 @@
-- Streaks are counted in the couple's shared local day, not the server's calendar day.
-- The zone is the first reported timezone in member id order (the same ordering that
-- decides user1/user2), falling back to UTC when neither partner has reported one.
COMMENT ON COLUMN "couple_streaks"."last_active_date" IS 'Couple-local date on which either partner last answered. The answered-today flags are read as false once this is not the couple-local today.';--> statement-breakpoint
COMMENT ON COLUMN "couple_streaks"."last_completed_date" IS 'Couple-local date on which both partners last answered. The streak is alive while this is the couple-local today or yesterday.';--> statement-breakpoint
-- Prunes the evening at-risk scan to couples that actually have a streak to lose.
CREATE INDEX IF NOT EXISTS "couple_streaks_live_idx" ON "couple_streaks" ("last_completed_date") WHERE "current_streak" > 0;--> statement-breakpoint
-- The legacy data plane maintained streaks from a trigger that read auth.uid(). The API
-- owns that write now and runs it in the same transaction as the response, so drop the
-- trigger defensively in case a cutover ever carries these objects across.
DROP TRIGGER IF EXISTS "on_response_update_streak" ON "responses";--> statement-breakpoint
DROP FUNCTION IF EXISTS "update_couple_streak"();--> statement-breakpoint
DROP FUNCTION IF EXISTS "reset_daily_streak_flags"();
@@ -0,0 +1,3 @@
-- A shared streak only changes behaviour if somebody is told it is still open, so the
-- evening at-risk nudge gets its own opt-out rather than riding on milestone celebrations.
ALTER TABLE "notification_preferences" ADD COLUMN IF NOT EXISTS "streak_reminders_enabled" boolean NOT NULL DEFAULT true;
@@ -0,0 +1,80 @@
-- Preserve the reviewed production catalogue and its append-only decision history
-- when importing from the legacy Supabase data plane.
DO $$
BEGIN
IF to_regtype('content_entity_type') IS NULL THEN
CREATE TYPE content_entity_type AS ENUM (
'categories', 'question_packs', 'questions', 'dare_packs', 'dares'
);
END IF;
END $$;
--> statement-breakpoint
ALTER TABLE categories
ADD COLUMN IF NOT EXISTS content_status content_review_status NOT NULL DEFAULT 'unreviewed',
ADD COLUMN IF NOT EXISTS content_review_reason text,
ADD COLUMN IF NOT EXISTS content_reviewed_at timestamptz,
ADD COLUMN IF NOT EXISTS content_reviewed_by uuid REFERENCES profiles(id) ON DELETE SET NULL;
--> statement-breakpoint
ALTER TABLE question_packs
ADD COLUMN IF NOT EXISTS content_status content_review_status NOT NULL DEFAULT 'unreviewed',
ADD COLUMN IF NOT EXISTS content_review_reason text,
ADD COLUMN IF NOT EXISTS content_reviewed_at timestamptz,
ADD COLUMN IF NOT EXISTS content_reviewed_by uuid REFERENCES profiles(id) ON DELETE SET NULL;
--> statement-breakpoint
ALTER TABLE questions
ADD COLUMN IF NOT EXISTS content_status content_review_status NOT NULL DEFAULT 'unreviewed',
ADD COLUMN IF NOT EXISTS content_review_reason text,
ADD COLUMN IF NOT EXISTS content_reviewed_at timestamptz,
ADD COLUMN IF NOT EXISTS content_reviewed_by uuid REFERENCES profiles(id) ON DELETE SET NULL;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS categories_content_status_idx
ON categories (content_status, is_public, sort_order);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS question_packs_content_status_idx
ON question_packs (content_status, is_public, sort_order);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS questions_content_status_idx
ON questions (pack_id, content_status, intensity);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS content_reviews (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
entity_type content_entity_type NOT NULL,
entity_id uuid NOT NULL,
previous_status content_review_status NOT NULL,
new_status content_review_status NOT NULL,
reason text NOT NULL,
changed_by uuid REFERENCES profiles(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS content_reviews_entity_idx
ON content_reviews (entity_type, entity_id, created_at DESC);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS content_reviews_created_at_idx
ON content_reviews (created_at DESC);
--> statement-breakpoint
-- The standalone audit model separates the acting profile from the admin row.
-- Retain legacy descriptive fields rather than dropping production history.
ALTER TABLE audit_logs
ADD COLUMN IF NOT EXISTS changed_fields text[],
ADD COLUMN IF NOT EXISTS admin_role text,
ADD COLUMN IF NOT EXISTS admin_username text;
--> statement-breakpoint
-- A small number of legacy Supabase objects have neither an owner nor a live
-- database reference. Preserve their bytes and metadata without making them
-- addressable through the authenticated media API or assigning false ownership.
CREATE TABLE IF NOT EXISTS legacy_media_quarantine (
id uuid PRIMARY KEY,
original_storage_key text NOT NULL UNIQUE,
bucket_id text NOT NULL,
object_name text NOT NULL,
source_owner_id uuid,
metadata jsonb,
mime_type text NOT NULL,
byte_size integer NOT NULL CHECK (byte_size > 0),
reason text NOT NULL,
source_created_at timestamptz NOT NULL,
source_updated_at timestamptz,
imported_at timestamptz NOT NULL DEFAULT now()
);
+42
View File
@@ -92,6 +92,48 @@
"when": 1787908500000,
"tag": "0012_catalogue_field_parity",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1787909400000,
"tag": "0013_dares_loop",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1787994900000,
"tag": "0014_daily_limit_local_reset",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1787994960000,
"tag": "0015_daily_limit_response_index",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1788081360000,
"tag": "0016_couple_streak_locality",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1788081420000,
"tag": "0017_streak_reminder_preference",
"breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1788081480000,
"tag": "0018_cutover_source_parity",
"breakpoints": true
}
]
}
+4
View File
@@ -17,8 +17,11 @@
"db:migrate:prod": "node dist/db/migrate.js",
"db:seed": "tsx src/db/seed.ts",
"migration:cutover": "tsx src/migration/run.ts",
"load:representative": "tsx src/load/representative.ts",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"test:integration": "vitest run --config vitest.integration.config.ts",
"test:integration:coverage": "vitest run --config vitest.integration.config.ts --coverage",
"typecheck": "tsc --noEmit",
"lint": "tsc --noEmit"
},
@@ -32,6 +35,7 @@
"zod": "^3.23.0"
},
"devDependencies": {
"@vitest/coverage-v8": "^2.1.9",
"@types/node": "^22.0.0",
"@types/pg": "^8.15.5",
"drizzle-kit": "^0.31.4",
+17
View File
@@ -13,6 +13,8 @@ import type { AnswersRepository } from './domains/answers/repository.js';
import { registerAnswerRoutes } from './domains/answers/routes.js';
import type { ChatRepository } from './domains/chat/repository.js';
import { registerChatRoutes } from './domains/chat/routes.js';
import type { DaresRepository } from './domains/dares/repository.js';
import { registerDareRoutes } from './domains/dares/routes.js';
import { registerCoupleRoutes } from './domains/couples/routes.js';
import type { CoupleService } from './domains/couples/service.js';
import type { PacksRepository } from './domains/packs/repository.js';
@@ -25,6 +27,7 @@ import type { FilesystemMediaStorage } from './domains/media/storage.js';
import type { AdminRequestAuth } from './domains/admin/auth.js';
import type { AdminRepository } from './domains/admin/repository.js';
import { registerAdminRoutes } from './domains/admin/routes.js';
import { recordRequest } from './telemetry.js';
type Variables = { identity: AuthIdentity };
@@ -35,6 +38,7 @@ export interface AppDependencies {
packsRepository?: PacksRepository;
answersRepository?: AnswersRepository;
chatRepository?: ChatRepository;
daresRepository?: DaresRepository;
profileSettingsRepository?: ProfileSettingsRepository;
accountOperationsService?: AccountOperationsService;
mediaRepository?: MediaRepository;
@@ -54,6 +58,18 @@ function error(code: string, message: string): ApiErrorResponse {
export function createApp(deps: AppDependencies): Hono<{ Variables: Variables }> {
const app = new Hono<{ Variables: Variables }>();
app.use('*', async (c, next) => {
const startedAt = performance.now();
try {
await next();
} finally {
// Hono returns the matched template after dispatch, never the raw path.
const candidate = c.req.routePath;
const route = candidate && !/[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}/i.test(candidate) && !candidate.includes('?') ? candidate : 'unmatched';
recordRequest(c.req.method, route, c.res.status, startedAt);
}
});
app.get('/health/live', (c) => c.json({ status: 'ok' }));
app.get('/health/ready', async (c) => {
try {
@@ -109,6 +125,7 @@ export function createApp(deps: AppDependencies): Hono<{ Variables: Variables }>
if (deps.packsRepository) registerPackRoutes(app, deps.packsRepository);
if (deps.answersRepository) registerAnswerRoutes(app, deps.answersRepository);
if (deps.chatRepository) registerChatRoutes(app, deps.chatRepository);
if (deps.daresRepository) registerDareRoutes(app, deps.daresRepository);
if (deps.profileSettingsRepository) registerProfileSettingsRoutes(app, deps.profileSettingsRepository);
if (deps.accountOperationsService) registerAccountOperationRoutes(app, deps.accountOperationsService);
if (deps.mediaRepository && deps.mediaStorage) registerMediaRoutes(app, deps.mediaRepository, deps.mediaStorage);
+9
View File
@@ -7,6 +7,9 @@ const baseSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().min(1).max(65535).default(3003),
DATABASE_URL: z.string().url(),
DATABASE_POOL_MAX: z.coerce.number().int().min(1).max(200).default(10),
DATABASE_POOL_IDLE_TIMEOUT_MS: z.coerce.number().int().min(0).max(3_600_000).default(30_000),
DATABASE_POOL_CONNECTION_TIMEOUT_MS: z.coerce.number().int().min(0).max(60_000).default(10_000),
SUPABASE_AUTH_URL: z.string().url().optional(),
SUPABASE_AUTH_ISSUER: z.string().url().optional(),
SUPABASE_AUTH_JWKS_URL: z.string().url().optional(),
@@ -28,6 +31,7 @@ export interface AppConfig {
nodeEnv: 'development' | 'test' | 'production';
port: number;
databaseUrl: string;
databasePool: { max: number; idleTimeoutMillis: number; connectionTimeoutMillis: number };
authIssuer: string;
authJwksUrl?: string;
authAudience: string;
@@ -81,6 +85,11 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
nodeEnv: parsed.NODE_ENV,
port: parsed.PORT,
databaseUrl: parsed.DATABASE_URL,
databasePool: {
max: parsed.DATABASE_POOL_MAX,
idleTimeoutMillis: parsed.DATABASE_POOL_IDLE_TIMEOUT_MS,
connectionTimeoutMillis: parsed.DATABASE_POOL_CONNECTION_TIMEOUT_MS,
},
authIssuer,
authJwksUrl,
authAudience: parsed.SUPABASE_AUTH_AUDIENCE,
+52
View File
@@ -0,0 +1,52 @@
import { Pool } from 'pg';
import { instrumentPool, type TelemetryProcess } from '../telemetry.js';
/**
* A repository is constructed either from a connection string, in which case it
* owns the pool it creates and must end it, or from a pool the caller owns. The
* API process takes the second path so every domain shares one bounded pool
* instead of opening a default-sized pool per repository; tests keep taking the
* first so each suite stays independently closable.
*/
export type DatabaseConnection = string | Pool;
export interface DatabasePoolOptions {
/** Server-wide ceiling on concurrent PostgreSQL connections. */
max?: number;
idleTimeoutMillis?: number;
connectionTimeoutMillis?: number;
}
/**
* `pg` defaults to ten connections per pool, which multiplied by one pool per
* repository. One shared pool of the same size keeps the same headroom for a
* single API process while bounding what a self-hosted PostgreSQL must accept.
*/
export const DEFAULT_POOL_MAX = 10;
export function createDatabasePool(databaseUrl: string, options: DatabasePoolOptions = {}, telemetryProcess?: TelemetryProcess): Pool {
const pool = new Pool({
connectionString: databaseUrl,
max: options.max ?? DEFAULT_POOL_MAX,
...(options.idleTimeoutMillis === undefined ? {} : { idleTimeoutMillis: options.idleTimeoutMillis }),
...(options.connectionTimeoutMillis === undefined ? {} : { connectionTimeoutMillis: options.connectionTimeoutMillis }),
});
return telemetryProcess ? instrumentPool(telemetryProcess, pool) : pool;
}
export interface ResolvedPool {
pool: Pool;
/** True when this repository created the pool and is responsible for ending it. */
owned: boolean;
}
export function resolvePool(connection: DatabaseConnection): ResolvedPool {
return typeof connection === 'string'
? { pool: new Pool({ connectionString: connection }), owned: true }
: { pool: connection, owned: false };
}
/** Ends the pool only when this repository created it, so shared pools survive. */
export async function closeResolvedPool(pool: Pool, owned: boolean): Promise<void> {
if (owned) await pool.end();
}
+25 -4
View File
@@ -2,6 +2,7 @@ import type { FeatureInterestResponse, Profile } from '@sauci/shared';
import { and, eq, sql } from 'drizzle-orm';
import { drizzle, type NodePgDatabase } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import { closeResolvedPool, resolvePool, type DatabaseConnection } from './pool.js';
import type { AuthIdentity } from '../auth.js';
import { featureInterests, profiles, type ProfileRow } from './schema.js';
@@ -44,10 +45,13 @@ function toProfile(row: ProfileRow): MobileCompatibleProfile {
export class PostgresRepository implements ApiRepository {
private readonly pool: Pool;
private readonly ownsPool: boolean;
private readonly db: NodePgDatabase;
constructor(databaseUrl: string) {
this.pool = new Pool({ connectionString: databaseUrl });
constructor(connection: DatabaseConnection) {
const resolved = resolvePool(connection);
this.pool = resolved.pool;
this.ownsPool = resolved.owned;
this.db = drizzle(this.pool);
}
@@ -55,7 +59,21 @@ export class PostgresRepository implements ApiRepository {
await this.db.select({ id: profiles.id }).from(profiles).limit(1);
}
/**
* Idempotent for the common case. `GET /v1/me` runs on every app start and on
* every session refresh, and the previous unconditional upsert made each of
* those a write that advanced `auth_synced_at` and produced dead row versions
* for autovacuum. The only fields this sync can actually change are the ones
* the conflict clause sets, so a profile that already carries the identity's
* email is returned as read and nothing is written. First-use creation and
* email enrichment keep their existing behaviour.
*/
async upsertProfile(identity: AuthIdentity): Promise<MobileCompatibleProfile> {
const [existing] = await this.db.select().from(profiles).where(eq(profiles.id, identity.id)).limit(1);
if (existing && (identity.email === null || identity.email === undefined || existing.email === identity.email)) {
return toProfile(existing);
}
const [row] = await this.db.insert(profiles).values({
id: identity.id,
name: identity.name,
@@ -65,7 +83,10 @@ export class PostgresRepository implements ApiRepository {
target: profiles.id,
set: {
email: sql`coalesce(excluded.email, ${profiles.email})`,
authSyncedAt: new Date(),
// Keep the write on the same clock as the column default. API and
// PostgreSQL hosts can differ slightly, which must not move this
// monotonic sync marker backwards.
authSyncedAt: sql`now()`,
},
}).returning();
@@ -93,6 +114,6 @@ export class PostgresRepository implements ApiRepository {
}
async close(): Promise<void> {
await this.pool.end();
await closeResolvedPool(this.pool, this.ownsPool);
}
}
+1
View File
@@ -6,6 +6,7 @@ export const profiles = pgTable('profiles', {
email: text('email'),
avatarUrl: text('avatar_url'),
pushToken: text('push_token'),
timezone: text('timezone'),
isPremium: boolean('is_premium').notNull().default(false),
coupleId: uuid('couple_id'),
gender: text('gender').$type<'male' | 'female' | 'non-binary' | 'prefer-not-to-say' | null>(),
@@ -1,4 +1,5 @@
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
import { AccountOperationError } from './types.js';
interface ProfileOperationRecord extends QueryResultRow {
@@ -63,9 +64,12 @@ async function lockCoupleAndPartner(
export class PostgresAccountOperationsRepository implements AccountOperationsRepository {
private readonly pool: Pool;
private readonly ownsPool: boolean;
constructor(databaseUrl: string) {
this.pool = new Pool({ connectionString: databaseUrl });
constructor(connection: DatabaseConnection) {
const resolved = resolvePool(connection);
this.pool = resolved.pool;
this.ownsPool = resolved.owned;
}
async deleteRelationship(userId: string): Promise<DestructiveOperationResult> {
@@ -135,7 +139,7 @@ export class PostgresAccountOperationsRepository implements AccountOperationsRep
}
async close(): Promise<void> {
await this.pool.end();
await closeResolvedPool(this.pool, this.ownsPool);
}
}
+5 -3
View File
@@ -1,4 +1,5 @@
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
import { readFile } from 'node:fs/promises';
import { resolve, sep } from 'node:path';
import { AdminError, type AdminPrincipal, type AdminQuery } from './types.js';
@@ -139,9 +140,10 @@ export interface AdminRepository {
export class PostgresAdminRepository implements AdminRepository {
private readonly pool: Pool;
constructor(databaseUrl: string, private readonly options: {
private readonly ownsPool: boolean;
constructor(connection: DatabaseConnection, private readonly options: {
adminPrivateKeyJwk?: string; mediaRoot?: string; authDirectory?: AdminAuthDirectory;
} = {}) { this.pool = new Pool({ connectionString: databaseUrl }); }
} = {}) { const resolved = resolvePool(connection); this.pool = resolved.pool; this.ownsPool = resolved.owned; }
async principal(userId: string): Promise<AdminPrincipal> {
const result = await this.pool.query<{ id: string; user_id: string; role: AdminPrincipal['role']; permissions: string[] }>(
@@ -356,5 +358,5 @@ export class PostgresAdminRepository implements AdminRepository {
await client.query('insert into audit_logs(admin_user_id,actor_user_id,table_name,action,record_id,old_values,new_values) values($1,$2,$3,$4,$5,$6,$7)', [principal.adminId, principal.userId, table, action, recordId || null, redactAudit(oldValues), redactAudit(newValues)]);
}
async close(): Promise<void> { await this.pool.end(); }
async close(): Promise<void> { await closeResolvedPool(this.pool, this.ownsPool); }
}
+126 -16
View File
@@ -1,10 +1,38 @@
import { Pool, type PoolClient } from 'pg';
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
import { AnswersError, calculateMatchType, type Answer, type MatchType, type QuestionType, type ResponseData } from './types.js';
interface ContextRow { couple_id: string | null; gender: string | null; max_intensity: number; is_premium: boolean }
interface QuestionRow { id: string; pack_id: string; text: string; partner_text: string | null; intensity: number; allowed_couple_genders: string[] | null; target_user_genders: string[] | null; question_type: QuestionType; config: Record<string, unknown>; created_at: string }
interface ResponseRow { id: string; user_id: string; question_id: string; couple_id: string; answer: Answer; response_data: ResponseData; created_at: string }
interface MatchRow { id: string; couple_id: string; question_id: string; match_type: MatchType; is_new: boolean; response_summary: Record<string, unknown> | null; created_at: string }
interface StreakRow { couple_id: string; current_streak: number; longest_streak: number; last_active_date: string | null; last_completed_date: string | null; user1_answered_today: boolean; user2_answered_today: boolean; streak_celebrated_at: number; created_at: Date; updated_at: Date }
/**
* A streak resolved from one partner's point of view. The stored row is positional
* (user1/user2 by member id order) because the writer has to be deterministic, but a
* client cannot act on "user2 has not answered" without re-deriving that ordering, so
* the API answers in terms of you and your partner instead.
*/
export interface CoupleStreakView {
couple_id: string;
current_streak: number;
longest_streak: number;
last_active_date: string | null;
last_completed_date: string | null;
you_answered_today: boolean;
partner_answered_today: boolean;
partner_name: string | null;
timezone: string;
streak_celebrated_at: number;
created_at: string;
updated_at: string;
}
// A response to a question the partner answered first is catching up, not new exploration.
// Those neither consume nor are blocked by the daily limit, so the reciprocity loop the
// answer-gap mechanic pushes can never be refused by the meter.
const catchUpExemption = "select 1 from responses pr where pr.question_id=r.question_id and pr.couple_id=r.couple_id and pr.user_id<>r.user_id and pr.created_at<r.created_at";
export interface SubmitInput { questionId: string; answer: Answer; responseData?: ResponseData }
export interface UpdateInput extends SubmitInput { confirmDeleteMatch: boolean }
@@ -17,16 +45,18 @@ export interface AnswersRepository {
submit(userId: string, input: SubmitInput): Promise<{ response: ResponseRow; match: (MatchRow & { question: QuestionRow }) | null }>;
update(userId: string, input: UpdateInput): Promise<Record<string, unknown>>;
responses(userId: string, page: number, limit: number): Promise<{ responses: unknown[]; totalCount: number }>;
matches(userId: string, page: number, limit: number, archived: boolean): Promise<{ matches: unknown[]; totalCount: number }>;
matches(userId: string, page: number, limit: number, archived: boolean): Promise<{ matches: unknown[]; totalCount: number | null; hasMore: boolean }>;
markSeen(userId: string, ids: string[]): Promise<void>;
archive(userId: string, matchId: string, archived: boolean): Promise<void>;
streak(userId: string): Promise<Record<string, unknown> | null>;
streak(userId: string): Promise<CoupleStreakView | null>;
close(): Promise<void>;
}
export class PostgresAnswersRepository implements AnswersRepository {
private readonly pool: Pool;
constructor(databaseUrl: string) { this.pool = new Pool({ connectionString: databaseUrl }); }
private readonly ownsPool: boolean;
private zones: Promise<Set<string>> | null = null;
constructor(connection: DatabaseConnection) { const resolved = resolvePool(connection); this.pool = resolved.pool; this.ownsPool = resolved.owned; }
private async context(client: Pool | PoolClient, userId: string): Promise<ContextRow> {
const result = await client.query<ContextRow>('select couple_id, gender, max_intensity, is_premium from profiles where id=$1', [userId]);
@@ -41,7 +71,7 @@ export class PostgresAnswersRepository implements AnswersRepository {
const p = partner.rows[0];
const config = await this.config();
if (!packId && p && (await this.answerGap(userId)).is_blocked) return [];
if (!packId && !ctx.is_premium && !p?.is_premium && (await this.dailyLimit(userId)).is_blocked) return [];
if (!ctx.is_premium && !p?.is_premium && (await this.dailyLimit(userId)).is_blocked) return [];
const genders = ctx.gender && p?.gender ? [ctx.gender, p.gender].sort().join('+') : null;
const maxIntensity = config.couple_intensity_gate_enabled && p ? Math.min(ctx.max_intensity, p.max_intensity) : ctx.max_intensity;
const result = await this.pool.query<QuestionRow>(`
@@ -117,7 +147,10 @@ export class PostgresAnswersRepository implements AnswersRepository {
await this.assertEligible(client,userId,ctx,q);
const existing = await client.query('select id from responses where user_id=$1 and question_id=$2 for update', [userId,input.questionId]);
if (!existing.rowCount) {
const limit = await this.dailyLimitWith(client,userId); if (limit.is_blocked) throw new AnswersError('daily_limit',429,'Daily response limit reached',{ daily_limit: limit.limit_value, reset_at: limit.reset_at });
const catchingUp = await client.query('select 1 from responses where couple_id=$1 and question_id=$2 and user_id<>$3 limit 1',[ctx.couple_id,input.questionId,userId]);
if (!catchingUp.rowCount) {
const limit = await this.dailyLimitWith(client,userId); if (limit.is_blocked) throw new AnswersError('daily_limit',429,'Daily response limit reached',{ daily_limit: limit.limit_value, responses_today: limit.responses_today, remaining: 0, reset_at: limit.reset_at });
}
}
const response = await client.query<ResponseRow>(`insert into responses(id,user_id,question_id,couple_id,answer,response_data) values(gen_random_uuid(),$1,$2,$3,$4,$5)
on conflict(user_id,question_id) do update set answer=excluded.answer,response_data=excluded.response_data returning *`,[userId,input.questionId,ctx.couple_id,input.answer,input.responseData ?? null]);
@@ -157,12 +190,30 @@ export class PostgresAnswersRepository implements AnswersRepository {
return result.rows[0];
}
/**
* The streak day is the couple's shared local day, not the server's. Both partners have
* to land inside the same calendar date for the day to count, so the zone belongs to the
* couple rather than to whoever happens to be answering: the first reported zone in member
* id order, which is the same ordering that decides user1/user2, falling back to UTC when
* neither partner has reported one. Without this a couple in a western zone loses the
* streak at UTC midnight while it is still the same evening for them.
*/
private async coupleZone(client:Pool|PoolClient,reported:(string|null)[]):Promise<string>{
const zone=reported.find(value=>value);
return zone&&(await this.timezoneNames(client)).has(zone)?zone:'UTC';
}
private async streakDay(client:Pool|PoolClient,zone:string){
const r=await client.query<{today:string;yesterday:string}>("select (now() at time zone $1::text)::date::text today,((now() at time zone $1::text)::date-1)::text yesterday",[zone]);
return r.rows[0];
}
private async touchStreak(client:PoolClient,userId:string,coupleId:string){
const users=await client.query<{id:string}>('select id from profiles where couple_id=$1 order by id',[coupleId]); if(users.rows.length<2)return;
const users=await client.query<{id:string;timezone:string|null}>('select id,timezone from profiles where couple_id=$1 order by id',[coupleId]); if(users.rows.length<2)return;
const user1=users.rows[0].id===userId;
await client.query('insert into couple_streaks(couple_id) values($1) on conflict do nothing',[coupleId]);
const locked=await client.query<{current_streak:number;longest_streak:number;last_active_date:string|null;last_completed_date:string|null;user1_answered_today:boolean;user2_answered_today:boolean}>(`select current_streak,longest_streak,last_active_date::text,last_completed_date::text,user1_answered_today,user2_answered_today from couple_streaks where couple_id=$1 for update`,[coupleId]);
const row=locked.rows[0]; const todayResult=await client.query<{today:string;yesterday:string}>('select current_date::text today,(current_date-1)::text yesterday'); const {today,yesterday}=todayResult.rows[0];
const row=locked.rows[0]; const {today,yesterday}=await this.streakDay(client,await this.coupleZone(client,users.rows.map(member=>member.timezone)));
let first=row.last_active_date===today?row.user1_answered_today:false; let second=row.last_active_date===today?row.user2_answered_today:false;
if(user1)first=true;else second=true;
let current=row.last_completed_date && row.last_completed_date!==yesterday && row.last_completed_date!==today?0:row.current_streak; let completed=row.last_completed_date;
@@ -181,22 +232,81 @@ export class PostgresAnswersRepository implements AnswersRepository {
return {responses:rows.rows.map(this.dates),totalCount:count.rows[0]?.count??0};
}
/**
* Reading one row past the page decides `hasMore` exactly, and the total — which
* the client only records when it refreshes — is counted on the first page only
* instead of on every scroll. Unread is aggregated once per couple rather than as
* a correlated count per row: the sort key depends on it, so the old subquery
* already ran for every candidate match, not just the twenty that were returned.
*/
async matches(userId:string,page:number,limit:number,archived:boolean){
const ctx=await this.context(this.pool,userId); const offset=page*limit;
const params=[userId,ctx.couple_id,archived,limit,offset];
const base=`from matches m join questions q on q.id=m.question_id left join match_archives a on a.match_id=m.id and a.user_id=$1 where m.couple_id=$2 and (($3 and a.id is not null) or (not $3 and a.id is null))`;
const [rows,count]=await Promise.all([this.pool.query(`select m.*,json_build_object('id',q.id,'pack_id',q.pack_id,'text',q.text,'partner_text',q.partner_text,'intensity',q.intensity,'question_type',q.question_type,'config',q.config,'created_at',q.created_at) question,(select count(*)::int from messages msg where msg.match_id=m.id and msg.user_id<>$1 and msg.read_at is null) "unreadCount" ${base} order by "unreadCount" desc,m.created_at desc limit $4 offset $5`,params),this.pool.query<{count:number}>(`select count(*)::int count ${base}`,params.slice(0,3))]);
return {matches:rows.rows.map(this.dates),totalCount:count.rows[0]?.count??0};
const params=[userId,ctx.couple_id,archived,limit+1,offset];
const from=`from matches m join questions q on q.id=m.question_id left join match_archives a on a.match_id=m.id and a.user_id=$1`;
const where=`where m.couple_id=$2 and (($3 and a.id is not null) or (not $3 and a.id is null))`;
const unread=`unread as (select msg.match_id,count(*)::int c from messages msg join matches um on um.id=msg.match_id and um.couple_id=$2 where msg.user_id<>$1 and msg.read_at is null group by msg.match_id)`;
const [rows,count]=await Promise.all([
this.pool.query(`with ${unread} select m.*,json_build_object('id',q.id,'pack_id',q.pack_id,'text',q.text,'partner_text',q.partner_text,'intensity',q.intensity,'question_type',q.question_type,'config',q.config,'created_at',q.created_at) question,coalesce(u.c,0) "unreadCount" ${from} left join unread u on u.match_id=m.id ${where} order by "unreadCount" desc,m.created_at desc limit $4 offset $5`,params),
page===0?this.pool.query<{count:number}>(`select count(*)::int count ${from} ${where}`,params.slice(0,3)):null]);
return {matches:rows.rows.slice(0,limit).map(this.dates),totalCount:count?count.rows[0]?.count??0:null,hasMore:rows.rows.length>limit};
}
async markSeen(userId:string,ids:string[]){const ctx=await this.context(this.pool,userId);await this.pool.query('update matches set is_new=false where couple_id=$1 and id=any($2::uuid[])',[ctx.couple_id,ids]);}
async archive(userId:string,matchId:string,archived:boolean){const ctx=await this.context(this.pool,userId);const found=await this.pool.query('select 1 from matches where id=$1 and couple_id=$2',[matchId,ctx.couple_id]);if(!found.rowCount)throw new AnswersError('match_not_found',404,'Match was not found');if(archived)await this.pool.query('insert into match_archives(match_id,user_id) values($1,$2) on conflict do nothing',[matchId,userId]);else await this.pool.query('delete from match_archives where match_id=$1 and user_id=$2',[matchId,userId]);}
async streak(userId:string){const ctx=await this.context(this.pool,userId);const r=await this.pool.query('select * from couple_streaks where couple_id=$1',[ctx.couple_id]);return r.rows[0]?this.dates(r.rows[0]):null;}
async streak(userId:string):Promise<CoupleStreakView|null>{
const ctx=await this.context(this.pool,userId);
const r=await this.pool.query<StreakRow>(`select couple_id,current_streak,longest_streak,last_active_date::text last_active_date,last_completed_date::text last_completed_date,
user1_answered_today,user2_answered_today,streak_celebrated_at,created_at,updated_at from couple_streaks where couple_id=$1`,[ctx.couple_id]);
const row=r.rows[0]; if(!row)return null;
const members=await this.pool.query<{id:string;name:string|null;timezone:string|null}>('select id,name,timezone from profiles where couple_id=$1 order by id',[ctx.couple_id]);
if(members.rows.length<2)return null;
const zone=await this.coupleZone(this.pool,members.rows.map(member=>member.timezone));
const {today,yesterday}=await this.streakDay(this.pool,zone);
// The row is only rewritten when somebody answers, so a lapsed streak keeps its stored
// number until the next response arrives. Reading applies the same day rules the writer
// does rather than showing a count that the next answer will silently reset to 1.
const active=row.last_active_date===today;
const first=active&&row.user1_answered_today; const second=active&&row.user2_answered_today;
const alive=row.last_completed_date===today||row.last_completed_date===yesterday;
const isUser1=members.rows[0].id===userId; const partner=members.rows[isUser1?1:0];
return {
couple_id:row.couple_id,
current_streak:alive?row.current_streak:0,
longest_streak:row.longest_streak,
last_active_date:row.last_active_date,
last_completed_date:row.last_completed_date,
you_answered_today:isUser1?first:second,
partner_answered_today:isUser1?second:first,
partner_name:partner?.name??null,
timezone:zone,
streak_celebrated_at:row.streak_celebrated_at,
created_at:row.created_at.toISOString(),
updated_at:row.updated_at.toISOString(),
};
}
private async question(client:PoolClient,id:string,lock=false){const r=await client.query<QuestionRow>(`select * from questions where id=$1 and deleted_at is null${lock?' for update':''}`,[id]);if(!r.rows[0])throw new AnswersError('question_not_found',404,'Question was not found');return r.rows[0];}
private timezoneNames(client:Pool|PoolClient){
// pg_timezone_names is a ~600-row function scan costing ~12ms. The list is static for the
// life of the process, so load it once instead of on every daily-limit check.
this.zones??=client.query<{name:string}>('select name from pg_timezone_names')
.then(r=>new Set(r.rows.map(row=>row.name)))
.catch(cause=>{this.zones=null;throw cause;});
return this.zones;
}
private async lockedContext(client:PoolClient,userId:string){await client.query('select id from profiles where id=$1 for update',[userId]);return this.context(client,userId);}
private async dailyLimitWith(client:Pool|PoolClient,userId:string){
const cfg=await this.config(client);if(cfg.daily_response_limit===0)return {responses_today:0,limit_value:0,remaining:0,reset_at:null,is_blocked:false};
const premium=await client.query<{premium:boolean}>('select (p.is_premium or coalesce(bool_or(o.is_premium),false)) premium from profiles p left join profiles o on o.couple_id=p.couple_id and o.id<>p.id where p.id=$1 group by p.is_premium',[userId]);if(premium.rows[0]?.premium)return {responses_today:0,limit_value:0,remaining:0,reset_at:null,is_blocked:false};
const r=await client.query<{count:number;reset_at:string}>("select count(*)::int count,((date_trunc('day',now() at time zone 'UTC') at time zone 'UTC')+interval '1 day') reset_at from responses where user_id=$1 and created_at>=(date_trunc('day',now() at time zone 'UTC') at time zone 'UTC')",[userId]);const count=r.rows[0]?.count??0;return {responses_today:count,limit_value:cfg.daily_response_limit,remaining:Math.max(0,cfg.daily_response_limit-count),reset_at:new Date(r.rows[0].reset_at).toISOString(),is_blocked:count>=cfg.daily_response_limit};
const cfg=await this.config(client);const unlimited={responses_today:0,limit_value:0,remaining:0,reset_at:null,is_blocked:false};
if(cfg.daily_response_limit===0)return unlimited;
// Premium is shared across a couple: either partner's subscription lifts the cap for both.
const profile=await client.query<{premium:boolean;zone:string|null}>('select (p.is_premium or coalesce(bool_or(o.is_premium),false)) premium,p.timezone zone from profiles p left join profiles o on o.couple_id=p.couple_id and o.id<>p.id where p.id=$1 group by p.is_premium,p.timezone',[userId]);
if(profile.rows[0]?.premium)return unlimited;
// The window is the user's local calendar day; an unreported or unrecognised zone falls
// back to UTC. Validating in JS keeps an unknown zone from erroring the query without
// paying for a pg_timezone_names scan inside submit()'s locked transaction.
const reported=profile.rows[0]?.zone;
const zone=reported&&(await this.timezoneNames(client)).has(reported)?reported:'UTC';
const r=await client.query<{count:number;reset_at:string}>(`select count(*)::int count,((date_trunc('day',now() at time zone $2::text)+interval '1 day') at time zone $2::text) reset_at from responses r where r.user_id=$1 and r.created_at>=(date_trunc('day',now() at time zone $2::text) at time zone $2::text) and not exists(${catchUpExemption})`,[userId,zone]);
const count=r.rows[0]?.count??0;
return {responses_today:count,limit_value:cfg.daily_response_limit,remaining:Math.max(0,cfg.daily_response_limit-count),reset_at:r.rows[0]?new Date(r.rows[0].reset_at).toISOString():null,is_blocked:count>=cfg.daily_response_limit};
}
private async assertEligible(client:PoolClient,userId:string,ctx:ContextRow,q:QuestionRow){
const partner=await client.query<ContextRow&{id:string}>('select id,couple_id,gender,max_intensity,is_premium from profiles where couple_id=$1 and id<>$2 order by id limit 1',[ctx.couple_id,userId]);const p=partner.rows[0];
@@ -207,5 +317,5 @@ export class PostgresAnswersRepository implements AnswersRepository {
}
private async tx<T>(fn:(client:PoolClient)=>Promise<T>):Promise<T>{const c=await this.pool.connect();try{await c.query('begin');const value=await fn(c);await c.query('commit');return value;}catch(e){await c.query('rollback');throw e;}finally{c.release();}}
private dates<T extends Record<string,any>>(row:T):T{return Object.fromEntries(Object.entries(row).map(([k,v])=>[k,v instanceof Date?v.toISOString():v])) as T;}
async close(){await this.pool.end();}
async close(){await closeResolvedPool(this.pool,this.ownsPool);}
}
+129 -2
View File
@@ -1,4 +1,5 @@
import { Pool, type QueryResultRow } from 'pg';
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
export interface PackContext { id: string; name: string; icon: string | null }
export interface AppQuestion {
@@ -19,6 +20,50 @@ export interface StrokeSegment {
}
export interface LiveDrawState { strokes: StrokeSegment[]; revision: number; updated_at: string | null; updated_by: string | null }
/**
* A change summary rather than a payload. The mobile app used to re-fetch profile,
* couple, matches, both pending directions and enabled packs every five seconds to
* notice partner activity; it now compares this summary with the last one it saw and
* refreshes only the domains whose marker actually moved. Every field is a marker,
* not display data, so the response stays a few hundred bytes whatever the couple's
* history looks like.
*/
export interface SyncSummary {
server_time: string;
couple_id: string | null;
profile_updated_at: string | null;
partner_id: string | null;
partner_updated_at: string | null;
/** Matches visible to this member: archiving one of them moves the count. */
match_count: number;
new_match_count: number;
latest_match_at: string | null;
/** Partner answered, this member has not. */
pending_yours: number;
/** This member answered, partner has not. */
pending_theirs: number;
unread_total: number;
/** Digest of the enabled pack ids, so an equal-and-opposite toggle is still a change. */
enabled_packs_fingerprint: string;
/**
* Digest of each visible match's type and response summary. `match_count` /
* `new_match_count` / `latest_match_at` only move when a match is created,
* archived or unarchived — not when a partner edits an existing response and
* changes its `match_type` or `response_summary` — so this fingerprint is
* what actually notices that edit.
*/
match_state_fingerprint: string;
/**
* Digest of each visible match's unread count. `unread_total` can stay equal
* while unread messages redistribute between matches (one read, another
* receives a message), which reorders the unread-first list without moving
* the total; this fingerprint is what notices that.
*/
match_unread_fingerprint: string;
/** couple_streaks.updated_at, so a partner's answer can silently refresh an already-loaded streak. */
streak_updated_at: string | null;
}
export class AppDataError extends Error {
constructor(public readonly code: 'not_found' | 'no_couple' | 'not_recipient' | 'rate_limited' | 'revision_conflict', message: string, public readonly status: 403 | 404 | 409 | 429, public readonly details?: Record<string, unknown>) { super(message); }
}
@@ -31,6 +76,7 @@ export interface AppDataRepository {
markMediaViewed(userId: string, messageId: string, expiresAt: string | null): Promise<{ media_viewed_at: string; media_expires_at: string | null }>;
nudgeStatus(userId: string): Promise<{ last_nudge_sent_at: string | null }>;
sendNudge(userId: string): Promise<{ success: true; notification_sent: boolean; reason?: string; next_nudge_available_at: string }>;
syncSummary(userId: string): Promise<SyncSummary>;
getLiveDraw(userId: string): Promise<LiveDrawState>;
putLiveDraw(userId: string, strokes: StrokeSegment[], baseRevision: number): Promise<LiveDrawState>;
close(): Promise<void>;
@@ -38,11 +84,22 @@ export interface AppDataRepository {
interface QuestionRow extends QueryResultRow, Omit<AppQuestion, 'created_at'> { created_at: Date }
interface SyncRow extends QueryResultRow {
server_time: Date; couple_id: string | null; profile_updated_at: Date | null;
partner_id: string | null; partner_updated_at: Date | null;
match_count: number | null; new_match_count: number | null; latest_match_at: Date | null;
pending_yours: number | null; pending_theirs: number | null; unread_total: number | null;
enabled_packs_fingerprint: string | null;
match_state_fingerprint: string | null; match_unread_fingerprint: string | null;
streak_updated_at: Date | null;
}
function question(row: QuestionRow): AppQuestion { return { ...row, created_at: row.created_at.toISOString() }; }
export class PostgresAppDataRepository implements AppDataRepository {
private readonly pool: Pool;
constructor(databaseUrl: string) { this.pool = new Pool({ connectionString: databaseUrl }); }
private readonly ownsPool: boolean;
constructor(connection: DatabaseConnection) { const resolved = resolvePool(connection); this.pool = resolved.pool; this.ownsPool = resolved.owned; }
private async visiblePack(userId: string, packId: string): Promise<PackContext> {
const result = await this.pool.query<PackContext>(`
@@ -189,5 +246,75 @@ export class PostgresAppDataRepository implements AppDataRepository {
}
return { strokes: row.strokes, revision: Number(row.revision), updated_at: row.updated_at.toISOString(), updated_by: row.updated_by };
}
async close(): Promise<void> { await this.pool.end(); }
/**
* One round trip, all scalar subqueries, nothing that grows with the couple's
* history. An unpaired member's couple-scoped markers all collapse to zero or null
* because `me.couple_id` is null, so the endpoint stays valid before pairing.
*/
async syncSummary(userId: string): Promise<SyncSummary> {
const result = await this.pool.query<SyncRow>(`
with me as (select id, couple_id, updated_at from profiles where id = $1),
visible as (
select m.id, m.is_new, m.created_at, m.match_type, m.response_summary from matches m, me
where m.couple_id = me.couple_id
and not exists (select 1 from match_archives a where a.match_id = m.id and a.user_id = me.id)
),
visible_unread as (
select v.id as match_id, count(msg.id)::int as c
from visible v
left join messages msg on msg.match_id = v.id and msg.user_id <> (select id from me)
and msg.read_at is null and msg.deleted_at is null
and not exists (select 1 from message_deletions d where d.message_id = msg.id and d.user_id = (select id from me))
group by v.id
)
select
now() as server_time,
me.couple_id,
me.updated_at as profile_updated_at,
(select p.id from profiles p where p.couple_id = me.couple_id and p.id <> me.id order by p.id limit 1) as partner_id,
(select max(p.updated_at) from profiles p where p.couple_id = me.couple_id and p.id <> me.id) as partner_updated_at,
(select count(*)::int from visible) as match_count,
(select count(*)::int from visible where is_new) as new_match_count,
(select max(created_at) from visible) as latest_match_at,
(select count(*)::int from responses r join questions q on q.id = r.question_id
where r.couple_id = me.couple_id and q.deleted_at is null and r.user_id <> me.id
and not exists (select 1 from responses o
where o.question_id = r.question_id and o.couple_id = me.couple_id and o.user_id = me.id)) as pending_yours,
(select count(*)::int from responses r join questions q on q.id = r.question_id
where r.couple_id = me.couple_id and q.deleted_at is null and r.user_id = me.id
and not exists (select 1 from responses o
where o.question_id = r.question_id and o.couple_id = me.couple_id and o.user_id <> me.id)) as pending_theirs,
(select count(*)::int from messages msg join matches ma on ma.id = msg.match_id
where ma.couple_id = me.couple_id and msg.user_id <> me.id and msg.read_at is null and msg.deleted_at is null
and not exists (select 1 from message_deletions d where d.message_id = msg.id and d.user_id = me.id)) as unread_total,
(select md5(coalesce(string_agg(cp.pack_id::text, ',' order by cp.pack_id), ''))
from couple_packs cp where cp.couple_id = me.couple_id and cp.enabled) as enabled_packs_fingerprint,
(select md5(coalesce(string_agg(v.id::text || ':' || v.match_type || ':' || coalesce(v.response_summary::text, ''), ',' order by v.id), ''))
from visible v) as match_state_fingerprint,
(select md5(coalesce(string_agg(vu.match_id::text || ':' || vu.c, ',' order by vu.match_id), ''))
from visible_unread vu) as match_unread_fingerprint,
(select cs.updated_at from couple_streaks cs where cs.couple_id = me.couple_id) as streak_updated_at
from me`, [userId]);
const row = result.rows[0];
if (!row) throw new AppDataError('not_found', 'Profile not found', 404);
return {
server_time: row.server_time.toISOString(),
couple_id: row.couple_id,
profile_updated_at: row.profile_updated_at?.toISOString() ?? null,
partner_id: row.partner_id,
partner_updated_at: row.partner_updated_at?.toISOString() ?? null,
match_count: row.match_count ?? 0,
new_match_count: row.new_match_count ?? 0,
latest_match_at: row.latest_match_at?.toISOString() ?? null,
pending_yours: row.pending_yours ?? 0,
pending_theirs: row.pending_theirs ?? 0,
unread_total: row.unread_total ?? 0,
enabled_packs_fingerprint: row.enabled_packs_fingerprint ?? '',
match_state_fingerprint: row.match_state_fingerprint ?? '',
match_unread_fingerprint: row.match_unread_fingerprint ?? '',
streak_updated_at: row.streak_updated_at?.toISOString() ?? null,
};
}
async close(): Promise<void> { await closeResolvedPool(this.pool, this.ownsPool); }
}
+8
View File
@@ -3,6 +3,7 @@ import type { Hono } from 'hono';
import { z } from 'zod';
import type { AuthIdentity } from '../../auth.js';
import { AppDataError, type AppDataRepository } from './repository.js';
import { recordSync } from '../../telemetry.js';
type App = Hono<{ Variables: { identity: AuthIdentity } }>;
const uuid = z.string().uuid();
@@ -44,6 +45,13 @@ export function registerAppDataRoutes(app: App, repository: AppDataRepository):
const result = await run(() => repository.markMediaViewed(c.get('identity').id, id.data, body.data.expires_at));
return result.ok ? c.json(result.value) : c.json(error(result.cause.code, result.cause.message), result.cause.status);
});
// The client polls this instead of re-fetching every domain; see SyncSummary.
app.get('/v1/me/sync', async c => {
const startedAt = performance.now();
const result = await run(() => repository.syncSummary(c.get('identity').id));
recordSync('api',result.ok?'ok':'error',startedAt);
return result.ok ? c.json(result.value) : c.json(error(result.cause.code, result.cause.message), result.cause.status);
});
app.get('/v1/me/nudge-status', async c => c.json(await repository.nudgeStatus(c.get('identity').id)));
app.post('/v1/me/nudge', async c => {
const result = await run(() => repository.sendNudge(c.get('identity').id));
+7 -3
View File
@@ -1,4 +1,5 @@
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
import { BillingError, type RedemptionResult, type RevenueCatWebhook, type SubscriptionStatus } from './types.js';
interface ProfileRecord extends QueryResultRow {
@@ -43,9 +44,12 @@ async function transaction<T>(pool: Pool, operation: (client: PoolClient) => Pro
export class PostgresBillingRepository implements BillingRepository {
private readonly pool: Pool;
private readonly ownsPool: boolean;
constructor(databaseUrl: string) {
this.pool = new Pool({ connectionString: databaseUrl });
constructor(connection: DatabaseConnection) {
const resolved = resolvePool(connection);
this.pool = resolved.pool;
this.ownsPool = resolved.owned;
}
async processRevenueCatEvent(
@@ -207,6 +211,6 @@ export class PostgresBillingRepository implements BillingRepository {
}
async close(): Promise<void> {
await this.pool.end();
await closeResolvedPool(this.pool, this.ownsPool);
}
}
+115 -21
View File
@@ -1,4 +1,5 @@
import { Pool, type QueryResultRow } from 'pg';
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
export type ChatMediaType = 'image' | 'video' | null;
export type ReportReason = 'harassment' | 'spam' | 'inappropriate_content' | 'other';
@@ -45,8 +46,37 @@ export class ChatError extends Error {
}
}
/**
* The reply to a message poll. A request without a cursor is a full snapshot; a
* request with one carries only what changed, so a quiet chat costs an empty
* array rather than the whole backlog.
*/
export interface MessagesPage {
/** Newest first. Messages created or changed since the cursor, or the snapshot. */
messages: ChatMessage[];
/** Messages the caller deleted for themselves since the cursor. */
removed_ids: string[];
/** Cursor to send as `since` on the next poll. */
server_time: string;
/** True when `messages` is a complete snapshot the client may replace its cache with. */
complete: boolean;
/** Present only when the caller asked for it, folding the typing poll into this one. */
typing?: PartnerTyping;
}
export interface PartnerTyping {
typing: boolean;
expires_at: string | null;
}
export interface ListMessagesOptions {
/** ISO timestamp from a previous `server_time`. Omit for a full snapshot. */
since?: string;
includeTyping?: boolean;
}
export interface ChatRepository {
listMessages(userId: string, matchId: string): Promise<ChatMessage[]>;
listMessages(userId: string, matchId: string, options?: ListMessagesOptions): Promise<MessagesPage>;
sendText(userId: string, matchId: string, content: string): Promise<ChatMessage>;
unreadCounts(userId: string): Promise<{ total: number; by_match: Record<string, number> }>;
markMatchRead(userId: string, matchId: string): Promise<{ updated: number; read_at: string }>;
@@ -55,13 +85,21 @@ export interface ChatRepository {
deleteForEveryone(userId: string, messageId: string): Promise<ChatMessage>;
report(userId: string, messageId: string, reason: ReportReason): Promise<void>;
setTyping(userId: string, matchId: string, ttlMs: number): Promise<void>;
getPartnerTyping(userId: string, matchId: string): Promise<{ typing: boolean; expires_at: string | null }>;
getPartnerTyping(userId: string, matchId: string): Promise<PartnerTyping>;
close(): Promise<void>;
}
// `media_expired` is derived rather than read. Nothing ever writes the stored
// column — it is inserted `false` and never updated — so a video that reached
// `media_expires_at` still arrived flagged as playable, while the media itself
// was already refused (see `accessible` in the media repository, which treats
// `media_expired or media_expires_at <= now()` as gone). Deriving it here is the
// same rule the media side already applies, and it is what makes the client's
// "Video expired" state reachable at all.
const columns = `m.id, m.match_id, m.user_id, m.content, m.created_at, m.read_at,
m.delivered_at, m.deleted_at, m.media_path, m.media_type, m.media_expires_at,
m.media_expired, m.media_viewed_at, m.version, m.encrypted_content,
(m.media_expired or (m.media_expires_at is not null and m.media_expires_at <= now())) as media_expired,
m.media_viewed_at, m.version, m.encrypted_content,
m.encryption_iv, m.keys_metadata, m.moderation_status, m.flag_reason, m.category`;
function iso(value: Date | null): string | null {
@@ -82,9 +120,12 @@ function toMessage(row: MessageRecord): ChatMessage {
export class PostgresChatRepository implements ChatRepository {
private readonly pool: Pool;
private readonly ownsPool: boolean;
constructor(databaseUrl: string) {
this.pool = new Pool({ connectionString: databaseUrl });
constructor(connection: DatabaseConnection) {
const resolved = resolvePool(connection);
this.pool = resolved.pool;
this.ownsPool = resolved.owned;
}
private async assertMatchAccess(userId: string, matchId: string): Promise<void> {
@@ -112,20 +153,68 @@ export class PostgresChatRepository implements ChatRepository {
return row;
}
async listMessages(userId: string, matchId: string): Promise<ChatMessage[]> {
/**
* Without `since` this is the previous behaviour: the newest 500 visible messages.
* With `since` it returns only rows whose visible state moved after that instant —
* new messages, existing ones whose delivery, read, deletion or media-view
* timestamp advanced, and videos whose media expired inside the window — plus the
* ids the caller has since deleted for themselves.
*
* Moderation metadata (`moderation_status`, `flag_reason`, `category`) is not
* covered: the classifier and the admin tool rewrite those columns without
* advancing any timestamp, so a delta cannot see them move. No client renders
* them today, so nothing user-visible goes stale; giving them a cursor would
* mean adding a column to `messages`.
* Rows are returned whole rather than as patches so the client merges by id and
* needs no second shape to interpret.
*
* The returned cursor is deliberately a couple of seconds behind the server clock:
* a transaction that started before this read but committed after it would
* otherwise fall into the gap between two polls. The overlap re-delivers a handful
* of rows the client already has, which its merge-by-id absorbs.
*/
async listMessages(userId: string, matchId: string, options: ListMessagesOptions = {}): Promise<MessagesPage> {
await this.assertMatchAccess(userId, matchId);
const result = await this.pool.query<MessageRecord>(
`select ${columns}
from messages m
where m.match_id = $1
and not exists (
select 1 from message_deletions d where d.message_id = m.id and d.user_id = $2
)
order by m.created_at desc, m.id desc
limit 500`,
[matchId, userId],
);
return result.rows.map(toMessage);
const since = options.since ?? null;
const visible = `not exists (select 1 from message_deletions d where d.message_id = m.id and d.user_id = $2)`;
// Expiry is the one visible change no row write announces: the clock crosses
// `media_expires_at` and nothing is updated, so no timestamp moves past the
// cursor. Comparing the deadline against the window instead emits the row in
// the single poll whose `(since, now()]` contains it, which is exactly when
// the derived `media_expired` above flips.
const changed = `(m.created_at > $3 or m.read_at > $3 or m.delivered_at > $3
or m.deleted_at > $3 or m.media_viewed_at > $3
or (m.media_expires_at > $3 and m.media_expires_at <= now()))`;
const [clock, messages, removed, typing] = await Promise.all([
this.pool.query<{ cursor: Date }>(`select now() - interval '2 seconds' as cursor`),
this.pool.query<MessageRecord>(
`select ${columns}
from messages m
where m.match_id = $1 and ${visible}${since === null ? '' : ` and ${changed}`}
order by m.created_at desc, m.id desc
limit 500`,
since === null ? [matchId, userId] : [matchId, userId, since],
),
since === null
? null
: this.pool.query<{ message_id: string }>(
`select d.message_id
from message_deletions d
join messages m on m.id = d.message_id
where m.match_id = $1 and d.user_id = $2 and d.deleted_at > $3`,
[matchId, userId, since],
),
options.includeTyping ? this.partnerTyping(userId, matchId) : null,
]);
return {
messages: messages.rows.map(toMessage),
removed_ids: removed?.rows.map((row) => row.message_id) ?? [],
server_time: clock.rows[0]!.cursor.toISOString(),
complete: since === null,
...(typing ? { typing } : {}),
};
}
async sendText(userId: string, matchId: string, content: string): Promise<ChatMessage> {
@@ -226,8 +315,8 @@ export class PostgresChatRepository implements ChatRepository {
);
}
async getPartnerTyping(userId: string, matchId: string): Promise<{ typing: boolean; expires_at: string | null }> {
await this.assertMatchAccess(userId, matchId);
/** Access already asserted by the caller, so the message poll can fold this in for free. */
private async partnerTyping(userId: string, matchId: string): Promise<PartnerTyping> {
const result = await this.pool.query<{ expires_at: Date }>(
`select expires_at from chat_typing_states
where match_id = $1 and user_id <> $2 and expires_at > now()
@@ -238,8 +327,13 @@ export class PostgresChatRepository implements ChatRepository {
return { typing: Boolean(expiresAt), expires_at: iso(expiresAt) };
}
async getPartnerTyping(userId: string, matchId: string): Promise<PartnerTyping> {
await this.assertMatchAccess(userId, matchId);
return this.partnerTyping(userId, matchId);
}
async close(): Promise<void> {
await this.pool.end();
await closeResolvedPool(this.pool, this.ownsPool);
}
}
+12 -2
View File
@@ -8,6 +8,7 @@ type AuthenticatedApp = Hono<{ Variables: { identity: AuthIdentity } }>;
const id = z.string().uuid();
const sendBody = z.object({ content: z.string().trim().min(1).max(10_000) }).strict();
const reportBody = z.object({ reason: z.enum(['harassment', 'spam', 'inappropriate_content', 'other']) }).strict();
const since = z.string().datetime({ offset: true });
function error(code: string, message: string): ApiErrorResponse {
return { error: { code, message } };
@@ -25,11 +26,20 @@ async function result<T>(operation: () => Promise<T>) {
export function registerChatRoutes(app: AuthenticatedApp, repository: ChatRepository): void {
app.get('/v1/chat/unread', async (c) => c.json(await repository.unreadCounts(c.get('identity').id)));
// `since` turns this into a delta poll and `typing=true` folds the typing check
// into the same request. Both are optional: a call with neither still answers with
// the full snapshot under the `messages` key it always used.
app.get('/v1/matches/:matchId/messages', async (c) => {
const parsedId = id.safeParse(c.req.param('matchId'));
if (!parsedId.success) return c.json(error('invalid_match_id', 'A valid match ID is required'), 400);
const response = await result(() => repository.listMessages(c.get('identity').id, parsedId.data));
return response.ok ? c.json({ messages: response.value }) : c.json(error(response.cause.code, response.cause.message), response.cause.status);
const rawSince = c.req.query('since');
const parsedSince = rawSince === undefined ? undefined : since.safeParse(rawSince);
if (parsedSince && !parsedSince.success) return c.json(error('invalid_since', 'since must be an ISO-8601 timestamp'), 400);
const response = await result(() => repository.listMessages(c.get('identity').id, parsedId.data, {
...(parsedSince?.success ? { since: parsedSince.data } : {}),
includeTyping: c.req.query('typing') === 'true',
}));
return response.ok ? c.json(response.value) : c.json(error(response.cause.code, response.cause.message), response.cause.status);
});
app.post('/v1/matches/:matchId/messages', async (c) => {
+7 -3
View File
@@ -1,5 +1,6 @@
import type { Couple, Profile } from '@sauci/shared';
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
import { CoupleError, type CoupleStateResponse } from './types.js';
interface ProfileRecord extends QueryResultRow {
@@ -93,9 +94,12 @@ async function transaction<T>(pool: Pool, work: (client: PoolClient) => Promise<
export class PostgresCoupleRepository implements CoupleRepository {
private readonly pool: Pool;
private readonly ownsPool: boolean;
constructor(databaseUrl: string) {
this.pool = new Pool({ connectionString: databaseUrl });
constructor(connection: DatabaseConnection) {
const resolved = resolvePool(connection);
this.pool = resolved.pool;
this.ownsPool = resolved.owned;
}
async getState(userId: string): Promise<CoupleStateResponse> {
@@ -199,6 +203,6 @@ export class PostgresCoupleRepository implements CoupleRepository {
}
async close(): Promise<void> {
await this.pool.end();
await closeResolvedPool(this.pool, this.ownsPool);
}
}
+484
View File
@@ -0,0 +1,484 @@
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
export type DareStatus =
| 'pending' | 'active' | 'submitted' | 'completed' | 'expired' | 'declined' | 'cancelled';
/** Free senders get a rolling weekly allowance; receiving and responding are never gated. */
export const FREE_WEEKLY_SEND_LIMIT = 3;
/** Durations offered in the send sheet. `null` means no deadline. */
export const DURATION_PRESET_HOURS = [1, 6, 12, 24, 72, 168] as const;
export interface DarePack {
id: string;
name: string;
description: string | null;
icon: string | null;
is_premium: boolean;
is_explicit: boolean;
sort_order: number;
category_id: string | null;
min_intensity: number | null;
max_intensity: number | null;
avg_intensity: number | null;
dare_count: number;
}
export interface DareItem {
id: string;
pack_id: string;
text: string;
intensity: number;
suggested_duration_hours: number | null;
}
export interface SentDare {
id: string;
couple_id: string;
dare_id: string | null;
text: string;
intensity: number;
is_custom: boolean;
sender_id: string;
recipient_id: string;
direction: 'incoming' | 'outgoing';
status: DareStatus;
sender_notes: string | null;
sent_at: string;
accepted_at: string | null;
submitted_at: string | null;
completed_at: string | null;
expires_at: string | null;
}
export interface DareEntitlement {
is_premium: boolean;
can_send_custom: boolean;
weekly_send_limit: number | null;
sends_remaining: number | null;
}
export interface DareCatalog {
entitlement: DareEntitlement;
packs: DarePack[];
}
export interface DareStats {
sent: number;
received: number;
completed_together: number;
active: number;
completed_by_me: number;
completed_by_partner: number;
}
export interface SendDareInput {
dare_id?: string | null;
custom_dare_text?: string | null;
custom_dare_intensity?: number | null;
duration_hours?: number | null;
sender_notes?: string | null;
}
export class DaresError extends Error {
constructor(
public readonly code: string,
message: string,
public readonly status: 400 | 402 | 403 | 404 | 409,
) {
super(message);
}
}
interface ContextRow extends QueryResultRow {
couple_id: string | null;
is_premium: boolean;
hide_nsfw: boolean;
max_intensity: number;
partner_id: string | null;
partner_premium: boolean;
}
interface SentDareRecord extends QueryResultRow {
id: string;
couple_id: string;
dare_id: string | null;
dare_text_snapshot: string;
dare_intensity_snapshot: number;
custom_dare_text: string | null;
sender_id: string;
recipient_id: string;
status: DareStatus;
sender_notes: string | null;
sent_at: Date;
accepted_at: Date | null;
submitted_at: Date | null;
completed_at: Date | null;
expires_at: Date | null;
}
export interface DaresRepository {
getCatalog(userId: string): Promise<DareCatalog>;
listPackDares(userId: string, packId: string): Promise<DareItem[]>;
listDares(userId: string, filter: 'active' | 'history'): Promise<SentDare[]>;
send(userId: string, input: SendDareInput): Promise<SentDare>;
respond(userId: string, dareId: string, action: 'accept' | 'decline'): Promise<SentDare>;
submit(userId: string, dareId: string): Promise<SentDare>;
complete(userId: string, dareId: string): Promise<SentDare>;
cancel(userId: string, dareId: string): Promise<SentDare>;
stats(userId: string): Promise<DareStats>;
close(): Promise<void>;
}
const BARE_COLUMNS = `id, couple_id, dare_id, dare_text_snapshot, dare_intensity_snapshot,
custom_dare_text, sender_id, recipient_id, status, sender_notes,
sent_at, accepted_at, submitted_at, completed_at, expires_at`;
const SENT_COLUMNS = BARE_COLUMNS.split(',').map((column) => `sd.${column.trim()}`).join(', ');
/** Statuses still awaiting somebody's action; everything else is history. */
const OPEN_STATUSES: readonly DareStatus[] = ['pending', 'active', 'submitted'];
function iso(value: Date | null): string | null {
return value?.toISOString() ?? null;
}
async function transaction<T>(pool: Pool, operation: (client: PoolClient) => Promise<T>): Promise<T> {
const client = await pool.connect();
try {
await client.query('begin');
const result = await operation(client);
await client.query('commit');
return result;
} catch (error) {
await client.query('rollback');
throw error;
} finally {
client.release();
}
}
function toSentDare(row: SentDareRecord, viewerId: string): SentDare {
return {
id: row.id,
couple_id: row.couple_id,
dare_id: row.dare_id,
text: row.dare_text_snapshot,
intensity: row.dare_intensity_snapshot,
is_custom: row.custom_dare_text !== null,
sender_id: row.sender_id,
recipient_id: row.recipient_id,
direction: row.recipient_id === viewerId ? 'incoming' : 'outgoing',
status: row.status,
sender_notes: row.sender_notes,
sent_at: row.sent_at.toISOString(),
accepted_at: iso(row.accepted_at),
submitted_at: iso(row.submitted_at),
completed_at: iso(row.completed_at),
expires_at: iso(row.expires_at),
};
}
export class PostgresDaresRepository implements DaresRepository {
private readonly pool: Pool;
private readonly ownsPool: boolean;
constructor(connection: DatabaseConnection) {
const resolved = resolvePool(connection);
this.pool = resolved.pool;
this.ownsPool = resolved.owned;
}
/**
* Premium is couple-shared, matching the answers domain: either partner paying
* unlocks the catalogue for both.
*/
private async context(userId: string): Promise<ContextRow> {
const result = await this.pool.query<ContextRow>(
`select p.couple_id, p.is_premium, p.hide_nsfw, p.max_intensity,
partner.id as partner_id,
coalesce(partner.is_premium, false) as partner_premium
from profiles p
left join lateral (
select id, is_premium from profiles other
where other.couple_id = p.couple_id and other.id <> p.id
order by other.id limit 1
) partner on true
where p.id = $1`,
[userId],
);
const row = result.rows[0];
if (!row) throw new DaresError('profile_not_found', 'Profile not found', 404);
return row;
}
private static isPremium(context: ContextRow): boolean {
return context.is_premium || context.partner_premium;
}
private async sendsThisWeek(userId: string, client: Pool | PoolClient = this.pool): Promise<number> {
const result = await client.query<{ count: string }>(
`select count(*) as count from sent_dares
where sender_id = $1 and sent_at > now() - interval '7 days'`,
[userId],
);
return Number(result.rows[0]?.count ?? 0);
}
private async entitlement(context: ContextRow, userId: string): Promise<DareEntitlement> {
const premium = PostgresDaresRepository.isPremium(context);
if (premium) {
return { is_premium: true, can_send_custom: true, weekly_send_limit: null, sends_remaining: null };
}
const used = await this.sendsThisWeek(userId);
return {
is_premium: false,
can_send_custom: false,
weekly_send_limit: FREE_WEEKLY_SEND_LIMIT,
sends_remaining: Math.max(0, FREE_WEEKLY_SEND_LIMIT - used),
};
}
async getCatalog(userId: string): Promise<DareCatalog> {
const context = await this.context(userId);
const maxIntensity = context.hide_nsfw ? 2 : context.max_intensity;
const [entitlement, packs] = await Promise.all([
this.entitlement(context, userId),
this.pool.query<DarePack & QueryResultRow>(
`select dp.id, dp.name, dp.description, dp.icon, dp.is_premium, dp.is_explicit,
dp.sort_order, dp.category_id, dp.min_intensity, dp.max_intensity,
dp.avg_intensity::double precision as avg_intensity,
count(d.id) filter (where d.content_status = 'allowed')::integer as dare_count
from dare_packs dp
left join categories c on c.id = dp.category_id
left join dares d on d.pack_id = dp.id
where dp.is_public = true
and dp.content_status = 'allowed'
and (dp.category_id is null or c.is_public = true)
and ($1::boolean = false or dp.is_explicit = false)
and (dp.min_intensity is null or dp.min_intensity <= $2::integer)
group by dp.id
having count(d.id) filter (where d.content_status = 'allowed') > 0
order by dp.sort_order, dp.name, dp.id`,
[context.hide_nsfw, maxIntensity],
),
]);
return { entitlement, packs: packs.rows };
}
async listPackDares(userId: string, packId: string): Promise<DareItem[]> {
const context = await this.context(userId);
const pack = await this.pool.query<{ is_premium: boolean; is_explicit: boolean }>(
`select dp.is_premium, dp.is_explicit
from dare_packs dp
left join categories c on c.id = dp.category_id
where dp.id = $1 and dp.is_public = true and dp.content_status = 'allowed'
and (dp.category_id is null or c.is_public = true)`,
[packId],
);
const row = pack.rows[0];
if (!row) throw new DaresError('pack_not_found', 'Dare pack not found', 404);
if (row.is_explicit && context.hide_nsfw) {
throw new DaresError('pack_not_found', 'Dare pack not found', 404);
}
if (row.is_premium && !PostgresDaresRepository.isPremium(context)) {
throw new DaresError('premium_required', 'This dare pack requires premium', 402);
}
const maxIntensity = context.hide_nsfw ? 2 : context.max_intensity;
const result = await this.pool.query<DareItem & QueryResultRow>(
`select d.id, d.pack_id, d.text, d.intensity, d.suggested_duration_hours
from dares d
where d.pack_id = $1 and d.content_status = 'allowed' and d.intensity <= $2::integer
order by d.intensity, d.created_at, d.id`,
[packId, maxIntensity],
);
return result.rows;
}
async listDares(userId: string, filter: 'active' | 'history'): Promise<SentDare[]> {
const context = await this.context(userId);
if (!context.couple_id) return [];
const result = await this.pool.query<SentDareRecord>(
`select ${SENT_COLUMNS}
from sent_dares sd
where sd.couple_id = $1
and (sd.status = any($2::text[])) = $3::boolean
order by sd.sent_at desc, sd.id desc
limit 200`,
[context.couple_id, OPEN_STATUSES, filter === 'active'],
);
return result.rows.map((row) => toSentDare(row, userId));
}
async send(userId: string, input: SendDareInput): Promise<SentDare> {
const context = await this.context(userId);
if (!context.couple_id || !context.partner_id) {
throw new DaresError('no_couple', 'Pair with your partner before sending dares', 409);
}
const premium = PostgresDaresRepository.isPremium(context);
const duration = input.duration_hours ?? null;
if (duration !== null && !DURATION_PRESET_HOURS.includes(duration as never)) {
throw new DaresError('invalid_duration', 'Duration must be one of the offered presets', 400);
}
let text: string;
let intensity: number;
let dareId: string | null = null;
let customText: string | null = null;
let customIntensity: number | null = null;
if (input.dare_id) {
// Re-check entitlement against the pack so a stale client cannot send a locked dare.
const dare = await this.pool.query<{ text: string; intensity: number; is_premium: boolean; is_explicit: boolean }>(
`select d.text, d.intensity, dp.is_premium, dp.is_explicit
from dares d
join dare_packs dp on dp.id = d.pack_id
left join categories c on c.id = dp.category_id
where d.id = $1 and d.content_status = 'allowed' and dp.content_status = 'allowed'
and dp.is_public = true and (dp.category_id is null or c.is_public = true)`,
[input.dare_id],
);
const row = dare.rows[0];
if (!row) throw new DaresError('dare_not_found', 'Dare not found', 404);
if (row.is_explicit && context.hide_nsfw) {
throw new DaresError('dare_not_found', 'Dare not found', 404);
}
if (row.is_premium && !premium) {
throw new DaresError('premium_required', 'This dare requires premium', 402);
}
dareId = input.dare_id;
text = row.text;
intensity = row.intensity;
} else {
if (!premium) throw new DaresError('premium_required', 'Custom dares require premium', 402);
const trimmed = input.custom_dare_text?.trim();
if (!trimmed) throw new DaresError('invalid_dare', 'Custom dare text is required', 400);
customText = trimmed;
customIntensity = input.custom_dare_intensity ?? 1;
text = trimmed;
intensity = customIntensity;
}
// The quota is a paywall boundary, so the count and the insert have to be atomic:
// locking the sender's profile row serializes concurrent sends by the same user.
return transaction(this.pool, async (client) => {
if (!premium) {
await client.query('select 1 from profiles where id = $1 for update', [userId]);
const used = await this.sendsThisWeek(userId, client);
if (used >= FREE_WEEKLY_SEND_LIMIT) {
throw new DaresError('send_limit_reached', 'Weekly dare limit reached', 402);
}
}
const result = await client.query<SentDareRecord>(
`insert into sent_dares (
couple_id, dare_id, custom_dare_text, custom_dare_intensity,
dare_text_snapshot, dare_intensity_snapshot,
sender_id, recipient_id, sender_notes, expires_at
) values (
$1, $2, $3, $4, $5, $6, $7, $8, $9,
case when $10::integer is null then null else now() + ($10::integer * interval '1 hour') end
)
returning ${BARE_COLUMNS}`,
[
context.couple_id, dareId, customText, customIntensity, text, intensity,
userId, context.partner_id, input.sender_notes?.trim() || null, duration,
],
);
return toSentDare(result.rows[0]!, userId);
});
}
/**
* Applies a status change, enforcing both the actor's role and the legal
* transitions out of the current status. Notifications are emitted by the
* `operations_dare_changed` trigger, so no enqueue happens here.
*/
private async transition(
userId: string,
dareId: string,
actor: 'sender' | 'recipient',
from: readonly DareStatus[],
to: DareStatus,
timestampColumn: 'accepted_at' | 'submitted_at' | 'completed_at' | null,
): Promise<SentDare> {
const actorColumn = actor === 'sender' ? 'sender_id' : 'recipient_id';
const setTimestamp = timestampColumn ? `, ${timestampColumn} = now()` : '';
const result = await this.pool.query<SentDareRecord>(
`update sent_dares sd
set status = $3::text${setTimestamp}
where sd.id = $1 and sd.${actorColumn} = $2 and sd.status = any($4::text[])
returning ${SENT_COLUMNS}`,
[dareId, userId, to, from],
);
const row = result.rows[0];
if (row) return toSentDare(row, userId);
// Distinguish "not yours / gone" from "wrong state" so the client can react.
const existing = await this.pool.query<{ sender_id: string; recipient_id: string; status: DareStatus }>(
'select sender_id, recipient_id, status from sent_dares where id = $1',
[dareId],
);
const found = existing.rows[0];
if (!found || (found.sender_id !== userId && found.recipient_id !== userId)) {
throw new DaresError('dare_not_found', 'Dare not found', 404);
}
const actorId = actor === 'sender' ? found.sender_id : found.recipient_id;
if (actorId !== userId) {
throw new DaresError('not_permitted', `Only the ${actor} can do that`, 403);
}
throw new DaresError('invalid_transition', `A ${found.status} dare cannot change to ${to}`, 409);
}
async respond(userId: string, dareId: string, action: 'accept' | 'decline'): Promise<SentDare> {
return action === 'accept'
? this.transition(userId, dareId, 'recipient', ['pending'], 'active', 'accepted_at')
: this.transition(userId, dareId, 'recipient', ['pending'], 'declined', null);
}
async submit(userId: string, dareId: string): Promise<SentDare> {
return this.transition(userId, dareId, 'recipient', ['active'], 'submitted', 'submitted_at');
}
async complete(userId: string, dareId: string): Promise<SentDare> {
return this.transition(userId, dareId, 'sender', ['active', 'submitted'], 'completed', 'completed_at');
}
async cancel(userId: string, dareId: string): Promise<SentDare> {
return this.transition(userId, dareId, 'sender', ['pending', 'active', 'submitted'], 'cancelled', null);
}
async stats(userId: string): Promise<DareStats> {
const context = await this.context(userId);
if (!context.couple_id) {
return { sent: 0, received: 0, completed_together: 0, active: 0, completed_by_me: 0, completed_by_partner: 0 };
}
const result = await this.pool.query<Record<string, string>>(
`select
count(*) filter (where sender_id = $2)::text as sent,
count(*) filter (where recipient_id = $2)::text as received,
count(*) filter (where status = 'completed')::text as completed_together,
count(*) filter (where status = any($3::text[]))::text as active,
count(*) filter (where status = 'completed' and recipient_id = $2)::text as completed_by_me,
count(*) filter (where status = 'completed' and sender_id = $2)::text as completed_by_partner
from sent_dares where couple_id = $1`,
[context.couple_id, userId, OPEN_STATUSES],
);
const row = result.rows[0] ?? {};
const read = (key: string): number => Number(row[key] ?? 0);
return {
sent: read('sent'),
received: read('received'),
completed_together: read('completed_together'),
active: read('active'),
completed_by_me: read('completed_by_me'),
completed_by_partner: read('completed_by_partner'),
};
}
async close(): Promise<void> {
await closeResolvedPool(this.pool, this.ownsPool);
}
}
+110
View File
@@ -0,0 +1,110 @@
import type { ApiErrorResponse } from '@sauci/shared';
import type { Hono } from 'hono';
import { z } from 'zod';
import type { AuthIdentity } from '../../auth.js';
import { DURATION_PRESET_HOURS, DaresError, type DaresRepository } from './repository.js';
type DaresApp = Hono<{ Variables: { identity: AuthIdentity } }>;
const id = z.string().uuid();
const sendBody = z
.object({
dare_id: id.nullish(),
custom_dare_text: z.string().trim().min(1).max(500).nullish(),
custom_dare_intensity: z.number().int().min(1).max(5).nullish(),
duration_hours: z.union([z.literal(1), z.literal(6), z.literal(12), z.literal(24), z.literal(72), z.literal(168)]).nullish(),
sender_notes: z.string().trim().max(500).nullish(),
})
.strict()
.refine((value) => Boolean(value.dare_id) !== Boolean(value.custom_dare_text), {
message: 'Provide either dare_id or custom_dare_text',
});
const respondBody = z.object({ action: z.enum(['accept', 'decline']) }).strict();
function error(code: string, message: string): ApiErrorResponse {
return { error: { code, message } };
}
async function result<T>(operation: () => Promise<T>) {
try {
return { ok: true as const, value: await operation() };
} catch (cause) {
if (cause instanceof DaresError) return { ok: false as const, cause };
throw cause;
}
}
export function registerDareRoutes(app: DaresApp, repository: DaresRepository): void {
app.get('/v1/dares/durations', (c) => c.json({ presets: DURATION_PRESET_HOURS }));
app.get('/v1/dares/packs', async (c) => {
const response = await result(() => repository.getCatalog(c.get('identity').id));
return response.ok
? c.json(response.value)
: c.json(error(response.cause.code, response.cause.message), response.cause.status);
});
app.get('/v1/dares/packs/:packId/dares', async (c) => {
const packId = id.safeParse(c.req.param('packId'));
if (!packId.success) return c.json(error('invalid_pack_id', 'A valid pack ID is required'), 400);
const response = await result(() => repository.listPackDares(c.get('identity').id, packId.data));
return response.ok
? c.json({ dares: response.value })
: c.json(error(response.cause.code, response.cause.message), response.cause.status);
});
app.get('/v1/dares', async (c) => {
const filter = c.req.query('filter') ?? 'active';
if (filter !== 'active' && filter !== 'history') {
return c.json(error('invalid_filter', 'Filter must be active or history'), 400);
}
const response = await result(() => repository.listDares(c.get('identity').id, filter));
return response.ok
? c.json({ dares: response.value })
: c.json(error(response.cause.code, response.cause.message), response.cause.status);
});
app.get('/v1/dares/stats', async (c) => {
const response = await result(() => repository.stats(c.get('identity').id));
return response.ok
? c.json(response.value)
: c.json(error(response.cause.code, response.cause.message), response.cause.status);
});
app.post('/v1/dares', async (c) => {
const body = sendBody.safeParse(await c.req.json().catch(() => ({})));
if (!body.success) return c.json(error('invalid_dare', 'Provide either a dare_id or custom dare text'), 400);
const response = await result(() => repository.send(c.get('identity').id, body.data));
return response.ok
? c.json({ dare: response.value }, 201)
: c.json(error(response.cause.code, response.cause.message), response.cause.status);
});
app.post('/v1/dares/:dareId/respond', async (c) => {
const dareId = id.safeParse(c.req.param('dareId'));
const body = respondBody.safeParse(await c.req.json().catch(() => ({})));
if (!dareId.success) return c.json(error('invalid_dare_id', 'A valid dare ID is required'), 400);
if (!body.success) return c.json(error('invalid_action', 'Action must be accept or decline'), 400);
const response = await result(() => repository.respond(c.get('identity').id, dareId.data, body.data.action));
return response.ok
? c.json({ dare: response.value })
: c.json(error(response.cause.code, response.cause.message), response.cause.status);
});
for (const [path, method] of [
['submit', 'submit'],
['complete', 'complete'],
['cancel', 'cancel'],
] as const) {
app.post(`/v1/dares/:dareId/${path}`, async (c) => {
const dareId = id.safeParse(c.req.param('dareId'));
if (!dareId.success) return c.json(error('invalid_dare_id', 'A valid dare ID is required'), 400);
const response = await result(() => repository[method](c.get('identity').id, dareId.data));
return response.ok
? c.json({ dare: response.value })
: c.json(error(response.cause.code, response.cause.message), response.cause.status);
});
}
}
+79
View File
@@ -0,0 +1,79 @@
import { boolean, check, index, integer, numeric, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { profiles } from '../../db/schema.js';
// `couples`, `categories`, and the review-status enum are owned by other verticals.
// Their FKs stay in SQL so this fragment does not import across domain boundaries.
export const darePacks = pgTable('dare_packs', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
description: text('description'),
icon: text('icon'),
isPremium: boolean('is_premium').notNull().default(false),
isPublic: boolean('is_public').notNull().default(true),
isExplicit: boolean('is_explicit').notNull().default(false),
sortOrder: integer('sort_order').notNull().default(0),
categoryId: uuid('category_id'),
minIntensity: integer('min_intensity'),
maxIntensity: integer('max_intensity'),
avgIntensity: numeric('avg_intensity', { precision: 3, scale: 2 }),
contentStatus: text('content_status').notNull().default('unreviewed'),
contentReviewReason: text('content_review_reason'),
contentReviewedAt: timestamp('content_reviewed_at', { withTimezone: true }),
contentReviewedBy: uuid('content_reviewed_by').references(() => profiles.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [index('dare_packs_content_status_idx').on(table.contentStatus, table.isPublic, table.sortOrder)]);
export const dares = pgTable('dares', {
id: uuid('id').primaryKey().defaultRandom(),
packId: uuid('pack_id').notNull().references(() => darePacks.id, { onDelete: 'cascade' }),
text: text('text').notNull(),
intensity: integer('intensity').notNull().default(1),
suggestedDurationHours: integer('suggested_duration_hours'),
contentStatus: text('content_status').notNull().default('unreviewed'),
contentReviewReason: text('content_review_reason'),
contentReviewedAt: timestamp('content_reviewed_at', { withTimezone: true }),
contentReviewedBy: uuid('content_reviewed_by').references(() => profiles.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
index('dares_pack_idx').on(table.packId),
index('dares_content_status_idx').on(table.packId, table.contentStatus, table.intensity),
]);
// `dare_text_snapshot` deliberately duplicates the catalogue text: a couple's
// history must survive the dare being edited, archived, or deleted.
export const sentDares = pgTable('sent_dares', {
id: uuid('id').primaryKey().defaultRandom(),
coupleId: uuid('couple_id').notNull(),
dareId: uuid('dare_id').references(() => dares.id, { onDelete: 'set null' }),
customDareText: text('custom_dare_text'),
customDareIntensity: integer('custom_dare_intensity'),
dareTextSnapshot: text('dare_text_snapshot').notNull(),
dareIntensitySnapshot: integer('dare_intensity_snapshot').notNull(),
senderId: uuid('sender_id').notNull().references(() => profiles.id, { onDelete: 'cascade' }),
recipientId: uuid('recipient_id').notNull().references(() => profiles.id, { onDelete: 'cascade' }),
status: text('status').notNull().default('pending'),
senderNotes: text('sender_notes'),
sentAt: timestamp('sent_at', { withTimezone: true }).notNull().defaultNow(),
acceptedAt: timestamp('accepted_at', { withTimezone: true }),
submittedAt: timestamp('submitted_at', { withTimezone: true }),
completedAt: timestamp('completed_at', { withTimezone: true }),
expiresAt: timestamp('expires_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
index('sent_dares_couple_idx').on(table.coupleId),
index('sent_dares_recipient_status_idx').on(table.recipientId, table.status),
index('sent_dares_sender_sent_at_idx').on(table.senderId, table.sentAt),
check('sent_dares_participants_distinct_check', sql`${table.senderId} <> ${table.recipientId}`),
check('sent_dares_status_check', sql`${table.status} in ('pending','active','submitted','completed','expired','declined','cancelled')`),
]);
export const dareMessages = pgTable('dare_messages', {
id: uuid('id').primaryKey().defaultRandom(),
sentDareId: uuid('sent_dare_id').notNull().references(() => sentDares.id, { onDelete: 'cascade' }),
senderId: uuid('sender_id').notNull().references(() => profiles.id, { onDelete: 'cascade' }),
content: text('content').notNull(),
readAt: timestamp('read_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [index('dare_messages_sent_dare_created_idx').on(table.sentDareId, table.createdAt)]);
+4 -2
View File
@@ -1,5 +1,6 @@
import { randomUUID } from 'node:crypto';
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
import type { MediaKind, MediaObject, MediaUploadContext } from './types.js';
interface MediaRow extends QueryResultRow {
@@ -28,7 +29,8 @@ function object(row: MediaRow): MediaObject {
export class PostgresMediaRepository implements MediaRepository {
private readonly pool: Pool;
constructor(databaseUrl: string) { this.pool = new Pool({ connectionString: databaseUrl }); }
private readonly ownsPool: boolean;
constructor(connection: DatabaseConnection) { const resolved = resolvePool(connection); this.pool = resolved.pool; this.ownsPool = resolved.owned; }
async create(userId: string, kind: MediaKind, storageKey: string, mimeType: string, byteSize: number, context: MediaUploadContext) {
const client = await this.pool.connect();
@@ -102,5 +104,5 @@ export class PostgresMediaRepository implements MediaRepository {
async acknowledgeDeletion(storageKey: string): Promise<void> {
await this.pool.query('delete from media_deletion_queue where storage_key=$1',[storageKey]);
}
async close() { await this.pool.end(); }
async close(): Promise<void> { await closeResolvedPool(this.pool, this.ownsPool); }
}
+106 -4
View File
@@ -1,4 +1,5 @@
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
import type { ClassifierRuntimeConfig, MessageForClassification, OperationItem, ProducerSummary } from './types.js';
interface OutboxRow extends QueryResultRow {
@@ -14,9 +15,19 @@ export interface OperationsRepository {
message(id: string): Promise<MessageForClassification | null>;
classifierConfig(): Promise<ClassifierRuntimeConfig | null>;
classify(id: string, status: 'safe' | 'flagged', reason: string | null, category: string): Promise<void>;
outboxState(): Promise<{ due: number; oldestDueAgeSeconds: number }>;
close(): Promise<void>;
}
/** Matches `claim`: only currently due, retryable, and unlocked/expired leases. */
export const OUTBOX_STATE_SQL = `select count(*)::int due,
coalesce(floor(extract(epoch from now()-min(available_at))),0)::int oldest_due_age_seconds
from operations_outbox
where sent_at is null
and attempts<5
and available_at<=now()
and (locked_at is null or locked_at<now()-interval '5 minutes')`;
function item(row: OutboxRow): OperationItem {
return { id: row.id, kind: row.kind, dedupeKey: row.dedupe_key, recipientId: row.recipient_id,
pushToken: row.push_token, payload: row.payload, attempts: row.attempts };
@@ -31,12 +42,13 @@ async function transaction<T>(pool: Pool, work: (client: PoolClient) => Promise<
export class PostgresOperationsRepository implements OperationsRepository {
private readonly pool: Pool;
constructor(databaseUrl: string) { this.pool = new Pool({ connectionString: databaseUrl }); }
private readonly ownsPool: boolean;
constructor(connection: DatabaseConnection) { const resolved = resolvePool(connection); this.pool = resolved.pool; this.ownsPool = resolved.owned; }
async produce(now: Date, limit=100): Promise<ProducerSummary> {
return transaction(this.pool, async (client) => {
const lock=await client.query<{locked:boolean}>("select pg_try_advisory_xact_lock(hashtext('sauci-operations-producers')) locked");
const empty={releasedPacks:0,streakMilestones:0,digests:0,packChanges:0,weeklySummaries:0,unpairedReminders:0,catchupReminders:0};
const empty={releasedPacks:0,streakMilestones:0,digests:0,packChanges:0,weeklySummaries:0,unpairedReminders:0,catchupReminders:0,streakReminders:0,daresExpired:0};
if (!lock.rows[0]?.locked) return empty;
const released=await client.query<{id:string;name:string}>(
@@ -138,12 +150,77 @@ export class PostgresOperationsRepository implements OperationsRepository {
from eligible e on conflict(dedupe_key) do nothing returning recipient_id
) update profiles p set last_unpaired_reminder_at=$1 from queued q where p.id=q.recipient_id returning p.id`, [now,limit]);
// Expiring pending dares too: the original design only swept `active`, so an
// invitation the recipient never opened stayed open forever.
const expiredDares=await client.query(
`with due as (
select id from sent_dares
where expires_at is not null and expires_at<=$1
and status in ('pending','active','submitted')
order by expires_at for update skip locked limit $2
) update sent_dares sd set status='expired' from due d where sd.id=d.id returning sd.id`, [now,limit]);
const catchup=await this.produceCatchups(client,now,limit);
const streakReminders=await this.produceStreakReminders(client,now);
return {releasedPacks:released.rowCount??0,streakMilestones:milestones.rowCount??0,digests:digests.rowCount??0,
packChanges:packChanges.rowCount??0,weeklySummaries:weekly.rowCount??0,unpairedReminders:unpaired.rowCount??0,catchupReminders:catchup};
packChanges:packChanges.rowCount??0,weeklySummaries:weekly.rowCount??0,unpairedReminders:unpaired.rowCount??0,
catchupReminders:catchup,streakReminders,daresExpired:expiredDares.rowCount??0};
});
}
/**
* A shared streak only changes behaviour if somebody hears that it is open before the day
* closes, so this fires in the couple's own late evening rather than on a server clock.
* Only the partner who still owes an answer is notified, and the copy names the other
* partner as an invitation rather than as a debt: guilt between partners is the failure
* mode for this mechanic, not the goal.
*/
private async produceStreakReminders(client: PoolClient, now: Date): Promise<number> {
const result=await client.query(
`with zones as (select name from pg_timezone_names),
couple_zone as (
select r.couple_id,coalesce(z.name,'UTC') zone from (
select p.couple_id,(array_agg(p.timezone order by p.id) filter (where p.timezone is not null))[1] reported
from profiles p join couple_streaks cs on cs.couple_id=p.couple_id and cs.current_streak>0
group by p.couple_id
) r left join zones z on z.name=r.reported
),
at_risk as (
select cs.couple_id,cs.current_streak,cs.last_active_date,cs.user1_answered_today,cs.user2_answered_today,
($1::timestamptz at time zone cz.zone)::date local_date
from couple_streaks cs join couple_zone cz on cz.couple_id=cs.couple_id
where cs.current_streak>0
and cs.last_completed_date=($1::timestamptz at time zone cz.zone)::date-1
and extract(hour from ($1::timestamptz at time zone cz.zone))>=20
),
sides as (
select ar.couple_id,ar.current_streak,to_char(ar.local_date,'YYYY-MM-DD') local_date,
me.id me_id,me.push_token,nullif(btrim(partner.name),'') partner_name,
case when ar.last_active_date is distinct from ar.local_date then false
when me.id<partner.id then ar.user1_answered_today else ar.user2_answered_today end me_answered,
case when ar.last_active_date is distinct from ar.local_date then false
when me.id<partner.id then ar.user2_answered_today else ar.user1_answered_today end partner_answered
from at_risk ar
join profiles me on me.couple_id=ar.couple_id
join profiles partner on partner.couple_id=ar.couple_id and partner.id<>me.id
)
insert into operations_outbox(kind,dedupe_key,recipient_id,payload)
select 'expo','streak_risk:'||s.couple_id||':'||s.local_date||':'||s.me_id,s.me_id,
jsonb_build_object(
'title',case when s.partner_answered then coalesce(s.partner_name,'Your partner')||' answered today'
else 'Your '||s.current_streak||'-day streak' end,
'body',case when s.partner_answered
then 'You two are '||s.current_streak||' days in together. One question keeps it going.'
else 'You and '||coalesce(s.partner_name,'your partner')||' are '||s.current_streak||' days in. One question each keeps it going.' end,
'data',jsonb_build_object('type','streak_reminder','streak',s.current_streak))
from sides s left join notification_preferences np on np.user_id=s.me_id
where not s.me_answered and s.push_token is not null and coalesce(np.streak_reminders_enabled,true)
and not exists(select 1 from operations_outbox o
where o.dedupe_key='streak_risk:'||s.couple_id||':'||s.local_date||':'||s.me_id)
on conflict(dedupe_key) do nothing`,[now]);
return result.rowCount??0;
}
private async produceCatchups(client: PoolClient, now: Date, limit: number): Promise<number> {
await client.query(
`with behind as (
@@ -202,6 +279,7 @@ export class PostgresOperationsRepository implements OperationsRepository {
when 'pack_change' then case when coalesce(np.pack_changes_enabled,true) then p.push_token end
when 'new_pack' then case when coalesce(np.new_packs_enabled,true) then p.push_token end
when 'streak_milestone' then case when coalesce(np.streak_milestones_enabled,true) then p.push_token end
when 'streak_reminder' then case when coalesce(np.streak_reminders_enabled,true) then p.push_token end
when 'weekly_summary' then case when coalesce(np.weekly_summary_enabled,true) then p.push_token end
when 'unpaired_reminder' then case when coalesce(np.unpaired_reminders_enabled,true) then p.push_token end
when 'catchup_reminder' then case when coalesce(np.catchup_reminders_enabled,true) then p.push_token end
@@ -212,6 +290,30 @@ export class PostgresOperationsRepository implements OperationsRepository {
return result.rows.map(item);
});
}
async outboxState(): Promise<{ due: number; oldestDueAgeSeconds: number }> {
const client = await this.pool.connect();
let released = false;
try {
await client.query('begin');
// A server-side timeout cancels the query and transaction; unlike a
// Promise race it cannot leave an unobserved query or borrowed client.
await client.query("set local statement_timeout = '250ms'");
const result = await client.query<{ due: number; oldest_due_age_seconds: number }>(OUTBOX_STATE_SQL);
await client.query('commit');
const row = result.rows[0];
return { due: row?.due ?? 0, oldestDueAgeSeconds: row?.oldest_due_age_seconds ?? 0 };
} catch (cause) {
try { await client.query('rollback'); }
catch (rollbackCause) {
// A failed rollback leaves transaction state uncertain. Evict this
// client instead of returning it to the shared pool, but retain the
// original observation failure for callers and logs.
client.release(rollbackCause instanceof Error ? rollbackCause : new Error('outbox observation rollback failed'));
released = true;
}
throw cause;
} finally { if (!released) client.release(); }
}
async complete(id:string) { await this.pool.query('update operations_outbox set sent_at=now(),locked_at=null,last_error=null where id=$1',[id]); }
async fail(id:string,message:string) { await this.pool.query(
`update operations_outbox set locked_at=null,last_error=$2,available_at=now()+make_interval(secs=>least(3600,power(2,attempts)::int*30)) where id=$1`,[id,message.slice(0,500)]); }
@@ -246,7 +348,7 @@ export class PostgresOperationsRepository implements OperationsRepository {
async classify(id:string,status:'safe'|'flagged',reason:string|null,category:string) {
await this.pool.query('update messages set moderation_status=$2,flag_reason=$3,category=$4 where id=$1',[id,status,reason,category]);
}
async close() { await this.pool.end(); }
async close(): Promise<void> { await closeResolvedPool(this.pool, this.ownsPool); }
}
function streakCopy(days:number) {
+6 -1
View File
@@ -1,6 +1,7 @@
import type { MediaJanitor } from '../media/janitor.js';
import type { OperationsRepository } from './repository.js';
import type { DiscordProvider, ExpoPushProvider, MessageClassifier } from './providers.js';
import { emitTelemetry } from '../../telemetry.js';
export class OperationsRunner {
constructor(private readonly repository:OperationsRepository,private readonly expo:ExpoPushProvider,private readonly discord:DiscordProvider,private readonly classifier:MessageClassifier,private readonly mediaJanitor?:MediaJanitor) {}
@@ -13,7 +14,11 @@ export class OperationsRunner {
await this.repository.complete(item.id);completed++;
} catch(cause) { await this.repository.fail(item.id,cause instanceof Error?cause.message:'unknown error');failed++; }
}
// Observation never blocks useful work. It runs after claiming/completing and
// has a short caller-side deadline; the claim lock predicate remains the
// source of truth when several worker instances race.
const after=await this.repository.outboxState().catch(()=>null);
if(after)emitTelemetry({event:'outbox',process:'worker',due_after:after.due,oldest_due_age_seconds:after.oldestDueAgeSeconds,claimed:items.length,completed,failed,count:1});
return {produced,claimed:items.length,completed,failed};
}
}
+2
View File
@@ -48,4 +48,6 @@ export interface ProducerSummary {
weeklySummaries: number;
unpairedReminders: number;
catchupReminders: number;
streakReminders: number;
daresExpired: number;
}
+7 -3
View File
@@ -1,4 +1,5 @@
import { Pool } from 'pg';
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
export interface PackCategory {
id: string;
@@ -69,9 +70,12 @@ interface ProfilePreferencesRow {
export class PostgresPacksRepository implements PacksRepository {
private readonly pool: Pool;
private readonly ownsPool: boolean;
constructor(databaseUrl: string) {
this.pool = new Pool({ connectionString: databaseUrl });
constructor(connection: DatabaseConnection) {
const resolved = resolvePool(connection);
this.pool = resolved.pool;
this.ownsPool = resolved.owned;
}
private async profile(userId: string): Promise<ProfilePreferencesRow> {
@@ -215,6 +219,6 @@ export class PostgresPacksRepository implements PacksRepository {
}
async close(): Promise<void> {
await this.pool.end();
await closeResolvedPool(this.pool, this.ownsPool);
}
}
+26
View File
@@ -10,6 +10,10 @@ export const categories = pgTable('categories', {
color: text('color'),
sortOrder: integer('sort_order').notNull().default(0),
isPublic: boolean('is_public').notNull().default(true),
contentStatus: text('content_status').notNull().default('unreviewed'),
contentReviewReason: text('content_review_reason'),
contentReviewedAt: timestamp('content_reviewed_at', { withTimezone: true }),
contentReviewedBy: uuid('content_reviewed_by').references(() => profiles.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
});
@@ -26,6 +30,10 @@ export const questionPacks = pgTable('question_packs', {
avgIntensity: numeric('avg_intensity', { precision: 3, scale: 2, mode: 'number' }),
sortOrder: integer('sort_order').notNull().default(0),
categoryId: uuid('category_id').references(() => categories.id, { onDelete: 'set null' }),
contentStatus: text('content_status').notNull().default('unreviewed'),
contentReviewReason: text('content_review_reason'),
contentReviewedAt: timestamp('content_reviewed_at', { withTimezone: true }),
contentReviewedBy: uuid('content_reviewed_by').references(() => profiles.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [index('question_packs_category_id_idx').on(table.categoryId)]);
@@ -42,6 +50,10 @@ export const questions = pgTable('questions', {
config: jsonb('config').notNull().default({}),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
inverseOf: uuid('inverse_of'),
contentStatus: text('content_status').notNull().default('unreviewed'),
contentReviewReason: text('content_review_reason'),
contentReviewedAt: timestamp('content_reviewed_at', { withTimezone: true }),
contentReviewedBy: uuid('content_reviewed_by').references(() => profiles.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [index('questions_pack_id_idx').on(table.packId)]);
@@ -64,3 +76,17 @@ export const couplePacks = pgTable('couple_packs', {
enabled: boolean('enabled').notNull().default(true),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [primaryKey({ columns: [table.coupleId, table.packId] })]);
export const contentReviews = pgTable('content_reviews', {
id: uuid('id').primaryKey().defaultRandom(),
entityType: text('entity_type').notNull(),
entityId: uuid('entity_id').notNull(),
previousStatus: text('previous_status').notNull(),
newStatus: text('new_status').notNull(),
reason: text('reason').notNull(),
changedBy: uuid('changed_by').references(() => profiles.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
index('content_reviews_entity_idx').on(table.entityType, table.entityId, table.createdAt),
index('content_reviews_created_at_idx').on(table.createdAt),
]);
@@ -1,5 +1,6 @@
import { randomUUID } from 'node:crypto';
import { Pool, type QueryResultRow } from 'pg';
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
import type {
FeedbackSubmission,
NotificationPreferenceKey,
@@ -16,9 +17,11 @@ interface PreferencesRow extends QueryResultRow {
pack_changes_enabled: boolean;
new_packs_enabled: boolean;
streak_milestones_enabled: boolean;
streak_reminders_enabled: boolean;
weekly_summary_enabled: boolean;
unpaired_reminders_enabled: boolean;
catchup_reminders_enabled: boolean;
dares_enabled: boolean;
created_at: Date;
updated_at: Date;
}
@@ -49,6 +52,7 @@ const profileColumns: Record<keyof ProfileUpdate, string> = {
onboarding_version: 'onboarding_version',
public_key_jwk: 'public_key_jwk',
push_token: 'push_token',
timezone: 'timezone',
};
function toPreferences(row: PreferencesRow): NotificationPreferences {
@@ -61,9 +65,11 @@ function toPreferences(row: PreferencesRow): NotificationPreferences {
pack_changes_enabled: row.pack_changes_enabled,
new_packs_enabled: row.new_packs_enabled,
streak_milestones_enabled: row.streak_milestones_enabled,
streak_reminders_enabled: row.streak_reminders_enabled,
weekly_summary_enabled: row.weekly_summary_enabled,
unpaired_reminders_enabled: row.unpaired_reminders_enabled,
catchup_reminders_enabled: row.catchup_reminders_enabled,
dares_enabled: row.dares_enabled,
created_at: row.created_at.toISOString(),
updated_at: row.updated_at.toISOString(),
};
@@ -75,9 +81,12 @@ function assertChanged(rowCount: number | null): void {
export class PostgresProfileSettingsRepository implements ProfileSettingsRepository {
private readonly pool: Pool;
private readonly ownsPool: boolean;
constructor(databaseUrl: string) {
this.pool = new Pool({ connectionString: databaseUrl });
constructor(connection: DatabaseConnection) {
const resolved = resolvePool(connection);
this.pool = resolved.pool;
this.ownsPool = resolved.owned;
}
async updateProfile(userId: string, update: ProfileUpdate): Promise<void> {
@@ -141,6 +150,6 @@ export class PostgresProfileSettingsRepository implements ProfileSettingsReposit
}
async close(): Promise<void> {
await this.pool.end();
await closeResolvedPool(this.pool, this.ownsPool);
}
}
@@ -18,6 +18,8 @@ const profileUpdateSchema = z.object({
onboarding_version: z.number().int().min(0).optional(),
public_key_jwk: z.record(z.unknown()).nullable().optional(),
push_token: z.string().trim().min(1).max(512).nullable().optional(),
// IANA zone from the client; the daily response limit buckets on the user's local day.
timezone: z.string().trim().min(1).max(64).nullable().optional(),
}).strict().refine((body) => Object.keys(body).length > 0, 'At least one profile field is required');
const preferenceSchema = z.object({
@@ -6,9 +6,11 @@ export const notificationPreferenceKeys = [
'pack_changes_enabled',
'new_packs_enabled',
'streak_milestones_enabled',
'streak_reminders_enabled',
'weekly_summary_enabled',
'unpaired_reminders_enabled',
'catchup_reminders_enabled',
'dares_enabled',
] as const;
export type NotificationPreferenceKey = typeof notificationPreferenceKeys[number];
@@ -30,6 +32,7 @@ export interface ProfileUpdate {
onboarding_version?: number;
public_key_jwk?: Record<string, unknown> | null;
push_token?: string | null;
timezone?: string | null;
}
export interface FeedbackSubmission {
+28 -24
View File
@@ -2,6 +2,8 @@ import { serve } from '@hono/node-server';
import { createApp } from './app.js';
import { createAuthVerifier } from './auth.js';
import { loadConfig } from './config.js';
import { createDatabasePool } from './db/pool.js';
import { recordPool } from './telemetry.js';
import { PostgresRepository } from './db/repository.js';
import { PostgresAppDataRepository } from './domains/app-data/repository.js';
import { AdminRequestAuth } from './domains/admin/auth.js';
@@ -17,6 +19,7 @@ import {
} from './domains/account-operations/index.js';
import { PostgresAnswersRepository } from './domains/answers/repository.js';
import { PostgresChatRepository } from './domains/chat/repository.js';
import { PostgresDaresRepository } from './domains/dares/repository.js';
import { PostgresCoupleRepository } from './domains/couples/repository.js';
import { CoupleService } from './domains/couples/service.js';
import { PostgresPacksRepository } from './domains/packs/repository.js';
@@ -25,29 +28,34 @@ import { FilesystemMediaStorage, PostgresMediaRepository } from './domains/media
const config = loadConfig();
const authVerifier = createAuthVerifier(config);
const repository = new PostgresRepository(config.databaseUrl);
const coupleRepository = new PostgresCoupleRepository(config.databaseUrl);
const packsRepository = new PostgresPacksRepository(config.databaseUrl);
const answersRepository = new PostgresAnswersRepository(config.databaseUrl);
const chatRepository = new PostgresChatRepository(config.databaseUrl);
const profileSettingsRepository = new PostgresProfileSettingsRepository(config.databaseUrl);
const accountOperationsRepository = new PostgresAccountOperationsRepository(config.databaseUrl);
// One deliberately bounded pool for the whole process. Repositories used to open
// a default-sized pool each, so a single API instance could hold twelve times the
// connections a self-hosted PostgreSQL was sized for.
const pool = createDatabasePool(config.databaseUrl, config.databasePool, 'api');
const repository = new PostgresRepository(pool);
const coupleRepository = new PostgresCoupleRepository(pool);
const packsRepository = new PostgresPacksRepository(pool);
const answersRepository = new PostgresAnswersRepository(pool);
const chatRepository = new PostgresChatRepository(pool);
const daresRepository = new PostgresDaresRepository(pool);
const profileSettingsRepository = new PostgresProfileSettingsRepository(pool);
const accountOperationsRepository = new PostgresAccountOperationsRepository(pool);
const accountOperationsService = new AccountOperationsService(
accountOperationsRepository,
new SupabaseAuthAdminClient(config.authIssuer, config.supabaseAuthServiceRoleKey),
new HttpRevenueCatClient(config.revenueCatApiKey, config.revenueCatEntitlementId),
new ExpoPartnerNotifier(),
);
const mediaRepository = new PostgresMediaRepository(config.databaseUrl);
const mediaRepository = new PostgresMediaRepository(pool);
const mediaStorage = new FilesystemMediaStorage(
config.mediaRoot,
config.mediaSigningSecret,
config.mediaPublicBaseUrl,
);
const appDataRepository = new PostgresAppDataRepository(config.databaseUrl);
const billingRepository = new PostgresBillingRepository(config.databaseUrl);
const appDataRepository = new PostgresAppDataRepository(pool);
const billingRepository = new PostgresBillingRepository(pool);
const billingService = new BillingService(billingRepository, config.revenueCatWebhookSecret);
const adminRepository = new PostgresAdminRepository(config.databaseUrl, {
const adminRepository = new PostgresAdminRepository(pool, {
adminPrivateKeyJwk: config.adminPrivateKeyJwk,
mediaRoot: config.mediaRoot,
authDirectory: new SupabaseAdminAuthDirectory(config.authIssuer, config.supabaseAuthServiceRoleKey),
@@ -59,6 +67,7 @@ const app = createApp({
packsRepository,
answersRepository,
chatRepository,
daresRepository,
profileSettingsRepository,
accountOperationsService,
mediaRepository,
@@ -73,6 +82,10 @@ const app = createApp({
),
});
// Sample pool state on the telemetry cadence rather than once per request.
const poolTelemetryTimer = setInterval(() => recordPool('api', pool), 60_000);
poolTelemetryTimer.unref();
const server = serve({ fetch: app.fetch, port: config.port }, (info) => {
console.log(`Sauci API listening on http://127.0.0.1:${info.port}`);
});
@@ -82,6 +95,7 @@ let shuttingDown = false;
async function shutdown(signal: string): Promise<void> {
if (shuttingDown) return;
shuttingDown = true;
clearInterval(poolTelemetryTimer);
console.log(`Received ${signal}; shutting down`);
const forcedExit = setTimeout(() => {
console.error('Graceful shutdown timed out');
@@ -91,19 +105,9 @@ async function shutdown(signal: string): Promise<void> {
server.close(async () => {
try {
await Promise.all([
repository.close(),
coupleRepository.close(),
packsRepository.close(),
answersRepository.close(),
chatRepository.close(),
profileSettingsRepository.close(),
accountOperationsRepository.close(),
mediaRepository.close(),
appDataRepository.close(),
billingRepository.close(),
adminRepository.close(),
]);
// Repositories share the process pool and no longer own a connection each,
// so ending the pool once is the whole of the database shutdown.
await pool.end();
process.exit(0);
} catch (error) {
console.error('Failed to close database connections', error);
+16
View File
@@ -0,0 +1,16 @@
/** Refuse fixture targets outside the local development boundary. */
export function requireLoopback(url: string, name: string): URL {
const parsed = new URL(url);
if (!['127.0.0.1', 'localhost', '::1'].includes(parsed.hostname)) {
throw new Error(`${name} must resolve to loopback; refusing non-local load`);
}
return parsed;
}
export function requireLocalApi(url: string, expectedPort = 3003): URL {
const parsed = requireLoopback(url, 'SAUCI_LOAD_API_URL');
if (parsed.protocol !== 'http:' || Number(parsed.port || 80) !== expectedPort) {
throw new Error(`SAUCI_LOAD_API_URL must be http loopback on port ${expectedPort}`);
}
return parsed;
}
+43
View File
@@ -0,0 +1,43 @@
import { createDatabasePool } from '../db/pool.js';
import { PostgresOperationsRepository } from '../domains/operations/repository.js';
import { emitTelemetry, recordPool } from '../telemetry.js';
import { requireLocalApi, requireLoopback } from './loopback.js';
function percentile(samples: number[], fraction: number): number {
const sorted = [...samples].sort((a, b) => a - b);
return Math.round((sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * fraction) - 1)] ?? 0) * 100) / 100;
}
async function read(url: URL, headers: HeadersInit): Promise<number> {
const startedAt = performance.now();
const response = await fetch(url, { headers });
if (!response.ok) throw new Error(`representative API read failed with ${response.status}`);
return performance.now() - startedAt;
}
async function main(): Promise<void> {
const expectedPort = Number(process.env.SAUCI_LOAD_API_PORT ?? '3003');
const apiUrl = requireLocalApi(process.env.SAUCI_LOAD_API_URL ?? `http://127.0.0.1:${expectedPort}`, expectedPort);
const databaseUrl = process.env.DATABASE_URL;
const authorization = process.env.SAUCI_LOAD_AUTHORIZATION;
const matchId = process.env.SAUCI_LOAD_MATCH_ID;
const expectedUserId = process.env.SAUCI_LOAD_USER_ID;
if (!databaseUrl || !authorization || !matchId || !expectedUserId) throw new Error('DATABASE_URL, SAUCI_LOAD_AUTHORIZATION, SAUCI_LOAD_MATCH_ID, and SAUCI_LOAD_USER_ID are required');
requireLoopback(databaseUrl, 'DATABASE_URL');
if (!/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(matchId) || !/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(expectedUserId)) throw new Error('SAUCI_LOAD_MATCH_ID and SAUCI_LOAD_USER_ID must be UUIDs');
const health = await fetch(new URL('/health/live', apiUrl));
if (!health.ok || (await health.json() as { status?: string }).status !== 'ok') throw new Error('local Sauci API health preflight failed');
const headers = { authorization };
const me = await fetch(new URL('/v1/me', apiUrl), { headers });
const profile = me.ok ? await me.json() as { profile?: { id?: string } } : undefined;
if (profile?.profile?.id !== expectedUserId) throw new Error('local fixture identity preflight failed');
const sync = await Promise.all(Array.from({ length: 5 }, () => read(new URL('/v1/me/sync', apiUrl), headers)));
const chat = await Promise.all(Array.from({ length: 5 }, () => read(new URL(`/v1/matches/${matchId}/messages?typing=true`, apiUrl), headers)));
for (const [operation, samples] of [['sync', sync], ['chat', chat]] as const) emitTelemetry({ event: 'load', process: 'load-fixture', operation, count: samples.length, p50_ms: percentile(samples, .5), p95_ms: percentile(samples, .95), p99_ms: percentile(samples, .99) });
const pool = createDatabasePool(databaseUrl, { max: 1 }, 'load-fixture');
try { await new PostgresOperationsRepository(pool).outboxState(); recordPool('load-fixture', pool); }
finally { await pool.end(); }
}
void main();
+108 -19
View File
@@ -1,14 +1,31 @@
import type { Pool, PoolClient } from 'pg';
import type { DatabaseMigrationOptions, TableResult } from './types.js';
interface TableSpec { name: string; key: string[]; replace?: boolean }
interface TableSpec {
name: string;
key: string[];
replace?: boolean;
/** Target-column to legacy source-column rename. */
columnMap?: Record<string, string>;
/** Target-column SQL expressions for legacy rows that need a derived value. */
sourceExpressions?: Record<string, string>;
/** Columns populated after all rows exist, for self-referential foreign keys. */
deferredColumns?: string[];
/** Keep a newer standalone-backend row when the same key exists in both systems. */
preserveNewer?: boolean;
/** Singleton runtime configuration is owned by the live target during an initial merge. */
preserveExistingOnInitial?: boolean;
}
export const TABLES: TableSpec[] = [
{ name: 'couples', key: ['id'] }, { name: 'profiles', key: ['id'] },
{ name: 'couples', key: ['id'], sourceExpressions: { invite_code: 'upper("couples"."invite_code")' } },
{ name: 'profiles', key: ['id'], preserveNewer: true },
{ name: 'admin_users', key: ['id'] }, { name: 'master_keys', key: ['id'] },
{ name: 'categories', key: ['id'] }, { name: 'question_packs', key: ['id'] },
{ name: 'topics', key: ['id'] }, { name: 'pack_topics', key: ['pack_id', 'topic_id'] },
{ name: 'questions', key: ['id'] }, { name: 'app_config', key: ['id'], replace: true },
{ name: 'ai_config', key: ['id'], replace: true },
{ name: 'questions', key: ['id'], deferredColumns: ['inverse_of'] },
{ name: 'content_reviews', key: ['id'] },
{ name: 'app_config', key: ['id'], replace: true, preserveExistingOnInitial: true },
{ name: 'ai_config', key: ['id'], replace: true, preserveExistingOnInitial: true },
{ name: 'dare_packs', key: ['id'] }, { name: 'dares', key: ['id'] },
{ name: 'sent_dares', key: ['id'] }, { name: 'dare_messages', key: ['id'] },
{ name: 'couple_packs', key: ['couple_id', 'pack_id'] }, { name: 'responses', key: ['id'] },
@@ -18,8 +35,21 @@ export const TABLES: TableSpec[] = [
{ name: 'notification_preferences', key: ['user_id'] }, { name: 'feedback', key: ['id'] },
{ name: 'subscriptions', key: ['id'] }, { name: 'revenuecat_webhook_events', key: ['id'] },
{ name: 'redemption_codes', key: ['id'] }, { name: 'code_redemptions', key: ['id'] },
{ name: 'feature_interests', key: ['user_id', 'feature'] }, { name: 'live_draw_sessions', key: ['couple_id'] },
{ name: 'catchup_reminder_tracking', key: ['user_id'] }, { name: 'audit_logs', key: ['id'] },
{ name: 'feature_interests', key: ['user_id', 'feature'], columnMap: { feature: 'feature_name' } },
{
name: 'live_draw_sessions', key: ['couple_id'],
sourceExpressions: {
updated_by: '(select p.id from profiles p where p.couple_id="live_draw_sessions"."couple_id" order by p.created_at,p.id limit 1)',
},
},
{ name: 'catchup_reminder_tracking', key: ['user_id'] },
{
name: 'audit_logs', key: ['id'],
sourceExpressions: {
actor_user_id: '"audit_logs"."admin_user_id"',
admin_user_id: '(select au.id from admin_users au where au.user_id="audit_logs"."admin_user_id" limit 1)',
},
},
];
const quote = (value: string) => `"${value.replaceAll('"', '""')}"`;
@@ -27,18 +57,42 @@ async function columns(pool: Pool | PoolClient, table: string): Promise<string[]
const result = await pool.query<{ column_name: string }>(`select column_name from information_schema.columns where table_schema=current_schema() and table_name=$1 order by ordinal_position`, [table]);
return result.rows.map((row) => row.column_name);
}
async function jsonColumns(pool: Pool | PoolClient, table: string): Promise<Set<string>> {
const result = await pool.query<{ column_name: string }>(`select column_name from information_schema.columns where table_schema=current_schema() and table_name=$1 and data_type in ('json','jsonb')`, [table]);
return new Set(result.rows.map((row) => row.column_name));
}
async function count(pool: Pool | PoolClient, table: string): Promise<number> {
return Number((await pool.query<{ count: string }>(`select count(*) count from ${quote(table)}`)).rows[0]?.count ?? 0);
}
async function upsertRows(client: PoolClient, spec: TableSpec, names: string[], rows: Record<string, unknown>[]): Promise<number> {
if (spec.replace) await client.query(`delete from ${quote(spec.name)}`);
async function upsertRows(client: PoolClient, spec: TableSpec, names: string[], rows: Record<string, unknown>[], jsonNames: Set<string>, finalSync: boolean): Promise<number> {
if (spec.preserveExistingOnInitial && !finalSync && await count(client, spec.name)) return 0;
if (spec.replace && finalSync) await client.query(`delete from ${quote(spec.name)}`);
if (!rows.length) return 0;
const update = names.filter((name) => !spec.key.includes(name));
const deferred = new Set(spec.deferredColumns ?? []);
const immediateNames = names.filter((name) => !deferred.has(name));
const update = immediateNames.filter((name) => !spec.key.includes(name));
const conflict = spec.key.map(quote).join(',');
const action = update.length ? `do update set ${update.map((name) => `${quote(name)}=excluded.${quote(name)}`).join(',')}` : 'do nothing';
const freshness = spec.preserveNewer && names.includes('updated_at')
? ` where ${quote(spec.name)}.${quote('updated_at')} is null or (excluded.${quote('updated_at')} is not null and excluded.${quote('updated_at')}>${quote(spec.name)}.${quote('updated_at')})`
: '';
const action = update.length ? `do update set ${update.map((name) => `${quote(name)}=excluded.${quote(name)}`).join(',')}${freshness}` : 'do nothing';
for (const row of rows) {
await client.query(`insert into ${quote(spec.name)} (${names.map(quote).join(',')}) values (${names.map((_, index) => `$${index + 1}`).join(',')}) on conflict (${conflict}) ${action}`, names.map((name) => row[name]));
await client.query(
`insert into ${quote(spec.name)} (${immediateNames.map(quote).join(',')}) values (${immediateNames.map((_, index) => `$${index + 1}`).join(',')}) on conflict (${conflict}) ${action}`,
immediateNames.map((name) => jsonNames.has(name) && row[name] !== null ? JSON.stringify(row[name]) : row[name]),
);
}
if (deferred.size) {
const deferredNames = names.filter((name) => deferred.has(name));
for (const row of rows) {
const values = deferredNames.map((name) => jsonNames.has(name) && row[name] !== null ? JSON.stringify(row[name]) : row[name]);
const whereValues = spec.key.map((name) => row[name]);
await client.query(
`update ${quote(spec.name)} set ${deferredNames.map((name, index) => `${quote(name)}=$${index + 1}`).join(',')} where ${spec.key.map((name, index) => `${quote(name)}=$${deferredNames.length + index + 1}`).join(' and ')}`,
[...values, ...whereValues],
);
}
}
return rows.length;
}
@@ -59,16 +113,24 @@ async function pruneRows(client: PoolClient, spec: TableSpec, sourceRows: Record
export async function migrateDatabase(source: Pool, target: Pool, options: DatabaseMigrationOptions): Promise<TableResult[]> {
const results: TableResult[] = [];
for (const spec of TABLES) {
const [sourceColumns, targetColumns] = await Promise.all([columns(source, spec.name), columns(target, spec.name)]);
const [sourceColumns, targetColumns, targetJsonColumns] = await Promise.all([columns(source, spec.name), columns(target, spec.name), jsonColumns(target, spec.name)]);
if (!sourceColumns.length || !targetColumns.length) {
results.push({ table: spec.name, sourceRows: 0, targetRows: targetColumns.length ? await count(target, spec.name) : 0, importedRows: 0, prunedRows: 0, status: 'skipped', note: !sourceColumns.length ? 'source table absent' : 'target table absent' });
continue;
}
const common = targetColumns.filter((name) => sourceColumns.includes(name));
const sourceOnly = sourceColumns.filter((name) => !targetColumns.includes(name));
const sourceName = (targetName: string) => spec.columnMap?.[targetName] ?? targetName;
const common = targetColumns.filter((name) => sourceColumns.includes(sourceName(name)) || Boolean(spec.sourceExpressions?.[name]));
const consumedSourceColumns = new Set(common.map(sourceName).filter((name) => sourceColumns.includes(name)));
const sourceOnly = sourceColumns.filter((name) => !consumedSourceColumns.has(name));
if (!spec.key.every((key) => common.includes(key))) throw new Error(`${spec.name} is missing required migration key columns`);
const sourceRows = (await source.query<Record<string, unknown>>(`select ${common.map(quote).join(',')} from ${quote(spec.name)}`)).rows;
let importedRows = 0; let prunedRows = 0;
const selections = common.map((targetName) => {
const expression = spec.sourceExpressions?.[targetName] ?? quote(sourceName(targetName));
return sourceName(targetName) === targetName && !spec.sourceExpressions?.[targetName]
? expression
: `${expression} as ${quote(targetName)}`;
});
const sourceRows = (await source.query<Record<string, unknown>>(`select ${selections.join(',')} from ${quote(spec.name)}`)).rows;
let importedRows = 0; let prunedRows = 0; let reconciledProfileCouples = 0;
const skip = !options.finalSync && options.checkpoint.completedTables.includes(spec.name);
if (!options.dryRun && !skip) {
const client = await target.connect();
@@ -77,7 +139,19 @@ export async function migrateDatabase(source: Pool, target: Pool, options: Datab
// Historical imports must not enqueue user notifications, classification,
// Discord events, or other operational side effects.
await client.query("set local sauci.suppress_operations = 'on'");
importedRows = await upsertRows(client, spec, common, sourceRows);
importedRows = await upsertRows(client, spec, common, sourceRows, targetJsonColumns, options.finalSync);
if (spec.name === 'profiles' && !options.finalSync) {
for (const row of sourceRows) {
if (!row.couple_id) continue;
const reconciled = await client.query(
`update profiles p set couple_id=$2
where p.id=$1 and p.couple_id is distinct from $2
and (select count(*) from profiles member where member.couple_id=p.couple_id)<=1`,
[row.id, row.couple_id],
);
reconciledProfileCouples += reconciled.rowCount ?? 0;
}
}
if (options.finalSync && options.prune) prunedRows = await pruneRows(client, spec, sourceRows);
await client.query('commit');
} catch (error) { await client.query('rollback'); throw error; } finally { client.release(); }
@@ -85,8 +159,23 @@ export async function migrateDatabase(source: Pool, target: Pool, options: Datab
await options.saveCheckpoint?.(options.checkpoint);
}
const targetRows = await count(target, spec.name);
const notes = [skip ? 'resumed from checkpoint' : '', sourceOnly.length ? `legacy-only columns intentionally omitted: ${sourceOnly.join(', ')}` : ''].filter(Boolean);
results.push({ table: spec.name, sourceRows: sourceRows.length, targetRows, importedRows, prunedRows, status: sourceRows.length === targetRows ? 'equal' : 'different', ...(notes.length ? { note: notes.join('; ') } : {}) });
const targetKeys = targetColumns.length
? (await target.query<Record<string, unknown>>(`select ${spec.key.map(quote).join(',')} from ${quote(spec.name)}`)).rows
: [];
const keyValue = (row: Record<string, unknown>) => JSON.stringify(spec.key.map((key) => row[key]));
const targetKeySet = new Set(targetKeys.map(keyValue));
const coveredSourceRows = sourceRows.filter((row) => targetKeySet.has(keyValue(row))).length;
const preservedSingleton = Boolean(spec.preserveExistingOnInitial && !options.finalSync && targetRows && !coveredSourceRows);
const parity = options.finalSync ? sourceRows.length === targetRows : preservedSingleton || coveredSourceRows === sourceRows.length;
const preservedTargetRows = Math.max(0, targetRows - coveredSourceRows);
const notes = [
skip ? 'resumed from checkpoint' : '',
preservedSingleton ? 'kept existing live target singleton during initial merge' : '',
reconciledProfileCouples ? `restored ${reconciledProfileCouples} established couple membership(s) over one-person target couples` : '',
!options.finalSync && preservedTargetRows ? `preserved ${preservedTargetRows} target-only row(s)` : '',
sourceOnly.length ? `legacy-only columns intentionally omitted: ${sourceOnly.join(', ')}` : '',
].filter(Boolean);
results.push({ table: spec.name, sourceRows: sourceRows.length, targetRows, importedRows, prunedRows, status: parity ? 'equal' : 'different', ...(notes.length ? { note: notes.join('; ') } : {}) });
}
return results;
}
+83 -27
View File
@@ -16,7 +16,9 @@ interface StorageObject {
const BUCKET_KIND: Record<string, 'avatar' | 'response' | 'chat' | 'feedback'> = {
avatars: 'avatar', 'response-media': 'response', 'chat-media': 'chat', 'feedback-screenshots': 'feedback',
};
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
// PostgreSQL's uuid type accepts legacy UUID-shaped identifiers whose version
// nibble is not RFC 4122 (the production fixture profile uses version 0).
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const quote = (value: string) => `"${value.replaceAll('"', '""')}"`;
function safeStorageKey(bucket: string, name: string): string {
@@ -50,22 +52,57 @@ export function createSupabaseDownloader(baseUrl: string, serviceRoleKey: string
};
}
async function mediaRelation(target: Pool, object: StorageObject, key: string) {
const first = object.name.split('/')[0];
const owner = object.owner_id && uuidPattern.test(object.owner_id) ? object.owner_id : (uuidPattern.test(first) && object.bucket_id !== 'chat-media' ? first : null);
if (!owner) throw new Error(`Cannot determine owner for ${key}`);
const profile = await target.query<{ couple_id: string | null }>('select couple_id from profiles where id=$1', [owner]);
if (!profile.rows[0]) throw new Error(`Storage owner does not exist in target for ${key}`);
let matchId: string | null = null; let questionId: string | null = null;
if (object.bucket_id === 'chat-media' && uuidPattern.test(first)) matchId = first;
if (object.bucket_id === 'response-media') {
const candidate = object.name.split('/').at(-1)?.split('_')[0] ?? '';
if (uuidPattern.test(candidate)) questionId = candidate;
const legacyPredicate = (column: string) => `(
${column}=$1 or ${column}=$2 or
(right(${column},length($1))=$1 and position('/'||$3||'/' in ${column})>0)
)`;
async function sourceReference(source: Pool, object: StorageObject) {
const values = [object.name, `${object.bucket_id}/${object.name}`, object.bucket_id];
if (object.bucket_id === 'avatars') {
return (await source.query<{ owner_id: string; match_id: null; question_id: null }>(
`select id owner_id,null::uuid match_id,null::uuid question_id from profiles where ${legacyPredicate('avatar_url')} limit 1`, values,
)).rows[0] ?? null;
}
return { ownerId: owner, coupleId: profile.rows[0].couple_id, matchId, questionId };
if (object.bucket_id === 'chat-media') {
return (await source.query<{ owner_id: string; match_id: string; question_id: null }>(
`select user_id owner_id,match_id,null::uuid question_id from messages where ${legacyPredicate('media_path')} limit 1`, values,
)).rows[0] ?? null;
}
if (object.bucket_id === 'feedback-screenshots') {
return (await source.query<{ owner_id: string; match_id: null; question_id: null }>(
`select user_id owner_id,null::uuid match_id,null::uuid question_id from feedback where ${legacyPredicate('screenshot_url')} limit 1`, values,
)).rows[0] ?? null;
}
return (await source.query<{ owner_id: string; match_id: null; question_id: string }>(
`select user_id owner_id,null::uuid match_id,question_id from responses
where response_data is not null and ${legacyPredicate("response_data->>'media_path'")} limit 1`, values,
)).rows[0] ?? null;
}
async function rewriteLegacyReference(target: Pool, object: StorageObject): Promise<void> {
async function mediaRelation(source: Pool, target: Pool, object: StorageObject) {
const first = object.name.split('/')[0];
const reference = await sourceReference(source, object);
const ownerCandidates = [object.owner_id, reference?.owner_id, object.bucket_id === 'chat-media' ? null : first]
.filter((value): value is string => Boolean(value && uuidPattern.test(value)));
let ownerId: string | null = null; let coupleId: string | null = null;
for (const candidate of ownerCandidates) {
const profile = await target.query<{ couple_id: string | null }>('select couple_id from profiles where id=$1', [candidate]);
if (profile.rows[0]) { ownerId = candidate; coupleId = profile.rows[0].couple_id; break; }
}
let matchId = reference?.match_id && uuidPattern.test(reference.match_id) ? reference.match_id : null;
if (!matchId && object.bucket_id === 'chat-media' && uuidPattern.test(first)) matchId = first;
if (matchId && !(await target.query('select 1 from matches where id=$1', [matchId])).rowCount) matchId = null;
let questionId = reference?.question_id && uuidPattern.test(reference.question_id) ? reference.question_id : null;
if (object.bucket_id === 'response-media') {
const candidate = object.name.split('/').at(-1)?.split('_')[0] ?? '';
if (!questionId && uuidPattern.test(candidate)) questionId = candidate;
}
if (questionId && !(await target.query('select 1 from questions where id=$1', [questionId])).rowCount) questionId = null;
return { ownerId, coupleId, matchId, questionId };
}
async function rewriteLegacyReference(source: Pool, target: Pool, object: StorageObject): Promise<void> {
const reference = `media:${object.id}`;
const legacyName = object.name;
const legacyKey = `${object.bucket_id}/${legacyName}`;
@@ -78,7 +115,11 @@ async function rewriteLegacyReference(target: Pool, object: StorageObject): Prom
} else if (object.bucket_id === 'chat-media') {
await target.query(`update messages set media_path=$1 where ${matchesLegacy('media_path')}`, [reference, legacyName, legacyKey]);
} else if (object.bucket_id === 'feedback-screenshots') {
await target.query(`update feedback set screenshot_url=$1 where ${matchesLegacy('screenshot_url')}`, [reference, legacyName, legacyKey]);
const sourceRows = await source.query<{ id: string }>(
`select id from feedback where ${legacyPredicate('screenshot_url')}`,
[legacyName, legacyKey, object.bucket_id],
);
if (sourceRows.rows.length) await target.query('update feedback set screenshot_media_id=$1 where id=any($2::uuid[])', [object.id, sourceRows.rows.map((row) => row.id)]);
} else if (object.bucket_id === 'response-media') {
await target.query(`
update responses
@@ -92,7 +133,7 @@ export async function migrateStorage(source: Pool, target: Pool, options: Storag
const schema = options.storageSchema ?? 'storage';
if (!/^[a-z_][a-z0-9_]*$/i.test(schema)) throw new Error('Invalid storage schema');
const objects = await source.query<StorageObject>(`select id,bucket_id,name,owner_id,metadata,created_at,updated_at from ${quote(schema)}.objects where bucket_id=any($1::text[]) order by bucket_id,name`, [Object.keys(BUCKET_KIND)]);
const result: StorageResult = { sourceFiles: objects.rowCount ?? objects.rows.length, targetFiles: 0, copiedFiles: 0, skippedFiles: 0, prunedFiles: 0, failedFiles: 0, sourceBytes: 0, copiedBytes: 0, missingOnDisk: [], failures: [] };
const result: StorageResult = { sourceFiles: objects.rowCount ?? objects.rows.length, targetFiles: 0, preservedTargetFiles: 0, copiedFiles: 0, quarantinedFiles: 0, skippedFiles: 0, prunedFiles: 0, failedFiles: 0, sourceBytes: 0, copiedBytes: 0, missingOnDisk: [], failures: [] };
const sourceKeys = new Set<string>();
for (const object of objects.rows) {
const key = safeStorageKey(object.bucket_id, object.name); const expectedSize = metadataSize(object.metadata); result.sourceBytes += expectedSize;
@@ -101,10 +142,11 @@ export async function migrateStorage(source: Pool, target: Pool, options: Storag
const marker = options.checkpoint.storage[key];
if (marker && marker.updatedAt === updatedAt && (!expectedSize || marker.byteSize === expectedSize)) {
if (options.dryRun) { result.skippedFiles += 1; continue; }
const registered = await target.query('select 1 from media_objects where id=$1 and storage_key=$2 and byte_size=$3', [object.id, key, marker.byteSize]);
const registered = await target.query(`select 1 from media_objects where id=$1 and storage_key=$2 and byte_size=$3
union all select 1 from legacy_media_quarantine where id=$1 and original_storage_key=$2 and byte_size=$3`, [object.id, key, marker.byteSize]);
try {
if (registered.rowCount && (await stat(destination(options.mediaRoot, key))).size === marker.byteSize) {
await rewriteLegacyReference(target, object);
await rewriteLegacyReference(source, target, object);
result.skippedFiles += 1;
continue;
}
@@ -112,16 +154,25 @@ export async function migrateStorage(source: Pool, target: Pool, options: Storag
}
if (options.dryRun) continue;
try {
const relation = await mediaRelation(source, target, object);
const bytes = await options.download(object.bucket_id, object.name);
if (expectedSize && bytes.byteLength !== expectedSize) throw new Error(`Size mismatch for ${key}: expected ${expectedSize}, received ${bytes.byteLength}`);
const relation = await mediaRelation(target, object, key);
const path = destination(options.mediaRoot, key); await mkdir(dirname(path), { recursive: true });
const temporary = `${path}.${process.pid}.tmp`; await writeFile(temporary, bytes, { mode: 0o600 }); await rename(temporary, path);
const mime = metadataString(object.metadata, 'mimetype', 'contentType') ?? 'application/octet-stream';
await target.query(`insert into media_objects(id,owner_id,couple_id,kind,storage_key,mime_type,byte_size,question_id,match_id,created_at)
values($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) on conflict(id) do update set owner_id=excluded.owner_id,couple_id=excluded.couple_id,kind=excluded.kind,storage_key=excluded.storage_key,mime_type=excluded.mime_type,byte_size=excluded.byte_size,question_id=excluded.question_id,match_id=excluded.match_id,created_at=excluded.created_at`,
[object.id, relation.ownerId, relation.coupleId, BUCKET_KIND[object.bucket_id], key, mime, bytes.byteLength, relation.questionId, relation.matchId, object.created_at]);
await rewriteLegacyReference(target, object);
if (relation.ownerId) {
await target.query(`insert into media_objects(id,owner_id,couple_id,kind,storage_key,mime_type,byte_size,question_id,match_id,created_at)
values($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) on conflict(id) do update set owner_id=excluded.owner_id,couple_id=excluded.couple_id,kind=excluded.kind,storage_key=excluded.storage_key,mime_type=excluded.mime_type,byte_size=excluded.byte_size,question_id=excluded.question_id,match_id=excluded.match_id,created_at=excluded.created_at`,
[object.id, relation.ownerId, relation.coupleId, BUCKET_KIND[object.bucket_id], key, mime, bytes.byteLength, relation.questionId, relation.matchId, object.created_at]);
await target.query('delete from legacy_media_quarantine where id=$1', [object.id]);
await rewriteLegacyReference(source, target, object);
} else {
await target.query(`insert into legacy_media_quarantine(id,original_storage_key,bucket_id,object_name,source_owner_id,metadata,mime_type,byte_size,reason,source_created_at,source_updated_at)
values($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) on conflict(id) do update set original_storage_key=excluded.original_storage_key,bucket_id=excluded.bucket_id,object_name=excluded.object_name,source_owner_id=excluded.source_owner_id,metadata=excluded.metadata,mime_type=excluded.mime_type,byte_size=excluded.byte_size,reason=excluded.reason,source_created_at=excluded.source_created_at,source_updated_at=excluded.source_updated_at`,
[object.id, key, object.bucket_id, object.name, object.owner_id, object.metadata, mime, bytes.byteLength, 'legacy object has no verifiable owner or live reference', object.created_at, object.updated_at]);
await target.query('delete from media_objects where id=$1', [object.id]);
result.quarantinedFiles += 1;
}
options.checkpoint.storage[key] = { updatedAt, byteSize: bytes.byteLength };
await options.saveCheckpoint?.(options.checkpoint); result.copiedFiles += 1; result.copiedBytes += bytes.byteLength;
} catch (error) {
@@ -131,16 +182,21 @@ export async function migrateStorage(source: Pool, target: Pool, options: Storag
}
if (!options.dryRun) {
if (options.prune) {
const existing = await target.query<{ id: string; storage_key: string }>("select id,storage_key from media_objects where kind=any($1::text[])", [Object.values(BUCKET_KIND)]);
const existing = await target.query<{ id: string; storage_key: string; quarantined: boolean }>(`
select id,storage_key,false quarantined from media_objects where kind=any($1::text[])
union all select id,original_storage_key storage_key,true quarantined from legacy_media_quarantine
`, [Object.values(BUCKET_KIND)]);
for (const row of existing.rows) if (!sourceKeys.has(row.storage_key)) {
await rm(destination(options.mediaRoot, row.storage_key), { force: true });
await target.query('delete from media_objects where id=$1', [row.id]);
await target.query(row.quarantined ? 'delete from legacy_media_quarantine where id=$1' : 'delete from media_objects where id=$1', [row.id]);
delete options.checkpoint.storage[row.storage_key]; result.prunedFiles += 1;
}
await options.saveCheckpoint?.(options.checkpoint);
}
const registered = await target.query<{ storage_key: string; byte_size: number }>("select storage_key,byte_size from media_objects where deleted_at is null and kind=any($1::text[])", [Object.values(BUCKET_KIND)]);
result.targetFiles = registered.rows.length;
const registered = await target.query<{ storage_key: string; byte_size: number }>(`select storage_key,byte_size from media_objects where deleted_at is null and kind=any($1::text[])
union all select original_storage_key storage_key,byte_size from legacy_media_quarantine`, [Object.values(BUCKET_KIND)]);
result.targetFiles = registered.rows.filter((row) => sourceKeys.has(row.storage_key)).length;
result.preservedTargetFiles = registered.rows.length - result.targetFiles;
for (const row of registered.rows) {
try { if ((await stat(destination(options.mediaRoot, row.storage_key))).size !== row.byte_size) result.missingOnDisk.push(row.storage_key); }
catch { result.missingOnDisk.push(row.storage_key); }
+2
View File
@@ -22,7 +22,9 @@ export interface TableResult {
export interface StorageResult {
sourceFiles: number;
targetFiles: number;
preservedTargetFiles: number;
copiedFiles: number;
quarantinedFiles: number;
skippedFiles: number;
prunedFiles: number;
failedFiles: number;
+64
View File
@@ -0,0 +1,64 @@
import type { Pool, PoolClient } from 'pg';
/** Fixed, privacy-safe JSON log vocabulary. Do not add free-form fields. */
export type TelemetryProcess = 'api' | 'worker' | 'load-fixture';
type Outcome = 'ok' | 'error';
type StatusClass = '2xx' | '3xx' | '4xx' | '5xx';
export type TelemetryRecord =
| { event: 'request'; process: 'api'; method: string; route: string; status_class: StatusClass; count: number; duration_ms: number }
| { event: 'query'; process: TelemetryProcess; operation: 'pool_query'; outcome: Outcome; count: number; duration_ms: number }
| { event: 'pool'; process: TelemetryProcess; total: number; idle: number; waiting: number }
| { event: 'sync'; process: 'api' | 'worker'; outcome: Outcome; count: number; duration_ms: number }
| { event: 'outbox'; process: 'worker'; due_after: number; oldest_due_age_seconds: number; claimed: number; completed: number; failed: number; count: number }
| { event: 'load'; process: 'load-fixture'; operation: 'sync' | 'chat'; count: number; p50_ms: number; p95_ms: number; p99_ms: number };
type TelemetrySink = (record: TelemetryRecord) => void;
let sink: TelemetrySink = (record) => console.log(JSON.stringify(record));
let pending = new Map<string, TelemetryRecord>();
function aggregate(record: Extract<TelemetryRecord, { event: 'request' | 'query' | 'sync' }>): void {
const key = record.event === 'request' ? `${record.event}:${record.method}:${record.route}:${record.status_class}` : `${record.event}:${record.process}:${record.outcome}`;
const prior = pending.get(key) as typeof record | undefined;
if (!prior) { pending.set(key, { ...record }); return; }
prior.count += record.count;
prior.duration_ms += record.duration_ms;
}
/** Emits bounded aggregates each minute; workers also flush after a tick. */
export function flushTelemetry(): void { for (const record of pending.values()) sink(record); pending = new Map(); }
const flushTimer = setInterval(flushTelemetry, 60_000);
flushTimer.unref();
export function emitTelemetry(record: Exclude<TelemetryRecord, { event: 'request' | 'query' | 'sync' }>): void { sink(record); }
export function recordRequest(method: string, route: string, status: number, startedAt: number): void {
const statusClass: StatusClass = status >= 500 ? '5xx' : status >= 400 ? '4xx' : status >= 300 ? '3xx' : '2xx';
aggregate({ event: 'request', process: 'api', method, route, status_class: statusClass, duration_ms: durationMs(startedAt), count: 1 });
}
export function recordSync(process: 'api' | 'worker', outcome: Outcome, startedAt: number): void { aggregate({ event: 'sync', process, outcome, duration_ms: durationMs(startedAt), count: 1 }); }
/** Test-only hook; production code always uses the JSON console sink. */
export function setTelemetrySinkForTests(next: TelemetrySink): () => void { const previous = sink; sink = next; return () => { sink = previous; pending = new Map(); }; }
export function durationMs(startedAt: number): number { return Math.max(0, Math.round((performance.now() - startedAt) * 100) / 100); }
export function recordPool(process: TelemetryProcess, pool: Pool): void { emitTelemetry({ event: 'pool', process, total: pool.totalCount, idle: pool.idleCount, waiting: pool.waitingCount }); }
export function instrumentPool(process: TelemetryProcess, pool: Pool): Pool {
const clients = new WeakSet<PoolClient>();
const instrumentQuery = <T extends { query: Pool['query'] }>(target: T): T => {
const query = target.query.bind(target) as (...args: unknown[]) => unknown;
target.query = ((...args: unknown[]) => {
const startedAt = performance.now();
const finish = (outcome: Outcome) => aggregate({ event: 'query', process, operation: 'pool_query', outcome, duration_ms: durationMs(startedAt), count: 1 });
try { const result = query(...args); if (result && typeof (result as Promise<unknown>).then === 'function') return (result as Promise<unknown>).then((value) => { finish('ok'); return value; }, (error) => { finish('error'); throw error; }); finish('ok'); return result; }
catch (error) { finish('error'); throw error; }
}) as Pool['query'];
return target;
};
instrumentQuery(pool);
const connect = pool.connect.bind(pool) as (...args: unknown[]) => unknown;
pool.connect = ((...args: unknown[]) => {
const result = connect(...args);
if (result && typeof (result as Promise<PoolClient>).then === 'function') return (result as Promise<PoolClient>).then((client) => { if (!clients.has(client)) { clients.add(client); instrumentQuery(client); } return client; });
return result;
}) as Pool['connect'];
return pool;
}
+2 -1
View File
@@ -4,6 +4,7 @@ const optionalString=z.preprocess(value=>value===''?undefined:value,z.string().m
const optionalUrl=z.preprocess(value=>value===''?undefined:value,z.string().url().optional());
const schema=z.object({
NODE_ENV:z.enum(['development','test','production']).default('development'), DATABASE_URL:z.string().url(),
DATABASE_POOL_MAX:z.coerce.number().int().min(1).max(200).default(4), DATABASE_POOL_IDLE_TIMEOUT_MS:z.coerce.number().int().min(0).max(3_600_000).default(30_000), DATABASE_POOL_CONNECTION_TIMEOUT_MS:z.coerce.number().int().min(0).max(60_000).default(10_000),
WORKER_POLL_INTERVAL_MS:z.coerce.number().int().min(1000).max(300000).default(30000), WORKER_BATCH_SIZE:z.coerce.number().int().min(1).max(100).default(25),
MEDIA_ROOT:z.string().min(1).default('/data/media'), MEDIA_SIGNING_SECRET:z.string().min(32).default('local-media-signing-secret-change-me-123456'),
MEDIA_PUBLIC_BASE_URL:z.string().url().default('http://127.0.0.1:3003'), CLASSIFIER_ENABLED:z.enum(['true','false']).default('true').transform(v=>v==='true'),
@@ -15,7 +16,7 @@ export function loadWorkerConfig(env:NodeJS.ProcessEnv=process.env) {
const value=schema.parse(env);
if(value.NODE_ENV==='production'&&value.CLASSIFIER_ENABLED&&!value.OPENROUTER_API_KEY)throw new Error('OPENROUTER_API_KEY is required when classification is enabled');
if(value.ADMIN_PRIVATE_KEY_JWK){try{JSON.parse(value.ADMIN_PRIVATE_KEY_JWK);}catch{throw new Error('ADMIN_PRIVATE_KEY_JWK must be valid JSON');}}
return {nodeEnv:value.NODE_ENV,databaseUrl:value.DATABASE_URL,pollIntervalMs:value.WORKER_POLL_INTERVAL_MS,batchSize:value.WORKER_BATCH_SIZE,
return {nodeEnv:value.NODE_ENV,databaseUrl:value.DATABASE_URL,databasePool:{max:value.DATABASE_POOL_MAX,idleTimeoutMillis:value.DATABASE_POOL_IDLE_TIMEOUT_MS,connectionTimeoutMillis:value.DATABASE_POOL_CONNECTION_TIMEOUT_MS},pollIntervalMs:value.WORKER_POLL_INTERVAL_MS,batchSize:value.WORKER_BATCH_SIZE,
mediaRoot:value.MEDIA_ROOT,mediaSigningSecret:value.MEDIA_SIGNING_SECRET,mediaPublicBaseUrl:value.MEDIA_PUBLIC_BASE_URL,classifierEnabled:value.CLASSIFIER_ENABLED,
openRouterApiKey:value.OPENROUTER_API_KEY,classifierModel:value.CLASSIFIER_MODEL,classifierPrompt:env.CLASSIFIER_PROMPT||defaultPrompt,
adminPrivateKeyJwk:value.ADMIN_PRIVATE_KEY_JWK,discordWebhookUrl:value.DISCORD_WEBHOOK_URL};
+8 -6
View File
@@ -3,14 +3,16 @@ import { PostgresMediaRepository } from '../domains/media/repository.js';
import { FilesystemMediaStorage } from '../domains/media/storage.js';
import { DiscordProvider, ExpoPushProvider, MessageClassifier, OperationsRunner, PostgresOperationsRepository } from '../domains/operations/index.js';
import { loadWorkerConfig } from './config.js';
import { createDatabasePool } from '../db/pool.js';
import { flushTelemetry, recordPool, recordSync } from '../telemetry.js';
import { WorkerLifecycle } from './lifecycle.js';
const config=loadWorkerConfig(); const operations=new PostgresOperationsRepository(config.databaseUrl); const mediaRepository=new PostgresMediaRepository(config.databaseUrl);
const config=loadWorkerConfig(); const pool=createDatabasePool(config.databaseUrl,config.databasePool,'worker'); const operations=new PostgresOperationsRepository(pool); const mediaRepository=new PostgresMediaRepository(pool);
const mediaStorage=new FilesystemMediaStorage(config.mediaRoot,config.mediaSigningSecret,config.mediaPublicBaseUrl);
const runner=new OperationsRunner(operations,new ExpoPushProvider(),new DiscordProvider(config.discordWebhookUrl),new MessageClassifier(operations,
{enabled:config.classifierEnabled,apiKey:config.openRouterApiKey,model:config.classifierModel,prompt:config.classifierPrompt,adminPrivateKeyJwk:config.adminPrivateKeyJwk,mediaRoot:config.mediaRoot}),
new MediaJanitor(mediaRepository,mediaStorage));
let stopped=false;let timer:NodeJS.Timeout|undefined;
async function tick(){if(stopped)return;try{const result=await runner.runOnce(new Date(),config.batchSize);if(result.claimed||result.failed)console.log('Operations worker tick',result);}catch(cause){console.error('Operations worker tick failed',cause);}finally{if(!stopped){timer=setTimeout(()=>void tick(),config.pollIntervalMs);}}}
async function shutdown(signal:string){if(stopped)return;stopped=true;if(timer)clearTimeout(timer);console.log(`Received ${signal}; stopping operations worker`);await Promise.all([operations.close(),mediaRepository.close()]);}
process.on('SIGINT',()=>void shutdown('SIGINT').then(()=>process.exit(0))); process.on('SIGTERM',()=>void shutdown('SIGTERM').then(()=>process.exit(0)));
console.log('Sauci operations worker started');void tick();
const lifecycle=new WorkerLifecycle({pollIntervalMs:config.pollIntervalMs,closePool:()=>pool.end(),onTickFailure:cause=>console.error('Operations worker tick failed',cause),runTick:async()=>{const startedAt=performance.now();try{const result=await runner.runOnce(new Date(),config.batchSize);recordSync('worker','ok',startedAt);recordPool('worker',pool);flushTelemetry();if(result.claimed||result.failed)console.log('Operations worker tick',result);}catch(cause){recordSync('worker','error',startedAt);recordPool('worker',pool);flushTelemetry();throw cause;}}});
function shutdown(signal:string){console.log(`Received ${signal}; stopping operations worker`);return lifecycle.shutdown();}
process.on('SIGINT',()=>void shutdown('SIGINT').then(code=>process.exit(code))); process.on('SIGTERM',()=>void shutdown('SIGTERM').then(code=>process.exit(code)));
console.log('Sauci operations worker started');lifecycle.start();
+47
View File
@@ -0,0 +1,47 @@
export interface WorkerLifecycleDependencies {
pollIntervalMs: number;
runTick: () => Promise<void>;
closePool: () => Promise<void>;
onTickFailure: (cause: unknown) => void;
graceMs?: number;
}
/** Owns worker tick scheduling and makes shutdown single-flight and bounded. */
export class WorkerLifecycle {
private stopped = false;
private timer: NodeJS.Timeout | undefined;
private activeTick: Promise<void> | undefined;
private shutdownPromise: Promise<number> | undefined;
private readonly graceMs: number;
constructor(private readonly deps: WorkerLifecycleDependencies) { this.graceMs = deps.graceMs ?? 25_000; }
start(): void { if (!this.activeTick && !this.stopped) this.activeTick = this.tick(); }
shutdown(): Promise<number> {
if (!this.shutdownPromise) this.shutdownPromise = this.stop();
return this.shutdownPromise;
}
private async tick(): Promise<void> {
try { await this.deps.runTick(); }
catch (cause) { this.deps.onTickFailure(cause); }
finally {
if (!this.stopped) this.timer = setTimeout(() => { this.activeTick = this.tick(); }, this.deps.pollIntervalMs);
}
}
private async stop(): Promise<number> {
this.stopped = true;
if (this.timer) clearTimeout(this.timer);
const work = (async () => {
try { if (this.activeTick) await this.activeTick; await this.deps.closePool(); return 0; }
catch { return 1; }
})();
let timeout: NodeJS.Timeout | undefined;
const timedOut = new Promise<number>((resolve) => { timeout = setTimeout(() => resolve(1), this.graceMs); });
const result = await Promise.race([work, timedOut]);
if (timeout) clearTimeout(timeout);
return result;
}
}
@@ -0,0 +1,84 @@
import { describe, expect, it, vi } from 'vitest';
import { AccountOperationsService } from '../src/domains/account-operations/service.js';
import type { AuthAdminClient, PartnerNotifier, RevenueCatClient } from '../src/domains/account-operations/clients.js';
import type { AccountOperationsRepository } from '../src/domains/account-operations/repository.js';
function setup(partnerPushToken: string | null = 'ExponentPushToken[partner]') {
const repository = {
deleteRelationship: vi.fn(async () => ({ partnerPushToken })),
resetProgress: vi.fn(async () => ({ partnerPushToken })),
deleteAccount: vi.fn(async (_userId: string, deleteAuthUser: () => Promise<void>) => {
await deleteAuthUser();
return { partnerPushToken };
}),
setPremium: vi.fn(async () => undefined),
close: vi.fn(async () => undefined),
} satisfies AccountOperationsRepository;
const authAdmin = { deleteUser: vi.fn(async () => undefined) } satisfies AuthAdminClient;
const revenueCat = { isEntitled: vi.fn(async () => true) } satisfies RevenueCatClient;
const notifier = {
relationshipDeleted: vi.fn(async () => undefined),
progressReset: vi.fn(async () => undefined),
partnerAccountDeleted: vi.fn(async () => undefined),
} satisfies PartnerNotifier;
return {
repository,
authAdmin,
revenueCat,
notifier,
service: new AccountOperationsService(repository, authAdmin, revenueCat, notifier),
};
}
describe('AccountOperationsService', () => {
it('deletes relationship data and notifies the partner', async () => {
const { service, repository, notifier } = setup();
await expect(service.deleteRelationship('user-1')).resolves.toEqual({
success: true,
message: 'Relationship data deleted successfully',
});
expect(repository.deleteRelationship).toHaveBeenCalledWith('user-1');
expect(notifier.relationshipDeleted).toHaveBeenCalledWith('ExponentPushToken[partner]');
});
it('resets progress and skips notification without a partner token', async () => {
const { service, notifier } = setup(null);
await expect(service.resetProgress('user-1')).resolves.toEqual({
success: true,
message: 'Progress reset successfully',
});
expect(notifier.progressReset).not.toHaveBeenCalled();
});
it('deletes hosted auth inside the repository operation before notifying', async () => {
const { service, repository, authAdmin, notifier } = setup();
await expect(service.deleteAccount('user-1')).resolves.toEqual({
success: true,
message: 'Account deleted successfully',
});
expect(repository.deleteAccount).toHaveBeenCalledOnce();
expect(authAdmin.deleteUser).toHaveBeenCalledWith('user-1');
expect(notifier.partnerAccountDeleted).toHaveBeenCalledWith('ExponentPushToken[partner]');
});
it('persists the entitlement returned by RevenueCat', async () => {
const { service, repository, revenueCat } = setup();
revenueCat.isEntitled.mockResolvedValueOnce(false);
await expect(service.syncSubscription('user-1')).resolves.toEqual({
success: true,
is_premium: false,
});
expect(repository.setPremium).toHaveBeenCalledWith('user-1', false);
});
it('keeps a committed destructive operation successful when notification fails', async () => {
const { service, notifier } = setup();
notifier.relationshipDeleted.mockRejectedValueOnce(new Error('push unavailable'));
const error = vi.spyOn(console, 'error').mockImplementation(() => undefined);
await expect(service.deleteRelationship('user-1')).resolves.toMatchObject({ success: true });
expect(error).toHaveBeenCalledWith('Partner notification failed', expect.any(Error));
error.mockRestore();
});
});
+99 -4
View File
@@ -5,13 +5,108 @@ import { afterAll,beforeAll,describe,expect,it } from 'vitest';
import { PostgresAnswersRepository } from '../src/domains/answers/repository.js';
const url=process.env.DATABASE_URL;const local=url?['127.0.0.1','localhost','::1'].includes(new URL(url).hostname):false;if(url&&!local)throw new Error('Answer integration tests only permit localhost');
describe.skipIf(!url||!local)('answers + PostgreSQL',()=>{const admin=new Pool({connectionString:url});const schema=`answers_${randomUUID().replaceAll('-','')}`;let pool:Pool;let repo:PostgresAnswersRepository;
const u1='11111111-1111-4111-8111-111111111111',u2='22222222-2222-4222-8222-222222222222',outsider='33333333-3333-4333-8333-333333333333',couple='44444444-4444-4444-8444-444444444444',other='55555555-5555-4555-8555-555555555555',pack='66666666-6666-4666-8666-666666666666',q='77777777-7777-4777-8777-777777777777',concurrentQ='88888888-8888-4888-8888-888888888888',femaleOnlyQ='99999999-9999-4999-8999-999999999999';
beforeAll(async()=>{await admin.query(`create schema "${schema}"`);const isolated=new URL(url!);isolated.searchParams.set('options',`-c search_path=${schema}`);pool=new Pool({connectionString:isolated.toString()});repo=new PostgresAnswersRepository(isolated.toString());for(const name of ['0000_identity_and_feature_interests.sql','0001_couples.sql','0002_packs_catalog_progress.sql','0003_answers_matches.sql']){const sql=await readFile(new URL(`../drizzle/${name}`,import.meta.url),'utf8');for(const s of sql.split('--> statement-breakpoint'))if(s.trim())await pool.query(s);}await pool.query('insert into couples(id,invite_code) values($1,$2),($3,$4)',[couple,'ANSWERS1',other,'ANSWERS2']);await pool.query("insert into profiles(id,couple_id,gender,max_intensity) values($1,$4,'female',5),($2,$4,'male',3),($3,$5,'male',5)",[u1,u2,outsider,couple,other]);await pool.query("insert into question_packs(id,name) values($1,'Core')",[pack]);await pool.query("insert into questions(id,pack_id,text,intensity,target_user_genders) values($1,$2,'Question',3,array['female']),($3,$2,'Concurrent',2,null),($4,$2,'Female first',2,array['female'])",[q,pack,concurrentQ,femaleOnlyQ]);await pool.query('insert into couple_packs(couple_id,pack_id,enabled) values($1,$2,true)',[couple,pack]);});afterAll(async()=>{await repo.close();await pool.end();await admin.query(`drop schema "${schema}" cascade`);await admin.end();});
const u1='11111111-1111-4111-8111-111111111111',u2='22222222-2222-4222-8222-222222222222',outsider='33333333-3333-4333-8333-333333333333',couple='44444444-4444-4444-8444-444444444444',other='55555555-5555-4555-8555-555555555555',pack='66666666-6666-4666-8666-666666666666',q='77777777-7777-4777-8777-777777777777',concurrentQ='88888888-8888-4888-8888-888888888888',femaleOnlyQ='99999999-9999-4999-8999-999999999999',limitQ1='aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',limitQ2='bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',catchUpQ='cccccccc-cccc-4ccc-8ccc-cccccccccccc';
beforeAll(async()=>{await admin.query(`create schema "${schema}"`);const isolated=new URL(url!);isolated.searchParams.set('options',`-c search_path=${schema}`);pool=new Pool({connectionString:isolated.toString()});repo=new PostgresAnswersRepository(isolated.toString());for(const name of ['0000_identity_and_feature_interests.sql','0001_couples.sql','0002_packs_catalog_progress.sql','0003_answers_matches.sql','0004_chat.sql','0014_daily_limit_local_reset.sql','0015_daily_limit_response_index.sql','0016_couple_streak_locality.sql']){const sql=await readFile(new URL(`../drizzle/${name}`,import.meta.url),'utf8');for(const s of sql.split('--> statement-breakpoint'))if(s.trim())await pool.query(s);}await pool.query('insert into couples(id,invite_code) values($1,$2),($3,$4)',[couple,'ANSWERS1',other,'ANSWERS2']);await pool.query("insert into profiles(id,couple_id,gender,max_intensity) values($1,$4,'female',5),($2,$4,'male',3),($3,$5,'male',5)",[u1,u2,outsider,couple,other]);await pool.query("insert into question_packs(id,name) values($1,'Core')",[pack]);await pool.query("insert into questions(id,pack_id,text,intensity,target_user_genders) values($1,$2,'Question',3,array['female']),($3,$2,'Concurrent',2,null),($4,$2,'Female first',2,array['female']),($5,$2,'Limit one',2,null),($6,$2,'Limit two',2,null),($7,$2,'Catch up',2,null)",[q,pack,concurrentQ,femaleOnlyQ,limitQ1,limitQ2,catchUpQ]);await pool.query('insert into couple_packs(couple_id,pack_id,enabled) values($1,$2,true)',[couple,pack]);});afterAll(async()=>{await repo.close();await pool.end();await admin.query(`drop schema "${schema}" cascade`);await admin.end();});
it('targets recommendation by gender and excludes completed responses',async()=>{expect((await repo.recommended(u1)).map(x=>x.id)).toContain(q);expect((await repo.recommended(u2)).map(x=>x.id)).not.toContain(q);await repo.submit(u1,{questionId:q,answer:'yes'});expect((await repo.recommended(u1)).map(x=>x.id)).not.toContain(q);});
it('rejects direct answers that bypass target-gender eligibility',async()=>{await expect(repo.submit(u2,{questionId:femaleOnlyQ,answer:'yes'})).rejects.toMatchObject({code:'question_not_eligible'});});
it('serializes concurrent partner submissions and still creates one match',async()=>{await Promise.all([repo.submit(u1,{questionId:concurrentQ,answer:'yes'}),repo.submit(u2,{questionId:concurrentQ,answer:'yes'})]);const counts=await pool.query('select count(*)::int count from matches where question_id=$1',[concurrentQ]);expect(counts.rows[0].count).toBe(1);});
it('is idempotent and creates one match only after both partners answer',async()=>{await repo.submit(u1,{questionId:q,answer:'yes'});expect((await repo.submit(u1,{questionId:q,answer:'maybe'})).match).toBeNull();const second=await repo.submit(u2,{questionId:q,answer:'yes'});expect(second.match?.match_type).toBe('yes_maybe');await repo.submit(u2,{questionId:q,answer:'yes'});const counts=await pool.query('select (select count(*) from responses where question_id=$1) responses,(select count(*) from matches where question_id=$1) matches',[q]);expect(counts.rows[0]).toMatchObject({responses:'2',matches:'1'});});
it('requires confirmation before destroying a match and isolates outsiders',async()=>{const pending=await repo.update(u1,{questionId:q,answer:'no',confirmDeleteMatch:false});expect(pending).toMatchObject({success:false,requires_confirmation:true,message_count:0});expect(await repo.update(u1,{questionId:q,answer:'no',confirmDeleteMatch:true})).toMatchObject({success:true,match_deleted:true});await expect(repo.archive(outsider,'88888888-8888-4888-8888-888888888888',true)).rejects.toMatchObject({code:'match_not_found'});});
it('records both partners once per day and increments on a consecutive day',async()=>{await repo.submit(u1,{questionId:q,answer:'yes'});await repo.submit(u2,{questionId:q,answer:'yes'});expect(await repo.streak(u1)).toMatchObject({current_streak:1,longest_streak:1,user1_answered_today:true,user2_answered_today:true});await pool.query("update couple_streaks set current_streak=1,longest_streak=1,last_active_date=current_date-1,last_completed_date=current_date-1,user1_answered_today=false,user2_answered_today=false where couple_id=$1",[couple]);await repo.submit(u1,{questionId:q,answer:'yes'});await repo.submit(u2,{questionId:q,answer:'yes'});expect(await repo.streak(u1)).toMatchObject({current_streak:2,longest_streak:2});});
it('serializes the daily limit across different questions',async()=>{await pool.query('delete from responses where user_id=$1',[u1]);await pool.query('update app_config set daily_response_limit=1');const results=await Promise.allSettled([repo.submit(u1,{questionId:q,answer:'yes'}),repo.submit(u1,{questionId:femaleOnlyQ,answer:'yes'})]);expect(results.filter(r=>r.status==='fulfilled')).toHaveLength(1);expect(results.filter(r=>r.status==='rejected')).toHaveLength(1);expect(await repo.dailyLimit(u1)).toMatchObject({responses_today:1,is_blocked:true});});
it('records both partners once per day and increments on a consecutive day',async()=>{await repo.submit(u1,{questionId:q,answer:'yes'});await repo.submit(u2,{questionId:q,answer:'yes'});expect(await repo.streak(u1)).toMatchObject({current_streak:1,longest_streak:1,you_answered_today:true,partner_answered_today:true});await pool.query("update couple_streaks set current_streak=1,longest_streak=1,last_active_date=current_date-1,last_completed_date=current_date-1,user1_answered_today=false,user2_answered_today=false where couple_id=$1",[couple]);await repo.submit(u1,{questionId:q,answer:'yes'});await repo.submit(u2,{questionId:q,answer:'yes'});expect(await repo.streak(u1)).toMatchObject({current_streak:2,longest_streak:2});});
it('answers the streak from the caller side rather than by stored position',async()=>{await pool.query("update profiles set name='Alex' where id=$1",[u2]);await pool.query("update profiles set name='Sam' where id=$1",[u1]);await pool.query('delete from responses where couple_id=$1',[couple]);await pool.query('delete from matches where couple_id=$1',[couple]);await pool.query("update couple_streaks set current_streak=0,last_active_date=null,last_completed_date=null,user1_answered_today=false,user2_answered_today=false where couple_id=$1",[couple]);
await repo.submit(u1,{questionId:q,answer:'yes'});
expect(await repo.streak(u1)).toMatchObject({you_answered_today:true,partner_answered_today:false,partner_name:'Alex',current_streak:0});
expect(await repo.streak(u2)).toMatchObject({you_answered_today:false,partner_answered_today:true,partner_name:'Sam',current_streak:0});});
describe('match pagination',()=>{
const paged=Array.from({length:5},(_,index)=>`d0000000-0000-4000-8000-00000000000${index}`);
const pagedQuestions=Array.from({length:5},(_,index)=>`f0000000-0000-4000-8000-00000000000${index}`);
const otherMatch='e0000000-0000-4000-8000-000000000000';
beforeAll(async()=>{
await pool.query('delete from match_archives');await pool.query('delete from messages');await pool.query('delete from matches');
// One question per match: a couple may only match a given question once.
for(const[index,id]of pagedQuestions.entries())await pool.query("insert into questions(id,pack_id,text,intensity) values($1,$2,$3,2)",[id,pack,`Paged ${index}`]);
// Distinct creation times so the ordering assertion is about the query, not tie-breaking.
for(const[index,id]of paged.entries())await pool.query("insert into matches(id,couple_id,question_id,match_type,created_at) values($1,$2,$3,'yes_yes',now()-($4::text||' minutes')::interval)",[id,couple,pagedQuestions[index],String(index)]);
// A match belonging to another couple must never appear, whatever the page.
await pool.query("insert into matches(id,couple_id,question_id,match_type) values($1,$2,$3,'yes_yes')",[otherMatch,other,q]);
// Two unread from the partner on the oldest match; one message of our own, which is never unread.
await pool.query("insert into messages(match_id,user_id,content) values($1,$2,'a'),($1,$2,'b'),($1,$3,'mine')",[paged[4],u2,u1]);
});
it('counts once on the first page, never again, and still reports hasMore',async()=>{
const first=await repo.matches(u1,0,2,false);
expect(first.totalCount).toBe(5);
expect(first.hasMore).toBe(true);
expect(first.matches).toHaveLength(2);
// Later pages skip the count entirely: the client already has it and paging
// is decided by hasMore, which comes from reading limit + 1 rows.
const second=await repo.matches(u1,1,2,false);
expect(second.totalCount).toBeNull();
expect(second.hasMore).toBe(true);
const last=await repo.matches(u1,2,2,false);
expect(last.totalCount).toBeNull();
expect(last.hasMore).toBe(false);
expect(last.matches).toHaveLength(1);
// An exactly-full final page must not claim another page exists.
expect(await repo.matches(u1,0,5,false)).toMatchObject({hasMore:false,totalCount:5});
});
it('keeps unread-first ordering, aggregates unread once, and isolates the other couple',async()=>{
const page=await repo.matches(u1,0,5,false);
// The unread match sorts ahead of newer ones; the rest stay newest-first.
expect(page.matches.map(item=>item.id)).toEqual([paged[4],paged[0],paged[1],paged[2],paged[3]]);
expect(page.matches[0]).toMatchObject({unreadCount:2});
// Our own message is not unread to us, and everything else is zero.
expect(page.matches.slice(1).every(item=>item.unreadCount===0)).toBe(true);
// Unread is per viewer: the partner's own two messages are not unread to them,
// but ours is, so the same thread reports a different count on each side.
expect((await repo.matches(u2,0,5,false)).matches.find(item=>item.id===paged[4])?.unreadCount).toBe(1);
// The other couple's match is invisible here, and their member sees only theirs.
expect(page.matches.some(item=>item.id===otherMatch)).toBe(false);
expect((await repo.matches(outsider,0,5,false)).matches.map(item=>item.id)).toEqual([otherMatch]);
});
it('pages the archived view separately and per user',async()=>{
await repo.archive(u1,paged[0],true);
const active=await repo.matches(u1,0,5,false);
expect(active.matches.some(item=>item.id===paged[0])).toBe(false);
expect(active.totalCount).toBe(4);
const archived=await repo.matches(u1,0,5,true);
expect(archived.matches.map(item=>item.id)).toEqual([paged[0]]);
expect(archived).toMatchObject({totalCount:1,hasMore:false});
// Archiving is per user, so the partner still sees it in their active list.
expect((await repo.matches(u2,0,5,false)).matches.some(item=>item.id===paged[0])).toBe(true);
await repo.archive(u1,paged[0],false);
});
});
it('counts the day in the couple shared zone and prefers the first reported member zone',async()=>{await pool.query('update profiles set timezone=$2 where id=$1',[u1,'Pacific/Auckland']);await pool.query('update profiles set timezone=$2 where id=$1',[u2,'America/Los_Angeles']);
await pool.query('delete from responses where couple_id=$1',[couple]);await pool.query('delete from matches where couple_id=$1',[couple]);
await repo.submit(u1,{questionId:q,answer:'yes'});
const expected=await pool.query<{day:string}>("select (now() at time zone 'Pacific/Auckland')::date::text as day");
expect(await repo.streak(u1)).toMatchObject({timezone:'Pacific/Auckland',last_active_date:expected.rows[0].day});
// The lower member id decides the zone, so an unreported zone there falls through to the partner.
await pool.query('update profiles set timezone=null where id=$1',[u1]);
expect((await repo.streak(u1))?.timezone).toBe('America/Los_Angeles');
await pool.query("update profiles set timezone='Not/AZone' where id=$1",[u1]);
expect((await repo.streak(u1))?.timezone).toBe('UTC');
await pool.query('update profiles set timezone=null where couple_id=$1',[couple]);});
it('reports a lapsed streak as broken before the next answer rewrites it',async()=>{await pool.query('update profiles set timezone=null where couple_id=$1',[couple]);await pool.query("update couple_streaks set current_streak=5,longest_streak=9,last_active_date=current_date-3,last_completed_date=current_date-3,user1_answered_today=true,user2_answered_today=true where couple_id=$1",[couple]);
expect(await repo.streak(u1)).toMatchObject({current_streak:0,longest_streak:9,you_answered_today:false,partner_answered_today:false});
// A streak completed yesterday is still alive and still shows its count.
await pool.query('update couple_streaks set last_completed_date=current_date-1,last_active_date=current_date-1 where couple_id=$1',[couple]);
expect(await repo.streak(u1)).toMatchObject({current_streak:5,you_answered_today:false});});
const resetLimit=async(limit:number,timezone:string|null=null)=>{await pool.query('delete from responses where couple_id=$1',[couple]);await pool.query('delete from matches where couple_id=$1',[couple]);await pool.query('update app_config set daily_response_limit=$1',[limit]);await pool.query('update profiles set timezone=$2 where id=$1',[u1,timezone]);};
it('serializes the daily limit across different questions',async()=>{await resetLimit(1);const results=await Promise.allSettled([repo.submit(u1,{questionId:limitQ1,answer:'yes'}),repo.submit(u1,{questionId:limitQ2,answer:'yes'})]);expect(results.filter(r=>r.status==='fulfilled')).toHaveLength(1);expect(results.filter(r=>r.status==='rejected')).toHaveLength(1);expect(await repo.dailyLimit(u1)).toMatchObject({responses_today:1,remaining:0,is_blocked:true});});
it('exempts catching up on a question the partner answered first',async()=>{await resetLimit(1);await repo.submit(u2,{questionId:catchUpQ,answer:'yes'});await repo.submit(u1,{questionId:limitQ1,answer:'yes'});expect(await repo.dailyLimit(u1)).toMatchObject({responses_today:1,is_blocked:true});await expect(repo.submit(u1,{questionId:catchUpQ,answer:'yes'})).resolves.toBeTruthy();expect(await repo.dailyLimit(u1)).toMatchObject({responses_today:1,is_blocked:true});await expect(repo.submit(u1,{questionId:limitQ2,answer:'yes'})).rejects.toMatchObject({code:'daily_limit'});});
it('reports the daily limit error with the shape the client renders',async()=>{await resetLimit(1);await repo.submit(u1,{questionId:limitQ1,answer:'yes'});await expect(repo.submit(u1,{questionId:limitQ2,answer:'yes'})).rejects.toMatchObject({code:'daily_limit',status:429,details:{daily_limit:1,responses_today:1,remaining:0}});});
it('buckets the window on the user local day and falls back to UTC',async()=>{await resetLimit(5,'Pacific/Auckland');const nz=await repo.dailyLimit(u1);const nzBoundary=await pool.query<{expected:string}>("select ((date_trunc('day',now() at time zone 'Pacific/Auckland')+interval '1 day') at time zone 'Pacific/Auckland') expected");expect(nz.reset_at).toBe(new Date(nzBoundary.rows[0].expected).toISOString());
const utcBoundary=await pool.query<{expected:string}>("select ((date_trunc('day',now() at time zone 'UTC')+interval '1 day') at time zone 'UTC') expected");
for(const zone of [null,'Not/AZone']){await pool.query('update profiles set timezone=$2 where id=$1',[u1,zone]);expect((await repo.dailyLimit(u1)).reset_at).toBe(new Date(utcBoundary.rows[0].expected).toISOString());}});
it('blocks pack browsing once the limit is reached',async()=>{await resetLimit(1);expect((await repo.recommended(u1,pack)).length).toBeGreaterThan(0);await repo.submit(u1,{questionId:limitQ1,answer:'yes'});expect(await repo.recommended(u1,pack)).toEqual([]);expect(await repo.recommended(u1)).toEqual([]);await resetLimit(0);});
});
+14 -2
View File
@@ -10,12 +10,24 @@ const questionId='22222222-2222-4222-8222-222222222222';
function repo():AnswersRepository{return {
recommended:vi.fn(async()=>[]),pending:vi.fn(async()=>[]),answerGap:vi.fn(async()=>({unanswered_by_partner:0,threshold:10,is_blocked:false})),
dailyLimit:vi.fn(async()=>({responses_today:0,limit_value:0,remaining:0,reset_at:null,is_blocked:false})),
submit:vi.fn(async()=>({response:{} as never,match:null})),update:vi.fn(async()=>({success:true})),responses:vi.fn(async()=>({responses:[],totalCount:0})),matches:vi.fn(async()=>({matches:[],totalCount:0})),markSeen:vi.fn(async()=>undefined),archive:vi.fn(async()=>undefined),streak:vi.fn(async()=>null),close:vi.fn(async()=>undefined)};}
submit:vi.fn(async()=>({response:{} as never,match:null})),update:vi.fn(async()=>({success:true})),responses:vi.fn(async()=>({responses:[],totalCount:0})),matches:vi.fn(async()=>({matches:[],totalCount:0,hasMore:false})),markSeen:vi.fn(async()=>undefined),archive:vi.fn(async()=>undefined),streak:vi.fn(async()=>null),close:vi.fn(async()=>undefined)};}
function app(r:AnswersRepository){const app=new Hono<{Variables:{identity:AuthIdentity}}>();app.use('/v1/*',async(c,n)=>{c.set('identity',identity);await n();});registerAnswerRoutes(app,r);return app;}
describe('answer routes',()=>{
it('takes ownership only from the bearer identity',async()=>{const r=repo();const a=app(r);await a.request('/v1/responses',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({question_id:questionId,answer:'yes',user_id:'attacker'})});expect(r.submit).not.toHaveBeenCalled();const ok=await a.request('/v1/responses',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({question_id:questionId,answer:'yes'})});expect(ok.status).toBe(200);expect(r.submit).toHaveBeenCalledWith(identity.id,{questionId,answer:'yes',responseData:undefined});});
it('maps confirmation and domain failures',async()=>{const r=repo();vi.mocked(r.update).mockResolvedValueOnce({success:false,requires_confirmation:true,message_count:2});const a=app(r);expect(await (await a.request(`/v1/responses/${questionId}`,{method:'PATCH',headers:{'content-type':'application/json'},body:'{"new_answer":"no"}'})).json()).toMatchObject({requires_confirmation:true,message_count:2});vi.mocked(r.submit).mockRejectedValueOnce(new AnswersError('daily_limit',429,'Daily response limit reached'));expect((await a.request('/v1/responses',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({question_id:questionId,answer:'yes'})})).status).toBe(429);});
it('maps confirmation and domain failures',async()=>{const r=repo();vi.mocked(r.update).mockResolvedValueOnce({success:false,requires_confirmation:true,message_count:2});const a=app(r);expect(await (await a.request(`/v1/responses/${questionId}`,{method:'PATCH',headers:{'content-type':'application/json'},body:'{"new_answer":"no"}'})).json()).toMatchObject({requires_confirmation:true,message_count:2});vi.mocked(r.submit).mockRejectedValueOnce(new AnswersError('daily_limit',429,'Daily response limit reached',{daily_limit:10,responses_today:10,remaining:0,reset_at:'2026-08-29T00:00:00.000Z'}));const blocked=await a.request('/v1/responses',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({question_id:questionId,answer:'yes'})});expect(blocked.status).toBe(429);
// The client renders the limit screen straight from this body, so its shape is a contract.
expect(await blocked.json()).toMatchObject({error:{code:'daily_limit'},details:{daily_limit:10,responses_today:10,remaining:0,reset_at:'2026-08-29T00:00:00.000Z'}});});
it('reports pagination from the repository, including a page with no total',async()=>{const r=repo();const a=app(r);
// Later pages skip the count entirely, so `hasMore` — not a client-side
// comparison against totalCount — is what decides whether to keep paging.
vi.mocked(r.matches).mockResolvedValueOnce({matches:[],totalCount:null,hasMore:true});
const later=await a.request('/v1/matches?page=2&limit=20');
expect(later.status).toBe(200);
expect(await later.json()).toMatchObject({totalCount:null,hasMore:true});
expect(r.matches).toHaveBeenCalledWith(identity.id,2,20,false);
await a.request('/v1/matches?page=0&limit=20&archived=true');
expect(r.matches).toHaveBeenLastCalledWith(identity.id,0,20,true);});
it('scopes match mutations to identity',async()=>{const r=repo();const a=app(r);await a.request('/v1/matches/seen',{method:'PATCH',headers:{'content-type':'application/json'},body:JSON.stringify({ids:[questionId]})});expect(r.markSeen).toHaveBeenCalledWith(identity.id,[questionId]);await a.request(`/v1/matches/${questionId}/archive`,{method:'PUT',headers:{'content-type':'application/json'},body:'{"archived":true}'});expect(r.archive).toHaveBeenCalledWith(identity.id,questionId,true);});
});
+27 -1
View File
@@ -28,7 +28,8 @@ describe.skipIf(!databaseUrl || !localDatabase)('API + PostgreSQL integration',
isolatedUrl.searchParams.set('options', `-c search_path=${schema}`);
pool = new Pool({ connectionString: isolatedUrl.toString() });
repository = new PostgresRepository(isolatedUrl.toString());
const migration = await readFile(new URL('../drizzle/0000_identity_and_feature_interests.sql', import.meta.url), 'utf8');
const migration = (await Promise.all(['0000_identity_and_feature_interests.sql','0014_daily_limit_local_reset.sql']
.map((name) => readFile(new URL(`../drizzle/${name}`, import.meta.url), 'utf8')))).join('\n--> statement-breakpoint\n');
for (const statement of migration.split('--> statement-breakpoint')) {
if (statement.trim()) await pool.query(statement);
}
@@ -74,6 +75,31 @@ describe.skipIf(!databaseUrl || !localDatabase)('API + PostgreSQL integration',
},
});
// Reading the profile again must not rewrite it. Every authenticated request the
// app makes starts here, so an unconditional upsert turned a read into a write
// on the busiest path in the API.
const stamps = async () => (await pool.query<{ auth_synced_at: Date | null; updated_at: Date }>(
'select auth_synced_at, updated_at from profiles where id = $1',
['33333333-3333-4333-8333-333333333333'],
)).rows[0];
const created = await stamps();
expect(created.auth_synced_at).not.toBeNull();
for (let attempt = 0; attempt < 3; attempt += 1) {
expect((await app.request('/v1/me', { headers })).status).toBe(200);
}
const unchanged = await stamps();
expect(unchanged.auth_synced_at?.getTime()).toBe(created.auth_synced_at?.getTime());
expect(unchanged.updated_at.getTime()).toBe(created.updated_at.getTime());
// A genuine identity change still writes through, so first use and a changed
// email are both recorded rather than skipped.
await pool.query('update profiles set email = $2 where id = $1', ['33333333-3333-4333-8333-333333333333', 'stale@sauci.test']);
expect((await app.request('/v1/me', { headers })).status).toBe(200);
const resynced = await stamps();
expect(resynced.auth_synced_at!.getTime()).toBeGreaterThan(created.auth_synced_at!.getTime());
expect(await (await app.request('/v1/me', { headers })).json()).toMatchObject({ profile: { email: 'integration@sauci.test' } });
expect((await app.request('/v1/me/feature-interests/better-chat', { method: 'PUT', headers })).status).toBe(200);
expect(await (await app.request('/v1/me/feature-interests/better-chat', { headers })).json())
.toEqual({ feature: 'better-chat', interested: true });
@@ -30,6 +30,99 @@ describe.skipIf(!databaseUrl || !local)('app-data repository + PostgreSQL', () =
await pool.query("insert into messages(id,match_id,user_id,media_path,media_type) values($1,$2,$3,'x.jpg','image')",[message,match,alice]);
});
afterAll(async()=>{await repo.close();await pool.end();await admin.query(`drop schema "${schema}" cascade`);await admin.end();});
it('summarises change markers per couple without leaking another couple state',async()=>{
// This one small read replaced a five-second refresh of profile, couple, matches,
// pending questions, packs and unread counts, so its markers have to move for
// exactly the changes those refreshes existed to notice.
const before=await repo.syncSummary(alice);
expect(before).toMatchObject({couple_id:couple,partner_id:bob,match_count:1,new_match_count:1,unread_total:0});
expect(before.profile_updated_at).not.toBeNull();
expect(Date.parse(before.server_time)).not.toBeNaN();
// The partner's own message is unread to them and not to us.
expect((await repo.syncSummary(bob)).unread_total).toBe(1);
// A partner profile edit moves the partner marker, which is what tells the
// client to re-read the couple rather than everything.
await pool.query("update profiles set name='Bobby', updated_at=now() where id=$1",[bob]);
const afterPartner=await repo.syncSummary(alice);
expect(Date.parse(afterPartner.partner_updated_at!)).toBeGreaterThan(Date.parse(before.partner_updated_at!));
expect(afterPartner.profile_updated_at).toBe(before.profile_updated_at);
// couple_packs carries no timestamp, so equal-and-opposite toggles have to be
// caught by a digest rather than a max(updated_at).
const emptyDigest=before.enabled_packs_fingerprint;
expect(emptyDigest).toEqual(expect.any(String));
await pool.query('insert into couple_packs(couple_id,pack_id,enabled) values($1,$2,true)',[couple,pack]);
const enabled=await repo.syncSummary(alice);
expect(enabled.enabled_packs_fingerprint).not.toBe(emptyDigest);
await pool.query('update couple_packs set enabled=false where couple_id=$1 and pack_id=$2',[couple,pack]);
expect((await repo.syncSummary(alice)).enabled_packs_fingerprint).toBe(emptyDigest);
// Toggling back must land on the same digest, so the client stops refetching.
await pool.query('update couple_packs set enabled=true where couple_id=$1 and pack_id=$2',[couple,pack]);
expect((await repo.syncSummary(alice)).enabled_packs_fingerprint).toBe(enabled.enabled_packs_fingerprint);
// An outsider's summary is about their own couple and never ours.
const other=await repo.syncSummary(outsider);
expect(other).toMatchObject({couple_id:otherCouple,partner_id:null,match_count:0,unread_total:0});
});
it('moves the match-state fingerprint on an in-place edit that the count and latest-at markers miss',async()=>{
// Editing an existing match's type/response summary (response editing) creates
// no new match and touches no created_at, so match_count/new_match_count/
// latest_match_at all hold steady; only the state fingerprint must move.
const before=await repo.syncSummary(alice);
await pool.query("update matches set match_type='yes_maybe', response_summary=$2 where id=$1",[match,JSON.stringify({edited:true})]);
const after=await repo.syncSummary(alice);
expect(after.match_count).toBe(before.match_count);
expect(after.new_match_count).toBe(before.new_match_count);
expect(after.latest_match_at).toBe(before.latest_match_at);
expect(after.match_state_fingerprint).not.toBe(before.match_state_fingerprint);
// Reverting lands back on the same digest, so the client stops refetching.
await pool.query("update matches set match_type='yes_yes', response_summary=null where id=$1",[match]);
expect((await repo.syncSummary(alice)).match_state_fingerprint).toBe(before.match_state_fingerprint);
});
it('moves the match-unread fingerprint when unread redistributes without changing the total',async()=>{
const secondMatch='cccccccc-cccc-4ccc-8ccc-cccccccccccc';
const secondQuestion='dddddddd-dddd-4ddd-8ddd-dddddddddddd';
const originalUnread='eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee';
const secondUnread='11111111-2222-4333-8444-555555555555';
await pool.query("insert into questions(id,pack_id,text) values($1,$2,'Question 2')",[secondQuestion,pack]);
await pool.query("insert into matches(id,couple_id,question_id,match_type) values($1,$2,$3,'yes_yes')",[secondMatch,couple,secondQuestion]);
// Alice's own message ('message', from beforeAll) is never unread to her, so
// give the original match a message from her partner to actually redistribute.
await pool.query("insert into messages(id,match_id,user_id,media_path,media_type) values($1,$2,$3,'y.jpg','image')",[originalUnread,match,bob]);
await pool.query("insert into messages(id,match_id,user_id,media_path,media_type) values($1,$2,$3,'y2.jpg','image')",[secondUnread,secondMatch,bob]);
const before=await repo.syncSummary(alice);
expect(before.unread_total).toBe(2);
// Read the original match's unread message while a new one lands in the
// second match: unread_total holds steady at 2, but which match owns it moves.
await pool.query('update messages set read_at=now() where id=$1',[originalUnread]);
const thirdMessage='ffffffff-ffff-4fff-8fff-ffffffffffff';
await pool.query("insert into messages(id,match_id,user_id,media_path,media_type) values($1,$2,$3,'z.jpg','image')",[thirdMessage,secondMatch,bob]);
const after=await repo.syncSummary(alice);
expect(after.unread_total).toBe(before.unread_total);
expect(after.match_unread_fingerprint).not.toBe(before.match_unread_fingerprint);
// Clean up so later assertions in this suite see the original single-match state.
await pool.query('delete from messages where id=any($1::uuid[])',[[originalUnread,secondUnread,thirdMessage]]);
await pool.query('delete from matches where id=$1',[secondMatch]);
await pool.query('delete from questions where id=$1',[secondQuestion]);
});
it('moves the streak marker from couple_streaks.updated_at',async()=>{
await pool.query('insert into couple_streaks(couple_id) values($1) on conflict do nothing',[couple]);
const before=await repo.syncSummary(alice);
expect(before.streak_updated_at).toEqual(expect.any(String));
await pool.query('update couple_streaks set current_streak=current_streak+1, updated_at=now() where couple_id=$1',[couple]);
const after=await repo.syncSummary(alice);
expect(Date.parse(after.streak_updated_at!)).toBeGreaterThan(Date.parse(before.streak_updated_at!));
});
it('returns only public catalog data and couple-owned match context',async()=>{
expect((await repo.packQuestions(alice,pack))[0]?.text).toBe('Question');
expect((await repo.matchContext(bob,match)).responses).toHaveLength(2);
+6
View File
@@ -16,6 +16,12 @@ function repository(): AppDataRepository {
markMediaViewed: vi.fn(async () => ({ media_viewed_at: 'now', media_expires_at: null })),
nudgeStatus: vi.fn(async () => ({ last_nudge_sent_at: null })),
sendNudge: vi.fn(async () => ({ success: true as const, notification_sent: false, reason: 'no_push_token', next_nudge_available_at: 'later' })),
syncSummary: vi.fn(async () => ({
server_time: 'now', couple_id: null, profile_updated_at: null, partner_id: null, partner_updated_at: null,
match_count: 0, new_match_count: 0, latest_match_at: null, pending_yours: 0, pending_theirs: 0,
unread_total: 0, enabled_packs_fingerprint: '',
match_state_fingerprint: '', match_unread_fingerprint: '', streak_updated_at: null,
})),
getLiveDraw: vi.fn(async () => ({ strokes: [], revision: 0, updated_at: null, updated_by: null })),
putLiveDraw: vi.fn(async () => ({ strokes: [], revision: 1, updated_at: 'now', updated_by: identity.id })),
close: vi.fn(async () => undefined),
+12
View File
@@ -5,6 +5,7 @@ import type {
ApiRepository,
MobileCompatibleProfile,
} from '../src/db/repository.js';
import { flushTelemetry, setTelemetrySinkForTests, type TelemetryRecord } from '../src/telemetry.js';
const alice: AuthIdentity = {
id: '11111111-1111-4111-8111-111111111111',
@@ -119,4 +120,15 @@ describe('Sauci API', () => {
});
expect(response.status).toBe(400);
});
it('logs only a route template and status class, never raw UUIDs or auth values', async () => {
const records: TelemetryRecord[] = []; const restore = setTelemetrySinkForTests((record) => records.push(record));
const app = createApp({ auth: verifier(), repository: new MemoryRepository() });
await app.request('/v1/me/11111111-1111-4111-8111-111111111111?token=private-token', { headers: { authorization: 'Bearer private-token' } });
flushTelemetry(); restore();
const serialized = JSON.stringify(records);
expect(records).toContainEqual(expect.objectContaining({ event: 'request', status_class: '4xx' }));
expect(serialized).not.toContain('11111111-1111-4111-8111-111111111111');
expect(serialized).not.toContain('private-token');
});
});
+99 -3
View File
@@ -48,7 +48,7 @@ describe.skipIf(!databaseUrl || !isLocal)('chat repository + PostgreSQL', () =>
it('sends, lists, counts and reads text messages for couple members only', async () => {
const sent = await repository.sendText(alice, match, 'Hello');
expect((await repository.listMessages(bob, match))[0]?.content).toBe('Hello');
expect((await repository.listMessages(bob, match)).messages[0]?.content).toBe('Hello');
expect(await repository.unreadCounts(bob)).toEqual({ total: 1, by_match: { [match]: 1 } });
expect((await repository.markDelivered(bob, sent.id)).delivered_at).not.toBeNull();
expect((await repository.markMatchRead(bob, match)).updated).toBe(1);
@@ -58,14 +58,110 @@ describe.skipIf(!databaseUrl || !isLocal)('chat repository + PostgreSQL', () =>
it('enforces per-user and author-only deletions plus reports', async () => {
const sent = await repository.sendText(alice, match, 'Moderate me');
await repository.deleteForSelf(bob, sent.id);
expect((await repository.listMessages(bob, match)).some(item => item.id === sent.id)).toBe(false);
expect((await repository.listMessages(alice, match)).some(item => item.id === sent.id)).toBe(true);
expect((await repository.listMessages(bob, match)).messages.some(item => item.id === sent.id)).toBe(false);
expect((await repository.listMessages(alice, match)).messages.some(item => item.id === sent.id)).toBe(true);
await expect(repository.deleteForEveryone(bob, sent.id)).rejects.toMatchObject({ code: 'not_message_author' });
expect((await repository.deleteForEveryone(alice, sent.id)).deleted_at).not.toBeNull();
await repository.report(bob, sent.id, 'harassment');
await expect(repository.report(bob, sent.id, 'spam')).rejects.toBeInstanceOf(ChatError);
});
it('answers a delta with only what changed, and the same rows a full read would give', async () => {
const before = await repository.listMessages(bob, match);
const cursor = before.server_time;
expect(before.complete).toBe(true);
// Nothing has happened since the cursor, so the delta carries no row the client
// has not already seen. The cursor is deliberately held a couple of seconds
// behind wall clock so a write racing the read is repeated rather than lost;
// that repetition is absorbed by merging on id, never by blanking the thread.
const known = new Set(before.messages.map(item => item.id));
const quiet = await repository.listMessages(bob, match, { since: cursor });
expect(quiet.complete).toBe(false);
expect(quiet.messages.every(item => known.has(item.id))).toBe(true);
expect(quiet.removed_ids?.every(id => !known.has(id))).toBe(true);
const fresh = await repository.sendText(alice, match, 'Delta arrival');
const delta = await repository.listMessages(bob, match, { since: cursor });
expect(delta.messages.map(item => item.id)).toContain(fresh.id);
// Media and moderation columns must survive the narrower query.
expect(delta.messages.find(item => item.id === fresh.id)).toMatchObject({
content: 'Delta arrival', media_path: null, media_type: null, media_expired: false, read_at: null,
});
// A status change with no new row still reaches the sender through the delta.
const afterSend = await repository.listMessages(alice, match);
await repository.markMatchRead(bob, match);
const readDelta = await repository.listMessages(alice, match, { since: afterSend.server_time });
expect(readDelta.messages.find(item => item.id === fresh.id)?.read_at).not.toBeNull();
// So does a deletion, which arrives as a removal rather than a silent gap.
const doomed = await repository.sendText(alice, match, 'Delete me');
const beforeDelete = await repository.listMessages(bob, match);
await repository.deleteForSelf(bob, doomed.id);
const removal = await repository.listMessages(bob, match, { since: beforeDelete.server_time });
expect(removal.removed_ids).toContain(doomed.id);
// Ordering is the contract the screen renders against: newest first, both ways.
const full = await repository.listMessages(bob, match);
const timestamps = full.messages.map(item => Date.parse(item.created_at));
expect([...timestamps].sort((a, b) => b - a)).toEqual(timestamps);
// Access control is unchanged on the delta path.
await expect(repository.listMessages(outsider, match, { since: cursor })).rejects.toMatchObject({ code: 'match_not_found' });
});
it('surfaces an expiring video to a chat that is already open, and only in the window it expires', async () => {
// Expiry is the one visible change no write announces: the deadline simply
// passes. Backdated so neither `created_at` nor `media_viewed_at` can be what
// puts the row in a delta — only the deadline crossing may.
const video = await repository.sendText(alice, match, 'Sent a video');
await pool.query(
`update messages
set media_path = 'media:expiring', media_type = 'video',
moderation_status = 'safe', encrypted_content = 'cipher', encryption_iv = 'iv',
created_at = now() - interval '1 hour',
media_viewed_at = now() - interval '1 hour',
media_expires_at = now() + interval '1 hour'
where id = $1`,
[video.id],
);
const before = await repository.listMessages(bob, match);
expect(before.messages.find(item => item.id === video.id)).toMatchObject({ media_expired: false });
// A deadline still in the future is not a change, so the row is not re-sent on
// every poll until it arrives.
const quiet = await repository.listMessages(bob, match, { since: before.server_time });
expect(quiet.messages.some(item => item.id === video.id)).toBe(false);
// The deadline now falls inside this poll's window.
await pool.query(`update messages set media_expires_at = now() - interval '1 second' where id = $1`, [video.id]);
const delta = await repository.listMessages(bob, match, { since: before.server_time });
const expired = delta.messages.find(item => item.id === video.id);
// Media, encryption and moderation columns still arrive whole on the delta path.
expect(expired).toMatchObject({
media_expired: true, media_type: 'video', media_path: 'media:expiring',
moderation_status: 'safe', encrypted_content: 'cipher', encryption_iv: 'iv',
});
expect(expired?.media_expires_at).not.toBeNull();
expect(expired?.media_viewed_at).not.toBeNull();
// A full read agrees, so leaving and re-entering the chat shows the same thing.
expect((await repository.listMessages(bob, match)).messages.find(item => item.id === video.id))
.toMatchObject({ media_expired: true });
});
it('folds the partner typing check into the message read when asked', async () => {
await repository.setTyping(alice, match, 4_000);
const withTyping = await repository.listMessages(bob, match, { includeTyping: true });
expect(withTyping.typing?.typing).toBe(true);
// Own typing is never reflected back, so the sender does not see themselves.
expect((await repository.listMessages(alice, match, { includeTyping: true })).typing?.typing).toBe(false);
// And it stays absent unless requested, so the default read costs no extra work.
expect((await repository.listMessages(bob, match)).typing).toBeUndefined();
});
it('stores typing state with a short expiry and exposes only the partner state', async () => {
await repository.setTyping(alice, match, 4_000);
expect((await repository.getPartnerTyping(bob, match)).typing).toBe(true);
+24 -1
View File
@@ -11,7 +11,8 @@ const message = { id: messageId, match_id: matchId, user_id: identity.id, conten
function setup() {
const repository: ChatRepository = {
listMessages: vi.fn(async () => [message]), sendText: vi.fn(async () => message),
listMessages: vi.fn(async () => ({ messages: [message], removed_ids: [], server_time: new Date().toISOString(), complete: true })),
sendText: vi.fn(async () => message),
unreadCounts: vi.fn(async () => ({ total: 1, by_match: { [matchId]: 1 } })),
markMatchRead: vi.fn(async () => ({ updated: 1, read_at: new Date().toISOString() })),
markDelivered: vi.fn(async () => message), deleteForSelf: vi.fn(async () => undefined),
@@ -43,6 +44,28 @@ describe('chat routes', () => {
expect(repository.sendText).not.toHaveBeenCalled();
});
it('passes a delta cursor and folded typing check through to the repository', async () => {
const { app, repository } = setup();
const since = '2026-08-28T10:00:00.000Z';
const delta = await app.request(`/v1/matches/${matchId}/messages?since=${encodeURIComponent(since)}&typing=true`);
expect(delta.status).toBe(200);
expect(repository.listMessages).toHaveBeenCalledWith(identity.id, matchId, { since, includeTyping: true });
// Omitting both keeps the full-snapshot contract the client had before.
const snapshot = await app.request(`/v1/matches/${matchId}/messages`);
expect(snapshot.status).toBe(200);
expect(await snapshot.json()).toMatchObject({ messages: [{ id: messageId }] });
expect(repository.listMessages).toHaveBeenLastCalledWith(identity.id, matchId, { includeTyping: false });
});
it('refuses a malformed cursor rather than silently reading everything', async () => {
const { app, repository } = setup();
const response = await app.request(`/v1/matches/${matchId}/messages?since=yesterday`);
expect(response.status).toBe(400);
expect(await response.json()).toMatchObject({ error: { code: 'invalid_since' } });
expect(repository.listMessages).not.toHaveBeenCalled();
});
it('supports unread, typing, deletion and reporting actions', async () => {
const { app, repository } = setup();
expect((await app.request('/v1/chat/unread')).status).toBe(200);
+206
View File
@@ -0,0 +1,206 @@
import { randomUUID } from 'node:crypto';
import { readdir, readFile } from 'node:fs/promises';
import { Pool } from 'pg';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { FREE_WEEKLY_SEND_LIMIT, PostgresDaresRepository } from '../src/domains/dares/repository.js';
const url = process.env.DATABASE_URL;
const local = url ? ['127.0.0.1', 'localhost', '::1'].includes(new URL(url).hostname) : false;
if (url && !local) throw new Error('Dare integration tests only permit localhost');
describe.skipIf(!url || !local)('dares + PostgreSQL', () => {
const admin = new Pool({ connectionString: url });
const schema = `dares_${randomUUID().replaceAll('-', '')}`;
let pool: Pool;
let repo: PostgresDaresRepository;
const sender = '11111111-1111-4111-8111-111111111111';
const recipient = '22222222-2222-4222-8222-222222222222';
const outsider = '33333333-3333-4333-8333-333333333333';
const couple = '44444444-4444-4444-8444-444444444444';
const otherCouple = '55555555-5555-4555-8555-555555555555';
const freePack = '66666666-6666-4666-8666-666666666666';
const premiumPack = '77777777-7777-4777-8777-777777777777';
const unreviewedPack = '88888888-8888-4888-8888-888888888888';
const freeDare = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
const premiumDare = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
const unreviewedDare = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc';
beforeAll(async () => {
await admin.query(`create schema "${schema}"`);
const isolated = new URL(url!);
isolated.searchParams.set('options', `-c search_path=${schema}`);
pool = new Pool({ connectionString: isolated.toString() });
repo = new PostgresDaresRepository(isolated.toString());
const dir = new URL('../drizzle/', import.meta.url);
const files = (await readdir(dir)).filter((name) => name.endsWith('.sql')).sort();
for (const name of files) {
const sql = await readFile(new URL(name, dir), 'utf8');
for (const statement of sql.split('--> statement-breakpoint')) {
if (statement.trim()) await pool.query(statement);
}
}
await pool.query('insert into couples(id,invite_code) values($1,$2),($3,$4)', [couple, 'DARESAA1', otherCouple, 'DARESBB2']);
await pool.query(
'insert into profiles(id,couple_id,is_premium,hide_nsfw,max_intensity) values($1,$4,false,false,5),($2,$4,false,false,5),($3,$5,false,false,5)',
[sender, recipient, outsider, couple, otherCouple],
);
await pool.query(
`insert into dare_packs(id,name,is_premium,content_status) values
($1,'Free Pack',false,'allowed'),($2,'Premium Pack',true,'allowed'),($3,'Unreviewed Pack',false,'unreviewed')`,
[freePack, premiumPack, unreviewedPack],
);
await pool.query(
`insert into dares(id,pack_id,text,intensity,content_status) values
($1,$4,'Free dare text',2,'allowed'),($2,$5,'Premium dare text',3,'allowed'),($3,$6,'Unreviewed dare text',1,'unreviewed')`,
[freeDare, premiumDare, unreviewedDare, freePack, premiumPack, unreviewedPack],
);
});
beforeEach(async () => {
await pool.query('delete from sent_dares');
await pool.query('delete from operations_outbox');
await pool.query('update profiles set is_premium=false');
});
afterAll(async () => {
await repo.close();
await pool.end();
await admin.query(`drop schema "${schema}" cascade`);
await admin.end();
});
it('hides unreviewed packs and dares from the catalogue', async () => {
const catalog = await repo.getCatalog(sender);
const ids = catalog.packs.map((pack) => pack.id);
expect(ids).toContain(freePack);
expect(ids).toContain(premiumPack);
expect(ids).not.toContain(unreviewedPack);
await expect(repo.listPackDares(sender, unreviewedPack)).rejects.toMatchObject({ code: 'pack_not_found' });
await expect(repo.send(sender, { dare_id: unreviewedDare })).rejects.toMatchObject({ code: 'dare_not_found' });
});
it('gates premium packs and custom dares, and unlocks when either partner pays', async () => {
await expect(repo.listPackDares(sender, premiumPack)).rejects.toMatchObject({ code: 'premium_required' });
await expect(repo.send(sender, { dare_id: premiumDare })).rejects.toMatchObject({ code: 'premium_required' });
await expect(repo.send(sender, { custom_dare_text: 'My own dare' })).rejects.toMatchObject({ code: 'premium_required' });
// Premium is couple-shared: the partner paying unlocks the sender.
await pool.query('update profiles set is_premium=true where id=$1', [recipient]);
expect((await repo.getCatalog(sender)).entitlement).toMatchObject({ is_premium: true, sends_remaining: null });
await expect(repo.listPackDares(sender, premiumPack)).resolves.toHaveLength(1);
const custom = await repo.send(sender, { custom_dare_text: ' My own dare ', custom_dare_intensity: 4 });
expect(custom).toMatchObject({ is_custom: true, text: 'My own dare', intensity: 4 });
});
it('enforces the free weekly send allowance without blocking the response side', async () => {
for (let index = 0; index < FREE_WEEKLY_SEND_LIMIT; index += 1) {
await repo.send(sender, { dare_id: freeDare });
}
expect((await repo.getCatalog(sender)).entitlement.sends_remaining).toBe(0);
await expect(repo.send(sender, { dare_id: freeDare })).rejects.toMatchObject({ code: 'send_limit_reached' });
// The quota is per-sender, so the recipient can still answer back.
const reply = await repo.send(recipient, { dare_id: freeDare });
expect(reply.direction).toBe('outgoing');
});
it('serializes concurrent sends so the free allowance cannot be raced', async () => {
const attempts = await Promise.allSettled(
Array.from({ length: FREE_WEEKLY_SEND_LIMIT + 3 }, () => repo.send(sender, { dare_id: freeDare })),
);
expect(attempts.filter((attempt) => attempt.status === 'fulfilled')).toHaveLength(FREE_WEEKLY_SEND_LIMIT);
const stored = await pool.query<{ count: string }>('select count(*) as count from sent_dares where sender_id=$1', [sender]);
expect(stored.rows[0]!.count).toBe(String(FREE_WEEKLY_SEND_LIMIT));
});
it('walks the full loop and records each transition exactly once', async () => {
const dare = await repo.send(sender, { dare_id: freeDare, duration_hours: 24, sender_notes: 'have fun' });
expect(dare).toMatchObject({ status: 'pending', direction: 'outgoing', text: 'Free dare text' });
expect(dare.expires_at).not.toBeNull();
expect((await repo.respond(recipient, dare.id, 'accept')).status).toBe('active');
expect((await repo.submit(recipient, dare.id)).status).toBe('submitted');
expect((await repo.complete(sender, dare.id)).status).toBe('completed');
const events = await pool.query<{ dedupe_key: string; recipient_id: string }>(
"select dedupe_key, recipient_id from operations_outbox where kind='expo' order by created_at",
);
expect(events.rows.map((row) => row.dedupe_key.split(':')[2])).toEqual(['sent', 'active', 'submitted', 'completed']);
expect(events.rows.map((row) => row.recipient_id)).toEqual([recipient, sender, sender, recipient]);
});
it('never puts dare text in a notification payload', async () => {
const dare = await repo.send(sender, { dare_id: freeDare });
await repo.respond(recipient, dare.id, 'accept');
const leaked = await pool.query<{ count: string }>(
"select count(*) as count from operations_outbox where payload::text ilike '%Free dare text%'",
);
expect(leaked.rows[0]!.count).toBe('0');
});
it('respects the dares notification preference', async () => {
await pool.query('insert into notification_preferences(user_id,dares_enabled) values($1,false)', [recipient]);
const dare = await repo.send(sender, { dare_id: freeDare });
const events = await pool.query('select 1 from operations_outbox where recipient_id=$1', [recipient]);
expect(events.rowCount).toBe(0);
// The sender has not opted out, so their side still fires.
await repo.respond(recipient, dare.id, 'decline');
const senderEvents = await pool.query('select 1 from operations_outbox where recipient_id=$1', [sender]);
expect(senderEvents.rowCount).toBe(1);
await pool.query('delete from notification_preferences where user_id=$1', [recipient]);
});
it('enforces actor and state on every transition', async () => {
const dare = await repo.send(sender, { dare_id: freeDare });
await expect(repo.respond(sender, dare.id, 'accept')).rejects.toMatchObject({ code: 'not_permitted' });
await expect(repo.submit(recipient, dare.id)).rejects.toMatchObject({ code: 'invalid_transition' });
await expect(repo.complete(sender, dare.id)).rejects.toMatchObject({ code: 'invalid_transition' });
await expect(repo.respond(outsider, dare.id, 'accept')).rejects.toMatchObject({ code: 'dare_not_found' });
await repo.respond(recipient, dare.id, 'accept');
await expect(repo.cancel(recipient, dare.id)).rejects.toMatchObject({ code: 'not_permitted' });
expect((await repo.cancel(sender, dare.id)).status).toBe('cancelled');
await expect(repo.complete(sender, dare.id)).rejects.toMatchObject({ code: 'invalid_transition' });
});
it('keeps history intact when the source dare is deleted', async () => {
const dare = await repo.send(sender, { dare_id: freeDare });
await pool.query('delete from dares where id=$1', [freeDare]);
const history = await repo.listDares(sender, 'active');
expect(history.find((row) => row.id === dare.id)).toMatchObject({ dare_id: null, text: 'Free dare text', intensity: 2 });
await pool.query(
"insert into dares(id,pack_id,text,intensity,content_status) values($1,$2,'Free dare text',2,'allowed')",
[freeDare, freePack],
);
});
it('scopes listing and stats to the couple', async () => {
const dare = await repo.send(sender, { dare_id: freeDare });
await repo.respond(recipient, dare.id, 'accept');
await repo.submit(recipient, dare.id);
await repo.complete(sender, dare.id);
expect(await repo.listDares(outsider, 'active')).toEqual([]);
expect(await repo.stats(outsider)).toMatchObject({ sent: 0, completed_together: 0 });
expect(await repo.stats(sender)).toMatchObject({ sent: 1, received: 0, completed_together: 1, completed_by_partner: 1 });
expect(await repo.stats(recipient)).toMatchObject({ sent: 0, received: 1, completed_together: 1, completed_by_me: 1 });
const active = await repo.listDares(sender, 'active');
const history = await repo.listDares(sender, 'history');
expect(active).toEqual([]);
expect(history).toHaveLength(1);
expect(history[0]!.direction).toBe('outgoing');
expect((await repo.listDares(recipient, 'history'))[0]!.direction).toBe('incoming');
});
it('rejects durations outside the offered presets', async () => {
await expect(repo.send(sender, { dare_id: freeDare, duration_hours: 5 })).rejects.toMatchObject({ code: 'invalid_duration' });
});
it('refuses to send without a partner', async () => {
await expect(repo.send(outsider, { dare_id: freeDare })).rejects.toMatchObject({ code: 'no_couple' });
});
});
+117
View File
@@ -0,0 +1,117 @@
import { Hono } from 'hono';
import { describe, expect, it, vi } from 'vitest';
import type { AuthIdentity } from '../src/auth.js';
import { DaresError, type DaresRepository, type SentDare } from '../src/domains/dares/repository.js';
import { registerDareRoutes } from '../src/domains/dares/routes.js';
const identity: AuthIdentity = { id: '11111111-1111-4111-8111-111111111111', email: null, name: null, avatarUrl: null };
const partner = '22222222-2222-4222-8222-222222222222';
const dareId = '33333333-3333-4333-8333-333333333333';
const packId = '44444444-4444-4444-8444-444444444444';
const catalogueDareId = '55555555-5555-4555-8555-555555555555';
const dare: SentDare = {
id: dareId, couple_id: '66666666-6666-4666-8666-666666666666', dare_id: catalogueDareId,
text: 'A dare', intensity: 2, is_custom: false, sender_id: identity.id, recipient_id: partner,
direction: 'outgoing', status: 'pending', sender_notes: null,
sent_at: new Date().toISOString(), accepted_at: null, submitted_at: null, completed_at: null, expires_at: null,
};
function setup(overrides: Partial<DaresRepository> = {}) {
const repository: DaresRepository = {
getCatalog: vi.fn(async () => ({
entitlement: { is_premium: false, can_send_custom: false, weekly_send_limit: 3, sends_remaining: 3 },
packs: [],
})),
listPackDares: vi.fn(async () => []),
listDares: vi.fn(async () => [dare]),
send: vi.fn(async () => dare),
respond: vi.fn(async () => ({ ...dare, status: 'active' as const })),
submit: vi.fn(async () => ({ ...dare, status: 'submitted' as const })),
complete: vi.fn(async () => ({ ...dare, status: 'completed' as const })),
cancel: vi.fn(async () => ({ ...dare, status: 'cancelled' as const })),
stats: vi.fn(async () => ({ sent: 1, received: 0, completed_together: 0, active: 1, completed_by_me: 0, completed_by_partner: 0 })),
close: vi.fn(async () => undefined),
...overrides,
};
const app = new Hono<{ Variables: { identity: AuthIdentity } }>();
app.use('/v1/*', async (c, next) => { c.set('identity', identity); await next(); });
registerDareRoutes(app, repository);
return { app, repository };
}
function post(app: Hono<{ Variables: { identity: AuthIdentity } }>, path: string, body?: unknown) {
return app.request(path, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
}
describe('dare routes', () => {
it('derives the actor from the bearer identity, never the body', async () => {
const { app, repository } = setup();
const response = await post(app, '/v1/dares', {
dare_id: catalogueDareId,
sender_notes: ' be brave ',
});
expect(response.status).toBe(201);
expect(repository.send).toHaveBeenCalledWith(identity.id, expect.objectContaining({ dare_id: catalogueDareId }));
});
it('rejects client-supplied ownership fields', async () => {
const { app } = setup();
const response = await post(app, '/v1/dares', { dare_id: catalogueDareId, sender_id: partner });
expect(response.status).toBe(400);
});
it('requires exactly one of dare_id or custom text', async () => {
const { app } = setup();
expect((await post(app, '/v1/dares', {})).status).toBe(400);
expect((await post(app, '/v1/dares', { dare_id: catalogueDareId, custom_dare_text: 'both' })).status).toBe(400);
expect((await post(app, '/v1/dares', { custom_dare_text: 'just text' })).status).toBe(201);
});
it('accepts only preset durations', async () => {
const { app } = setup();
expect((await post(app, '/v1/dares', { dare_id: catalogueDareId, duration_hours: 24 })).status).toBe(201);
expect((await post(app, '/v1/dares', { dare_id: catalogueDareId, duration_hours: 5 })).status).toBe(400);
});
it('validates identifiers and filters', async () => {
const { app } = setup();
expect((await app.request('/v1/dares?filter=nonsense')).status).toBe(400);
expect((await app.request('/v1/dares/packs/not-a-uuid/dares')).status).toBe(400);
expect((await post(app, '/v1/dares/not-a-uuid/submit')).status).toBe(400);
expect((await post(app, `/v1/dares/${dareId}/respond`, { action: 'maybe' })).status).toBe(400);
});
it('routes each transition to its repository method', async () => {
const { app, repository } = setup();
await post(app, `/v1/dares/${dareId}/respond`, { action: 'decline' });
expect(repository.respond).toHaveBeenCalledWith(identity.id, dareId, 'decline');
await post(app, `/v1/dares/${dareId}/submit`);
expect(repository.submit).toHaveBeenCalledWith(identity.id, dareId);
await post(app, `/v1/dares/${dareId}/complete`);
expect(repository.complete).toHaveBeenCalledWith(identity.id, dareId);
await post(app, `/v1/dares/${dareId}/cancel`);
expect(repository.cancel).toHaveBeenCalledWith(identity.id, dareId);
});
it('surfaces domain errors with their status codes', async () => {
const { app } = setup({
send: vi.fn(async () => { throw new DaresError('premium_required', 'Custom dares require premium', 402); }),
submit: vi.fn(async () => { throw new DaresError('invalid_transition', 'A pending dare cannot change to submitted', 409); }),
});
const paywalled = await post(app, '/v1/dares', { custom_dare_text: 'mine' });
expect(paywalled.status).toBe(402);
expect(await paywalled.json()).toMatchObject({ error: { code: 'premium_required' } });
expect((await post(app, `/v1/dares/${dareId}/submit`)).status).toBe(409);
});
it('exposes the duration presets the send sheet offers', async () => {
const { app } = setup();
const response = await app.request('/v1/dares/durations');
expect(await response.json()).toEqual({ presets: [1, 6, 12, 24, 72, 168] });
});
});
+49
View File
@@ -0,0 +1,49 @@
import { Pool } from 'pg';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
DEFAULT_POOL_MAX,
closeResolvedPool,
createDatabasePool,
resolvePool,
} from '../src/db/pool.js';
const url = 'postgres://user:pass@127.0.0.1:5432/sauci_test';
afterEach(() => vi.restoreAllMocks());
describe('database pool', () => {
it('bounds the shared pool and only forwards timeouts that were configured', () => {
// A process-wide pool is the whole point of the change: an unbounded default
// multiplied by one pool per repository is what the self-hosted database had
// to absorb before.
const bounded = createDatabasePool(url, { max: 4, idleTimeoutMillis: 1_000, connectionTimeoutMillis: 2_000 });
expect(bounded.options.max).toBe(4);
expect(bounded.options.idleTimeoutMillis).toBe(1_000);
expect(bounded.options.connectionTimeoutMillis).toBe(2_000);
const defaults = createDatabasePool(url);
expect(defaults.options.max).toBe(DEFAULT_POOL_MAX);
return Promise.all([bounded.end(), defaults.end()]);
});
it('owns a pool it built from a URL and never ends one it was handed', async () => {
// Repositories in the API process share one pool, so closing any single
// repository must not take the connections out from under the others. Tests
// still construct from a URL and must be able to close what they created.
const owned = resolvePool(url);
expect(owned.owned).toBe(true);
const ownedEnd = vi.spyOn(owned.pool, 'end');
await closeResolvedPool(owned.pool, owned.owned);
expect(ownedEnd).toHaveBeenCalledTimes(1);
const shared = new Pool({ connectionString: url });
const borrowed = resolvePool(shared);
expect(borrowed.owned).toBe(false);
expect(borrowed.pool).toBe(shared);
const sharedEnd = vi.spyOn(shared, 'end');
await closeResolvedPool(borrowed.pool, borrowed.owned);
expect(sharedEnd).not.toHaveBeenCalled();
await shared.end();
});
});
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { requireLocalApi, requireLoopback } from '../src/load/loopback.js';
describe('representative load fixture boundary', () => {
it('accepts only loopback API and PostgreSQL URLs', () => {
expect(requireLoopback('http://127.0.0.1:3003', 'API').hostname).toBe('127.0.0.1');
expect(requireLoopback('postgresql://sauci:test@localhost:5432/sauci', 'DATABASE').hostname).toBe('localhost');
expect(() => requireLoopback('https://api.sauci.example', 'API')).toThrow('must resolve to loopback');
expect(() => requireLoopback('postgresql://sauci:test@db.internal:5432/sauci', 'DATABASE')).toThrow('must resolve to loopback');
});
it('allows only the controlled local HTTP API port', () => {
expect(requireLocalApi('http://127.0.0.1:3003').port).toBe('3003');
expect(() => requireLocalApi('https://127.0.0.1:3003')).toThrow('must be http loopback');
expect(() => requireLocalApi('http://127.0.0.1:3004')).toThrow('must be http loopback');
});
});
+473 -35
View File
@@ -1,38 +1,476 @@
import { randomUUID } from 'node:crypto';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Pool } from 'pg';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { migrateDatabase, relationalParity } from '../src/migration/database.js';
import { migrateStorage, fileDigest } from '../src/migration/storage.js';
import type { MigrationCheckpoint } from '../src/migration/types.js';
import { randomUUID } from "node:crypto";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Pool } from "pg";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
migrateDatabase,
relationalParity,
} from "../src/migration/database.js";
import { migrateStorage, fileDigest } from "../src/migration/storage.js";
import type { MigrationCheckpoint } from "../src/migration/types.js";
const databaseUrl=process.env.DATABASE_URL;const local=databaseUrl?['127.0.0.1','localhost','::1'].includes(new URL(databaseUrl).hostname):false;if(databaseUrl&&!local)throw new Error('Migration integration tests only permit localhost');
describe.skipIf(!databaseUrl||!local)('cutover migration fixtures',()=>{
const admin=new Pool({connectionString:databaseUrl});const suffix=randomUUID().replaceAll('-','');const sourceSchema=`migration_source_${suffix}`;const targetSchema=`migration_target_${suffix}`;const storageSchema=`migration_storage_${suffix}`;let source:Pool;let target:Pool;let mediaRoot:string;
const couple='11111111-1111-4111-8111-111111111111',user='22222222-2222-4222-8222-222222222222',pack='33333333-3333-4333-8333-333333333333',question='44444444-4444-4444-8444-444444444444',response='55555555-5555-4555-8555-555555555555',object='66666666-6666-4666-8666-666666666666';
const checkpoint=():MigrationCheckpoint=>({version:1,runId:randomUUID(),mode:'initial',completedTables:[],storage:{},updatedAt:new Date().toISOString()});
beforeAll(async()=>{
await admin.query(`create schema "${sourceSchema}"; create schema "${targetSchema}"; create schema "${storageSchema}"`);
const sourceUrl=new URL(databaseUrl!);sourceUrl.searchParams.set('options',`-c search_path=${sourceSchema}`);source=new Pool({connectionString:sourceUrl.toString()});
const targetUrl=new URL(databaseUrl!);targetUrl.searchParams.set('options',`-c search_path=${targetSchema}`);target=new Pool({connectionString:targetUrl.toString()});
for(let index=0;index<=12;index++){const files=(await import('node:fs/promises')).readdir(new URL('../drizzle',import.meta.url));const name=(await files).find(file=>file.startsWith(`${String(index).padStart(4,'0')}_`));if(!name)continue;const sql=await readFile(new URL(`../drizzle/${name}`,import.meta.url),'utf8');for(const statement of sql.split('--> statement-breakpoint'))if(statement.trim())await target.query(statement);}
await source.query(`create table couples(id uuid primary key,invite_code text,created_at timestamptz);create table profiles(id uuid primary key,name text,email text,couple_id uuid,created_at timestamptz,updated_at timestamptz);create table question_packs(id uuid primary key,name text,is_public boolean,is_premium boolean,avg_intensity numeric(3,2),created_at timestamptz);create table questions(id uuid primary key,pack_id uuid,text text,intensity int,required_props text[],created_at timestamptz);create table responses(id uuid primary key,user_id uuid,question_id uuid,couple_id uuid,answer text,created_at timestamptz);`);
await source.query(`insert into couples values($1,'CUTOVER1','2024-01-01T00:00:00Z')`,[couple]);
await source.query(`insert into profiles values($1,'Alice','alice@test',$2,'2024-01-02T00:00:00Z','2024-01-03T00:00:00Z')`,[user,couple]);
await source.query('alter table profiles add column avatar_url text');
await source.query('update profiles set avatar_url=$2 where id=$1',[user,`https://source.test/storage/v1/object/public/avatars/${user}/avatar.png`]);
await source.query(`insert into question_packs values($1,'Core',true,false,3.36,'2024-01-04T00:00:00Z')`,[pack]);
await source.query(`insert into questions values($1,$2,'Question',2,array['blindfold'],'2024-01-05T00:00:00Z')`,[question,pack]);
await source.query(`insert into responses values($1,$2,$3,$4,'yes','2024-01-06T00:00:00Z')`,[response,user,question,couple]);
await admin.query(`create table "${storageSchema}".objects(id uuid primary key,bucket_id text,name text,owner_id uuid,metadata jsonb,created_at timestamptz,updated_at timestamptz)`);
await admin.query(`insert into "${storageSchema}".objects values($1,'avatars',$2,$3,$4,now(),now())`,[object,`${user}/avatar.png`,user,{size:4,mimetype:'image/png'}]);
mediaRoot=await mkdtemp(join(tmpdir(),'sauci-migration-'));
const databaseUrl = process.env.DATABASE_URL;
const local = databaseUrl
? ["127.0.0.1", "localhost", "::1"].includes(new URL(databaseUrl).hostname)
: false;
if (databaseUrl && !local)
throw new Error("Migration integration tests only permit localhost");
describe.skipIf(!databaseUrl || !local)("cutover migration fixtures", () => {
const admin = new Pool({ connectionString: databaseUrl });
const suffix = randomUUID().replaceAll("-", "");
const sourceSchema = `migration_source_${suffix}`;
const targetSchema = `migration_target_${suffix}`;
const storageSchema = `migration_storage_${suffix}`;
let source: Pool;
let target: Pool;
let mediaRoot: string;
const couple = "11111111-1111-4111-8111-111111111111",
user = "22222222-2222-4222-8222-222222222222",
pack = "33333333-3333-4333-8333-333333333333",
question = "44444444-4444-4444-8444-444444444444",
inverseQuestion = "45454545-4545-4545-8545-454545454545",
response = "55555555-5555-4555-8555-555555555555",
object = "66666666-6666-4666-8666-666666666666",
adminRow = "77777777-7777-4777-8777-777777777777",
category = "88888888-8888-4888-8888-888888888888",
review = "99999999-9999-4999-8999-999999999999",
audit = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
legacyUser = "a0000000-0000-0000-0000-000000000001",
sourceAppConfig = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
const checkpoint = (): MigrationCheckpoint => ({
version: 1,
runId: randomUUID(),
mode: "initial",
completedTables: [],
storage: {},
updatedAt: new Date().toISOString(),
});
beforeAll(async () => {
await admin.query(
`create schema "${sourceSchema}"; create schema "${targetSchema}"; create schema "${storageSchema}"`,
);
const sourceUrl = new URL(databaseUrl!);
sourceUrl.searchParams.set("options", `-c search_path=${sourceSchema}`);
source = new Pool({ connectionString: sourceUrl.toString() });
const targetUrl = new URL(databaseUrl!);
targetUrl.searchParams.set("options", `-c search_path=${targetSchema}`);
target = new Pool({ connectionString: targetUrl.toString() });
for (let index = 0; index <= 18; index++) {
const files = (await import("node:fs/promises")).readdir(
new URL("../drizzle", import.meta.url),
);
const name = (await files).find((file) =>
file.startsWith(`${String(index).padStart(4, "0")}_`),
);
if (!name) continue;
const sql = await readFile(
new URL(`../drizzle/${name}`, import.meta.url),
"utf8",
);
for (const statement of sql.split("--> statement-breakpoint"))
if (statement.trim()) await target.query(statement);
}
await source.query(
`create table couples(id uuid primary key,invite_code text,created_at timestamptz);create table profiles(id uuid primary key,name text,email text,couple_id uuid,created_at timestamptz,updated_at timestamptz);create table admin_users(id uuid primary key,user_id uuid,role text,created_at timestamptz,created_by uuid,updated_at timestamptz,permissions jsonb);create table categories(id uuid primary key,name text,is_public boolean,sort_order int,content_status text,content_review_reason text,content_reviewed_at timestamptz,content_reviewed_by uuid,created_at timestamptz);create table question_packs(id uuid primary key,name text,is_public boolean,is_premium boolean,avg_intensity numeric(3,2),content_status text,content_review_reason text,content_reviewed_at timestamptz,content_reviewed_by uuid,created_at timestamptz);create table questions(id uuid primary key,pack_id uuid,text text,intensity int,required_props text[],inverse_of uuid,content_status text,content_review_reason text,content_reviewed_at timestamptz,content_reviewed_by uuid,created_at timestamptz);create table content_reviews(id uuid primary key,entity_type text,entity_id uuid,previous_status text,new_status text,reason text,changed_by uuid,created_at timestamptz);create table responses(id uuid primary key,user_id uuid,question_id uuid,couple_id uuid,answer text,created_at timestamptz);create table feature_interests(id uuid primary key,user_id uuid,feature_name text,created_at timestamptz);create table live_draw_sessions(id uuid primary key,couple_id uuid,strokes jsonb,updated_at timestamptz,created_at timestamptz);create table audit_logs(id uuid primary key,table_name text,record_id uuid,action text,old_values jsonb,new_values jsonb,changed_fields text[],admin_user_id uuid,admin_role text,created_at timestamptz,admin_username text);create table app_config(id uuid primary key,answer_gap_threshold int,daily_response_limit int,updated_at timestamptz);`,
);
await source.query(
`insert into couples values($1,'e5d3012e','2024-01-01T00:00:00Z')`,
[couple],
);
await source.query(
`insert into app_config values($1,99,99,'2024-01-01T00:00:00Z')`,
[sourceAppConfig],
);
await source.query(
`insert into profiles values($1,'Alice','alice@test',$2,'2024-01-02T00:00:00Z','2024-01-03T00:00:00Z')`,
[user, couple],
);
await source.query(
`insert into admin_users values($1,$2,'super_admin',now(),null,now(),$3::jsonb)`,
[adminRow, user, JSON.stringify(["manage_packs"])],
);
await source.query("alter table profiles add column avatar_url text");
await source.query("update profiles set avatar_url=$2 where id=$1", [
user,
`https://source.test/storage/v1/object/public/avatars/${user}/avatar.png`,
]);
await source.query(
`insert into categories values($1,'Core',true,1,'allowed','Reviewed',null,null,'2024-01-03T00:00:00Z')`,
[category],
);
await source.query(
`insert into question_packs values($1,'Core',true,false,3.36,'allowed','Reviewed',null,null,'2024-01-04T00:00:00Z')`,
[pack],
);
await source.query(
`insert into questions values($1,$2,'Question',2,array['blindfold'],$3,'allowed','Reviewed',null,null,'2024-01-05T00:00:00Z'),($3,$2,'Inverse',2,array[]::text[],null,'allowed','Reviewed',null,null,'2024-01-05T00:00:01Z')`,
[question, pack, inverseQuestion],
);
await source.query(
`insert into content_reviews values($1,'categories',$2,'unreviewed','allowed','Reviewed',null,'2024-01-03T00:00:00Z')`,
[review, category],
);
await source.query(
`insert into responses values($1,$2,$3,$4,'yes','2024-01-06T00:00:00Z')`,
[response, user, question, couple],
);
await source.query(
`insert into feature_interests values(gen_random_uuid(),$1,'daily_dares','2024-01-07T00:00:00Z')`,
[user],
);
await source.query(
`insert into live_draw_sessions values(gen_random_uuid(),$1,'[]'::jsonb,'2024-01-08T00:00:00Z','2024-01-08T00:00:00Z')`,
[couple],
);
await source.query(
`insert into audit_logs values($1,'categories',$2,'UPDATE',null,$3::jsonb,array['content_status'],$4,'super_admin','2024-01-09T00:00:00Z','alice')`,
[audit, category, JSON.stringify({ content_status: "allowed" }), user],
);
await admin.query(
`create table "${storageSchema}".objects(id uuid primary key,bucket_id text,name text,owner_id uuid,metadata jsonb,created_at timestamptz,updated_at timestamptz)`,
);
await admin.query(
`insert into "${storageSchema}".objects values($1,'avatars',$2,$3,$4,now(),now())`,
[object, `${user}/avatar.png`, user, { size: 4, mimetype: "image/png" }],
);
mediaRoot = await mkdtemp(join(tmpdir(), "sauci-migration-"));
});
afterAll(async () => {
await Promise.all([source?.end(), target?.end()]);
await admin.query(
`drop schema if exists "${storageSchema}" cascade;drop schema if exists "${sourceSchema}" cascade;drop schema if exists "${targetSchema}" cascade`,
);
await admin.end();
if (mediaRoot) await rm(mediaRoot, { recursive: true, force: true });
});
it("preserves IDs, schema mappings, catalogue reviews, timestamps, side-effect suppression, and FK parity", async () => {
const state = checkpoint();
const result = await migrateDatabase(source, target, {
dryRun: false,
finalSync: false,
prune: false,
checkpoint: state,
});
expect(result.find((row) => row.table === "responses")).toMatchObject({
sourceRows: 1,
targetRows: 1,
status: "equal",
});
expect(result.find((row) => row.table === "app_config")).toMatchObject({
sourceRows: 1,
targetRows: 1,
importedRows: 0,
status: "equal",
});
expect(
(await target.query("select id from app_config")).rows[0].id,
).not.toBe(sourceAppConfig);
const row = await target.query("select id,created_at from responses");
expect(row.rows[0].id).toBe(response);
expect(new Date(row.rows[0].created_at).toISOString()).toBe(
"2024-01-06T00:00:00.000Z",
);
expect(
(
await target.query("select invite_code from couples where id=$1", [
couple,
])
).rows[0].invite_code,
).toBe("E5D3012E");
expect(
(
await target.query(
"select avg_intensity,content_status from question_packs where id=$1",
[pack],
)
).rows[0],
).toMatchObject({ avg_intensity: "3.36", content_status: "allowed" });
expect(
(
await target.query(
"select required_props,inverse_of,content_status from questions where id=$1",
[question],
)
).rows[0],
).toMatchObject({
required_props: ["blindfold"],
inverse_of: inverseQuestion,
content_status: "allowed",
});
expect(
(
await target.query(
"select feature from feature_interests where user_id=$1",
[user],
)
).rows[0].feature,
).toBe("daily_dares");
expect(
(
await target.query(
"select updated_by from live_draw_sessions where couple_id=$1",
[couple],
)
).rows[0].updated_by,
).toBe(user);
expect(
(
await target.query(
"select actor_user_id,admin_user_id,changed_fields,admin_role,admin_username from audit_logs where id=$1",
[audit],
)
).rows[0],
).toMatchObject({
actor_user_id: user,
admin_user_id: adminRow,
changed_fields: ["content_status"],
admin_role: "super_admin",
admin_username: "alice",
});
expect(
(
await target.query(
"select count(*)::int count from content_reviews where id=$1",
[review],
)
).rows[0].count,
).toBe(1);
expect(
(await target.query("select count(*)::int count from operations_outbox"))
.rows[0].count,
).toBe(0);
expect(await relationalParity(target)).toEqual({
profiles_without_couple: 0,
responses_with_missing_owner: 0,
matches_with_missing_relation: 0,
messages_with_missing_relation: 0,
couple_mismatch_responses: 0,
});
});
it("keeps a newer live profile during an initial merge", async () => {
const liveCouple = "cccccccc-cccc-4ccc-8ccc-cccccccccccc";
await target.query(
"insert into couples(id,invite_code,created_at) values($1,'LIVE0001',now())",
[liveCouple],
);
await target.query(
"update profiles set name='Live Alice',couple_id=$2,updated_at='2026-08-28T00:00:00Z' where id=$1",
[user, liveCouple],
);
await migrateDatabase(source, target, {
dryRun: false,
finalSync: false,
prune: false,
checkpoint: checkpoint(),
});
expect(
(
await target.query(
"select name,couple_id,updated_at from profiles where id=$1",
[user],
)
).rows[0],
).toMatchObject({ name: "Live Alice", couple_id: couple });
});
it("dry-run reads and reports without changing the target", async () => {
await source.query(
"update profiles set name='Changed at source' where id=$1",
[user],
);
await migrateDatabase(source, target, {
dryRun: true,
finalSync: false,
prune: false,
checkpoint: checkpoint(),
});
expect(
(await target.query("select name from profiles where id=$1", [user]))
.rows[0].name,
).toBe("Live Alice");
await source.query("update profiles set name='Alice' where id=$1", [user]);
});
it("is resumable and a stopped-source prune restores exact row parity", async () => {
const state = checkpoint();
state.completedTables = ["couples"];
await target.query(
`insert into couples(id,invite_code,created_at) values('77777777-7777-4777-8777-777777777777','STALE001',now())`,
);
const resumed = await migrateDatabase(source, target, {
dryRun: false,
finalSync: false,
prune: false,
checkpoint: state,
});
expect(resumed.find((row) => row.table === "couples")?.note).toContain(
"resumed from checkpoint",
);
const final = await migrateDatabase(source, target, {
dryRun: false,
finalSync: true,
prune: true,
checkpoint: checkpoint(),
});
expect(final.find((row) => row.table === "couples")).toMatchObject({
sourceRows: 1,
targetRows: 1,
prunedRows: 2,
status: "equal",
});
});
it("copies storage atomically, quarantines unattributable legacy objects, rewrites references, verifies checkpoints, and prunes source-deleted files", async () => {
const legacyAvatar = randomUUID(),
orphanChat = randomUUID(),
unmatchedChat = randomUUID(),
feedbackObject = randomUUID(),
feedbackId = randomUUID();
await source.query(
"insert into profiles values($1,$2,$3,$4,now(),now(),null)",
[legacyUser, "Legacy", "legacy@test", couple],
);
await target.query(
"insert into profiles(id,name,email,couple_id,created_at,updated_at) values($1,$2,$3,$4,now(),now())",
[legacyUser, "Legacy", "legacy@test", couple],
);
await source.query(
"create table messages(user_id uuid,match_id uuid,media_path text);create table feedback(id uuid primary key,user_id uuid,screenshot_url text)",
);
await source.query("insert into feedback values($1,$2,$3)", [
feedbackId,
user,
`https://source.test/storage/v1/object/public/feedback-screenshots/${user}/shot.jpg`,
]);
await target.query(
"insert into feedback(id,user_id,type,title,description) values($1,$2,'bug','Screenshot','Details')",
[feedbackId, user],
);
await admin.query(
`insert into "${storageSchema}".objects values($1,'avatars',$2,$3,$4,now(),now()),($5,'chat-media',$6,null,$4,now(),now()),($7,'chat-media',$8,$9,$4,now(),now()),($10,'feedback-screenshots',$11,$9,$4,now(),now())`,
[
legacyAvatar,
`${legacyUser}/legacy.jpg`,
legacyUser,
{ size: 4, mimetype: "image/jpeg" },
orphanChat,
`${randomUUID()}/orphan.jpg`,
unmatchedChat,
`${randomUUID()}/chat.jpg`,
user,
feedbackObject,
`${user}/shot.jpg`,
],
);
const state = checkpoint();
const first = await migrateStorage(source, target, {
dryRun: false,
mediaRoot,
storageSchema,
checkpoint: state,
download: async () => new Uint8Array([1, 2, 3, 4]),
});
expect(first.failures).toEqual([]);
expect(first).toMatchObject({
sourceFiles: 5,
targetFiles: 5,
copiedFiles: 5,
quarantinedFiles: 1,
failedFiles: 0,
missingOnDisk: [],
});
const path = join(mediaRoot, "avatars", user, "avatar.png");
expect(await fileDigest(path)).toBe(
"9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a",
);
expect(
(
await target.query(
"select owner_id,kind,byte_size from media_objects where id=$1",
[object],
)
).rows[0],
).toMatchObject({ owner_id: user, kind: "avatar", byte_size: 4 });
expect(
(
await target.query("select owner_id from media_objects where id=$1", [
legacyAvatar,
])
).rows[0].owner_id,
).toBe(legacyUser);
expect(
(
await target.query("select match_id from media_objects where id=$1", [
unmatchedChat,
])
).rows[0].match_id,
).toBeNull();
expect(
(
await target.query(
"select reason from legacy_media_quarantine where id=$1",
[orphanChat],
)
).rowCount,
).toBe(1);
expect(
(
await target.query(
"select screenshot_media_id from feedback where id=$1",
[feedbackId],
)
).rows[0].screenshot_media_id,
).toBe(feedbackObject);
expect(
(
await target.query("select avatar_url from profiles where id=$1", [
user,
])
).rows[0].avatar_url,
).toBe(`media:${object}`);
const resumed = await migrateStorage(source, target, {
dryRun: false,
mediaRoot,
storageSchema,
checkpoint: state,
download: async () => {
throw new Error("must not redownload");
},
});
expect(resumed).toMatchObject({
copiedFiles: 0,
skippedFiles: 5,
failedFiles: 0,
});
const stale = "77777777-7777-4777-8777-777777777777";
const staleKey = `avatars/${user}/stale.png`;
await target.query(
"insert into media_objects(id,owner_id,couple_id,kind,storage_key,mime_type,byte_size) values($1,$2,$3,'avatar',$4,'image/png',1)",
[stale, user, couple, staleKey],
);
await writeFile(join(mediaRoot, staleKey), new Uint8Array([9]));
await admin.query(
`delete from "${storageSchema}".objects where id=$1`,
[orphanChat],
);
const pruned = await migrateStorage(source, target, {
dryRun: false,
prune: true,
mediaRoot,
storageSchema,
checkpoint: state,
download: async () => {
throw new Error("must not redownload");
},
});
expect(pruned).toMatchObject({
sourceFiles: 4,
targetFiles: 4,
prunedFiles: 2,
skippedFiles: 4,
failedFiles: 0,
});
expect(
(await target.query("select 1 from legacy_media_quarantine where id=$1", [orphanChat])).rowCount,
).toBe(0);
});
afterAll(async()=>{await Promise.all([source?.end(),target?.end()]);await admin.query(`drop schema if exists "${storageSchema}" cascade;drop schema if exists "${sourceSchema}" cascade;drop schema if exists "${targetSchema}" cascade`);await admin.end();if(mediaRoot)await rm(mediaRoot,{recursive:true,force:true});});
it('preserves IDs, precision, catalogue fields, timestamps, side-effect suppression, and FK parity',async()=>{const state=checkpoint();const result=await migrateDatabase(source,target,{dryRun:false,finalSync:false,prune:false,checkpoint:state});expect(result.find(row=>row.table==='responses')).toMatchObject({sourceRows:1,targetRows:1,status:'equal'});const row=await target.query('select id,created_at from responses');expect(row.rows[0].id).toBe(response);expect(new Date(row.rows[0].created_at).toISOString()).toBe('2024-01-06T00:00:00.000Z');expect((await target.query('select avg_intensity from question_packs where id=$1',[pack])).rows[0].avg_intensity).toBe('3.36');expect((await target.query('select required_props from questions where id=$1',[question])).rows[0].required_props).toEqual(['blindfold']);expect((await target.query('select count(*)::int count from operations_outbox')).rows[0].count).toBe(0);expect(await relationalParity(target)).toEqual({profiles_without_couple:0,responses_with_missing_owner:0,matches_with_missing_relation:0,messages_with_missing_relation:0,couple_mismatch_responses:0});});
it('dry-run reads and reports without changing the target',async()=>{await source.query("update profiles set name='Changed at source' where id=$1",[user]);await migrateDatabase(source,target,{dryRun:true,finalSync:false,prune:false,checkpoint:checkpoint()});expect((await target.query('select name from profiles where id=$1',[user])).rows[0].name).toBe('Alice');await source.query("update profiles set name='Alice' where id=$1",[user]);});
it('is resumable and a stopped-source prune restores exact row parity',async()=>{const state=checkpoint();state.completedTables=['couples'];await target.query(`insert into couples(id,invite_code,created_at) values('77777777-7777-4777-8777-777777777777','STALE001',now())`);const resumed=await migrateDatabase(source,target,{dryRun:false,finalSync:false,prune:false,checkpoint:state});expect(resumed.find(row=>row.table==='couples')?.note).toBe('resumed from checkpoint');const final=await migrateDatabase(source,target,{dryRun:false,finalSync:true,prune:true,checkpoint:checkpoint()});expect(final.find(row=>row.table==='couples')).toMatchObject({sourceRows:1,targetRows:1,prunedRows:1,status:'equal'});});
it('copies storage atomically, rewrites legacy references, verifies checkpoints, and prunes source-deleted files',async()=>{const state=checkpoint();const first=await migrateStorage(source,target,{dryRun:false,mediaRoot,storageSchema,checkpoint:state,download:async()=>new Uint8Array([1,2,3,4])});expect(first).toMatchObject({sourceFiles:1,targetFiles:1,copiedFiles:1,failedFiles:0,missingOnDisk:[]});const path=join(mediaRoot,'avatars',user,'avatar.png');expect(await fileDigest(path)).toBe('9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a');expect((await target.query('select owner_id,kind,byte_size from media_objects where id=$1',[object])).rows[0]).toMatchObject({owner_id:user,kind:'avatar',byte_size:4});expect((await target.query('select avatar_url from profiles where id=$1',[user])).rows[0].avatar_url).toBe(`media:${object}`);const resumed=await migrateStorage(source,target,{dryRun:false,mediaRoot,storageSchema,checkpoint:state,download:async()=>{throw new Error('must not redownload');}});expect(resumed).toMatchObject({copiedFiles:0,skippedFiles:1,failedFiles:0});const stale='77777777-7777-4777-8777-777777777777';const staleKey=`avatars/${user}/stale.png`;await target.query("insert into media_objects(id,owner_id,couple_id,kind,storage_key,mime_type,byte_size) values($1,$2,$3,'avatar',$4,'image/png',1)",[stale,user,couple,staleKey]);await writeFile(join(mediaRoot,staleKey),new Uint8Array([9]));const pruned=await migrateStorage(source,target,{dryRun:false,prune:true,mediaRoot,storageSchema,checkpoint:state,download:async()=>{throw new Error('must not redownload');}});expect(pruned).toMatchObject({sourceFiles:1,targetFiles:1,prunedFiles:1,skippedFiles:1,failedFiles:0});});
});
+49 -2
View File
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { Pool } from 'pg';
import { afterAll,beforeAll,describe,expect,it } from 'vitest';
import { PostgresOperationsRepository } from '../src/domains/operations/repository.js';
import { OUTBOX_STATE_SQL, PostgresOperationsRepository } from '../src/domains/operations/repository.js';
const databaseUrl=process.env.DATABASE_URL;const local=databaseUrl?['127.0.0.1','localhost','::1'].includes(new URL(databaseUrl).hostname):false;
if(databaseUrl&&!local)throw new Error('Operations integration tests only permit a localhost DATABASE_URL');
@@ -12,7 +12,7 @@ describe.skipIf(!databaseUrl||!local)('PostgresOperationsRepository',()=>{
const pack='44444444-4444-4444-8444-444444444444',question='55555555-5555-4555-8555-555555555555';let pool:Pool;let repo:PostgresOperationsRepository;let repo2:PostgresOperationsRepository;
beforeAll(async()=>{
await admin.query(`create schema "${schema}"`);const url=new URL(databaseUrl!);url.searchParams.set('options',`-c search_path=${schema}`);pool=new Pool({connectionString:url.toString()});repo=new PostgresOperationsRepository(url.toString());repo2=new PostgresOperationsRepository(url.toString());
for(const name of ['0000_identity_and_feature_interests.sql','0001_couples.sql','0002_packs_catalog_progress.sql','0003_answers_matches.sql','0004_chat.sql','0005_profile_settings.sql','0006_media_storage.sql','0009_operations_workers.sql','0010_admin.sql']){
for(const name of ['0000_identity_and_feature_interests.sql','0001_couples.sql','0002_packs_catalog_progress.sql','0003_answers_matches.sql','0004_chat.sql','0005_profile_settings.sql','0006_media_storage.sql','0009_operations_workers.sql','0010_admin.sql','0014_daily_limit_local_reset.sql','0016_couple_streak_locality.sql','0017_streak_reminder_preference.sql']){
const sql=await readFile(new URL(`../drizzle/${name}`,import.meta.url),'utf8');for(const statement of sql.split('--> statement-breakpoint'))if(statement.trim())await pool.query(statement);
}
await pool.query('insert into couples(id,invite_code) values($1,$2)',[couple,'ABCD2345']);
@@ -68,9 +68,56 @@ describe.skipIf(!databaseUrl||!local)('PostgresOperationsRepository',()=>{
await repo.produce(new Date(Date.now()+10*60*1000),100);expect((await pool.query('select count(*)::int count from operations_outbox')).rows[0].count).toBe(before);
});
it('nudges only the partner who still owes an answer, in the couple evening',async()=>{
// Alice sorts below Bob, so Alice is user1. She has answered today; Bob has not.
await pool.query("update profiles set timezone='UTC' where couple_id=$1",[couple]);
const evening=new Date('2026-08-27T21:00:00Z');
await pool.query(`insert into couple_streaks(couple_id,current_streak,longest_streak,last_active_date,last_completed_date,user1_answered_today,user2_answered_today,streak_celebrated_at)
values($1,6,6,'2026-08-27','2026-08-26',true,false,6) on conflict(couple_id) do update set current_streak=6,longest_streak=6,
last_active_date='2026-08-27',last_completed_date='2026-08-26',user1_answered_today=true,user2_answered_today=false,streak_celebrated_at=6`,[couple]);
expect((await repo.produce(evening,100)).streakReminders).toBe(1);
const queued=await pool.query<{recipient_id:string;title:string;body:string}>(
`select recipient_id,payload->>'title' title,payload->>'body' body from operations_outbox where dedupe_key like 'streak_risk:%'`);
expect(queued.rows).toHaveLength(1);
expect(queued.rows[0]).toMatchObject({recipient_id:bob,title:'Alice answered today'});
expect(queued.rows[0].body).toContain('6 days in');
expect((await pool.query('select 1 from operations_outbox where dedupe_key=$1',[`streak_risk:${couple}:2026-08-27:${bob}`])).rowCount).toBe(1);
// A second tick inside the same couple-local evening must not queue a second nudge.
expect((await repo.produce(evening,100)).streakReminders).toBe(0);
});
it('addresses both partners when neither has answered and honours the preference',async()=>{
const next=new Date('2026-08-28T21:00:00Z');
await pool.query(`update couple_streaks set last_active_date='2026-08-27',last_completed_date='2026-08-27',
user1_answered_today=true,user2_answered_today=true where couple_id=$1`,[couple]);
expect((await repo.produce(next,100)).streakReminders).toBe(2);
const both=await pool.query<{title:string;body:string}>(
`select payload->>'title' title,payload->>'body' body from operations_outbox where dedupe_key like $1 order by recipient_id`,
[`streak_risk:${couple}:2026-08-28:%`]);
expect(both.rows.map(r=>r.title)).toEqual(['Your 6-day streak','Your 6-day streak']);
expect(both.rows[0].body).toContain('One question each');
await pool.query(`insert into notification_preferences(user_id,streak_reminders_enabled) values($1,false),($2,false)
on conflict(user_id) do update set streak_reminders_enabled=false`,[alice,bob]);
await pool.query(`update couple_streaks set last_active_date='2026-08-28',last_completed_date='2026-08-28' where couple_id=$1`,[couple]);
expect((await repo.produce(new Date('2026-08-29T21:00:00Z'),100)).streakReminders).toBe(0);
await pool.query('update notification_preferences set streak_reminders_enabled=true where user_id in ($1,$2)',[alice,bob]);
});
it('uses skip-locked claims so concurrent workers never receive the same item',async()=>{
await pool.query('insert into notification_preferences(user_id,messages_enabled) values($1,false) on conflict(user_id) do update set messages_enabled=false',[bob]);
const [a,b]=await Promise.all([repo.claim(100),repo2.claim(100)]);const ids=[...a,...b].map(item=>item.id);expect(new Set(ids).size).toBe(ids.length);expect(ids.length).toBeGreaterThan(0);
expect([...a,...b].find(item=>item.dedupeKey.startsWith('message:'))?.pushToken).toBeNull();
});
it('keeps due/unlocked outbox observation eligible for the existing due index',async()=>{
const client=await pool.connect();
try {
await client.query('begin'); await client.query('set local enable_seqscan=off');
const plan=await client.query<{"QUERY PLAN":string}>(`explain (costs off) ${OUTBOX_STATE_SQL}`);
expect(plan.rows.map(row=>row['QUERY PLAN']).join('\n')).toContain('operations_outbox_due_idx');
} finally { await client.query('rollback').catch(()=>undefined); client.release(); }
});
});
@@ -0,0 +1,29 @@
import { describe, expect, it, vi } from 'vitest';
import { OUTBOX_STATE_SQL, PostgresOperationsRepository } from '../src/domains/operations/repository.js';
describe('operations outbox observation', () => {
it('uses a transaction-scoped statement timeout and always releases on query failure', async () => {
const query = vi.fn(async (sql: string) => {
if (sql === OUTBOX_STATE_SQL) throw new Error('statement timeout');
return { rows: [], rowCount: 0 };
});
const release = vi.fn();
const pool = { connect: vi.fn(async () => ({ query, release })) };
const repository = new PostgresOperationsRepository(pool as never);
await expect(repository.outboxState()).rejects.toThrow('statement timeout');
expect(query).toHaveBeenNthCalledWith(1, 'begin');
expect(query).toHaveBeenNthCalledWith(2, "set local statement_timeout = '250ms'");
expect(query).toHaveBeenNthCalledWith(3, OUTBOX_STATE_SQL);
expect(query).toHaveBeenCalledWith('rollback');
expect(release).toHaveBeenCalledOnce();
});
it('evicts the client when rollback itself fails while preserving the original error', async () => {
const original = new Error('statement timeout'); const rollback = new Error('rollback failed');
const query = vi.fn(async (sql: string) => { if (sql === OUTBOX_STATE_SQL) throw original; if (sql === 'rollback') throw rollback; return { rows: [], rowCount: 0 }; });
const release = vi.fn(); const repository = new PostgresOperationsRepository({ connect: vi.fn(async () => ({ query, release })) } as never);
await expect(repository.outboxState()).rejects.toBe(original);
expect(release).toHaveBeenCalledTimes(1);
expect(release).toHaveBeenCalledWith(rollback);
});
});
+9 -2
View File
@@ -5,9 +5,9 @@ import { OperationsRunner } from '../src/domains/operations/runner.js';
import type { OperationItem } from '../src/domains/operations/types.js';
import { loadWorkerConfig } from '../src/workers/config.js';
const summary={releasedPacks:0,streakMilestones:0,digests:0,packChanges:0,weeklySummaries:0,unpairedReminders:0,catchupReminders:0};
const summary={releasedPacks:0,streakMilestones:0,digests:0,packChanges:0,weeklySummaries:0,unpairedReminders:0,catchupReminders:0,streakReminders:0,daresExpired:0};
function repository(items:OperationItem[]):OperationsRepository {
return {produce:vi.fn(async()=>summary),claim:vi.fn(async()=>items),complete:vi.fn(async()=>undefined),fail:vi.fn(async()=>undefined),
return {produce:vi.fn(async()=>summary),claim:vi.fn(async()=>items),complete:vi.fn(async()=>undefined),fail:vi.fn(async()=>undefined),outboxState:vi.fn(async()=>({due:items.length,oldestDueAgeSeconds:0})),
message:vi.fn(async()=>null),classifierConfig:vi.fn(async()=>null),classify:vi.fn(async()=>undefined),close:vi.fn(async()=>undefined)};
}
@@ -71,4 +71,11 @@ describe('worker configuration',()=>{
discordWebhookUrl:undefined,
});
});
it('uses a distinct bounded pool configuration for the worker process',()=>{
expect(loadWorkerConfig({
DATABASE_URL:'postgresql://sauci:test@localhost:5432/sauci',
DATABASE_POOL_MAX:'3', DATABASE_POOL_IDLE_TIMEOUT_MS:'1200', DATABASE_POOL_CONNECTION_TIMEOUT_MS:'3400',
}).databasePool).toEqual({max:3,idleTimeoutMillis:1200,connectionTimeoutMillis:3400});
});
});
@@ -20,9 +20,11 @@ const preferences = {
pack_changes_enabled: true,
new_packs_enabled: true,
streak_milestones_enabled: true,
streak_reminders_enabled: true,
weekly_summary_enabled: true,
unpaired_reminders_enabled: true,
catchup_reminders_enabled: true,
dares_enabled: true,
created_at: '2026-08-27T10:00:00.000Z',
updated_at: '2026-08-27T10:00:00.000Z',
};
+50
View File
@@ -0,0 +1,50 @@
import { Pool } from 'pg';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createDatabasePool } from '../src/db/pool.js';
import { flushTelemetry, recordPool, setTelemetrySinkForTests, type TelemetryRecord } from '../src/telemetry.js';
const url = 'postgres://user:pass@127.0.0.1:5432/sauci_test';
describe('privacy-safe telemetry', () => {
let restore: () => void = () => {};
afterEach(() => { restore(); vi.restoreAllMocks(); });
it('records query duration without SQL or bound values', async () => {
const records: TelemetryRecord[] = [];
restore = setTelemetrySinkForTests((record) => records.push(record));
vi.spyOn(Pool.prototype, 'query').mockResolvedValueOnce({ rows: [], rowCount: 0 } as never);
const pool = createDatabasePool(url, {}, 'api');
await pool.query('select secret from profiles where id=$1', ['private-user-id']);
flushTelemetry();
const serialized = JSON.stringify(records);
expect(records).toContainEqual(expect.objectContaining({ event: 'query', process: 'api', operation: 'pool_query', outcome: 'ok', count: 1 }));
expect(serialized).not.toContain('secret');
expect(serialized).not.toContain('private-user-id');
await pool.end();
});
it('emits only numeric pool state', async () => {
const records: TelemetryRecord[] = [];
restore = setTelemetrySinkForTests((record) => records.push(record));
const pool = new Pool({ connectionString: url });
recordPool('worker', pool);
expect(records).toEqual([{ event: 'pool', process: 'worker', total: 0, idle: 0, waiting: 0 }]);
await pool.end();
});
it('also instruments transaction-client queries', async () => {
const records: TelemetryRecord[] = [];
restore = setTelemetrySinkForTests((record) => records.push(record));
const client = { query: vi.fn(async () => ({ rows: [], rowCount: 0 })), release: vi.fn() };
vi.spyOn(Pool.prototype, 'connect').mockResolvedValueOnce(client as never);
const pool = createDatabasePool(url, {}, 'worker');
const borrowed = await pool.connect();
await borrowed.query('select message_body from messages where id=$1', ['private-message-id']);
flushTelemetry();
const serialized = JSON.stringify(records);
expect(records).toContainEqual(expect.objectContaining({ event: 'query', process: 'worker', outcome: 'ok', count: 1 }));
expect(serialized).not.toContain('message_body');
expect(serialized).not.toContain('private-message-id');
await pool.end();
});
});
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, it, vi } from 'vitest';
import { WorkerLifecycle } from '../src/workers/lifecycle.js';
function deferred<T>() { let resolve!: (value: T) => void; const promise = new Promise<T>((next) => { resolve = next; }); return { promise, resolve }; }
describe('WorkerLifecycle', () => {
it('reuses one shutdown promise, awaits the active tick, and closes once', async () => {
const tick = deferred<void>(); const closePool = vi.fn(async () => undefined);
const lifecycle = new WorkerLifecycle({ pollIntervalMs: 60_000, runTick: () => tick.promise, closePool, onTickFailure: vi.fn(), graceMs: 50 });
lifecycle.start(); const first = lifecycle.shutdown(); const second = lifecycle.shutdown();
expect(second).toBe(first); expect(closePool).not.toHaveBeenCalled();
tick.resolve(); await expect(first).resolves.toBe(0); expect(closePool).toHaveBeenCalledTimes(1);
});
it('returns nonzero when an active tick exceeds the one total deadline', async () => {
vi.useFakeTimers();
const lifecycle = new WorkerLifecycle({ pollIntervalMs: 60_000, runTick: () => new Promise<void>(() => undefined), closePool: vi.fn(async () => undefined), onTickFailure: vi.fn(), graceMs: 10 });
lifecycle.start(); const shutdown = lifecycle.shutdown(); await vi.advanceTimersByTimeAsync(10);
await expect(shutdown).resolves.toBe(1); vi.useRealTimers();
});
it('returns nonzero and closes once when pool shutdown rejects or hangs', async () => {
const rejected = new WorkerLifecycle({ pollIntervalMs: 60_000, runTick: async () => undefined, closePool: vi.fn(async () => { throw new Error('close failed'); }), onTickFailure: vi.fn(), graceMs: 50 });
rejected.start(); await expect(rejected.shutdown()).resolves.toBe(1);
vi.useFakeTimers(); const closePool = vi.fn(() => new Promise<void>(() => undefined));
const hung = new WorkerLifecycle({ pollIntervalMs: 60_000, runTick: async () => undefined, closePool, onTickFailure: vi.fn(), graceMs: 10 });
hung.start(); const shutdown = hung.shutdown(); await vi.advanceTimersByTimeAsync(10);
await expect(shutdown).resolves.toBe(1); expect(closePool).toHaveBeenCalledTimes(1); vi.useRealTimers();
});
});
+11
View File
@@ -5,5 +5,16 @@ export default defineConfig({
environment: 'node',
include: ['test/**/*.test.ts'],
exclude: ['test/**/*.integration.test.ts'],
coverage: {
provider: 'v8',
include: ['src/**/*.ts'],
reporter: ['text', 'json-summary', 'lcov'],
thresholds: {
statements: 30,
branches: 60,
functions: 40,
lines: 30,
},
},
},
});
+11
View File
@@ -5,5 +5,16 @@ export default defineConfig({
environment: 'node',
include: ['test/**/*.integration.test.ts'],
fileParallelism: false,
coverage: {
provider: 'v8',
include: ['src/**/*.ts'],
reporter: ['text', 'json-summary', 'lcov'],
thresholds: {
statements: 55,
branches: 65,
functions: 65,
lines: 55,
},
},
},
});
+17 -12
View File
@@ -14,6 +14,7 @@ react {
hermesCommand = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/sdks/hermesc/%OS-BIN%/hermesc"
codegenDir = new File(["node", "--print", "require.resolve('@react-native/codegen/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
enableBundleCompression = (findProperty('android.enableBundleCompression') ?: false).toBoolean()
// Use Expo CLI to bundle the app, this ensures the Metro config
// works correctly with Expo projects.
cliFile = new File(["node", "--print", "require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })"].execute(null, rootDir).text.trim())
@@ -63,9 +64,9 @@ react {
}
/**
* Set this to true to Run Proguard on Release builds to minify the Java bytecode.
* Set this to true in release builds to optimize the app using R8.
*/
def enableProguardInReleaseBuilds = (findProperty('android.enableProguardInReleaseBuilds') ?: false).toBoolean()
def enableMinifyInReleaseBuilds = (findProperty('android.enableMinifyInReleaseBuilds') ?: false).toBoolean()
/**
* The preferred build flavor of JavaScriptCore (JSC)
@@ -78,7 +79,7 @@ def enableProguardInReleaseBuilds = (findProperty('android.enableProguardInRelea
* give correct results when using with locales other than en-US. Note that
* this variant is about 6MiB larger per architecture than default.
*/
def jscFlavor = 'org.webkit:android-jsc:+'
def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+'
android {
ndkVersion rootProject.ext.ndkVersion
@@ -91,8 +92,9 @@ android {
applicationId 'com.sauci.app'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 45
versionName "1.0.5"
versionCode 48
versionName "1.0.6"
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
}
signingConfigs {
debug {
@@ -110,15 +112,18 @@ android {
// Caution! In production, you need to generate your own keystore file.
// see https://reactnative.dev/docs/signed-apk-android.
signingConfig signingConfigs.debug
shrinkResources (findProperty('android.enableShrinkResourcesInReleaseBuilds')?.toBoolean() ?: false)
minifyEnabled enableProguardInReleaseBuilds
def enableShrinkResources = findProperty('android.enableShrinkResourcesInReleaseBuilds') ?: 'false'
shrinkResources enableShrinkResources.toBoolean()
minifyEnabled enableMinifyInReleaseBuilds
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
crunchPngs (findProperty('android.enablePngCrunchInReleaseBuilds')?.toBoolean() ?: true)
def enablePngCrunchInRelease = findProperty('android.enablePngCrunchInReleaseBuilds') ?: 'true'
crunchPngs enablePngCrunchInRelease.toBoolean()
}
}
packagingOptions {
jniLibs {
useLegacyPackaging (findProperty('expo.useLegacyPackaging')?.toBoolean() ?: false)
def enableLegacyPackaging = findProperty('expo.useLegacyPackaging') ?: 'false'
useLegacyPackaging enableLegacyPackaging.toBoolean()
}
}
androidResources {
@@ -156,15 +161,15 @@ dependencies {
if (isGifEnabled) {
// For animated gif support
implementation("com.facebook.fresco:animated-gif:${reactAndroidLibs.versions.fresco.get()}")
implementation("com.facebook.fresco:animated-gif:${expoLibs.versions.fresco.get()}")
}
if (isWebpEnabled) {
// For webp support
implementation("com.facebook.fresco:webpsupport:${reactAndroidLibs.versions.fresco.get()}")
implementation("com.facebook.fresco:webpsupport:${expoLibs.versions.fresco.get()}")
if (isWebpAnimatedEnabled) {
// Animated webp support
implementation("com.facebook.fresco:animated-webp:${reactAndroidLibs.versions.fresco.get()}")
implementation("com.facebook.fresco:animated-webp:${expoLibs.versions.fresco.get()}")
}
}
@@ -2,6 +2,10 @@
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
@@ -17,7 +21,7 @@
<data android:scheme="https"/>
</intent>
</queries>
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true" android:requestLegacyExternalStorage="true" android:fullBackupContent="@xml/secure_store_backup_rules" android:dataExtractionRules="@xml/secure_store_data_extraction_rules">
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true" android:requestLegacyExternalStorage="true" android:fullBackupContent="@xml/secure_store_backup_rules" android:dataExtractionRules="@xml/secure_store_data_extraction_rules" android:enableOnBackInvokedCallback="false">
<meta-data android:name="com.google.firebase.messaging.default_notification_color" android:resource="@color/notification_icon_color"/>
<meta-data android:name="com.google.firebase.messaging.default_notification_icon" android:resource="@drawable/notification_icon"/>
<meta-data android:name="expo.modules.notifications.default_notification_color" android:resource="@color/notification_icon_color"/>
@@ -8,10 +8,10 @@ import com.facebook.react.ReactApplication
import com.facebook.react.ReactNativeHost
import com.facebook.react.ReactPackage
import com.facebook.react.ReactHost
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.load
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint
import com.facebook.react.defaults.DefaultReactNativeHost
import com.facebook.react.soloader.OpenSourceMergedSoMapping
import com.facebook.soloader.SoLoader
import com.facebook.react.common.ReleaseLevel
import expo.modules.ApplicationLifecycleDispatcher
import expo.modules.ReactNativeHostWrapper
@@ -42,11 +42,12 @@ class MainApplication : Application(), ReactApplication {
override fun onCreate() {
super.onCreate()
SoLoader.init(this, OpenSourceMergedSoMapping)
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
// If you opted-in for the New Architecture, we load the native entry point for this app.
load()
DefaultNewArchitectureEntryPoint.releaseLevel = try {
ReleaseLevel.valueOf(BuildConfig.REACT_NATIVE_RELEASE_LEVEL.uppercase())
} catch (e: IllegalArgumentException) {
ReleaseLevel.STABLE
}
loadReactNative(this)
ApplicationLifecycleDispatcher.onApplicationCreate(this)
}
@@ -1,5 +1,6 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
<item name="android:enforceNavigationBarContrast" tools:targetApi="29">true</item>
<item name="android:textColor">@android:color/black</item>
<item name="android:editTextStyle">@style/ResetEditText</item>
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
+2 -19
View File
@@ -1,15 +1,6 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
ext {
buildToolsVersion = findProperty('android.buildToolsVersion') ?: '35.0.0'
minSdkVersion = Integer.parseInt(findProperty('android.minSdkVersion') ?: '24')
compileSdkVersion = Integer.parseInt(findProperty('android.compileSdkVersion') ?: '35')
targetSdkVersion = Integer.parseInt(findProperty('android.targetSdkVersion') ?: '34')
kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.25'
ndkVersion = "26.1.10909125"
}
repositories {
google()
mavenCentral()
@@ -19,23 +10,15 @@ buildscript {
classpath 'com.google.gms:google-services:4.4.1'
classpath('com.android.tools.build:gradle')
classpath('com.facebook.react:react-native-gradle-plugin')
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
classpath('org.jetbrains.kotlin:kotlin-gradle-plugin')
}
}
apply plugin: "expo-root-project"
apply plugin: "com.facebook.react.rootproject"
allprojects {
repositories {
maven {
// All of React Native (JS, Obj-C sources, Android binaries) is installed from npm
url(new File(['node', '--print', "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim(), '../android'))
}
maven {
// Android JSC is installed from npm
url(new File(['node', '--print', "require.resolve('jsc-android/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim(), '../dist'))
}
google()
mavenCentral()
maven { url 'https://www.jitpack.io' }
+13 -3
View File
@@ -35,7 +35,7 @@ reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
# your application. You should enable this flag either if you want
# to write custom TurboModules/Fabric components OR use libraries that
# are providing them.
newArchEnabled=false
newArchEnabled=true
# Use this property to enable or disable the Hermes JS engine.
# If set to false, you will be using JSC instead.
@@ -55,6 +55,16 @@ EX_DEV_CLIENT_NETWORK_INSPECTOR=true
# Use legacy packaging to compress native libraries in the resulting APK.
expo.useLegacyPackaging=false
android.targetSdkVersion=35
android.kotlinVersion=1.9.25
android.targetSdkVersion=36
android.extraMavenRepos=[]
# Specifies whether the app is configured to use edge-to-edge via the app config or plugin
# WARNING: This property has been deprecated and will be removed in Expo SDK 55. Use `edgeToEdgeEnabled` or `react.edgeToEdgeEnabled` to determine whether the project is using edge-to-edge.
expo.edgeToEdgeEnabled=true
# Use this property to enable edge-to-edge display support.
# This allows your app to draw behind system bars for an immersive UI.
# Note: Only works with ReactActivity and should not be used with custom Activity.
edgeToEdgeEnabled=true
android.compileSdkVersion=36
android.buildToolsVersion=36.0.0
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
+25 -24
View File
@@ -1,38 +1,39 @@
pluginManagement {
includeBuild(new File(["node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().toString())
def reactNativeGradlePlugin = new File(
providers.exec {
workingDir(rootDir)
commandLine("node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })")
}.standardOutput.asText.get().trim()
).getParentFile().absolutePath
includeBuild(reactNativeGradlePlugin)
def expoPluginsPath = new File(
providers.exec {
workingDir(rootDir)
commandLine("node", "--print", "require.resolve('expo-modules-autolinking/package.json', { paths: [require.resolve('expo/package.json')] })")
}.standardOutput.asText.get().trim(),
"../android/expo-gradle-plugin"
).absolutePath
includeBuild(expoPluginsPath)
}
plugins {
id("com.facebook.react.settings")
id("expo-autolinking-settings")
}
plugins { id("com.facebook.react.settings") }
extensions.configure(com.facebook.react.ReactSettingsExtension) { ex ->
if (System.getenv('EXPO_USE_COMMUNITY_AUTOLINKING') == '1') {
ex.autolinkLibrariesFromCommand()
} else {
def command = [
'node',
'--no-warnings',
'--eval',
'require(require.resolve(\'expo-modules-autolinking\', { paths: [require.resolve(\'expo/package.json\')] }))(process.argv.slice(1))',
'react-native-config',
'--json',
'--platform',
'android'
].toList()
ex.autolinkLibrariesFromCommand(command)
ex.autolinkLibrariesFromCommand(expoAutolinking.rnConfigCommand)
}
}
rootProject.name = 'Sauci'
dependencyResolutionManagement {
versionCatalogs {
reactAndroidLibs {
from(files(new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim(), "../gradle/libs.versions.toml")))
}
}
}
apply from: new File(["node", "--print", "require.resolve('expo/package.json')"].execute(null, rootDir).text.trim(), "../scripts/autolinking.gradle");
useExpoModules()
expoAutolinking.useExpoModules()
expoAutolinking.useExpoVersionCatalog()
include ':app'
includeBuild(new File(["node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile())
includeBuild(expoAutolinking.reactNativeGradlePlugin)
+11 -7
View File
@@ -2,12 +2,12 @@
"expo": {
"name": "Sauci",
"slug": "sauci",
"version": "1.0.5",
"version": "1.0.6",
"orientation": "portrait",
"icon": "./assets/icon.png",
"scheme": "app.sauci",
"userInterfaceStyle": "automatic",
"newArchEnabled": false,
"newArchEnabled": true,
"splash": {
"image": "./assets/splash-icon.png",
"resizeMode": "contain",
@@ -16,12 +16,15 @@
"ios": {
"supportsTablet": false,
"bundleIdentifier": "com.sauci.app",
"config": {
"usesNonExemptEncryption": false
},
"entitlements": {
"com.apple.security.application-groups": [
"group.com.sauci.app"
]
},
"buildNumber": "42",
"buildNumber": "44",
"usesAppleSignIn": true,
"googleServicesFile": "./GoogleService-Info.plist",
"infoPlist": {
@@ -35,7 +38,7 @@
"NSPhotoLibraryAddUsageDescription": "Sauci saves photos and videos from your partner's messages to your photo library when you tap the save button.",
"NSMicrophoneUsageDescription": "Sauci uses your microphone to record audio when you capture videos to share privately with your partner."
},
"privacyManifest": {
"privacyManifests": {
"NSPrivacyTracking": false,
"NSPrivacyCollectedDataTypes": [
{
@@ -183,7 +186,7 @@
"backgroundColor": "#1a1a2e"
},
"package": "com.sauci.app",
"versionCode": 45,
"versionCode": 48,
"googleServicesFile": "./google-services.json",
"permissions": [
"RECEIVE_BOOT_COMPLETED",
@@ -205,8 +208,9 @@
"expo-build-properties",
{
"android": {
"targetSdkVersion": 35,
"kotlinVersion": "1.9.25",
"buildToolsVersion": "36.0.0",
"compileSdkVersion": 36,
"targetSdkVersion": 36,
"extraProguardRules": "-keep class com.facebook.hermes.unicode.** { *; }",
"useLegacyPackaging": false,
"manifestPlaceholders": {
Loaded 100 of 217 files, more files were not shown because too many files have changed in this diff. Show more