From 59c0e955096ce390def7769f67ce592bcc0fca36 Mon Sep 17 00:00:00 2001 From: Charles Hudson Date: Wed, 25 Feb 2026 17:52:01 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=98=20docs(SpecKit):=20Adding=20founda?= =?UTF-8?q?tional=20specs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [[NT-2553](https://contentful.atlassian.net/browse/NT-2553)] --- .specify/research/repository-research.md | 62 +- .specify/scripts/bash/check-prerequisites.sh | 166 ++++ .specify/scripts/bash/common.sh | 156 ++++ .specify/scripts/bash/create-new-feature.sh | 297 +++++++ .specify/scripts/bash/setup-plan.sh | 61 ++ .specify/scripts/bash/update-agent-context.sh | 810 ++++++++++++++++++ .specify/templates/agent-file-template.md | 28 + .specify/templates/checklist-template.md | 40 + .specify/templates/constitution-template.md | 74 ++ .specify/templates/plan-template.md | 108 +++ .specify/templates/spec-template.md | 118 +++ .specify/templates/tasks-template.md | 255 ++++++ implementations/react-native/pnpm-lock.yaml | 4 +- platforms/javascript/react-native/README.md | 8 +- .../react-native/src/builders/EventBuilder.ts | 69 -- .../javascript/react-native/src/index.ts | 21 +- .../javascript/web/src/Optimization.test.ts | 25 + platforms/javascript/web/src/Optimization.ts | 10 +- specs/001-shared-and-foundational/spec.md | 119 +++ specs/002-contentful-cda-schemas/spec.md | 126 +++ specs/003-experience-api-schemas/spec.md | 134 +++ specs/004-insights-api-schemas/spec.md | 107 +++ .../spec.md | 137 +++ specs/006-api-client-experience-api/spec.md | 139 +++ specs/007-api-client-insights-api/spec.md | 117 +++ specs/008-api-client-event-builder/spec.md | 137 +++ .../009-core-foundational-and-shared/spec.md | 147 ++++ .../spec.md | 146 ++++ .../spec.md | 158 ++++ .../spec.md | 157 ++++ .../spec.md | 149 ++++ specs/014-web-foundational-and-shared/spec.md | 148 ++++ specs/015-web-state-management/spec.md | 140 +++ specs/016-web-event-enrichment/spec.md | 129 +++ .../spec.md | 168 ++++ specs/018-web-preview-panel/spec.md | 154 ++++ .../spec.md | 151 ++++ .../020-react-native-state-management/spec.md | 156 ++++ .../021-react-native-event-enrichment/spec.md | 135 +++ .../spec.md | 141 +++ specs/023-react-native-hooks/spec.md | 140 +++ .../spec.md | 151 ++++ universal/core/README.md | 12 +- universal/core/src/CoreStateful.test.ts | 58 ++ universal/core/src/CoreStateful.ts | 22 +- universal/core/src/ProductBase.ts | 4 +- 46 files changed, 5670 insertions(+), 124 deletions(-) create mode 100755 .specify/scripts/bash/check-prerequisites.sh create mode 100755 .specify/scripts/bash/common.sh create mode 100755 .specify/scripts/bash/create-new-feature.sh create mode 100755 .specify/scripts/bash/setup-plan.sh create mode 100755 .specify/scripts/bash/update-agent-context.sh create mode 100644 .specify/templates/agent-file-template.md create mode 100644 .specify/templates/checklist-template.md create mode 100644 .specify/templates/constitution-template.md create mode 100644 .specify/templates/plan-template.md create mode 100644 .specify/templates/spec-template.md create mode 100644 .specify/templates/tasks-template.md delete mode 100644 platforms/javascript/react-native/src/builders/EventBuilder.ts create mode 100644 specs/001-shared-and-foundational/spec.md create mode 100644 specs/002-contentful-cda-schemas/spec.md create mode 100644 specs/003-experience-api-schemas/spec.md create mode 100644 specs/004-insights-api-schemas/spec.md create mode 100644 specs/005-api-client-foundational-and-shared/spec.md create mode 100644 specs/006-api-client-experience-api/spec.md create mode 100644 specs/007-api-client-insights-api/spec.md create mode 100644 specs/008-api-client-event-builder/spec.md create mode 100644 specs/009-core-foundational-and-shared/spec.md create mode 100644 specs/010-core-stateless-environment-support/spec.md create mode 100644 specs/011-core-stateful-environment-support/spec.md create mode 100644 specs/012-core-personalized-data-resolution/spec.md create mode 100644 specs/013-node-sdk-foundational-and-shared/spec.md create mode 100644 specs/014-web-foundational-and-shared/spec.md create mode 100644 specs/015-web-state-management/spec.md create mode 100644 specs/016-web-event-enrichment/spec.md create mode 100644 specs/017-web-automatic-component-view-tracking/spec.md create mode 100644 specs/018-web-preview-panel/spec.md create mode 100644 specs/019-react-native-foundational-and-shared/spec.md create mode 100644 specs/020-react-native-state-management/spec.md create mode 100644 specs/021-react-native-event-enrichment/spec.md create mode 100644 specs/022-react-native-contexts-and-providers/spec.md create mode 100644 specs/023-react-native-hooks/spec.md create mode 100644 specs/024-react-native-personalization-and-analytics-components/spec.md diff --git a/.specify/research/repository-research.md b/.specify/research/repository-research.md index 538192e8..94ace486 100644 --- a/.specify/research/repository-research.md +++ b/.specify/research/repository-research.md @@ -37,10 +37,10 @@ ### Size and Test Surface - Code files (`ts/tsx/js/jsx/mjs/cjs`) across `universal`, `platforms`, `lib`, `implementations`: - `328` -- Code LOC across same surface: `33,606` + `357` +- Code LOC across same surface: `37,084` - Unit test files in SDK and shared libraries (`universal`, `platforms`, `lib`): `35` -- Implementation test/e2e files (`implementations`): `16` +- Implementation test/e2e files (`implementations`): `20` ### Top-Level Responsibilities @@ -55,20 +55,21 @@ | Package | Path | Layer | Approx LOC | Test Files | | -------------------------------------------- | ---------------------------------------- | ------------------------ | ---------: | ---------: | -| `@contentful/optimization-api-schemas` | `universal/api-schemas` | Contracts | 2,280 | 0 | -| `@contentful/optimization-api-client` | `universal/api-client` | Transport client | 3,334 | 7 | -| `@contentful/optimization-core` | `universal/core` | Runtime core | 7,371 | 12 | -| `@contentful/optimization-node` | `platforms/javascript/node` | Platform adapter | 465 | 1 | -| `@contentful/optimization-web` | `platforms/javascript/web` | Platform adapter | 4,034 | 5 | -| `@contentful/optimization-web-preview-panel` | `platforms/javascript/web-preview-panel` | Preview tooling | 1,413 | 0 | -| `@contentful/optimization-react-native` | `platforms/javascript/react-native` | Platform adapter | 9,280 | 6 | -| `logger` | `lib/logger` | Internal utility | 462 | 2 | -| `mocks` | `lib/mocks` | Internal testing infra | 1,051 | 0 | -| `build-tools` | `lib/build-tools` | Internal build helpers | 293 | 2 | +| `@contentful/optimization-api-schemas` | `universal/api-schemas` | Contracts | 2,791 | 1 | +| `@contentful/optimization-api-client` | `universal/api-client` | Transport client | 3,406 | 7 | +| `@contentful/optimization-core` | `universal/core` | Runtime core | 7,205 | 11 | +| `@contentful/optimization-node` | `platforms/javascript/node` | Platform adapter | 505 | 1 | +| `@contentful/optimization-web` | `platforms/javascript/web` | Platform adapter | 4,346 | 5 | +| `@contentful/optimization-web-preview-panel` | `platforms/javascript/web-preview-panel` | Preview tooling | 1,863 | 0 | +| `@contentful/optimization-react-native` | `platforms/javascript/react-native` | Platform adapter | 9,683 | 6 | +| `logger` | `lib/logger` | Internal utility | 682 | 2 | +| `mocks` | `lib/mocks` | Internal testing infra | 1,190 | 0 | +| `build-tools` | `lib/build-tools` | Internal build helpers | 393 | 2 | | `@implementation/node-ssr-only` | `implementations/node-ssr-only` | Reference implementation | 389 | 2 | | `@implementation/node-ssr-web-vanilla` | `implementations/node-ssr-web-vanilla` | Reference implementation | 461 | 4 | | `@implementation/web-vanilla` | `implementations/web-vanilla` | Reference implementation | 282 | 3 | -| `@implementation/react-native` | `implementations/react-native` | Reference implementation | 2,491 | 7 | +| `@implementation/web-react` | `implementations/web-react` | Reference implementation | 1,264 | 4 | +| `@implementation/react-native` | `implementations/react-native` | Reference implementation | 2,624 | 7 | ### Internal Dependency Direction (Validated) @@ -107,7 +108,7 @@ graph LR A --> J ``` -- Local package graph is acyclic (`cycle: no` in validation script). +- Local package graph is acyclic (`cycle: no` from current manifest graph validation). - `pnpm-workspace.yaml` includes `lib/*`, `platforms/javascript/*`, `universal/*`; implementations are intentionally outside workspace and consume packed SDK tarballs via overrides. @@ -136,16 +137,17 @@ graph LR consent). - preview bridge method `registerPreviewPanel()` returns mutable `signals` and `signalFns`. -### 3) Consent, Blocking, and Duplication Semantics +### 3) Consent and Blocking Semantics - `ProductBase` defaults pre-consent allow-list to `['page', 'identify']`. +- Stateful products normalize `trackComponentView`/`trackFlagView` to `component` for allow-list + evaluation. - Guarding is method-level via stage-3 decorator `@guardedBy`: - synchronous predicate gating. - optional `onBlocked` hook. - blocked async methods return `Promise` to preserve call shape. - Blocked event payload is structured and emitted to signal/callback: - - `{ reason: 'consent'|'duplication', product, method, args }`. -- Duplication is handled via scoped `ValuePresence` detector for component/flag view methods. + - `{ reason: 'consent', product, method, args }`. ### 4) Stateful Queue and Flush Behavior @@ -315,7 +317,8 @@ graph LR - Main pipeline (`.github/workflows/main-pipeline.yaml`): - path-filtered change detection for build/unit/e2e workloads, - install/setup, license checks, format, build, typecheck, lint, package-matrix unit tests, - - per-implementation e2e jobs including dedicated RN Android emulator lane. + - per-implementation e2e jobs (`node-ssr-only`, `node-ssr-web-vanilla`, `web-vanilla`, + `web-react`) plus dedicated RN Android emulator lane. - Publish workflow (`publish-npm.yaml`): - release/manual dispatch, - version derivation from tag, @@ -331,6 +334,7 @@ graph LR - Node SSR only, - Node SSR + Web vanilla, - Web vanilla, + - Web React + Web SDK, - React Native (Detox Android lane in CI). - Constitution explicitly positions reference implementations as required verification gates for user-visible behavior changes. @@ -376,7 +380,7 @@ graph LR ### DEC-005: Guard behavior is decorator-based and synchronous - `status`: `accepted` -- `decision`: Use stage-3 `@guardedBy` wrappers for consent/duplication guard composition. +- `decision`: Use stage-3 `@guardedBy` wrappers for consent guard composition. - `rationale`: Consistent guard semantics without repeated inline guard logic. - `alternatives_considered`: inline method-level checks; middleware chains per product. - `consequences`: decorator support required in build pipeline. @@ -457,19 +461,20 @@ graph LR ### RSK-001: SpecKit template bootstrap is incomplete -- `severity`: `medium` -- `evidence`: constitution sync report flags missing `.specify/templates/*` artifacts and command - templates. +- `severity`: `low` +- `evidence`: `.specify/templates/commands/` artifacts referenced by constitution sync guidance are + still absent. - `impact`: plan/spec/task workflows cannot be fully constitution-enforced through local template - checks. -- `mitigation`: bootstrap the missing template set and wire compliance checks into contributor - workflow. + command checks. +- `mitigation`: add the missing command template set under `.specify/templates/commands/` and wire + compliance checks into contributor workflow. ### RSK-002: Contract and preview packages have thin direct unit-test coverage - `severity`: `medium` - `evidence`: - - `universal/api-schemas` has no unit test suite. + - `universal/api-schemas` has a unit suite, but coverage is narrow (validation utility-centric; + limited direct schema-shape edge tests). - `platforms/javascript/web-preview-panel` has no unit test suite (`test:unit` is TODO/no-op). - `impact`: schema regressions or preview override regressions may primarily surface via downstream integration tests. @@ -489,7 +494,8 @@ graph LR ## Appendix B: Quality and Delivery Snapshot - Main CI lanes: `setup`, `license-check`, `format`, `build`, `type-check`, `lint`, per-package unit - matrix, per-implementation e2e. + matrix, per-implementation e2e (`node-ssr-only`, `node-ssr-web-vanilla`, `web-vanilla`, + `web-react`, `react-native`). - RN Android e2e lane provisions emulator, mock server, Metro bundler, and runs Detox suites. - Publish lane bumps package versions from release tags and publishes built artifacts after build/pack steps. diff --git a/.specify/scripts/bash/check-prerequisites.sh b/.specify/scripts/bash/check-prerequisites.sh new file mode 100755 index 00000000..98e387c2 --- /dev/null +++ b/.specify/scripts/bash/check-prerequisites.sh @@ -0,0 +1,166 @@ +#!/usr/bin/env bash + +# Consolidated prerequisite checking script +# +# This script provides unified prerequisite checking for Spec-Driven Development workflow. +# It replaces the functionality previously spread across multiple scripts. +# +# Usage: ./check-prerequisites.sh [OPTIONS] +# +# OPTIONS: +# --json Output in JSON format +# --require-tasks Require tasks.md to exist (for implementation phase) +# --include-tasks Include tasks.md in AVAILABLE_DOCS list +# --paths-only Only output path variables (no validation) +# --help, -h Show help message +# +# OUTPUTS: +# JSON mode: {"FEATURE_DIR":"...", "AVAILABLE_DOCS":["..."]} +# Text mode: FEATURE_DIR:... \n AVAILABLE_DOCS: \n ✓/✗ file.md +# Paths only: REPO_ROOT: ... \n BRANCH: ... \n FEATURE_DIR: ... etc. + +set -e + +# Parse command line arguments +JSON_MODE=false +REQUIRE_TASKS=false +INCLUDE_TASKS=false +PATHS_ONLY=false + +for arg in "$@"; do + case "$arg" in + --json) + JSON_MODE=true + ;; + --require-tasks) + REQUIRE_TASKS=true + ;; + --include-tasks) + INCLUDE_TASKS=true + ;; + --paths-only) + PATHS_ONLY=true + ;; + --help|-h) + cat << 'EOF' +Usage: check-prerequisites.sh [OPTIONS] + +Consolidated prerequisite checking for Spec-Driven Development workflow. + +OPTIONS: + --json Output in JSON format + --require-tasks Require tasks.md to exist (for implementation phase) + --include-tasks Include tasks.md in AVAILABLE_DOCS list + --paths-only Only output path variables (no prerequisite validation) + --help, -h Show this help message + +EXAMPLES: + # Check task prerequisites (plan.md required) + ./check-prerequisites.sh --json + + # Check implementation prerequisites (plan.md + tasks.md required) + ./check-prerequisites.sh --json --require-tasks --include-tasks + + # Get feature paths only (no validation) + ./check-prerequisites.sh --paths-only + +EOF + exit 0 + ;; + *) + echo "ERROR: Unknown option '$arg'. Use --help for usage information." >&2 + exit 1 + ;; + esac +done + +# Source common functions +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" + +# Get feature paths and validate branch +eval $(get_feature_paths) +check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1 + +# If paths-only mode, output paths and exit (support JSON + paths-only combined) +if $PATHS_ONLY; then + if $JSON_MODE; then + # Minimal JSON paths payload (no validation performed) + printf '{"REPO_ROOT":"%s","BRANCH":"%s","FEATURE_DIR":"%s","FEATURE_SPEC":"%s","IMPL_PLAN":"%s","TASKS":"%s"}\n' \ + "$REPO_ROOT" "$CURRENT_BRANCH" "$FEATURE_DIR" "$FEATURE_SPEC" "$IMPL_PLAN" "$TASKS" + else + echo "REPO_ROOT: $REPO_ROOT" + echo "BRANCH: $CURRENT_BRANCH" + echo "FEATURE_DIR: $FEATURE_DIR" + echo "FEATURE_SPEC: $FEATURE_SPEC" + echo "IMPL_PLAN: $IMPL_PLAN" + echo "TASKS: $TASKS" + fi + exit 0 +fi + +# Validate required directories and files +if [[ ! -d "$FEATURE_DIR" ]]; then + echo "ERROR: Feature directory not found: $FEATURE_DIR" >&2 + echo "Run /speckit.specify first to create the feature structure." >&2 + exit 1 +fi + +if [[ ! -f "$IMPL_PLAN" ]]; then + echo "ERROR: plan.md not found in $FEATURE_DIR" >&2 + echo "Run /speckit.plan first to create the implementation plan." >&2 + exit 1 +fi + +# Check for tasks.md if required +if $REQUIRE_TASKS && [[ ! -f "$TASKS" ]]; then + echo "ERROR: tasks.md not found in $FEATURE_DIR" >&2 + echo "Run /speckit.tasks first to create the task list." >&2 + exit 1 +fi + +# Build list of available documents +docs=() + +# Always check these optional docs +[[ -f "$RESEARCH" ]] && docs+=("research.md") +[[ -f "$DATA_MODEL" ]] && docs+=("data-model.md") + +# Check contracts directory (only if it exists and has files) +if [[ -d "$CONTRACTS_DIR" ]] && [[ -n "$(ls -A "$CONTRACTS_DIR" 2>/dev/null)" ]]; then + docs+=("contracts/") +fi + +[[ -f "$QUICKSTART" ]] && docs+=("quickstart.md") + +# Include tasks.md if requested and it exists +if $INCLUDE_TASKS && [[ -f "$TASKS" ]]; then + docs+=("tasks.md") +fi + +# Output results +if $JSON_MODE; then + # Build JSON array of documents + if [[ ${#docs[@]} -eq 0 ]]; then + json_docs="[]" + else + json_docs=$(printf '"%s",' "${docs[@]}") + json_docs="[${json_docs%,}]" + fi + + printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s}\n' "$FEATURE_DIR" "$json_docs" +else + # Text output + echo "FEATURE_DIR:$FEATURE_DIR" + echo "AVAILABLE_DOCS:" + + # Show status of each potential document + check_file "$RESEARCH" "research.md" + check_file "$DATA_MODEL" "data-model.md" + check_dir "$CONTRACTS_DIR" "contracts/" + check_file "$QUICKSTART" "quickstart.md" + + if $INCLUDE_TASKS; then + check_file "$TASKS" "tasks.md" + fi +fi diff --git a/.specify/scripts/bash/common.sh b/.specify/scripts/bash/common.sh new file mode 100755 index 00000000..2c3165e4 --- /dev/null +++ b/.specify/scripts/bash/common.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +# Common functions and variables for all scripts + +# Get repository root, with fallback for non-git repositories +get_repo_root() { + if git rev-parse --show-toplevel >/dev/null 2>&1; then + git rev-parse --show-toplevel + else + # Fall back to script location for non-git repos + local script_dir="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + (cd "$script_dir/../../.." && pwd) + fi +} + +# Get current branch, with fallback for non-git repositories +get_current_branch() { + # First check if SPECIFY_FEATURE environment variable is set + if [[ -n "${SPECIFY_FEATURE:-}" ]]; then + echo "$SPECIFY_FEATURE" + return + fi + + # Then check git if available + if git rev-parse --abbrev-ref HEAD >/dev/null 2>&1; then + git rev-parse --abbrev-ref HEAD + return + fi + + # For non-git repos, try to find the latest feature directory + local repo_root=$(get_repo_root) + local specs_dir="$repo_root/specs" + + if [[ -d "$specs_dir" ]]; then + local latest_feature="" + local highest=0 + + for dir in "$specs_dir"/*; do + if [[ -d "$dir" ]]; then + local dirname=$(basename "$dir") + if [[ "$dirname" =~ ^([0-9]{3})- ]]; then + local number=${BASH_REMATCH[1]} + number=$((10#$number)) + if [[ "$number" -gt "$highest" ]]; then + highest=$number + latest_feature=$dirname + fi + fi + fi + done + + if [[ -n "$latest_feature" ]]; then + echo "$latest_feature" + return + fi + fi + + echo "main" # Final fallback +} + +# Check if we have git available +has_git() { + git rev-parse --show-toplevel >/dev/null 2>&1 +} + +check_feature_branch() { + local branch="$1" + local has_git_repo="$2" + + # For non-git repos, we can't enforce branch naming but still provide output + if [[ "$has_git_repo" != "true" ]]; then + echo "[specify] Warning: Git repository not detected; skipped branch validation" >&2 + return 0 + fi + + if [[ ! "$branch" =~ ^[0-9]{3}- ]]; then + echo "ERROR: Not on a feature branch. Current branch: $branch" >&2 + echo "Feature branches should be named like: 001-feature-name" >&2 + return 1 + fi + + return 0 +} + +get_feature_dir() { echo "$1/specs/$2"; } + +# Find feature directory by numeric prefix instead of exact branch match +# This allows multiple branches to work on the same spec (e.g., 004-fix-bug, 004-add-feature) +find_feature_dir_by_prefix() { + local repo_root="$1" + local branch_name="$2" + local specs_dir="$repo_root/specs" + + # Extract numeric prefix from branch (e.g., "004" from "004-whatever") + if [[ ! "$branch_name" =~ ^([0-9]{3})- ]]; then + # If branch doesn't have numeric prefix, fall back to exact match + echo "$specs_dir/$branch_name" + return + fi + + local prefix="${BASH_REMATCH[1]}" + + # Search for directories in specs/ that start with this prefix + local matches=() + if [[ -d "$specs_dir" ]]; then + for dir in "$specs_dir"/"$prefix"-*; do + if [[ -d "$dir" ]]; then + matches+=("$(basename "$dir")") + fi + done + fi + + # Handle results + if [[ ${#matches[@]} -eq 0 ]]; then + # No match found - return the branch name path (will fail later with clear error) + echo "$specs_dir/$branch_name" + elif [[ ${#matches[@]} -eq 1 ]]; then + # Exactly one match - perfect! + echo "$specs_dir/${matches[0]}" + else + # Multiple matches - this shouldn't happen with proper naming convention + echo "ERROR: Multiple spec directories found with prefix '$prefix': ${matches[*]}" >&2 + echo "Please ensure only one spec directory exists per numeric prefix." >&2 + echo "$specs_dir/$branch_name" # Return something to avoid breaking the script + fi +} + +get_feature_paths() { + local repo_root=$(get_repo_root) + local current_branch=$(get_current_branch) + local has_git_repo="false" + + if has_git; then + has_git_repo="true" + fi + + # Use prefix-based lookup to support multiple branches per spec + local feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch") + + cat </dev/null) ]] && echo " ✓ $2" || echo " ✗ $2"; } + diff --git a/.specify/scripts/bash/create-new-feature.sh b/.specify/scripts/bash/create-new-feature.sh new file mode 100755 index 00000000..c40cfd77 --- /dev/null +++ b/.specify/scripts/bash/create-new-feature.sh @@ -0,0 +1,297 @@ +#!/usr/bin/env bash + +set -e + +JSON_MODE=false +SHORT_NAME="" +BRANCH_NUMBER="" +ARGS=() +i=1 +while [ $i -le $# ]; do + arg="${!i}" + case "$arg" in + --json) + JSON_MODE=true + ;; + --short-name) + if [ $((i + 1)) -gt $# ]; then + echo 'Error: --short-name requires a value' >&2 + exit 1 + fi + i=$((i + 1)) + next_arg="${!i}" + # Check if the next argument is another option (starts with --) + if [[ "$next_arg" == --* ]]; then + echo 'Error: --short-name requires a value' >&2 + exit 1 + fi + SHORT_NAME="$next_arg" + ;; + --number) + if [ $((i + 1)) -gt $# ]; then + echo 'Error: --number requires a value' >&2 + exit 1 + fi + i=$((i + 1)) + next_arg="${!i}" + if [[ "$next_arg" == --* ]]; then + echo 'Error: --number requires a value' >&2 + exit 1 + fi + BRANCH_NUMBER="$next_arg" + ;; + --help|-h) + echo "Usage: $0 [--json] [--short-name ] [--number N] " + echo "" + echo "Options:" + echo " --json Output in JSON format" + echo " --short-name Provide a custom short name (2-4 words) for the branch" + echo " --number N Specify branch number manually (overrides auto-detection)" + echo " --help, -h Show this help message" + echo "" + echo "Examples:" + echo " $0 'Add user authentication system' --short-name 'user-auth'" + echo " $0 'Implement OAuth2 integration for API' --number 5" + exit 0 + ;; + *) + ARGS+=("$arg") + ;; + esac + i=$((i + 1)) +done + +FEATURE_DESCRIPTION="${ARGS[*]}" +if [ -z "$FEATURE_DESCRIPTION" ]; then + echo "Usage: $0 [--json] [--short-name ] [--number N] " >&2 + exit 1 +fi + +# Function to find the repository root by searching for existing project markers +find_repo_root() { + local dir="$1" + while [ "$dir" != "/" ]; do + if [ -d "$dir/.git" ] || [ -d "$dir/.specify" ]; then + echo "$dir" + return 0 + fi + dir="$(dirname "$dir")" + done + return 1 +} + +# Function to get highest number from specs directory +get_highest_from_specs() { + local specs_dir="$1" + local highest=0 + + if [ -d "$specs_dir" ]; then + for dir in "$specs_dir"/*; do + [ -d "$dir" ] || continue + dirname=$(basename "$dir") + number=$(echo "$dirname" | grep -o '^[0-9]\+' || echo "0") + number=$((10#$number)) + if [ "$number" -gt "$highest" ]; then + highest=$number + fi + done + fi + + echo "$highest" +} + +# Function to get highest number from git branches +get_highest_from_branches() { + local highest=0 + + # Get all branches (local and remote) + branches=$(git branch -a 2>/dev/null || echo "") + + if [ -n "$branches" ]; then + while IFS= read -r branch; do + # Clean branch name: remove leading markers and remote prefixes + clean_branch=$(echo "$branch" | sed 's/^[* ]*//; s|^remotes/[^/]*/||') + + # Extract feature number if branch matches pattern ###-* + if echo "$clean_branch" | grep -q '^[0-9]\{3\}-'; then + number=$(echo "$clean_branch" | grep -o '^[0-9]\{3\}' || echo "0") + number=$((10#$number)) + if [ "$number" -gt "$highest" ]; then + highest=$number + fi + fi + done <<< "$branches" + fi + + echo "$highest" +} + +# Function to check existing branches (local and remote) and return next available number +check_existing_branches() { + local specs_dir="$1" + + # Fetch all remotes to get latest branch info (suppress errors if no remotes) + git fetch --all --prune 2>/dev/null || true + + # Get highest number from ALL branches (not just matching short name) + local highest_branch=$(get_highest_from_branches) + + # Get highest number from ALL specs (not just matching short name) + local highest_spec=$(get_highest_from_specs "$specs_dir") + + # Take the maximum of both + local max_num=$highest_branch + if [ "$highest_spec" -gt "$max_num" ]; then + max_num=$highest_spec + fi + + # Return next number + echo $((max_num + 1)) +} + +# Function to clean and format a branch name +clean_branch_name() { + local name="$1" + echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//' +} + +# Resolve repository root. Prefer git information when available, but fall back +# to searching for repository markers so the workflow still functions in repositories that +# were initialised with --no-git. +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if git rev-parse --show-toplevel >/dev/null 2>&1; then + REPO_ROOT=$(git rev-parse --show-toplevel) + HAS_GIT=true +else + REPO_ROOT="$(find_repo_root "$SCRIPT_DIR")" + if [ -z "$REPO_ROOT" ]; then + echo "Error: Could not determine repository root. Please run this script from within the repository." >&2 + exit 1 + fi + HAS_GIT=false +fi + +cd "$REPO_ROOT" + +SPECS_DIR="$REPO_ROOT/specs" +mkdir -p "$SPECS_DIR" + +# Function to generate branch name with stop word filtering and length filtering +generate_branch_name() { + local description="$1" + + # Common stop words to filter out + local stop_words="^(i|a|an|the|to|for|of|in|on|at|by|with|from|is|are|was|were|be|been|being|have|has|had|do|does|did|will|would|should|could|can|may|might|must|shall|this|that|these|those|my|your|our|their|want|need|add|get|set)$" + + # Convert to lowercase and split into words + local clean_name=$(echo "$description" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/ /g') + + # Filter words: remove stop words and words shorter than 3 chars (unless they're uppercase acronyms in original) + local meaningful_words=() + for word in $clean_name; do + # Skip empty words + [ -z "$word" ] && continue + + # Keep words that are NOT stop words AND (length >= 3 OR are potential acronyms) + if ! echo "$word" | grep -qiE "$stop_words"; then + if [ ${#word} -ge 3 ]; then + meaningful_words+=("$word") + elif echo "$description" | grep -q "\b${word^^}\b"; then + # Keep short words if they appear as uppercase in original (likely acronyms) + meaningful_words+=("$word") + fi + fi + done + + # If we have meaningful words, use first 3-4 of them + if [ ${#meaningful_words[@]} -gt 0 ]; then + local max_words=3 + if [ ${#meaningful_words[@]} -eq 4 ]; then max_words=4; fi + + local result="" + local count=0 + for word in "${meaningful_words[@]}"; do + if [ $count -ge $max_words ]; then break; fi + if [ -n "$result" ]; then result="$result-"; fi + result="$result$word" + count=$((count + 1)) + done + echo "$result" + else + # Fallback to original logic if no meaningful words found + local cleaned=$(clean_branch_name "$description") + echo "$cleaned" | tr '-' '\n' | grep -v '^$' | head -3 | tr '\n' '-' | sed 's/-$//' + fi +} + +# Generate branch name +if [ -n "$SHORT_NAME" ]; then + # Use provided short name, just clean it up + BRANCH_SUFFIX=$(clean_branch_name "$SHORT_NAME") +else + # Generate from description with smart filtering + BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION") +fi + +# Determine branch number +if [ -z "$BRANCH_NUMBER" ]; then + if [ "$HAS_GIT" = true ]; then + # Check existing branches on remotes + BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR") + else + # Fall back to local directory check + HIGHEST=$(get_highest_from_specs "$SPECS_DIR") + BRANCH_NUMBER=$((HIGHEST + 1)) + fi +fi + +# Force base-10 interpretation to prevent octal conversion (e.g., 010 → 8 in octal, but should be 10 in decimal) +FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))") +BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" + +# GitHub enforces a 244-byte limit on branch names +# Validate and truncate if necessary +MAX_BRANCH_LENGTH=244 +if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then + # Calculate how much we need to trim from suffix + # Account for: feature number (3) + hyphen (1) = 4 chars + MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - 4)) + + # Truncate suffix at word boundary if possible + TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH) + # Remove trailing hyphen if truncation created one + TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//') + + ORIGINAL_BRANCH_NAME="$BRANCH_NAME" + BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}" + + >&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit" + >&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)" + >&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)" +fi + +if [ "$HAS_GIT" = true ]; then + git checkout -b "$BRANCH_NAME" +else + >&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME" +fi + +FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME" +mkdir -p "$FEATURE_DIR" + +TEMPLATE="$REPO_ROOT/.specify/templates/spec-template.md" +SPEC_FILE="$FEATURE_DIR/spec.md" +if [ -f "$TEMPLATE" ]; then cp "$TEMPLATE" "$SPEC_FILE"; else touch "$SPEC_FILE"; fi + +# Set the SPECIFY_FEATURE environment variable for the current session +export SPECIFY_FEATURE="$BRANCH_NAME" + +if $JSON_MODE; then + printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' "$BRANCH_NAME" "$SPEC_FILE" "$FEATURE_NUM" +else + echo "BRANCH_NAME: $BRANCH_NAME" + echo "SPEC_FILE: $SPEC_FILE" + echo "FEATURE_NUM: $FEATURE_NUM" + echo "SPECIFY_FEATURE environment variable set to: $BRANCH_NAME" +fi diff --git a/.specify/scripts/bash/setup-plan.sh b/.specify/scripts/bash/setup-plan.sh new file mode 100755 index 00000000..d01c6d6c --- /dev/null +++ b/.specify/scripts/bash/setup-plan.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash + +set -e + +# Parse command line arguments +JSON_MODE=false +ARGS=() + +for arg in "$@"; do + case "$arg" in + --json) + JSON_MODE=true + ;; + --help|-h) + echo "Usage: $0 [--json]" + echo " --json Output results in JSON format" + echo " --help Show this help message" + exit 0 + ;; + *) + ARGS+=("$arg") + ;; + esac +done + +# Get script directory and load common functions +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" + +# Get all paths and variables from common functions +eval $(get_feature_paths) + +# Check if we're on a proper feature branch (only for git repos) +check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1 + +# Ensure the feature directory exists +mkdir -p "$FEATURE_DIR" + +# Copy plan template if it exists +TEMPLATE="$REPO_ROOT/.specify/templates/plan-template.md" +if [[ -f "$TEMPLATE" ]]; then + cp "$TEMPLATE" "$IMPL_PLAN" + echo "Copied plan template to $IMPL_PLAN" +else + echo "Warning: Plan template not found at $TEMPLATE" + # Create a basic plan file if template doesn't exist + touch "$IMPL_PLAN" +fi + +# Output results +if $JSON_MODE; then + printf '{"FEATURE_SPEC":"%s","IMPL_PLAN":"%s","SPECS_DIR":"%s","BRANCH":"%s","HAS_GIT":"%s"}\n' \ + "$FEATURE_SPEC" "$IMPL_PLAN" "$FEATURE_DIR" "$CURRENT_BRANCH" "$HAS_GIT" +else + echo "FEATURE_SPEC: $FEATURE_SPEC" + echo "IMPL_PLAN: $IMPL_PLAN" + echo "SPECS_DIR: $FEATURE_DIR" + echo "BRANCH: $CURRENT_BRANCH" + echo "HAS_GIT: $HAS_GIT" +fi + diff --git a/.specify/scripts/bash/update-agent-context.sh b/.specify/scripts/bash/update-agent-context.sh new file mode 100755 index 00000000..a33ea5cd --- /dev/null +++ b/.specify/scripts/bash/update-agent-context.sh @@ -0,0 +1,810 @@ +#!/usr/bin/env bash + +# Update agent context files with information from plan.md +# +# This script maintains AI agent context files by parsing feature specifications +# and updating agent-specific configuration files with project information. +# +# MAIN FUNCTIONS: +# 1. Environment Validation +# - Verifies git repository structure and branch information +# - Checks for required plan.md files and templates +# - Validates file permissions and accessibility +# +# 2. Plan Data Extraction +# - Parses plan.md files to extract project metadata +# - Identifies language/version, frameworks, databases, and project types +# - Handles missing or incomplete specification data gracefully +# +# 3. Agent File Management +# - Creates new agent context files from templates when needed +# - Updates existing agent files with new project information +# - Preserves manual additions and custom configurations +# - Supports multiple AI agent formats and directory structures +# +# 4. Content Generation +# - Generates language-specific build/test commands +# - Creates appropriate project directory structures +# - Updates technology stacks and recent changes sections +# - Maintains consistent formatting and timestamps +# +# 5. Multi-Agent Support +# - Handles agent-specific file paths and naming conventions +# - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, Amazon Q Developer CLI, or Antigravity +# - Can update single agents or all existing agent files +# - Creates default Claude file if no agent files exist +# +# Usage: ./update-agent-context.sh [agent_type] +# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|q|agy|bob|qodercli +# Leave empty to update all existing agent files + +set -e + +# Enable strict error handling +set -u +set -o pipefail + +#============================================================================== +# Configuration and Global Variables +#============================================================================== + +# Get script directory and load common functions +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" + +# Get all paths and variables from common functions +eval $(get_feature_paths) + +NEW_PLAN="$IMPL_PLAN" # Alias for compatibility with existing code +AGENT_TYPE="${1:-}" + +# Agent-specific file paths +CLAUDE_FILE="$REPO_ROOT/CLAUDE.md" +GEMINI_FILE="$REPO_ROOT/GEMINI.md" +COPILOT_FILE="$REPO_ROOT/.github/agents/copilot-instructions.md" +CURSOR_FILE="$REPO_ROOT/.cursor/rules/specify-rules.mdc" +QWEN_FILE="$REPO_ROOT/QWEN.md" +AGENTS_FILE="$REPO_ROOT/AGENTS.md" +WINDSURF_FILE="$REPO_ROOT/.windsurf/rules/specify-rules.md" +KILOCODE_FILE="$REPO_ROOT/.kilocode/rules/specify-rules.md" +AUGGIE_FILE="$REPO_ROOT/.augment/rules/specify-rules.md" +ROO_FILE="$REPO_ROOT/.roo/rules/specify-rules.md" +CODEBUDDY_FILE="$REPO_ROOT/CODEBUDDY.md" +QODER_FILE="$REPO_ROOT/QODER.md" +AMP_FILE="$REPO_ROOT/AGENTS.md" +SHAI_FILE="$REPO_ROOT/SHAI.md" +Q_FILE="$REPO_ROOT/AGENTS.md" +AGY_FILE="$REPO_ROOT/.agent/rules/specify-rules.md" +BOB_FILE="$REPO_ROOT/AGENTS.md" + +# Template file +TEMPLATE_FILE="$REPO_ROOT/.specify/templates/agent-file-template.md" + +# Global variables for parsed plan data +NEW_LANG="" +NEW_FRAMEWORK="" +NEW_DB="" +NEW_PROJECT_TYPE="" + +#============================================================================== +# Utility Functions +#============================================================================== + +log_info() { + echo "INFO: $1" +} + +log_success() { + echo "✓ $1" +} + +log_error() { + echo "ERROR: $1" >&2 +} + +log_warning() { + echo "WARNING: $1" >&2 +} + +# Cleanup function for temporary files +cleanup() { + local exit_code=$? + rm -f /tmp/agent_update_*_$$ + rm -f /tmp/manual_additions_$$ + exit $exit_code +} + +# Set up cleanup trap +trap cleanup EXIT INT TERM + +#============================================================================== +# Validation Functions +#============================================================================== + +validate_environment() { + # Check if we have a current branch/feature (git or non-git) + if [[ -z "$CURRENT_BRANCH" ]]; then + log_error "Unable to determine current feature" + if [[ "$HAS_GIT" == "true" ]]; then + log_info "Make sure you're on a feature branch" + else + log_info "Set SPECIFY_FEATURE environment variable or create a feature first" + fi + exit 1 + fi + + # Check if plan.md exists + if [[ ! -f "$NEW_PLAN" ]]; then + log_error "No plan.md found at $NEW_PLAN" + log_info "Make sure you're working on a feature with a corresponding spec directory" + if [[ "$HAS_GIT" != "true" ]]; then + log_info "Use: export SPECIFY_FEATURE=your-feature-name or create a new feature first" + fi + exit 1 + fi + + # Check if template exists (needed for new files) + if [[ ! -f "$TEMPLATE_FILE" ]]; then + log_warning "Template file not found at $TEMPLATE_FILE" + log_warning "Creating new agent files will fail" + fi +} + +#============================================================================== +# Plan Parsing Functions +#============================================================================== + +extract_plan_field() { + local field_pattern="$1" + local plan_file="$2" + + grep "^\*\*${field_pattern}\*\*: " "$plan_file" 2>/dev/null | \ + head -1 | \ + sed "s|^\*\*${field_pattern}\*\*: ||" | \ + sed 's/^[ \t]*//;s/[ \t]*$//' | \ + grep -v "NEEDS CLARIFICATION" | \ + grep -v "^N/A$" || echo "" +} + +parse_plan_data() { + local plan_file="$1" + + if [[ ! -f "$plan_file" ]]; then + log_error "Plan file not found: $plan_file" + return 1 + fi + + if [[ ! -r "$plan_file" ]]; then + log_error "Plan file is not readable: $plan_file" + return 1 + fi + + log_info "Parsing plan data from $plan_file" + + NEW_LANG=$(extract_plan_field "Language/Version" "$plan_file") + NEW_FRAMEWORK=$(extract_plan_field "Primary Dependencies" "$plan_file") + NEW_DB=$(extract_plan_field "Storage" "$plan_file") + NEW_PROJECT_TYPE=$(extract_plan_field "Project Type" "$plan_file") + + # Log what we found + if [[ -n "$NEW_LANG" ]]; then + log_info "Found language: $NEW_LANG" + else + log_warning "No language information found in plan" + fi + + if [[ -n "$NEW_FRAMEWORK" ]]; then + log_info "Found framework: $NEW_FRAMEWORK" + fi + + if [[ -n "$NEW_DB" ]] && [[ "$NEW_DB" != "N/A" ]]; then + log_info "Found database: $NEW_DB" + fi + + if [[ -n "$NEW_PROJECT_TYPE" ]]; then + log_info "Found project type: $NEW_PROJECT_TYPE" + fi +} + +format_technology_stack() { + local lang="$1" + local framework="$2" + local parts=() + + # Add non-empty parts + [[ -n "$lang" && "$lang" != "NEEDS CLARIFICATION" ]] && parts+=("$lang") + [[ -n "$framework" && "$framework" != "NEEDS CLARIFICATION" && "$framework" != "N/A" ]] && parts+=("$framework") + + # Join with proper formatting + if [[ ${#parts[@]} -eq 0 ]]; then + echo "" + elif [[ ${#parts[@]} -eq 1 ]]; then + echo "${parts[0]}" + else + # Join multiple parts with " + " + local result="${parts[0]}" + for ((i=1; i<${#parts[@]}; i++)); do + result="$result + ${parts[i]}" + done + echo "$result" + fi +} + +#============================================================================== +# Template and Content Generation Functions +#============================================================================== + +get_project_structure() { + local project_type="$1" + + if [[ "$project_type" == *"web"* ]]; then + echo "backend/\\nfrontend/\\ntests/" + else + echo "src/\\ntests/" + fi +} + +get_commands_for_language() { + local lang="$1" + + case "$lang" in + *"Python"*) + echo "cd src && pytest && ruff check ." + ;; + *"Rust"*) + echo "cargo test && cargo clippy" + ;; + *"JavaScript"*|*"TypeScript"*) + echo "npm test \\&\\& npm run lint" + ;; + *) + echo "# Add commands for $lang" + ;; + esac +} + +get_language_conventions() { + local lang="$1" + echo "$lang: Follow standard conventions" +} + +create_new_agent_file() { + local target_file="$1" + local temp_file="$2" + local project_name="$3" + local current_date="$4" + + if [[ ! -f "$TEMPLATE_FILE" ]]; then + log_error "Template not found at $TEMPLATE_FILE" + return 1 + fi + + if [[ ! -r "$TEMPLATE_FILE" ]]; then + log_error "Template file is not readable: $TEMPLATE_FILE" + return 1 + fi + + log_info "Creating new agent context file from template..." + + if ! cp "$TEMPLATE_FILE" "$temp_file"; then + log_error "Failed to copy template file" + return 1 + fi + + # Replace template placeholders + local project_structure + project_structure=$(get_project_structure "$NEW_PROJECT_TYPE") + + local commands + commands=$(get_commands_for_language "$NEW_LANG") + + local language_conventions + language_conventions=$(get_language_conventions "$NEW_LANG") + + # Perform substitutions with error checking using safer approach + # Escape special characters for sed by using a different delimiter or escaping + local escaped_lang=$(printf '%s\n' "$NEW_LANG" | sed 's/[\[\.*^$()+{}|]/\\&/g') + local escaped_framework=$(printf '%s\n' "$NEW_FRAMEWORK" | sed 's/[\[\.*^$()+{}|]/\\&/g') + local escaped_branch=$(printf '%s\n' "$CURRENT_BRANCH" | sed 's/[\[\.*^$()+{}|]/\\&/g') + + # Build technology stack and recent change strings conditionally + local tech_stack + if [[ -n "$escaped_lang" && -n "$escaped_framework" ]]; then + tech_stack="- $escaped_lang + $escaped_framework ($escaped_branch)" + elif [[ -n "$escaped_lang" ]]; then + tech_stack="- $escaped_lang ($escaped_branch)" + elif [[ -n "$escaped_framework" ]]; then + tech_stack="- $escaped_framework ($escaped_branch)" + else + tech_stack="- ($escaped_branch)" + fi + + local recent_change + if [[ -n "$escaped_lang" && -n "$escaped_framework" ]]; then + recent_change="- $escaped_branch: Added $escaped_lang + $escaped_framework" + elif [[ -n "$escaped_lang" ]]; then + recent_change="- $escaped_branch: Added $escaped_lang" + elif [[ -n "$escaped_framework" ]]; then + recent_change="- $escaped_branch: Added $escaped_framework" + else + recent_change="- $escaped_branch: Added" + fi + + local substitutions=( + "s|\[PROJECT NAME\]|$project_name|" + "s|\[DATE\]|$current_date|" + "s|\[EXTRACTED FROM ALL PLAN.MD FILES\]|$tech_stack|" + "s|\[ACTUAL STRUCTURE FROM PLANS\]|$project_structure|g" + "s|\[ONLY COMMANDS FOR ACTIVE TECHNOLOGIES\]|$commands|" + "s|\[LANGUAGE-SPECIFIC, ONLY FOR LANGUAGES IN USE\]|$language_conventions|" + "s|\[LAST 3 FEATURES AND WHAT THEY ADDED\]|$recent_change|" + ) + + for substitution in "${substitutions[@]}"; do + if ! sed -i.bak -e "$substitution" "$temp_file"; then + log_error "Failed to perform substitution: $substitution" + rm -f "$temp_file" "$temp_file.bak" + return 1 + fi + done + + # Convert \n sequences to actual newlines + newline=$(printf '\n') + sed -i.bak2 "s/\\\\n/${newline}/g" "$temp_file" + + # Clean up backup files + rm -f "$temp_file.bak" "$temp_file.bak2" + + return 0 +} + + + + +update_existing_agent_file() { + local target_file="$1" + local current_date="$2" + + log_info "Updating existing agent context file..." + + # Use a single temporary file for atomic update + local temp_file + temp_file=$(mktemp) || { + log_error "Failed to create temporary file" + return 1 + } + + # Process the file in one pass + local tech_stack=$(format_technology_stack "$NEW_LANG" "$NEW_FRAMEWORK") + local new_tech_entries=() + local new_change_entry="" + + # Prepare new technology entries + if [[ -n "$tech_stack" ]] && ! grep -q "$tech_stack" "$target_file"; then + new_tech_entries+=("- $tech_stack ($CURRENT_BRANCH)") + fi + + if [[ -n "$NEW_DB" ]] && [[ "$NEW_DB" != "N/A" ]] && [[ "$NEW_DB" != "NEEDS CLARIFICATION" ]] && ! grep -q "$NEW_DB" "$target_file"; then + new_tech_entries+=("- $NEW_DB ($CURRENT_BRANCH)") + fi + + # Prepare new change entry + if [[ -n "$tech_stack" ]]; then + new_change_entry="- $CURRENT_BRANCH: Added $tech_stack" + elif [[ -n "$NEW_DB" ]] && [[ "$NEW_DB" != "N/A" ]] && [[ "$NEW_DB" != "NEEDS CLARIFICATION" ]]; then + new_change_entry="- $CURRENT_BRANCH: Added $NEW_DB" + fi + + # Check if sections exist in the file + local has_active_technologies=0 + local has_recent_changes=0 + + if grep -q "^## Active Technologies" "$target_file" 2>/dev/null; then + has_active_technologies=1 + fi + + if grep -q "^## Recent Changes" "$target_file" 2>/dev/null; then + has_recent_changes=1 + fi + + # Process file line by line + local in_tech_section=false + local in_changes_section=false + local tech_entries_added=false + local changes_entries_added=false + local existing_changes_count=0 + local file_ended=false + + while IFS= read -r line || [[ -n "$line" ]]; do + # Handle Active Technologies section + if [[ "$line" == "## Active Technologies" ]]; then + echo "$line" >> "$temp_file" + in_tech_section=true + continue + elif [[ $in_tech_section == true ]] && [[ "$line" =~ ^##[[:space:]] ]]; then + # Add new tech entries before closing the section + if [[ $tech_entries_added == false ]] && [[ ${#new_tech_entries[@]} -gt 0 ]]; then + printf '%s\n' "${new_tech_entries[@]}" >> "$temp_file" + tech_entries_added=true + fi + echo "$line" >> "$temp_file" + in_tech_section=false + continue + elif [[ $in_tech_section == true ]] && [[ -z "$line" ]]; then + # Add new tech entries before empty line in tech section + if [[ $tech_entries_added == false ]] && [[ ${#new_tech_entries[@]} -gt 0 ]]; then + printf '%s\n' "${new_tech_entries[@]}" >> "$temp_file" + tech_entries_added=true + fi + echo "$line" >> "$temp_file" + continue + fi + + # Handle Recent Changes section + if [[ "$line" == "## Recent Changes" ]]; then + echo "$line" >> "$temp_file" + # Add new change entry right after the heading + if [[ -n "$new_change_entry" ]]; then + echo "$new_change_entry" >> "$temp_file" + fi + in_changes_section=true + changes_entries_added=true + continue + elif [[ $in_changes_section == true ]] && [[ "$line" =~ ^##[[:space:]] ]]; then + echo "$line" >> "$temp_file" + in_changes_section=false + continue + elif [[ $in_changes_section == true ]] && [[ "$line" == "- "* ]]; then + # Keep only first 2 existing changes + if [[ $existing_changes_count -lt 2 ]]; then + echo "$line" >> "$temp_file" + ((existing_changes_count++)) + fi + continue + fi + + # Update timestamp + if [[ "$line" =~ \*\*Last\ updated\*\*:.*[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9] ]]; then + echo "$line" | sed "s/[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]/$current_date/" >> "$temp_file" + else + echo "$line" >> "$temp_file" + fi + done < "$target_file" + + # Post-loop check: if we're still in the Active Technologies section and haven't added new entries + if [[ $in_tech_section == true ]] && [[ $tech_entries_added == false ]] && [[ ${#new_tech_entries[@]} -gt 0 ]]; then + printf '%s\n' "${new_tech_entries[@]}" >> "$temp_file" + tech_entries_added=true + fi + + # If sections don't exist, add them at the end of the file + if [[ $has_active_technologies -eq 0 ]] && [[ ${#new_tech_entries[@]} -gt 0 ]]; then + echo "" >> "$temp_file" + echo "## Active Technologies" >> "$temp_file" + printf '%s\n' "${new_tech_entries[@]}" >> "$temp_file" + tech_entries_added=true + fi + + if [[ $has_recent_changes -eq 0 ]] && [[ -n "$new_change_entry" ]]; then + echo "" >> "$temp_file" + echo "## Recent Changes" >> "$temp_file" + echo "$new_change_entry" >> "$temp_file" + changes_entries_added=true + fi + + # Move temp file to target atomically + if ! mv "$temp_file" "$target_file"; then + log_error "Failed to update target file" + rm -f "$temp_file" + return 1 + fi + + return 0 +} +#============================================================================== +# Main Agent File Update Function +#============================================================================== + +update_agent_file() { + local target_file="$1" + local agent_name="$2" + + if [[ -z "$target_file" ]] || [[ -z "$agent_name" ]]; then + log_error "update_agent_file requires target_file and agent_name parameters" + return 1 + fi + + log_info "Updating $agent_name context file: $target_file" + + local project_name + project_name=$(basename "$REPO_ROOT") + local current_date + current_date=$(date +%Y-%m-%d) + + # Create directory if it doesn't exist + local target_dir + target_dir=$(dirname "$target_file") + if [[ ! -d "$target_dir" ]]; then + if ! mkdir -p "$target_dir"; then + log_error "Failed to create directory: $target_dir" + return 1 + fi + fi + + if [[ ! -f "$target_file" ]]; then + # Create new file from template + local temp_file + temp_file=$(mktemp) || { + log_error "Failed to create temporary file" + return 1 + } + + if create_new_agent_file "$target_file" "$temp_file" "$project_name" "$current_date"; then + if mv "$temp_file" "$target_file"; then + log_success "Created new $agent_name context file" + else + log_error "Failed to move temporary file to $target_file" + rm -f "$temp_file" + return 1 + fi + else + log_error "Failed to create new agent file" + rm -f "$temp_file" + return 1 + fi + else + # Update existing file + if [[ ! -r "$target_file" ]]; then + log_error "Cannot read existing file: $target_file" + return 1 + fi + + if [[ ! -w "$target_file" ]]; then + log_error "Cannot write to existing file: $target_file" + return 1 + fi + + if update_existing_agent_file "$target_file" "$current_date"; then + log_success "Updated existing $agent_name context file" + else + log_error "Failed to update existing agent file" + return 1 + fi + fi + + return 0 +} + +#============================================================================== +# Agent Selection and Processing +#============================================================================== + +update_specific_agent() { + local agent_type="$1" + + case "$agent_type" in + claude) + update_agent_file "$CLAUDE_FILE" "Claude Code" + ;; + gemini) + update_agent_file "$GEMINI_FILE" "Gemini CLI" + ;; + copilot) + update_agent_file "$COPILOT_FILE" "GitHub Copilot" + ;; + cursor-agent) + update_agent_file "$CURSOR_FILE" "Cursor IDE" + ;; + qwen) + update_agent_file "$QWEN_FILE" "Qwen Code" + ;; + opencode) + update_agent_file "$AGENTS_FILE" "opencode" + ;; + codex) + update_agent_file "$AGENTS_FILE" "Codex CLI" + ;; + windsurf) + update_agent_file "$WINDSURF_FILE" "Windsurf" + ;; + kilocode) + update_agent_file "$KILOCODE_FILE" "Kilo Code" + ;; + auggie) + update_agent_file "$AUGGIE_FILE" "Auggie CLI" + ;; + roo) + update_agent_file "$ROO_FILE" "Roo Code" + ;; + codebuddy) + update_agent_file "$CODEBUDDY_FILE" "CodeBuddy CLI" + ;; + qodercli) + update_agent_file "$QODER_FILE" "Qoder CLI" + ;; + amp) + update_agent_file "$AMP_FILE" "Amp" + ;; + shai) + update_agent_file "$SHAI_FILE" "SHAI" + ;; + q) + update_agent_file "$Q_FILE" "Amazon Q Developer CLI" + ;; + agy) + update_agent_file "$AGY_FILE" "Antigravity" + ;; + bob) + update_agent_file "$BOB_FILE" "IBM Bob" + ;; + generic) + log_info "Generic agent: no predefined context file. Use the agent-specific update script for your agent." + ;; + *) + log_error "Unknown agent type '$agent_type'" + log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|q|agy|bob|qodercli|generic" + exit 1 + ;; + esac +} + +update_all_existing_agents() { + local found_agent=false + + # Check each possible agent file and update if it exists + if [[ -f "$CLAUDE_FILE" ]]; then + update_agent_file "$CLAUDE_FILE" "Claude Code" + found_agent=true + fi + + if [[ -f "$GEMINI_FILE" ]]; then + update_agent_file "$GEMINI_FILE" "Gemini CLI" + found_agent=true + fi + + if [[ -f "$COPILOT_FILE" ]]; then + update_agent_file "$COPILOT_FILE" "GitHub Copilot" + found_agent=true + fi + + if [[ -f "$CURSOR_FILE" ]]; then + update_agent_file "$CURSOR_FILE" "Cursor IDE" + found_agent=true + fi + + if [[ -f "$QWEN_FILE" ]]; then + update_agent_file "$QWEN_FILE" "Qwen Code" + found_agent=true + fi + + if [[ -f "$AGENTS_FILE" ]]; then + update_agent_file "$AGENTS_FILE" "Codex/opencode" + found_agent=true + fi + + if [[ -f "$WINDSURF_FILE" ]]; then + update_agent_file "$WINDSURF_FILE" "Windsurf" + found_agent=true + fi + + if [[ -f "$KILOCODE_FILE" ]]; then + update_agent_file "$KILOCODE_FILE" "Kilo Code" + found_agent=true + fi + + if [[ -f "$AUGGIE_FILE" ]]; then + update_agent_file "$AUGGIE_FILE" "Auggie CLI" + found_agent=true + fi + + if [[ -f "$ROO_FILE" ]]; then + update_agent_file "$ROO_FILE" "Roo Code" + found_agent=true + fi + + if [[ -f "$CODEBUDDY_FILE" ]]; then + update_agent_file "$CODEBUDDY_FILE" "CodeBuddy CLI" + found_agent=true + fi + + if [[ -f "$SHAI_FILE" ]]; then + update_agent_file "$SHAI_FILE" "SHAI" + found_agent=true + fi + + if [[ -f "$QODER_FILE" ]]; then + update_agent_file "$QODER_FILE" "Qoder CLI" + found_agent=true + fi + + if [[ -f "$Q_FILE" ]]; then + update_agent_file "$Q_FILE" "Amazon Q Developer CLI" + found_agent=true + fi + + if [[ -f "$AGY_FILE" ]]; then + update_agent_file "$AGY_FILE" "Antigravity" + found_agent=true + fi + if [[ -f "$BOB_FILE" ]]; then + update_agent_file "$BOB_FILE" "IBM Bob" + found_agent=true + fi + + # If no agent files exist, create a default Claude file + if [[ "$found_agent" == false ]]; then + log_info "No existing agent files found, creating default Claude file..." + update_agent_file "$CLAUDE_FILE" "Claude Code" + fi +} +print_summary() { + echo + log_info "Summary of changes:" + + if [[ -n "$NEW_LANG" ]]; then + echo " - Added language: $NEW_LANG" + fi + + if [[ -n "$NEW_FRAMEWORK" ]]; then + echo " - Added framework: $NEW_FRAMEWORK" + fi + + if [[ -n "$NEW_DB" ]] && [[ "$NEW_DB" != "N/A" ]]; then + echo " - Added database: $NEW_DB" + fi + + echo + + log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|q|agy|bob|qodercli]" +} + +#============================================================================== +# Main Execution +#============================================================================== + +main() { + # Validate environment before proceeding + validate_environment + + log_info "=== Updating agent context files for feature $CURRENT_BRANCH ===" + + # Parse the plan file to extract project information + if ! parse_plan_data "$NEW_PLAN"; then + log_error "Failed to parse plan data" + exit 1 + fi + + # Process based on agent type argument + local success=true + + if [[ -z "$AGENT_TYPE" ]]; then + # No specific agent provided - update all existing agent files + log_info "No agent specified, updating all existing agent files..." + if ! update_all_existing_agents; then + success=false + fi + else + # Specific agent provided - update only that agent + log_info "Updating specific agent: $AGENT_TYPE" + if ! update_specific_agent "$AGENT_TYPE"; then + success=false + fi + fi + + # Print summary + print_summary + + if [[ "$success" == true ]]; then + log_success "Agent context update completed successfully" + exit 0 + else + log_error "Agent context update completed with errors" + exit 1 + fi +} + +# Execute main function if script is run directly +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi + diff --git a/.specify/templates/agent-file-template.md b/.specify/templates/agent-file-template.md new file mode 100644 index 00000000..4cc7fd66 --- /dev/null +++ b/.specify/templates/agent-file-template.md @@ -0,0 +1,28 @@ +# [PROJECT NAME] Development Guidelines + +Auto-generated from all feature plans. Last updated: [DATE] + +## Active Technologies + +[EXTRACTED FROM ALL PLAN.MD FILES] + +## Project Structure + +```text +[ACTUAL STRUCTURE FROM PLANS] +``` + +## Commands + +[ONLY COMMANDS FOR ACTIVE TECHNOLOGIES] + +## Code Style + +[LANGUAGE-SPECIFIC, ONLY FOR LANGUAGES IN USE] + +## Recent Changes + +[LAST 3 FEATURES AND WHAT THEY ADDED] + + + diff --git a/.specify/templates/checklist-template.md b/.specify/templates/checklist-template.md new file mode 100644 index 00000000..de196916 --- /dev/null +++ b/.specify/templates/checklist-template.md @@ -0,0 +1,40 @@ +# [CHECKLIST TYPE] Checklist: [FEATURE NAME] + +**Purpose**: [Brief description of what this checklist covers] **Created**: [DATE] **Feature**: +[Link to spec.md or relevant documentation] + +**Note**: This checklist is generated by the `/speckit.checklist` command based on feature context +and requirements. + + + +## [Category 1] + +- [ ] CHK001 First checklist item with clear action +- [ ] CHK002 Second checklist item +- [ ] CHK003 Third checklist item + +## [Category 2] + +- [ ] CHK004 Another category item +- [ ] CHK005 Item with specific criteria +- [ ] CHK006 Final item in this category + +## Notes + +- Check items off as completed: `[x]` +- Add comments or findings inline +- Link to relevant resources or documentation +- Items are numbered sequentially for easy reference diff --git a/.specify/templates/constitution-template.md b/.specify/templates/constitution-template.md new file mode 100644 index 00000000..37ad718f --- /dev/null +++ b/.specify/templates/constitution-template.md @@ -0,0 +1,74 @@ +# [PROJECT_NAME] Constitution + + + +## Core Principles + +### [PRINCIPLE_1_NAME] + + + +[PRINCIPLE_1_DESCRIPTION] + + + +### [PRINCIPLE_2_NAME] + + + +[PRINCIPLE_2_DESCRIPTION] + + + +### [PRINCIPLE_3_NAME] + + + +[PRINCIPLE_3_DESCRIPTION] + + + +### [PRINCIPLE_4_NAME] + + + +[PRINCIPLE_4_DESCRIPTION] + + + +### [PRINCIPLE_5_NAME] + + + +[PRINCIPLE_5_DESCRIPTION] + + + +## [SECTION_2_NAME] + + + +[SECTION_2_CONTENT] + + + +## [SECTION_3_NAME] + + + +[SECTION_3_CONTENT] + + + +## Governance + + + +[GOVERNANCE_RULES] + + + +**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: +[LAST_AMENDED_DATE] + + diff --git a/.specify/templates/plan-template.md b/.specify/templates/plan-template.md new file mode 100644 index 00000000..fba37524 --- /dev/null +++ b/.specify/templates/plan-template.md @@ -0,0 +1,108 @@ +# Implementation Plan: [FEATURE] + +**Branch**: `[###-feature-name]` | **Date**: [DATE] | **Spec**: [link] **Input**: Feature +specification from `/specs/[###-feature-name]/spec.md` + +**Note**: This template is filled in by the `/speckit.plan` command. See +`.specify/templates/plan-template.md` for the execution workflow. + +## Summary + +[Extract from feature spec: primary requirement + technical approach from research] + +## Technical Context + + + +**Language/Version**: [e.g., Python 3.11, Swift 5.9, Rust 1.75 or NEEDS CLARIFICATION] +**Primary Dependencies**: [e.g., FastAPI, UIKit, LLVM or NEEDS CLARIFICATION] +**Storage**: [if applicable, e.g., PostgreSQL, CoreData, files or N/A] +**Testing**: [e.g., pytest, XCTest, cargo test or NEEDS CLARIFICATION] +**Target Platform**: [e.g., Linux server, iOS 15+, WASM or NEEDS CLARIFICATION] **Project Type**: +[e.g., library/cli/web-service/mobile-app/compiler/desktop-app or NEEDS CLARIFICATION] +**Performance Goals**: [domain-specific, e.g., 1000 req/s, 10k lines/sec, 60 fps or NEEDS +CLARIFICATION] +**Constraints**: [domain-specific, e.g., <200ms p95, <100MB memory, offline-capable or NEEDS +CLARIFICATION] +**Scale/Scope**: [domain-specific, e.g., 10k users, 1M LOC, 50 screens or NEEDS CLARIFICATION] + +## Constitution Check + +_GATE: Must pass before Phase 0 research. Re-check after Phase 1 design._ + +[Gates determined based on constitution file] + +## Project Structure + +### Documentation (this feature) + +```text +specs/[###-feature]/ +├── plan.md # This file (/speckit.plan command output) +├── research.md # Phase 0 output (/speckit.plan command) +├── data-model.md # Phase 1 output (/speckit.plan command) +├── quickstart.md # Phase 1 output (/speckit.plan command) +├── contracts/ # Phase 1 output (/speckit.plan command) +└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan) +``` + +### Source Code (repository root) + + + +```text +# [REMOVE IF UNUSED] Option 1: Single project (DEFAULT) +src/ +├── models/ +├── services/ +├── cli/ +└── lib/ + +tests/ +├── contract/ +├── integration/ +└── unit/ + +# [REMOVE IF UNUSED] Option 2: Web application (when "frontend" + "backend" detected) +backend/ +├── src/ +│ ├── models/ +│ ├── services/ +│ └── api/ +└── tests/ + +frontend/ +├── src/ +│ ├── components/ +│ ├── pages/ +│ └── services/ +└── tests/ + +# [REMOVE IF UNUSED] Option 3: Mobile + API (when "iOS/Android" detected) +api/ +└── [same as backend above] + +ios/ or android/ +└── [platform-specific structure: feature modules, UI flows, platform tests] +``` + +**Structure Decision**: [Document the selected structure and reference the real directories captured +above] + +## Complexity Tracking + +> **Fill ONLY if Constitution Check has violations that must be justified** + +| Violation | Why Needed | Simpler Alternative Rejected Because | +| -------------------------- | ------------------ | ------------------------------------ | +| [e.g., 4th project] | [current need] | [why 3 projects insufficient] | +| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] | diff --git a/.specify/templates/spec-template.md b/.specify/templates/spec-template.md new file mode 100644 index 00000000..b72e14a6 --- /dev/null +++ b/.specify/templates/spec-template.md @@ -0,0 +1,118 @@ +# Feature Specification: [FEATURE NAME] + +**Feature Branch**: `[###-feature-name]` +**Created**: [DATE] +**Status**: Draft +**Input**: User description: "$ARGUMENTS" + +## User Scenarios & Testing _(mandatory)_ + + + +### User Story 1 - [Brief Title] (Priority: P1) + +[Describe this user journey in plain language] + +**Why this priority**: [Explain the value and why it has this priority level] + +**Independent Test**: [Describe how this can be tested independently - e.g., "Can be fully tested by +[specific action] and delivers [specific value]"] + +**Acceptance Scenarios**: + +1. **Given** [initial state], **When** [action], **Then** [expected outcome] +2. **Given** [initial state], **When** [action], **Then** [expected outcome] + +--- + +### User Story 2 - [Brief Title] (Priority: P2) + +[Describe this user journey in plain language] + +**Why this priority**: [Explain the value and why it has this priority level] + +**Independent Test**: [Describe how this can be tested independently] + +**Acceptance Scenarios**: + +1. **Given** [initial state], **When** [action], **Then** [expected outcome] + +--- + +### User Story 3 - [Brief Title] (Priority: P3) + +[Describe this user journey in plain language] + +**Why this priority**: [Explain the value and why it has this priority level] + +**Independent Test**: [Describe how this can be tested independently] + +**Acceptance Scenarios**: + +1. **Given** [initial state], **When** [action], **Then** [expected outcome] + +--- + +[Add more user stories as needed, each with an assigned priority] + +### Edge Cases + + + +- What happens when [boundary condition]? +- How does system handle [error scenario]? + +## Requirements _(mandatory)_ + + + +### Functional Requirements + +- **FR-001**: System MUST [specific capability, e.g., "allow users to create accounts"] +- **FR-002**: System MUST [specific capability, e.g., "validate email addresses"] +- **FR-003**: Users MUST be able to [key interaction, e.g., "reset their password"] +- **FR-004**: System MUST [data requirement, e.g., "persist user preferences"] +- **FR-005**: System MUST [behavior, e.g., "log all security events"] + +_Example of marking unclear requirements:_ + +- **FR-006**: System MUST authenticate users via [NEEDS CLARIFICATION: auth method not specified - + email/password, SSO, OAuth?] +- **FR-007**: System MUST retain user data for [NEEDS CLARIFICATION: retention period not specified] + +### Key Entities _(include if feature involves data)_ + +- **[Entity 1]**: [What it represents, key attributes without implementation] +- **[Entity 2]**: [What it represents, relationships to other entities] + +## Success Criteria _(mandatory)_ + + + +### Measurable Outcomes + +- **SC-001**: [Measurable metric, e.g., "Users can complete account creation in under 2 minutes"] +- **SC-002**: [Measurable metric, e.g., "System handles 1000 concurrent users without degradation"] +- **SC-003**: [User satisfaction metric, e.g., "90% of users successfully complete primary task on + first attempt"] +- **SC-004**: [Business metric, e.g., "Reduce support tickets related to [X] by 50%"] diff --git a/.specify/templates/tasks-template.md b/.specify/templates/tasks-template.md new file mode 100644 index 00000000..5bc7cd35 --- /dev/null +++ b/.specify/templates/tasks-template.md @@ -0,0 +1,255 @@ +--- +description: 'Task list template for feature implementation' +--- + +# Tasks: [FEATURE NAME] + +**Input**: Design documents from `/specs/[###-feature-name]/` **Prerequisites**: plan.md (required), +spec.md (required for user stories), research.md, data-model.md, contracts/ + +**Tests**: The examples below include test tasks. Tests are OPTIONAL - only include them if +explicitly requested in the feature specification. + +**Organization**: Tasks are grouped by user story to enable independent implementation and testing +of each story. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3) +- Include exact file paths in descriptions + +## Path Conventions + +- **Single project**: `src/`, `tests/` at repository root +- **Web app**: `backend/src/`, `frontend/src/` +- **Mobile**: `api/src/`, `ios/src/` or `android/src/` +- Paths shown below assume single project - adjust based on plan.md structure + + + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Project initialization and basic structure + +- [ ] T001 Create project structure per implementation plan +- [ ] T002 Initialize [language] project with [framework] dependencies +- [ ] T003 [P] Configure linting and formatting tools + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Core infrastructure that MUST be complete before ANY user story can be implemented + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete + +Examples of foundational tasks (adjust based on your project): + +- [ ] T004 Setup database schema and migrations framework +- [ ] T005 [P] Implement authentication/authorization framework +- [ ] T006 [P] Setup API routing and middleware structure +- [ ] T007 Create base models/entities that all stories depend on +- [ ] T008 Configure error handling and logging infrastructure +- [ ] T009 Setup environment configuration management + +**Checkpoint**: Foundation ready - user story implementation can now begin in parallel + +--- + +## Phase 3: User Story 1 - [Title] (Priority: P1) 🎯 MVP + +**Goal**: [Brief description of what this story delivers] + +**Independent Test**: [How to verify this story works on its own] + +### Tests for User Story 1 (OPTIONAL - only if tests requested) ⚠️ + +> **NOTE: Write these tests FIRST, ensure they FAIL before implementation** + +- [ ] T010 [P] [US1] Contract test for [endpoint] in tests/contract/test\_[name].py +- [ ] T011 [P] [US1] Integration test for [user journey] in tests/integration/test\_[name].py + +### Implementation for User Story 1 + +- [ ] T012 [P] [US1] Create [Entity1] model in src/models/[entity1].py +- [ ] T013 [P] [US1] Create [Entity2] model in src/models/[entity2].py +- [ ] T014 [US1] Implement [Service] in src/services/[service].py (depends on T012, T013) +- [ ] T015 [US1] Implement [endpoint/feature] in src/[location]/[file].py +- [ ] T016 [US1] Add validation and error handling +- [ ] T017 [US1] Add logging for user story 1 operations + +**Checkpoint**: At this point, User Story 1 should be fully functional and testable independently + +--- + +## Phase 4: User Story 2 - [Title] (Priority: P2) + +**Goal**: [Brief description of what this story delivers] + +**Independent Test**: [How to verify this story works on its own] + +### Tests for User Story 2 (OPTIONAL - only if tests requested) ⚠️ + +- [ ] T018 [P] [US2] Contract test for [endpoint] in tests/contract/test\_[name].py +- [ ] T019 [P] [US2] Integration test for [user journey] in tests/integration/test\_[name].py + +### Implementation for User Story 2 + +- [ ] T020 [P] [US2] Create [Entity] model in src/models/[entity].py +- [ ] T021 [US2] Implement [Service] in src/services/[service].py +- [ ] T022 [US2] Implement [endpoint/feature] in src/[location]/[file].py +- [ ] T023 [US2] Integrate with User Story 1 components (if needed) + +**Checkpoint**: At this point, User Stories 1 AND 2 should both work independently + +--- + +## Phase 5: User Story 3 - [Title] (Priority: P3) + +**Goal**: [Brief description of what this story delivers] + +**Independent Test**: [How to verify this story works on its own] + +### Tests for User Story 3 (OPTIONAL - only if tests requested) ⚠️ + +- [ ] T024 [P] [US3] Contract test for [endpoint] in tests/contract/test\_[name].py +- [ ] T025 [P] [US3] Integration test for [user journey] in tests/integration/test\_[name].py + +### Implementation for User Story 3 + +- [ ] T026 [P] [US3] Create [Entity] model in src/models/[entity].py +- [ ] T027 [US3] Implement [Service] in src/services/[service].py +- [ ] T028 [US3] Implement [endpoint/feature] in src/[location]/[file].py + +**Checkpoint**: All user stories should now be independently functional + +--- + +[Add more user story phases as needed, following the same pattern] + +--- + +## Phase N: Polish & Cross-Cutting Concerns + +**Purpose**: Improvements that affect multiple user stories + +- [ ] TXXX [P] Documentation updates in docs/ +- [ ] TXXX Code cleanup and refactoring +- [ ] TXXX Performance optimization across all stories +- [ ] TXXX [P] Additional unit tests (if requested) in tests/unit/ +- [ ] TXXX Security hardening +- [ ] TXXX Run quickstart.md validation + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies - can start immediately +- **Foundational (Phase 2)**: Depends on Setup completion - BLOCKS all user stories +- **User Stories (Phase 3+)**: All depend on Foundational phase completion + - User stories can then proceed in parallel (if staffed) + - Or sequentially in priority order (P1 → P2 → P3) +- **Polish (Final Phase)**: Depends on all desired user stories being complete + +### User Story Dependencies + +- **User Story 1 (P1)**: Can start after Foundational (Phase 2) - No dependencies on other stories +- **User Story 2 (P2)**: Can start after Foundational (Phase 2) - May integrate with US1 but should + be independently testable +- **User Story 3 (P3)**: Can start after Foundational (Phase 2) - May integrate with US1/US2 but + should be independently testable + +### Within Each User Story + +- Tests (if included) MUST be written and FAIL before implementation +- Models before services +- Services before endpoints +- Core implementation before integration +- Story complete before moving to next priority + +### Parallel Opportunities + +- All Setup tasks marked [P] can run in parallel +- All Foundational tasks marked [P] can run in parallel (within Phase 2) +- Once Foundational phase completes, all user stories can start in parallel (if team capacity + allows) +- All tests for a user story marked [P] can run in parallel +- Models within a story marked [P] can run in parallel +- Different user stories can be worked on in parallel by different team members + +--- + +## Parallel Example: User Story 1 + +```bash +# Launch all tests for User Story 1 together (if tests requested): +Task: "Contract test for [endpoint] in tests/contract/test_[name].py" +Task: "Integration test for [user journey] in tests/integration/test_[name].py" + +# Launch all models for User Story 1 together: +Task: "Create [Entity1] model in src/models/[entity1].py" +Task: "Create [Entity2] model in src/models/[entity2].py" +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1: Setup +2. Complete Phase 2: Foundational (CRITICAL - blocks all stories) +3. Complete Phase 3: User Story 1 +4. **STOP and VALIDATE**: Test User Story 1 independently +5. Deploy/demo if ready + +### Incremental Delivery + +1. Complete Setup + Foundational → Foundation ready +2. Add User Story 1 → Test independently → Deploy/Demo (MVP!) +3. Add User Story 2 → Test independently → Deploy/Demo +4. Add User Story 3 → Test independently → Deploy/Demo +5. Each story adds value without breaking previous stories + +### Parallel Team Strategy + +With multiple developers: + +1. Team completes Setup + Foundational together +2. Once Foundational is done: + - Developer A: User Story 1 + - Developer B: User Story 2 + - Developer C: User Story 3 +3. Stories complete and integrate independently + +--- + +## Notes + +- [P] tasks = different files, no dependencies +- [Story] label maps task to specific user story for traceability +- Each user story should be independently completable and testable +- Verify tests fail before implementing +- Commit after each task or logical group +- Stop at any checkpoint to validate story independently +- Avoid: vague tasks, same file conflicts, cross-story dependencies that break independence diff --git a/implementations/react-native/pnpm-lock.yaml b/implementations/react-native/pnpm-lock.yaml index 9dcccd38..7253e20e 100644 --- a/implementations/react-native/pnpm-lock.yaml +++ b/implementations/react-native/pnpm-lock.yaml @@ -557,11 +557,11 @@ packages: version: 0.0.0 '@contentful/optimization-core@file:../../pkgs/contentful-optimization-core-0.0.0.tgz': - resolution: {integrity: sha512-OQ2V/cmKS/b+jwjV4HYrbUb3XfsNVs2A4sn2hGxZTJkiiUhmCEhobVbhtEM6dakhyhDrV+T/rfXVWx4wwOx3Pg==, tarball: file:../../pkgs/contentful-optimization-core-0.0.0.tgz} + resolution: {integrity: sha512-WuV0q3BPKqu/eXXASy9s/HlN6rKt9jsEwXZqwu/EsmiAppJlJCQTaToppXYet6w2MxJF6ORm13FeQ+XuxcHDgQ==, tarball: file:../../pkgs/contentful-optimization-core-0.0.0.tgz} version: 0.0.0 '@contentful/optimization-react-native@file:../../pkgs/contentful-optimization-react-native-0.0.0.tgz': - resolution: {integrity: sha512-3y19iStvy6t3b3wOzgSyv7MI4qnu2LTBQx6mMaJfBN5Uz5iAJoHO6fosoc51tuLl/MeqOTUxgwBS0ktx/W4i1g==, tarball: file:../../pkgs/contentful-optimization-react-native-0.0.0.tgz} + resolution: {integrity: sha512-WUThstPi4KPevPUY3rdkrSVj3o1wvNJwYu8XbE3+4sVAhxHE0ZDYLe7PtyNopSfFJSp7inFLhTmVXb9Jo8vzlQ==, tarball: file:../../pkgs/contentful-optimization-react-native-0.0.0.tgz} version: 0.0.0 engines: {node: '>=18'} peerDependencies: diff --git a/platforms/javascript/react-native/README.md b/platforms/javascript/react-native/README.md index 7ec2600d..56d94f33 100644 --- a/platforms/javascript/react-native/README.md +++ b/platforms/javascript/react-native/README.md @@ -39,6 +39,8 @@ based on the [Optimization Core Library](/universal/core/README.md). This SDK is - [`` - For Personalized Entries](#personalization----for-personalized-entries) - [`` - For Non-Personalized Entries](#analytics----for-non-personalized-entries) - [ScrollView vs Non-ScrollView Usage](#scrollview-vs-non-scrollview-usage) + - [Inside ScrollView (Recommended for Scrollable Content)](#inside-scrollview-recommended-for-scrollable-content) + - [Outside ScrollView (For Non-Scrollable Content)](#outside-scrollview-for-non-scrollable-content) - [Custom Tracking Thresholds](#custom-tracking-thresholds) - [Manual Analytics Tracking](#manual-analytics-tracking) - [OptimizationRoot](#optimizationroot) @@ -46,8 +48,12 @@ based on the [Optimization Core Library](/universal/core/README.md). This SDK is - [Live Updates Behavior](#live-updates-behavior) - [Default Behavior (Recommended)](#default-behavior-recommended) - [Enabling Live Updates](#enabling-live-updates) + - [1. Preview Panel (Automatic)](#1-preview-panel-automatic) + - [2. Global Setting via OptimizationRoot](#2-global-setting-via-optimizationroot) + - [3. Per-Component Override](#3-per-component-override) - [Priority Order](#priority-order) - [React Native-Specific Defaults](#react-native-specific-defaults) + - [Persistence Behavior](#persistence-behavior) - [Offline Support](#offline-support) - [How It Works](#how-it-works) - [Polyfills](#polyfills) @@ -95,7 +101,7 @@ const optimization = await Optimization.create({ | Option | Required? | Default | Description | | -------------------------- | --------- | ----------------------------- | ----------------------------------------------------------------- | -| `allowedEventTypes` | No | `['identify', 'page']` | Allow-listed event types permitted when consent is not set | +| `allowedEventTypes` | No | `['identify', 'screen']` | Allow-listed event types permitted when consent is not set | | `analytics` | No | See "Analytics Options" | Configuration specific to the Analytics/Insights API | | `clientId` | Yes | N/A | The Optimization API key | | `defaults` | No | `undefined` | Set of default state values applied on initialization | diff --git a/platforms/javascript/react-native/src/builders/EventBuilder.ts b/platforms/javascript/react-native/src/builders/EventBuilder.ts deleted file mode 100644 index d9b09e04..00000000 --- a/platforms/javascript/react-native/src/builders/EventBuilder.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { Dimensions, Platform } from 'react-native' - -/** - * Returns a user-agent string identifying the React Native platform and version. - * - * @returns A string in the format `React Native/{version} ({os})` - * - * @example - * ```ts - * getUserAgent() // "React Native/33 (ios)" - * ``` - * - * @internal - */ -export function getUserAgent(): string { - return `React Native/${Platform.Version} (${Platform.OS})` -} - -/** - * Returns the default locale for event attribution. - * - * @returns The locale string - * - * @remarks - * Currently returns a static `'en-US'`. For dynamic locale detection, configure - * `eventBuilder.getLocale` in the SDK options with a library like `react-native-localize`. - * - * @internal - */ -export function getLocale(): string { - return 'en-US' -} - -/** - * Page properties attached to outgoing events in a React Native context. - * - * @internal - */ -export interface PageProperties { - path: string - query: Record - referrer: string - search: string - title: string - url: string - height?: number - width?: number -} - -/** - * Returns default page properties for event attribution in React Native. - * - * @returns Page properties including the current window dimensions - * - * @internal - */ -export function getPageProperties(): PageProperties { - const { width, height } = Dimensions.get('window') - return { - path: '/', - query: {}, - referrer: '', - search: '', - title: 'React Native App', - url: 'app://', - width, - height, - } -} diff --git a/platforms/javascript/react-native/src/index.ts b/platforms/javascript/react-native/src/index.ts index 44a9bde1..bb395071 100644 --- a/platforms/javascript/react-native/src/index.ts +++ b/platforms/javascript/react-native/src/index.ts @@ -13,14 +13,12 @@ import './images' import './polyfills/crypto' import { - type CoreConfig, type CoreStatefulConfig, CoreStateful, effect, signals, } from '@contentful/optimization-core' import { merge } from 'es-toolkit' -import { getLocale, getPageProperties, getUserAgent } from './builders/EventBuilder' import { OPTIMIZATION_REACT_NATIVE_SDK_NAME, OPTIMIZATION_REACT_NATIVE_SDK_VERSION, @@ -38,10 +36,11 @@ import AsyncStorageStore from './storage/AsyncStorageStore' * @internal */ async function mergeConfig({ + allowedEventTypes, defaults, logLevel, ...config -}: CoreStatefulConfig): Promise { +}: CoreStatefulConfig): Promise { await AsyncStorageStore.initialize() const { @@ -51,7 +50,7 @@ async function mergeConfig({ personalizations = AsyncStorageStore.personalizations, } = defaults ?? {} - return merge( + const mergedConfig = merge( { defaults: { consent, @@ -65,14 +64,16 @@ async function mergeConfig({ name: OPTIMIZATION_REACT_NATIVE_SDK_NAME, version: OPTIMIZATION_REACT_NATIVE_SDK_VERSION, }, - getLocale, - getPageProperties, - getUserAgent, }, logLevel: AsyncStorageStore.debug ? 'debug' : logLevel, }, config, ) + + return { + ...mergedConfig, + allowedEventTypes: allowedEventTypes ?? ['identify', 'screen'], + } } let activeOptimizationInstance: Optimization | undefined = undefined @@ -104,11 +105,11 @@ class Optimization extends CoreStateful { private readonly cleanupAppStateListener: () => void - private constructor(config: CoreConfig) { + private constructor(config: CoreStatefulConfig) { super(config) this.cleanupOnlineListener = createOnlineChangeListener((isOnline) => { - this.online(isOnline) + this.online = isOnline }) this.cleanupAppStateListener = createAppStateChangeListener(async () => { @@ -168,7 +169,7 @@ class Optimization extends CoreStateful { * * @public */ - static async create(config: CoreConfig): Promise { + static async create(config: CoreStatefulConfig): Promise { if (activeOptimizationInstance) { throw new Error( 'Optimization React Native SDK is already initialized. Reuse the existing instance.', diff --git a/platforms/javascript/web/src/Optimization.test.ts b/platforms/javascript/web/src/Optimization.test.ts index f5ec0dbc..f331a41b 100644 --- a/platforms/javascript/web/src/Optimization.test.ts +++ b/platforms/javascript/web/src/Optimization.test.ts @@ -16,6 +16,16 @@ const getAutoTrackEntryViews = (optimization: Optimization): boolean | undefined return typeof value === 'boolean' ? value : undefined } +const getAllowedEventTypes = (optimization: Optimization): string[] | undefined => { + const value = Reflect.get(optimization.personalization, 'allowedEventTypes') + + if (!Array.isArray(value)) { + return + } + + return value.filter((eventType): eventType is string => typeof eventType === 'string') +} + describe('Optimization', () => { beforeEach(() => { delete window.optimization @@ -46,6 +56,21 @@ describe('Optimization', () => { expect(getAutoTrackEntryViews(web)).toBe(true) }) + it('defaults allowedEventTypes to identify/page for web', () => { + const web = new Optimization(config) + + expect(getAllowedEventTypes(web)).toEqual(['identify', 'page']) + }) + + it('uses user-provided allowedEventTypes when configured', () => { + const web = new Optimization({ + ...config, + allowedEventTypes: ['identify', 'page', 'screen'], + }) + + expect(getAllowedEventTypes(web)).toEqual(['identify', 'page', 'screen']) + }) + it('forwards onEventBlocked callback to core stateful guards', async () => { const onEventBlocked = rs.fn() const web = new Optimization({ ...config, onEventBlocked }) diff --git a/platforms/javascript/web/src/Optimization.ts b/platforms/javascript/web/src/Optimization.ts index 2b5f16d7..4652133b 100644 --- a/platforms/javascript/web/src/Optimization.ts +++ b/platforms/javascript/web/src/Optimization.ts @@ -139,6 +139,7 @@ export interface OptimizationWebConfig extends CoreStatefulConfig { */ function mergeConfig({ app, + allowedEventTypes, defaults, logLevel, ...config @@ -150,7 +151,7 @@ function mergeConfig({ personalizations = LocalStore.personalizations, } = defaults ?? {} - return merge( + const mergedConfig = merge( { analytics: { beaconHandler }, defaults: { @@ -172,6 +173,11 @@ function mergeConfig({ }, config, ) + + return { + ...mergedConfig, + allowedEventTypes: allowedEventTypes ?? ['identify', 'page'], + } } /** @@ -272,7 +278,7 @@ class Optimization extends CoreStateful { } this.cleanupOnlineListener = createOnlineChangeListener((isOnline) => { - this.online(isOnline) + this.online = isOnline }) this.cleanupVisibilityListener = createVisibilityChangeListener(async () => { diff --git a/specs/001-shared-and-foundational/spec.md b/specs/001-shared-and-foundational/spec.md new file mode 100644 index 00000000..ccfc6885 --- /dev/null +++ b/specs/001-shared-and-foundational/spec.md @@ -0,0 +1,119 @@ +# Feature Specification: Shared and Foundational Schema Contracts + +**Feature Branch**: `[001-shared-and-foundational]` +**Created**: 2026-02-26 +**Status**: Draft +**Input**: User description: "Examine current functionality in +`@contentful/optimization-api-schemas` package and derive a list of SpecKit-compatible +specifications..." + +## User Scenarios & Testing _(mandatory)_ + +### User Story 1 - Runtime Boundary Validation (Priority: P1) + +As an SDK runtime developer, I need a shared way to validate unknown payloads at runtime so API +boundary failures are caught early and reported with actionable error messages. + +**Why this priority**: Runtime contract validation is the safety boundary for all higher-layer SDK +packages. + +**Independent Test**: Call `parseWithFriendlyError` with valid and invalid payloads and verify typed +output or thrown error text with meaningful details. + +**Acceptance Scenarios**: + +1. **Given** a valid payload and matching Zod Mini schema, **When** `parseWithFriendlyError` is + called, **Then** parsed typed data is returned. +2. **Given** an invalid payload, **When** `parseWithFriendlyError` is called, **Then** an `Error` is + thrown with prettified field/path details. + +--- + +### User Story 2 - Stable Public Contract Surface (Priority: P2) + +As an SDK package consumer, I need stable top-level and domain-level exports so I can import +schemas/types consistently across runtimes. + +**Why this priority**: The package is a shared dependency for API client, core, and platform +packages. + +**Independent Test**: Build and typecheck dependent packages that import from root and domain +barrels. + +**Acceptance Scenarios**: + +1. **Given** package root import usage, **When** the consumer imports from + `@contentful/optimization-api-schemas`, **Then** contentful/experience/insights/validation + exports are available. +2. **Given** a production build, **When** artifacts are published, **Then** both ESM and CJS entry + points plus dual declaration files are emitted. + +--- + +### User Story 3 - Extensible Schema Design Patterns (Priority: P3) + +As a schema maintainer, I need consistent extensibility patterns so future API evolution can be +introduced with minimal breakage. + +**Why this priority**: API contracts evolve; extensibility prevents frequent hard-breaking updates. + +**Independent Test**: Extend existing schema unions/defaults in a branch and confirm existing use +sites continue passing typecheck and runtime parsing. + +**Acceptance Scenarios**: + +1. **Given** schema families with discriminators, **When** new event/change variants are added, + **Then** existing variants remain backward compatible. +2. **Given** optional/defaulted fields, **When** fields are omitted, **Then** deterministic defaults + are applied where defined. + +--- + +### Edge Cases + +- Invalid root-level payloads must still return descriptive errors even when no object path exists. +- Nested object failures must include dot-path context (for example, `context.locale`). +- Optional nullable configuration fields must behave deterministically for omitted, `null`, and + partial object inputs. +- Schema parsing utility must throw plain `Error` objects rather than leaking `ZodError` instances. + +## Requirements _(mandatory)_ + +### Functional Requirements + +- **FR-001**: The package MUST use `zod/mini` schemas as the runtime validation primitive. +- **FR-002**: The package MUST expose a root barrel exporting `contentful`, `experience`, + `insights`, and `validation` domains. +- **FR-003**: The package MUST expose domain barrel files so callers can import grouped schema + families. +- **FR-004**: The package MUST provide `parseWithFriendlyError(schema, data)` that returns typed + parsed data on success. +- **FR-005**: `parseWithFriendlyError` MUST throw `Error` with `z.prettifyError` output on parse + failure. +- **FR-006**: The validation utility MUST configure English locale messaging for Zod errors. +- **FR-007**: Schema families SHOULD use discriminated unions for event/change variants where a + `type` discriminator exists. +- **FR-008**: Schema families SHOULD use explicit defaulting (`prefault`) for optional fields that + require stable runtime defaults. +- **FR-009**: The package MUST publish ESM and CJS build outputs and dual declaration outputs. +- **FR-010**: Unit tests MUST verify friendly error message behavior for root-level and nested path + failures. + +### Key Entities _(include if feature involves data)_ + +- **Schema Domain Barrel**: Export group for one contract family (`contentful`, `experience`, + `insights`, `validation`). +- **Validation Utility**: `parseWithFriendlyError` adapter over `schema.safeParse`. +- **Discriminated Contract Family**: Schema set keyed by `type` literals for runtime event/change + routing. +- **Published Artifact Set**: Dist outputs for `.mjs`, `.cjs`, and type declarations. + +## Success Criteria _(mandatory)_ + +### Measurable Outcomes + +- **SC-001**: Validation utility tests pass for success, root-error, and nested-path cases. +- **SC-002**: A clean package build emits both `dist/index.mjs` and `dist/index.cjs`. +- **SC-003**: A clean package build emits dual declaration outputs for both import and require type + resolution. +- **SC-004**: Dependent workspace packages can import root/domain schemas without type errors. diff --git a/specs/002-contentful-cda-schemas/spec.md b/specs/002-contentful-cda-schemas/spec.md new file mode 100644 index 00000000..b42853dd --- /dev/null +++ b/specs/002-contentful-cda-schemas/spec.md @@ -0,0 +1,126 @@ +# Feature Specification: Contentful CDA Personalization Contract Schemas + +**Feature Branch**: `[002-contentful-cda-schemas]` +**Created**: 2026-02-26 +**Status**: Draft +**Input**: User description: "Derive SpecKit-compatible specifications for Contentful CDA schemas in +`@contentful/optimization-api-schemas`." + +## User Scenarios & Testing _(mandatory)_ + +### User Story 1 - Validate CDA Entry Shapes for Runtime Use (Priority: P1) + +As a personalization runtime developer, I need to validate CDA entries and links before using them +so personalization logic only runs on structurally valid content data. + +**Why this priority**: Entry parsing is the first gate before personalization resolution. + +**Independent Test**: Parse representative `CtflEntry`, `Link`, and specialized entry payloads and +assert success/failure paths. + +**Acceptance Scenarios**: + +1. **Given** a generic CDA entry with `fields`, `metadata.tags`, and `sys`, **When** parsed with + `CtflEntry`, **Then** parsing succeeds. +2. **Given** an invalid entry missing required `sys` metadata, **When** parsed with `CtflEntry`, + **Then** parsing fails. + +--- + +### User Story 2 - Validate Personalization Entry Configuration (Priority: P2) + +As a personalization feature developer, I need structured schemas for personalization entries and +their config components so variant selection logic receives predictable input. + +**Why this priority**: Personalization behavior depends on strict interpretation of config fields. + +**Independent Test**: Parse entries with omitted/null/partial `nt_config` and confirm deterministic +defaulting behavior. + +**Acceptance Scenarios**: + +1. **Given** a personalization entry with `nt_config` set to `null`, **When** parsed, **Then** a + default config object is produced. +2. **Given** a personalization entry with component definitions, **When** parsed, **Then** component + schema discrimination succeeds for supported component types. + +--- + +### User Story 3 - Use Type Guards in Runtime Entry Resolution (Priority: P3) + +As a core SDK maintainer, I need type guard helpers for generic and specialized entries so runtime +resolution can branch safely without manual casting. + +**Why this priority**: Type guards reduce unsafe assumptions and improve integration ergonomics. + +**Independent Test**: Run guards (`isEntry`, `isPersonalizedEntry`, `isPersonalizationEntry`, +component guards) against valid and invalid samples. + +**Acceptance Scenarios**: + +1. **Given** a generic entry payload, **When** `isEntry` is called, **Then** result reflects schema + validity. +2. **Given** a personalized entry payload with `nt_experiences`, **When** `isPersonalizedEntry` is + called, **Then** result is `true`. + +--- + +### Edge Cases + +- `nt_config` omitted or `null` must still produce deterministic fallback config. +- `nt_variants` omitted must default to an empty array. +- Entry replacement components may omit `type`; omitted discriminator must still be treated as entry + replacement logic. +- `MergeTagEntry` must fail if `sys.contentType` is not `nt_mergetag`. +- Personalization arrays must allow both unresolved links and resolved personalization entries. + +## Requirements _(mandatory)_ + +### Functional Requirements + +- **FR-001**: The schema set MUST include base Contentful link primitives (`Link`, + `ContentTypeLink`, `EnvironmentLink`, `SpaceLink`, `TagLink`). +- **FR-002**: The schema set MUST include `EntrySys` and `CtflEntry` as reusable base entry + contracts. +- **FR-003**: `CtflEntry.fields` MUST accept JSON-like key/value data through catch-all validation. +- **FR-004**: `PersonalizedEntry` MUST extend `CtflEntry` with required + `fields.nt_experiences: PersonalizationEntryArray`. +- **FR-005**: `PersonalizationEntry` MUST require `nt_name`, `nt_type`, and `nt_experience_id` in + fields. +- **FR-006**: `PersonalizationType` MUST support exactly `nt_experiment` and `nt_personalization`. +- **FR-007**: `PersonalizationEntryFields.nt_config` MUST accept nullable/optional values and + transform missing/null to default + `{ traffic: 0, distribution: [0.5, 0.5], components: [], sticky: false }`. +- **FR-008**: `PersonalizationConfig` MUST model distribution, traffic, components, and sticky with + explicit defaults for omitted fields. +- **FR-009**: Personalization components MUST be represented as a discriminated union between + `EntryReplacement` and `InlineVariable` schemas. +- **FR-010**: `MergeTagEntry` MUST constrain `sys.contentType.sys.id` to `nt_mergetag`. +- **FR-011**: `AudienceEntry` MUST define `nt_audience_id` and optional descriptive fields. +- **FR-012**: Skeleton schemas MUST exist for audience (`nt_audience`) and personalization + (`nt_experience`) content types. +- **FR-013**: Type guard helpers MUST be provided for entry-level runtime narrowing (`isEntry`, + `isPersonalizedEntry`, `isPersonalizationEntry`). +- **FR-014**: Component-level type guard helpers MUST be provided for entry replacement, inline + variable, and replacement variant schemas. + +### Key Entities _(include if feature involves data)_ + +- **CtflEntry**: Base Contentful entry contract with `fields`, `metadata`, and `sys`. +- **PersonalizedEntry**: Base entry extended with attached personalization references. +- **PersonalizationEntry**: Specialized entry describing one personalization/experiment definition. +- **PersonalizationConfig**: Behavior/config block controlling traffic, distribution, and component + variants. +- **AudienceEntry**: Audience metadata used for targeting conditions. +- **MergeTagEntry**: Merge tag entry with fallback and constrained content type. + +## Success Criteria _(mandatory)_ + +### Measurable Outcomes + +- **SC-001**: Valid CDA fixture payloads parse successfully for base and specialized entry schemas. +- **SC-002**: Invalid payloads missing required system or field-level data are rejected by schema + parsing. +- **SC-003**: Null/omitted personalization config inputs produce deterministic defaulted outputs. +- **SC-004**: Type guard functions correctly narrow types for valid inputs and reject invalid + shapes. diff --git a/specs/003-experience-api-schemas/spec.md b/specs/003-experience-api-schemas/spec.md new file mode 100644 index 00000000..c6e4fb49 --- /dev/null +++ b/specs/003-experience-api-schemas/spec.md @@ -0,0 +1,134 @@ +# Feature Specification: Experience API Request, Event, and Response Schemas + +**Feature Branch**: `[003-experience-api-schemas]` +**Created**: 2026-02-26 +**Status**: Draft +**Input**: User description: "Derive SpecKit-compatible specifications for Experience API schemas in +`@contentful/optimization-api-schemas`." + +## User Scenarios & Testing _(mandatory)_ + +### User Story 1 - Validate Outbound Experience Requests (Priority: P1) + +As an API client developer, I need request payload schemas for single and batch experience calls so +outbound events are validated before network transmission. + +**Why this priority**: Bad outbound requests cause immediate API errors and downstream behavior +drift. + +**Independent Test**: Validate request payloads with empty and non-empty event arrays for both +single and batch request schemas. + +**Acceptance Scenarios**: + +1. **Given** a request with at least one valid experience event, **When** parsed as + `ExperienceRequestData`, **Then** parsing succeeds. +2. **Given** a request with zero events, **When** parsed as `ExperienceRequestData`, **Then** + parsing fails. + +--- + +### User Story 2 - Validate Inbound Experience Responses (Priority: P2) + +As a runtime integrator, I need response envelope/data schemas so received Experience API payloads +can be trusted before they enter personalization logic. + +**Why this priority**: Response parsing protects core logic and state from malformed API data. + +**Independent Test**: Parse standard and batch response fixtures, including invalid envelopes. + +**Acceptance Scenarios**: + +1. **Given** a valid experience response envelope with profile, experiences, and changes, **When** + parsed with `ExperienceResponse`, **Then** parsing succeeds. +2. **Given** a response missing required envelope fields (`message`, `error`), **When** parsed, + **Then** parsing fails. + +--- + +### User Story 3 - Maintain a Typed Event and Profile Taxonomy (Priority: P3) + +As a schema maintainer, I need a complete discriminated event model and profile/change primitives so +all personalization and analytics builders share one contract source. + +**Why this priority**: A unified event/profile contract prevents divergence between builders and API +client layers. + +**Independent Test**: Parse each event variant and verify discriminator behavior plus per-variant +required fields. + +**Acceptance Scenarios**: + +1. **Given** event payloads for `alias`, `component`, `group`, `identify`, `page`, `screen`, and + `track`, **When** parsed with `ExperienceEvent`, **Then** all valid variants are accepted. +2. **Given** batch events missing `anonymousId`, **When** parsed with `BatchExperienceEvent`, + **Then** parsing fails. + +--- + +### Edge Cases + +- Timestamp fields must be ISO datetime strings and fail validation on malformed values. +- `countryCode` in geo-location must be exactly two characters when present. +- Unknown change objects may parse with `UnknownChange`, but the `Change` discriminated union must + currently only accept `Variable`. +- `BatchExperienceResponseData.profiles` may be omitted entirely. +- `PageViewEvent` and `ScreenViewEvent` must enforce page/screen-specific context overrides. + +## Requirements _(mandatory)_ + +### Functional Requirements + +- **FR-001**: `ExperienceRequestData` MUST require `events` and enforce minimum length of 1. +- **FR-002**: `BatchExperienceRequestData` MUST require `events` and enforce minimum length of 1. +- **FR-003**: Request schemas MUST support optional request options with optional + `features: string[]`. +- **FR-004**: `ExperienceEvent` MUST be a discriminated union over `alias`, `component`, `group`, + `identify`, `page`, `screen`, and `track`. +- **FR-005**: `BatchExperienceEvent` MUST mirror `ExperienceEvent` variants and require + `anonymousId` on every variant. +- **FR-006**: Universal event contracts MUST require channel, context, message identifier, and three + ISO datetime timestamps (`originalTimestamp`, `sentAt`, `timestamp`). +- **FR-007**: Universal event context MUST require GDPR consent state and library metadata. +- **FR-008**: `PageViewEvent` MUST require page properties and page context; `name` remains + optional. +- **FR-009**: `ScreenViewEvent` MUST require screen name and screen context; properties remain + optional. +- **FR-010**: `TrackEvent` MUST require event name plus generic properties map. +- **FR-011**: `ComponentViewEvent` MUST require component type/id and variant index, with optional + `experienceId`. +- **FR-012**: `ExperienceResponse` MUST extend a shared response envelope and include `profile`, + `experiences`, and `changes` in `data`. +- **FR-013**: `BatchExperienceResponse` MUST extend a shared response envelope and include optional + `profiles` in `data`. +- **FR-014**: `Profile` MUST include id, stable id, random, audiences, traits, location, and session + statistics. +- **FR-015**: `PartialProfile` MUST require `id` and allow additional JSON-compatible attributes. +- **FR-016**: `SelectedPersonalization` MUST include experience id, variant index, variants map, and + defaultable sticky flag. +- **FR-017**: Change contracts MUST include typed variable changes and expose unknown-change + fallback schema for forward-compatible handling. +- **FR-018**: Property-level schemas MUST constrain channel values to `mobile|server|web` and + `GeoLocation.countryCode` length to two characters when present. + +### Key Entities _(include if feature involves data)_ + +- **ExperienceRequestData**: Outbound single-request payload (events + options). +- **BatchExperienceRequestData**: Outbound batch-request payload (batch events + options). +- **ExperienceEvent / BatchExperienceEvent**: Discriminated event unions used for tracking and + personalization evaluation. +- **ResponseEnvelope**: Shared response metadata wrapper (`data`, `message`, `error`). +- **ExperienceResponse / BatchExperienceResponse**: Typed inbound response contracts. +- **Profile / PartialProfile**: Full and partial profile representations. +- **Change / VariableChange**: Contract for personalization flag/change payloads. +- **SelectedPersonalization**: Chosen experience/variant mapping outcome. + +## Success Criteria _(mandatory)_ + +### Measurable Outcomes + +- **SC-001**: Single and batch request schemas reject empty event arrays in runtime validation. +- **SC-002**: All seven supported experience event variants parse successfully with valid payloads. +- **SC-003**: Batch experience events fail validation whenever `anonymousId` is absent. +- **SC-004**: Standard and batch response schemas accept valid envelope/data payloads and reject + malformed envelope fields. diff --git a/specs/004-insights-api-schemas/spec.md b/specs/004-insights-api-schemas/spec.md new file mode 100644 index 00000000..c356396f --- /dev/null +++ b/specs/004-insights-api-schemas/spec.md @@ -0,0 +1,107 @@ +# Feature Specification: Insights API Event and Batch Ingestion Schemas + +**Feature Branch**: `[004-insights-api-schemas]` +**Created**: 2026-02-26 +**Status**: Draft +**Input**: User description: "Derive SpecKit-compatible specifications for Insights API schemas in +`@contentful/optimization-api-schemas`." + +## User Scenarios & Testing _(mandatory)_ + +### User Story 1 - Validate Insights Events Prior to Send (Priority: P1) + +As an analytics pipeline developer, I need a schema for Insights events so only valid event types +are accepted before sending to the Insights API. + +**Why this priority**: Analytics ingestion should reject invalid events before transport attempts. + +**Independent Test**: Parse valid and invalid insight event payloads through `InsightsEvent`. + +**Acceptance Scenarios**: + +1. **Given** a valid component view event payload, **When** parsed with `InsightsEvent`, **Then** + parsing succeeds. +2. **Given** a non-supported event type payload, **When** parsed with `InsightsEvent`, **Then** + parsing fails. + +--- + +### User Story 2 - Validate Batched Insights Request Bodies (Priority: P2) + +As an SDK client implementer, I need a batched schema contract that pairs profile identity with +events so request bodies match ingestion API expectations. + +**Why this priority**: Batch structure is the direct transport payload for Insights ingestion. + +**Independent Test**: Parse batch payloads that vary profile completeness and event list +composition. + +**Acceptance Scenarios**: + +1. **Given** a batch payload with `profile.id` and valid events, **When** parsed with + `BatchInsightsEvent`, **Then** parsing succeeds. +2. **Given** a batch payload missing `profile.id`, **When** parsed with `BatchInsightsEvent`, + **Then** parsing fails. + +--- + +### User Story 3 - Preserve Cross-Domain Contract Reuse (Priority: P3) + +As a maintainer, I need Insights schemas to reuse Experience-domain primitives so event/profile +compatibility remains consistent across APIs. + +**Why this priority**: Reuse prevents drift between personalization and analytics data contracts. + +**Independent Test**: Verify `InsightsEvent` and `BatchInsightsEvent` are composed from +`ComponentViewEvent` and `PartialProfile` contracts. + +**Acceptance Scenarios**: + +1. **Given** updates to shared component event or profile schemas, **When** Insights schemas are + parsed, **Then** behavior remains aligned with shared primitives. +2. **Given** batch arrays of profile+events objects, **When** parsed with `BatchInsightsEventArray`, + **Then** each element enforces identical schema rules. + +--- + +### Edge Cases + +- Batch payloads with empty `events` arrays are currently valid and must remain parseable unless the + contract is intentionally tightened. +- Unsupported insight event discriminators must be rejected. +- Profile payloads may include additional JSON fields, but must always include `id`. +- Component events missing required identifiers (`componentId`) must fail parsing. + +## Requirements _(mandatory)_ + +### Functional Requirements + +- **FR-001**: `InsightsEvent` MUST be a discriminated union keyed by `type`. +- **FR-002**: The current `InsightsEvent` union MUST include `ComponentViewEvent` as the supported + event variant. +- **FR-003**: `InsightsEventArray` MUST represent an array of `InsightsEvent`. +- **FR-004**: `BatchInsightsEvent` MUST require a `profile` object validated by `PartialProfile`. +- **FR-005**: `BatchInsightsEvent` MUST require an `events` array validated by `InsightsEventArray`. +- **FR-006**: `BatchInsightsEventArray` MUST represent an array of `BatchInsightsEvent`. +- **FR-007**: Insights contracts MUST reuse Experience-domain shared schemas (`ComponentViewEvent`, + `PartialProfile`) rather than duplicate equivalent schema definitions. +- **FR-008**: Insights domain barrels MUST export event and batch schema contracts through + `insights/index.ts` and `insights/event/index.ts`. + +### Key Entities _(include if feature involves data)_ + +- **InsightsEvent**: Valid analytics event contract for Insights ingestion. +- **InsightsEventArray**: Collection of analytics events for a profile context. +- **BatchInsightsEvent**: One profile-scoped batch payload containing events. +- **BatchInsightsEventArray**: Multi-batch request payload structure. +- **PartialProfile**: Shared profile contract requiring `id` plus optional JSON attributes. + +## Success Criteria _(mandatory)_ + +### Measurable Outcomes + +- **SC-001**: Valid component-view events parse successfully through `InsightsEvent`. +- **SC-002**: Unsupported event types are rejected by `InsightsEvent` parsing. +- **SC-003**: Batch payloads without `profile.id` are rejected by `BatchInsightsEvent`. +- **SC-004**: Array-level validation applies consistently for every element in + `BatchInsightsEventArray`. diff --git a/specs/005-api-client-foundational-and-shared/spec.md b/specs/005-api-client-foundational-and-shared/spec.md new file mode 100644 index 00000000..52df1375 --- /dev/null +++ b/specs/005-api-client-foundational-and-shared/spec.md @@ -0,0 +1,137 @@ +# Feature Specification: API Client Foundational and Shared Contracts + +**Feature Branch**: `[005-api-client-foundational-and-shared]` +**Created**: 2026-02-26 +**Status**: Draft +**Input**: User description: "Examine current functionality in `@contentful/optimization-api-client` +and derive SpecKit-compatible specifications." + +## User Scenarios & Testing _(mandatory)_ + +### User Story 1 - Configure a Single Aggregated Client (Priority: P1) + +As an SDK integrator, I need one top-level client that wires both Experience and Insights APIs with +shared configuration so I can initialize once and call both APIs consistently. + +**Why this priority**: Aggregation and config inheritance are the primary entry point for runtime +adoption. + +**Independent Test**: Instantiate `ApiClient` with shared config and verify `experience` and +`insights` clients receive shared fields plus per-client overrides. + +**Acceptance Scenarios**: + +1. **Given** a valid `ApiClient` config with `clientId`, **When** the client is created, **Then** + `.experience` and `.insights` are initialized and available. +2. **Given** per-client `baseUrl` overrides, **When** the client is created, **Then** each + sub-client uses only its own override. +3. **Given** an unsupported top-level `baseUrl` at runtime, **When** the client is created, **Then** + sub-clients ignore it. + +--- + +### User Story 2 - Apply Shared Transport Protection (Priority: P1) + +As an API client maintainer, I need shared timeout, retry, and error logging behavior so transport +failures are handled consistently across all API clients. + +**Why this priority**: Cross-client transport consistency prevents divergent reliability and logging +behavior. + +**Independent Test**: Verify `Fetch.create` composition, timeout callbacks, retry-on-503 behavior, +and log behavior for abort vs non-abort errors. + +**Acceptance Scenarios**: + +1. **Given** protected fetch configuration, **When** `Fetch.create` is called, **Then** timeout + protection is composed before retry protection. +2. **Given** a request timeout, **When** no custom timeout callback is provided, **Then** the + timeout wrapper logs an error and aborts the request. +3. **Given** a `503` response, **When** retry config allows attempts, **Then** the request is + retried. +4. **Given** a non-`503` non-OK response, **When** retry logic executes, **Then** retries stop and + the request fails with a non-retriable error. + +--- + +### User Story 3 - Preserve a Stable Shared Package Surface (Priority: P2) + +As a downstream package developer, I need a stable root export surface so I can import client types, +builders, and schema contracts from one package. + +**Why this priority**: Multiple workspace packages depend on a predictable shared import surface. + +**Independent Test**: Build/typecheck dependent packages importing from the root package and verify +dual module output artifacts. + +**Acceptance Scenarios**: + +1. **Given** root imports from `@contentful/optimization-api-client`, **When** consumers import + client/builders/experience/insights/schema exports, **Then** type resolution succeeds. +2. **Given** a production build, **When** artifacts are emitted, **Then** both ESM/CJS bundles and + dual declarations are generated. + +--- + +### Edge Cases + +- Missing `environment` must default to `'main'`. +- `ApiClientBase.logRequestError` must not log for thrown values that are not `Error` instances. +- Timeout aborts may be transformed into a generic non-retriable request error by retry handling. +- Retry logic must treat only `503` as retryable under current transport policy. +- `createProtectedFetchMethod` must rethrow original errors after logging (for `Error` instances). + +## Requirements _(mandatory)_ + +### Functional Requirements + +- **FR-001**: `ApiClient` MUST accept shared config fields `clientId`, optional `environment`, and + optional `fetchOptions`. +- **FR-002**: `ApiClient` MUST construct both `ExperienceApiClient` and `InsightsApiClient` from + shared config merged with per-client overrides. +- **FR-003**: Shared config MUST NOT define a top-level supported `baseUrl` field; base URLs are + per-client configuration. +- **FR-004**: `ApiClientBase` MUST default `environment` to `'main'` when omitted. +- **FR-005**: `ApiClientBase` MUST create its fetch method via + `Fetch.create({ ...fetchOptions, apiName })`. +- **FR-006**: `ApiClientBase.logRequestError` MUST log abort errors at warning level with request + context. +- **FR-007**: `ApiClientBase.logRequestError` MUST log non-abort `Error` instances at error level. +- **FR-008**: `ApiClientBase.logRequestError` MUST ignore non-`Error` thrown values. +- **FR-009**: `createTimeoutFetchMethod` MUST default `requestTimeout` to `3000ms`. +- **FR-010**: `createTimeoutFetchMethod` MUST invoke `onRequestTimeout({ apiName })` when provided + before aborting. +- **FR-011**: `createTimeoutFetchMethod` MUST log timeout failure when no timeout callback is + provided. +- **FR-012**: `createRetryFetchMethod` MUST default to `retries: 1` and `intervalTimeout: 0`. +- **FR-013**: `createRetryFetchMethod` MUST retry only when response status is `503`. +- **FR-014**: `createRetryFetchMethod` MUST abort and fail non-`503` failures as non-retriable + request errors. +- **FR-015**: `createRetryFetchMethod` MUST pass `apiName` to `onFailedAttempt` callback metadata. +- **FR-016**: `createProtectedFetchMethod` MUST compose timeout wrapper first, then retry wrapper. +- **FR-017**: `createProtectedFetchMethod` MUST log and rethrow `Error` failures (abort vs non-abort + severity differs). +- **FR-018**: Package root exports MUST include API client classes, builders, experience/insights + modules, and re-exported schema contracts. +- **FR-019**: Package build output MUST include ESM (`.mjs`), CJS (`.cjs`), and dual declaration + types (`.d.mts`/`.d.cts`). + +### Key Entities _(include if feature involves data)_ + +- **ApiClient**: Aggregated top-level client exposing `.experience` and `.insights`. +- **ApiClientBase**: Shared base class for config, protected fetch creation, and request error + logging. +- **Protected Fetch Method**: Timeout + retry composed fetch wrapper used by all API clients. +- **Fetch Callback Options**: Metadata passed to timeout/retry callbacks (`apiName`, attempt data, + optional error). +- **Package Export Surface**: Root and submodule contract exposed to downstream packages. + +## Success Criteria _(mandatory)_ + +### Measurable Outcomes + +- **SC-001**: `ApiClient` initialization tests confirm both sub-clients are always constructed. +- **SC-002**: Runtime tests confirm per-client `baseUrl` overrides remain isolated. +- **SC-003**: Transport wrapper tests confirm timeout, retry, and callback behavior under success + and failure paths. +- **SC-004**: Build artifacts include dual runtime module formats and dual declaration formats. diff --git a/specs/006-api-client-experience-api/spec.md b/specs/006-api-client-experience-api/spec.md new file mode 100644 index 00000000..1b3680af --- /dev/null +++ b/specs/006-api-client-experience-api/spec.md @@ -0,0 +1,139 @@ +# Feature Specification: Experience API Client Contracts + +**Feature Branch**: `[006-api-client-experience-api]` +**Created**: 2026-02-26 +**Status**: Draft +**Input**: User description: "Derive SpecKit-compatible specs from current Experience client +behavior in `@contentful/optimization-api-client`." + +## User Scenarios & Testing _(mandatory)_ + +### User Story 1 - Retrieve and Mutate Profiles Reliably (Priority: P1) + +As a personalization runtime developer, I need strongly defined profile read/write methods so I can +fetch, create, and update profile state with predictable request/response behavior. + +**Why this priority**: Profile retrieval and mutation are core Experience API capabilities. + +**Independent Test**: Execute `getProfile`, `createProfile`, and `updateProfile` with valid and +invalid input and validate URL shape, headers, response mapping, and thrown errors. + +**Acceptance Scenarios**: + +1. **Given** a valid profile ID, **When** `getProfile(id)` is called, **Then** the client requests + `/profiles/:id` and returns `{ profile, personalizations, changes }`. +2. **Given** a missing/empty profile ID, **When** `getProfile` or `updateProfile` is called, + **Then** a validation error is thrown before network execution. +3. **Given** valid events, **When** `createProfile` or `updateProfile` is called, **Then** the body + is schema-validated and a parsed Experience response is returned. + +--- + +### User Story 2 - Apply Request Options with Deterministic Precedence (Priority: P1) + +As an integrator, I need request option precedence to be deterministic across client defaults and +per-call overrides so transport and API semantics stay predictable. + +**Why this priority**: Option precedence affects behavior across SSR, browser, and server contexts. + +**Independent Test**: Configure client-level defaults and per-request overrides; verify query +params, headers, and body options match precedence rules. + +**Acceptance Scenarios**: + +1. **Given** client-level options and no per-call overrides, **When** a mutation request is sent, + **Then** defaults apply. +2. **Given** per-request overrides (`plainText`, `ip`, `preflight`, `locale`), **When** a request is + sent, **Then** override values are applied. +3. **Given** no explicit `enabledFeatures`, **When** request body options are built, **Then** + default features are included. + +--- + +### User Story 3 - Upsert Many Profiles in a Single Batch (Priority: P2) + +As a backend developer, I need a batch upsert endpoint wrapper so multiple anonymous profile events +can be sent in one request and return updated profiles. + +**Why this priority**: Batch profile upserts are high-value for server-side throughput. + +**Independent Test**: Call `upsertManyProfiles` with valid and invalid batches and verify endpoint, +content type, and parsed profile list behavior. + +**Acceptance Scenarios**: + +1. **Given** a non-empty valid batch event array, **When** `upsertManyProfiles` is called, **Then** + the client posts to `/events` and returns parsed `profiles`. +2. **Given** an empty batch array, **When** `upsertManyProfiles` is called, **Then** validation + fails and the request is rejected. + +--- + +### Edge Cases + +- `baseUrl` defaults to `https://experience.ninetailed.co/` when omitted or falsey. +- `plainText` defaults to `true` for profile mutation requests, while `upsertManyProfiles` uses + `plainText: false` as its method-level fallback. +- Missing or empty `enabledFeatures` resolves to default `['ip-enrichment', 'location']`. +- `preflight` adds `type=preflight` query only when effective value is `true`. +- `getProfile` method options intentionally exclude `plainText` and `preflight`. + +## Requirements _(mandatory)_ + +### Functional Requirements + +- **FR-001**: `ExperienceApiClient` MUST default `baseUrl` to `https://experience.ninetailed.co/` + when no truthy override is provided. +- **FR-002**: `getProfile(id, options)` MUST reject empty IDs with + `Error('Valid profile ID required.')`. +- **FR-003**: `getProfile` MUST issue `GET` to + `/v2/organizations/{clientId}/environments/{environment}/profiles/{id}`. +- **FR-004**: `createProfile` MUST issue `POST` to + `/v2/organizations/{clientId}/environments/{environment}/profiles`. +- **FR-005**: `updateProfile` MUST reject empty `profileId` and otherwise issue `POST` to + `/v2/organizations/{clientId}/environments/{environment}/profiles/{profileId}`. +- **FR-006**: `upsertProfile` MUST route to `createProfile` when `profileId` is absent and + `updateProfile` when `profileId` is present. +- **FR-007**: `upsertManyProfiles` MUST issue `POST` to + `/v2/organizations/{clientId}/environments/{environment}/events`. +- **FR-008**: All profile mutation requests MUST send `keepalive: true`. +- **FR-009**: Mutation request headers MUST include `Content-Type: text/plain` by default. +- **FR-010**: Mutation request headers MUST switch to `Content-Type: application/json` when + effective `plainText` is `false`. +- **FR-011**: Mutation request headers MUST include `X-Force-IP` when effective `ip` is provided. +- **FR-012**: Request URL construction MUST apply `locale` query param from per-request override, + else client default, when available. +- **FR-013**: Request URL construction MUST apply `type=preflight` query param when effective + `preflight` is true. +- **FR-014**: Singular profile mutation body MUST validate against `ExperienceRequestData` and + include schema-validated `events`. +- **FR-015**: Batch mutation body MUST validate against `BatchExperienceRequestData`. +- **FR-016**: Body options MUST include `features`, using effective `enabledFeatures` when + non-empty, otherwise defaulting to `['ip-enrichment', 'location']`. +- **FR-017**: `upsertManyProfiles` MUST use `plainText: false` as the default method-level request + option before applying call-time overrides. +- **FR-018**: `getProfile`, `createProfile`, and `updateProfile` MUST parse responses with + `ExperienceResponse` and map `experiences` to returned `personalizations`. +- **FR-019**: `upsertManyProfiles` MUST parse responses with `BatchExperienceResponse` and return + `data.profiles`. +- **FR-020**: On request failure, Experience client methods MUST log via `logRequestError` and + rethrow the original error. + +### Key Entities _(include if feature involves data)_ + +- **ExperienceApiClient**: API client for profile retrieval/mutation and batch upserts. +- **RequestOptions**: Effective option set controlling locale/preflight/query/body/header behavior. +- **ExperienceRequestData**: Validated payload for singular profile mutations. +- **BatchExperienceRequestData**: Validated payload for batch profile updates. +- **OptimizationData**: Returned shape containing `profile`, `personalizations`, and `changes`. + +## Success Criteria _(mandatory)_ + +### Measurable Outcomes + +- **SC-001**: Experience client tests confirm endpoint paths and query parameter behavior for + profile read/write operations. +- **SC-002**: Invalid IDs and empty event arrays are rejected before successful request completion. +- **SC-003**: Header/content-type behavior is deterministic across defaults and overrides. +- **SC-004**: Batch upsert returns parsed profile arrays for valid inputs and rejects invalid + batches. diff --git a/specs/007-api-client-insights-api/spec.md b/specs/007-api-client-insights-api/spec.md new file mode 100644 index 00000000..9cbbcafd --- /dev/null +++ b/specs/007-api-client-insights-api/spec.md @@ -0,0 +1,117 @@ +# Feature Specification: Insights API Client Contracts + +**Feature Branch**: `[007-api-client-insights-api]` +**Created**: 2026-02-26 +**Status**: Draft +**Input**: User description: "Derive SpecKit-compatible specs from current Insights client behavior +in `@contentful/optimization-api-client`." + +## User Scenarios & Testing _(mandatory)_ + +### User Story 1 - Send Validated Batch Events to Insights (Priority: P1) + +As an analytics integrator, I need batched events to be validated and posted to the Insights ingest +endpoint so invalid payloads fail early and valid payloads are sent consistently. + +**Why this priority**: Batched event ingestion is the primary Insights client capability. + +**Independent Test**: Send valid/invalid `BatchInsightsEventArray` payloads and verify schema +validation, endpoint URL, headers, and boolean result behavior. + +**Acceptance Scenarios**: + +1. **Given** valid event batches, **When** `sendBatchEvents` is called without beacon support, + **Then** the client posts JSON to `/events` and returns `true`. +2. **Given** invalid batch shape, **When** `sendBatchEvents` is called, **Then** payload validation + fails before request execution. + +--- + +### User Story 2 - Prefer Beacon Queuing When Available (Priority: P1) + +As a web runtime developer, I need optional beacon-based queuing so event delivery can use +non-blocking browser transport when available. + +**Why this priority**: Beacon transport improves unload-time reliability and runtime performance. + +**Independent Test**: Provide beacon handlers at client and per-call scope; verify call precedence +and fallback-to-fetch behavior. + +**Acceptance Scenarios**: + +1. **Given** a beacon handler that returns `true`, **When** `sendBatchEvents` is called, **Then** no + fetch request is made and the method resolves `true`. +2. **Given** a beacon handler that returns `false`, **When** `sendBatchEvents` is called, **Then** + the client logs a warning and falls back to immediate fetch. +3. **Given** both client-level and method-level handlers, **When** `sendBatchEvents` is called with + method options, **Then** the method-level handler takes precedence. + +--- + +### User Story 3 - Signal Delivery Outcomes Predictably (Priority: P2) + +As a caller implementing retry/circuit policy above the client, I need deterministic boolean success +signals so I can decide whether to retain or drop queued analytics batches. + +**Why this priority**: Upstream queue policy depends on stable success/failure semantics. + +**Independent Test**: Simulate network failures and confirm method returns `false` after logging, +without throwing transport errors. + +**Acceptance Scenarios**: + +1. **Given** a network error during fetch path, **When** `sendBatchEvents` is called, **Then** + request failure is logged and the method resolves `false`. +2. **Given** successful fetch or successful beacon queue, **When** `sendBatchEvents` completes, + **Then** the method resolves `true`. + +--- + +### Edge Cases + +- `baseUrl` defaults to `https://ingest.insights.ninetailed.co/` when omitted or falsey. +- Beacon handler receives a `URL` object and validated payload, not raw caller input. +- `sendBatchEvents` can succeed without performing fetch when beacon queuing succeeds. +- Fetch failures return `false` instead of rethrowing. + +## Requirements _(mandatory)_ + +### Functional Requirements + +- **FR-001**: `InsightsApiClient` MUST default `baseUrl` to `https://ingest.insights.ninetailed.co/` + when no truthy override is provided. +- **FR-002**: `sendBatchEvents` MUST construct request URL + `/v1/organizations/{clientId}/environments/{environment}/events` against effective `baseUrl`. +- **FR-003**: `sendBatchEvents` MUST validate `batches` using `BatchInsightsEventArray` prior to + transmission. +- **FR-004**: `sendBatchEvents` MUST resolve effective `beaconHandler` by preferring per-request + handler over client-level handler. +- **FR-005**: When effective `beaconHandler` exists, `sendBatchEvents` MUST call it with + `(url, validatedBody)`. +- **FR-006**: When `beaconHandler` returns `true`, `sendBatchEvents` MUST return `true` without + fetch. +- **FR-007**: When `beaconHandler` returns `false`, `sendBatchEvents` MUST log a warning and + continue via fetch transport. +- **FR-008**: Fetch fallback MUST `POST` JSON with `Content-Type: application/json`, + `keepalive: true`, and `body: JSON.stringify(validatedBody)`. +- **FR-009**: Successful fetch fallback MUST return `true`. +- **FR-010**: Fetch failure MUST invoke shared request error logging and return `false`. +- **FR-011**: `sendBatchEvents` MUST NOT throw transport errors from fetch path; failures are + represented by `false`. + +### Key Entities _(include if feature involves data)_ + +- **InsightsApiClient**: API client responsible for Insights batch ingestion. +- **BatchInsightsEventArray**: Validated request payload contract for batched profile-scoped events. +- **Beacon Handler**: Optional transport strategy for queued/non-blocking event delivery. +- **Delivery Result**: Boolean contract indicating enqueue/send success (`true`) or failure + (`false`). + +## Success Criteria _(mandatory)_ + +### Measurable Outcomes + +- **SC-001**: Valid batched events are accepted and sent to the correct ingest path. +- **SC-002**: Beacon success path bypasses fetch while still returning `true`. +- **SC-003**: Beacon failure path logs warning and falls back to fetch successfully. +- **SC-004**: Network failure path logs request failure and returns `false` without throwing. diff --git a/specs/008-api-client-event-builder/spec.md b/specs/008-api-client-event-builder/spec.md new file mode 100644 index 00000000..30c8e7fe --- /dev/null +++ b/specs/008-api-client-event-builder/spec.md @@ -0,0 +1,137 @@ +# Feature Specification: Event Builder Contracts + +**Feature Branch**: `[008-api-client-event-builder]` +**Created**: 2026-02-26 +**Status**: Draft +**Input**: User description: "Derive SpecKit-compatible specs from current Event Builder +functionality in `@contentful/optimization-api-client`." + +## User Scenarios & Testing _(mandatory)_ + +### User Story 1 - Build Shared Event Metadata Automatically (Priority: P1) + +As an SDK consumer, I need a reusable builder that injects consistent universal event metadata so +all event types share channel, timestamps, and context defaults. + +**Why this priority**: Universal metadata consistency is required for both Experience and Insights +event ingestion. + +**Independent Test**: Construct an `EventBuilder` with minimal config and verify universal fields +and fallback values on built events. + +**Acceptance Scenarios**: + +1. **Given** only required builder config (`channel`, `library`), **When** `buildTrack` is called, + **Then** timestamps, message ID, GDPR consent, locale default, and default page context are + populated. +2. **Given** optional overrides in method args (`locale`, `campaign`, `page`, etc.), **When** events + are built, **Then** overrides are used in universal context. + +--- + +### User Story 2 - Build Typed Event Variants with Safe Defaults (Priority: P1) + +As an application developer, I need specialized builder methods for component, flag, identify, page, +screen, and track events so event payloads conform to schema contracts with minimal boilerplate. + +**Why this priority**: Typed builder methods are the main ergonomics layer for producing valid +events. + +**Independent Test**: Call each builder method with valid and invalid inputs and verify output +discriminators, required fields, and defaulted values. + +**Acceptance Scenarios**: + +1. **Given** valid component args without variant index, **When** `buildComponentView` is called, + **Then** `variantIndex` defaults to `0` and `componentType` is `'Entry'`. +2. **Given** valid component args, **When** `buildFlagView` is called, **Then** event type remains + `component` and `componentType` is `'Variable'`. +3. **Given** identify args without traits, **When** `buildIdentify` is called, **Then** `traits` + defaults to an empty object. +4. **Given** track args without properties, **When** `buildTrack` is called, **Then** `properties` + defaults to an empty object. + +--- + +### User Story 3 - Enforce Context-Specific Event Shape (Priority: P2) + +As a maintainer, I need page and screen events to normalize context shape correctly so page events +do not carry screen-only context and screen events do not carry page-only context. + +**Why this priority**: Context normalization avoids mixed-context payloads and schema drift. + +**Independent Test**: Build page/screen events with mixed universal args and verify context pruning +and schema parsing behavior. + +**Acceptance Scenarios**: + +1. **Given** page-view inputs, **When** `buildPageView` is called, **Then** output context is parsed + as `PageEventContext` and excludes screen context. +2. **Given** screen-view inputs, **When** `buildScreenView` is called, **Then** output context is + parsed as `ScreenEventContext` and excludes page context. +3. **Given** invalid method arguments, **When** a builder method is called, **Then** argument + parsing fails with friendly schema errors. + +--- + +### Edge Cases + +- If `getLocale` returns `undefined`, locale must fall back to `'en-US'`. +- If base page properties omit `title`, page view payload must fall back to empty-string title. +- Page view properties are deep-merged with base page properties rather than replaced. +- `buildScreenView` requires `properties` input (no default object at method level). +- Universal timestamp fields (`originalTimestamp`, `sentAt`, `timestamp`) are generated from the + same instant. + +## Requirements _(mandatory)_ + +### Functional Requirements + +- **FR-001**: `EventBuilder` constructor MUST require `channel` and `library`. +- **FR-002**: Constructor MUST allow optional `app`, `getLocale`, `getPageProperties`, and + `getUserAgent`. +- **FR-003**: Constructor defaults MUST be: `getLocale: () => 'en-US'`, + `getPageProperties: () => DEFAULT_PAGE_PROPERTIES`, `getUserAgent: () => undefined`. +- **FR-004**: `DEFAULT_PAGE_PROPERTIES` MUST include: `path`, `query`, `referrer`, `search`, + `title`, `url` with empty defaults. +- **FR-005**: Universal event building MUST set `channel`, `context.app`, `context.campaign`, + `context.gdpr.isConsentGiven=true`, `context.library`, `context.locale`, optional + location/page/screen/userAgent, `messageId`, and ISO timestamp fields. +- **FR-006**: `messageId` MUST be generated using `crypto.randomUUID()`. +- **FR-007**: `buildComponentView` MUST validate args, emit `type: 'component'`, + `componentType: 'Entry'`, and default `variantIndex` to `0` when omitted. +- **FR-008**: `buildFlagView` MUST reuse `buildComponentView` output and override + `componentType: 'Variable'`. +- **FR-009**: `buildIdentify` MUST require `userId` and default missing `traits` to `{}`. +- **FR-010**: `buildPageView` MUST accept optional args and default to `{}`. +- **FR-011**: `buildPageView` MUST merge page properties from `getPageProperties()` with provided + partial overrides and fallback title to `DEFAULT_PAGE_PROPERTIES.title` when needed. +- **FR-012**: `buildPageView` MUST remove screen context and validate resulting context with + `PageEventContext`. +- **FR-013**: `buildScreenView` MUST require `name` and `properties`. +- **FR-014**: `buildScreenView` MUST remove page context and validate resulting context with + `ScreenEventContext`. +- **FR-015**: `buildTrack` MUST require `event` and default missing `properties` to `{}`. +- **FR-016**: All builder methods MUST validate input arguments using schema parsing with friendly + error semantics. + +### Key Entities _(include if feature involves data)_ + +- **EventBuilder**: Helper for creating typed events compatible with Experience/Insights contracts. +- **UniversalEventBuilderArgs**: Optional shared overrides applied to all event variants. +- **ComponentViewBuilderArgs / IdentifyBuilderArgs / PageViewBuilderArgs / ScreenViewBuilderArgs / + TrackBuilderArgs**: Method-specific validated argument contracts. +- **DEFAULT_PAGE_PROPERTIES**: Canonical fallback page context object used across builder methods. +- **UniversalEventProperties**: Shared emitted fields attached to every built event. + +## Success Criteria _(mandatory)_ + +### Measurable Outcomes + +- **SC-001**: Every builder method returns a payload matching its expected event discriminator and + required properties. +- **SC-002**: Default behaviors (locale, page fields, traits/properties, variant index) are + deterministic when optional inputs are omitted. +- **SC-003**: Page and screen events emit context compatible with their respective context schemas. +- **SC-004**: Invalid arguments are rejected during builder invocation with actionable validation + errors. diff --git a/specs/009-core-foundational-and-shared/spec.md b/specs/009-core-foundational-and-shared/spec.md new file mode 100644 index 00000000..b1165166 --- /dev/null +++ b/specs/009-core-foundational-and-shared/spec.md @@ -0,0 +1,147 @@ +# Feature Specification: Optimization Core Foundational and Shared Contracts + +**Feature Branch**: `[009-core-foundational-and-shared]` +**Created**: 2026-02-26 +**Status**: Draft +**Input**: User description: "Examine the current functionality in `@contentful/optimization-core` +package and derive SpecKit-compatible specifications." + +## User Scenarios & Testing _(mandatory)_ + +### User Story 1 - Bootstrap a Shared Core Runtime (Priority: P1) + +As an SDK integrator, I need one core base runtime that consistently wires API client, event +builder, logging, and lifecycle interceptors so product implementations start from identical shared +infrastructure. + +**Why this priority**: Every stateful and stateless product depends on this composition boundary. + +**Independent Test**: Instantiate a `CoreBase` descendent and verify shared API config forwarding, +event-builder defaults, and interceptor availability. + +**Acceptance Scenarios**: + +1. **Given** a core config with `clientId` and optional scoped API config, **When** a core instance + is created, **Then** one `ApiClient` is created with shared/global properties plus isolated + `analytics` and `personalization` overrides. +2. **Given** no explicit `eventBuilder` config, **When** the core is created, **Then** event builder + defaults use `channel: 'server'` and library metadata derived from core package constants. +3. **Given** a created core instance, **When** lifecycle hooks are accessed, **Then** separate + `event` and `state` interceptor managers are available. + +--- + +### User Story 2 - Use a Unified Core Facade (Priority: P1) + +As a product SDK developer, I need one top-level facade for personalization resolution and event +emission methods so consumers can call core methods without reaching into product internals. + +**Why this priority**: The top-level core facade is the primary public integration surface. + +**Independent Test**: Call core facade methods and assert delegation to the expected product methods +and return behavior. + +**Acceptance Scenarios**: + +1. **Given** a core instance, **When** `identify/page/screen/track` are called, **Then** each method + delegates to personalization and returns the delegated result. +2. **Given** `trackComponentView` payload with `sticky: true`, **When** the method is called, + **Then** the call delegates to personalization component tracking. +3. **Given** `trackComponentView` payload with `sticky` omitted/false, **When** the method is + called, **Then** the call delegates to analytics component tracking. +4. **Given** `trackFlagView` payload, **When** the method is called, **Then** it delegates to + analytics flag tracking. + +--- + +### User Story 3 - Extend Runtime Behavior Safely (Priority: P2) + +As a maintainer extending core behavior, I need shared guard/interceptor/blocked-event primitives so +I can add behavior safely without forking base flow logic. + +**Why this priority**: Extensibility points are required by downstream platform SDKs and preview +tooling. + +**Independent Test**: Register interceptors, run guarded methods, and verify blocked-event callback +and signal behavior under both success and failure callbacks. + +**Acceptance Scenarios**: + +1. **Given** registered interceptors, **When** `run` is invoked, **Then** interceptors execute in + insertion order and return a transformed value. +2. **Given** a blocked call and a throwing `onEventBlocked` callback, **When** blocked event + reporting runs, **Then** callback errors are swallowed and blocked event signal still updates. +3. **Given** package root imports, **When** consumers import from `@contentful/optimization-core`, + **Then** core classes, product modules, interceptors/decorators, API client contracts, and logger + exports are available. + +--- + +### Edge Cases + +- Analytics and personalization API base URLs must remain isolated when only one side is overridden. +- Shared fetch options must flow to API client config without being dropped. +- `trackComponentView` routing is sticky-aware and does not automatically dual-send on sticky calls. +- `InterceptorManager.run` must snapshot registered interceptors so in-flight add/remove does not + alter current execution order. +- `guardedBy` must preserve async method shape by returning `Promise` for blocked async + calls. + +## Requirements _(mandatory)_ + +### Functional Requirements + +- **FR-001**: `CoreBase` MUST accept `clientId`, optional `environment`, optional `fetchOptions`, + optional scoped `analytics`, optional scoped `personalization`, optional `eventBuilder`, and + optional `logLevel`. +- **FR-002**: `CoreBase` MUST create a shared `ApiClient` using top-level global API properties plus + scoped `analytics`/`personalization` config objects. +- **FR-003**: `CoreBase` MUST initialize an `EventBuilder` from supplied config or default to + `channel: 'server'` and library metadata from core constants. +- **FR-004**: `CoreBase` MUST expose lifecycle interceptors with separate managers for `event` and + `state`. +- **FR-005**: `CoreBase.getCustomFlag` MUST delegate to personalization flag resolution. +- **FR-006**: `CoreBase.personalizeEntry` MUST delegate to personalization entry resolution. +- **FR-007**: `CoreBase.getMergeTagValue` MUST delegate to personalization merge-tag resolution. +- **FR-008**: `CoreBase.identify`, `page`, `screen`, and `track` MUST delegate to personalization + methods and return delegated results. +- **FR-009**: `CoreBase.trackComponentView` MUST delegate to personalization when `payload.sticky` + is truthy; otherwise it MUST delegate to analytics. +- **FR-010**: `CoreBase.trackFlagView` MUST delegate to analytics flag tracking. +- **FR-011**: `ProductBase` MUST default `allowedEventTypes` to `['identify', 'page', 'screen']` + when unspecified. +- **FR-012**: `ProductBase.reportBlockedEvent` MUST publish blocked event payloads to both optional + callback and shared blocked-event signal. +- **FR-013**: `ProductBase.reportBlockedEvent` MUST swallow callback exceptions and continue blocked + event publication. +- **FR-014**: `InterceptorManager` MUST support add/remove/clear/count and sequential sync+async + execution via `run`. +- **FR-015**: `InterceptorManager.run` MUST execute a snapshot of registered interceptors captured + at invocation time. +- **FR-016**: `guardedBy` MUST enforce a synchronous predicate and optionally execute a synchronous + `onBlocked` hook when calls are blocked. +- **FR-017**: Package root exports MUST include core classes, analytics/personalization modules, + decorators, interceptors, signals utilities, API client/schema contracts, and logger exports. +- **FR-018**: Package build output MUST include ESM (`.mjs`), CJS (`.cjs`), and dual declaration + artifacts (`.d.mts`/`.d.cts`). + +### Key Entities _(include if feature involves data)_ + +- **CoreBase**: Shared runtime composition boundary for API, builder, logging, and facade methods. +- **ProductBase**: Shared product primitive for allowed-event configuration and blocked-event + reporting. +- **Lifecycle Interceptors**: `event` and `state` interceptor managers that mutate data in-flight. +- **BlockedEvent**: Diagnostics payload with `reason`, `product`, `method`, and original arguments. +- **Shared Package Surface**: Root export contract consumed by downstream platform SDKs. + +## Success Criteria _(mandatory)_ + +### Measurable Outcomes + +- **SC-001**: Core initialization tests confirm config preservation, default builder metadata, and + isolated scoped API client settings. +- **SC-002**: Facade delegation tests confirm sticky component routing and analytics/personalization + method dispatch behavior. +- **SC-003**: Interceptor and guard tests confirm deterministic execution ordering, snapshot + semantics, and blocked-call return behavior. +- **SC-004**: Build artifacts provide dual runtime module formats and dual declaration formats. diff --git a/specs/010-core-stateless-environment-support/spec.md b/specs/010-core-stateless-environment-support/spec.md new file mode 100644 index 00000000..f9a9a822 --- /dev/null +++ b/specs/010-core-stateless-environment-support/spec.md @@ -0,0 +1,146 @@ +# Feature Specification: Optimization Core Stateless Environment Support + +**Feature Branch**: `[010-core-stateless-environment-support]` +**Created**: 2026-02-26 +**Status**: Draft +**Input**: User description: "Examine the current functionality in `@contentful/optimization-core` +package and derive SpecKit-compatible specifications." + +## User Scenarios & Testing _(mandatory)_ + +### User Story 1 - Initialize Core for Stateless Runtimes (Priority: P1) + +As a server-side SDK author, I need a stateless core that wires stateless analytics and +personalization products with shared API and event-builder dependencies so I can run in Node/SSR +without local runtime state. + +**Why this priority**: Stateless composition is the base requirement for server and function +environments. + +**Independent Test**: Create `CoreStateless` and verify product construction, config typing +constraints, and absence of stateful-only behavior. + +**Acceptance Scenarios**: + +1. **Given** a `CoreStateless` config with `clientId`, **When** the instance is created, **Then** + stateless analytics and personalization products are constructed with shared + API/builder/interceptors. +2. **Given** stateless event builder overrides, **When** config is typed, **Then** stateful-only + getter options are not part of stateless event-builder config. +3. **Given** stateless analytics config, **When** config is typed, **Then** `beaconHandler` is not + part of stateless analytics config. + +--- + +### User Story 2 - Send Personalization Events Immediately (Priority: P1) + +As an integrator in a stateless host, I need personalization events to be built, validated, +intercepted, and sent immediately via Experience API upsert so no internal queue/state coordination +is required. + +**Why this priority**: Stateless behavior relies on direct request-response processing. + +**Independent Test**: Call `identify/page/screen/track/trackComponentView` with and without +`profile.id` and verify one upsert call per method with schema-validated single-event payload. + +**Acceptance Scenarios**: + +1. **Given** valid identify payload, **When** `identify` is called, **Then** one validated identify + event is sent through `upsertProfile`. +2. **Given** valid page/screen/track payloads, **When** each method is called, **Then** each emits + one validated event through `upsertProfile`. +3. **Given** optional `profile.id`, **When** an event is upserted, **Then** that value is used as + `profileId`; otherwise `profileId` is omitted. + +--- + +### User Story 3 - Send Analytics View Events as Single Batches (Priority: P2) + +As an analytics integrator in stateless environments, I need component/flag view events to be sent +as one-event Insights batches so analytics delivery remains simple and deterministic. + +**Why this priority**: Stateless analytics transport correctness depends on strict one-call mapping. + +**Independent Test**: Call `trackComponentView` and `trackFlagView`, then verify built event type, +interceptor application, schema parsing, and one-batch send shape. + +**Acceptance Scenarios**: + +1. **Given** component view payload, **When** `trackComponentView` is called, **Then** one + `ComponentViewEvent` is validated and sent in a single-item `BatchInsightsEventArray`. +2. **Given** flag view payload, **When** `trackFlagView` is called, **Then** event type remains + component and `componentType` is derived from flag builder output before sending. +3. **Given** optional partial profile payload, **When** batch payload is built, **Then** batch + includes optional profile alongside one event. + +--- + +### Edge Cases + +- Stateless products do not maintain consent/profile/changes/personalizations signals internally. +- In stateless core, omitted `changes` in `getCustomFlag` returns unresolved flag values + (`undefined` per key lookup). +- In stateless core, omitted selected personalizations in `personalizeEntry` returns baseline entry. +- In stateless core, omitted profile in `getMergeTagValue` resolves merge-tag fallback behavior. +- Stateless analytics methods await Insights send call but do not add queue/backoff/circuit + behavior. + +## Requirements _(mandatory)_ + +### Functional Requirements + +- **FR-001**: `CoreStateless` MUST extend `CoreBase` and instantiate `AnalyticsStateless` and + `PersonalizationStateless` with shared `api`, `builder`, and `interceptors`. +- **FR-002**: `CoreStatelessConfig.analytics` MUST omit `beaconHandler`. +- **FR-003**: `CoreStatelessConfig.eventBuilder` MUST omit `getLocale`, `getPageProperties`, and + `getUserAgent`. +- **FR-004**: `PersonalizationStateless.identify` MUST build identify events with `EventBuilder`, + validate with `IdentifyEvent`, and send through one `upsertProfile` call. +- **FR-005**: `PersonalizationStateless.page` MUST build page-view events, validate with + `PageViewEvent`, and send through one `upsertProfile` call. +- **FR-006**: `PersonalizationStateless.screen` MUST build screen-view events, validate with + `ScreenViewEvent`, and send through one `upsertProfile` call. +- **FR-007**: `PersonalizationStateless.track` MUST build track events, validate with `TrackEvent`, + and send through one `upsertProfile` call. +- **FR-008**: `PersonalizationStateless.trackComponentView` MUST build component-view events, + validate with `ComponentViewEvent`, and send through one `upsertProfile` call. +- **FR-009**: `PersonalizationStateless` MUST run event interceptors before sending personalization + events. +- **FR-010**: `PersonalizationStateless.upsertProfile` payload MUST include `events: [intercepted]` + and optional `profileId: profile?.id`. +- **FR-011**: `AnalyticsStateless.trackComponentView` MUST build component-view events, run event + interceptors, validate with `ComponentViewEvent`, and send as one-item Insights batch. +- **FR-012**: `AnalyticsStateless.trackFlagView` MUST build flag-view events, run event + interceptors, validate with `ComponentViewEvent`, and send as one-item Insights batch. +- **FR-013**: `AnalyticsStateless.sendBatchEvent` MUST validate outgoing payload with + `BatchInsightsEventArray`. +- **FR-014**: Stateless analytics batch payload MUST use shape + `[{ profile?: PartialProfile, events: [event] }]`. +- **FR-015**: Stateless core MUST expose shared resolution methods (`getCustomFlag`, + `personalizeEntry`, `getMergeTagValue`) without requiring internal mutable state. +- **FR-016**: Stateless core MUST avoid stateful-only APIs and stateful singleton ownership + semantics. + +### Key Entities _(include if feature involves data)_ + +- **CoreStateless**: Stateless runtime composed from shared core infrastructure. +- **PersonalizationStateless**: Immediate Experience API upsert product for + identify/page/screen/track/component events. +- **AnalyticsStateless**: Immediate Insights batch sender for component/flag view events. +- **TrackViewArgs**: Component/flag view payload with optional partial profile for stateless + analytics. +- **Stateless Upsert Payload**: `{ profileId?: string, events: ExperienceEventArray }` sent per + call. + +## Success Criteria _(mandatory)_ + +### Measurable Outcomes + +- **SC-001**: Stateless core initialization yields both stateless products wired to shared API and + builder instances. +- **SC-002**: Each stateless personalization method results in exactly one Experience API upsert + call with a single validated event. +- **SC-003**: Stateless analytics component/flag methods each result in one Insights send call with + a single validated batch item. +- **SC-004**: Stateless usage does not require consent/state observables, queue policies, or preview + panel signal registration. diff --git a/specs/011-core-stateful-environment-support/spec.md b/specs/011-core-stateful-environment-support/spec.md new file mode 100644 index 00000000..09e08423 --- /dev/null +++ b/specs/011-core-stateful-environment-support/spec.md @@ -0,0 +1,158 @@ +# Feature Specification: Optimization Core Stateful Environment Support + +**Feature Branch**: `[011-core-stateful-environment-support]` **Created**: 2026-02-26 **Status**: +Draft **Input**: User description: "Examine the current functionality in +`@contentful/optimization-core` package and derive SpecKit-compatible specifications." + +## User Scenarios & Testing _(mandatory)_ + +### User Story 1 - Manage One Stateful Runtime with Observable State (Priority: P1) + +As a client-side SDK integrator, I need one runtime-scoped stateful core instance with observable +consent/profile/event streams so personalization and analytics state stays coherent across the app. + +**Why this priority**: Stateful behavior depends on globally consistent shared state. + +**Independent Test**: Create/destroy stateful instances and verify singleton lock behavior, state +defaults, and observable stream availability. + +**Acceptance Scenarios**: + +1. **Given** one active `CoreStateful` instance, **When** a second instance is created in the same + runtime before destroy, **Then** initialization fails with singleton-lock error. +2. **Given** `defaults` config, **When** stateful core initializes, **Then** + consent/profile/changes/ personalizations defaults are applied to signals. +3. **Given** `core.states`, **When** states are read, **Then** observables for consent, blocked + event stream, event stream, flags, profile, and personalizations are exposed. + +--- + +### User Story 2 - Enforce Consent Gating with Blocked Event Telemetry (Priority: P1) + +As a privacy-focused integrator, I need stateful event methods to be guarded by consent and report +blocked calls through callback and stream state so I can audit blocked behavior. + +**Why this priority**: Consent gating is mandatory for compliant runtime behavior. + +**Independent Test**: Invoke guarded methods under denied/undefined consent and verify blocked-event +callback payload and blocked-event stream emission. + +**Acceptance Scenarios**: + +1. **Given** no consent and disallowed event type, **When** a guarded analytics/personalization + method is called, **Then** execution is blocked and blocked-event diagnostics are emitted. +2. **Given** allowed event types (`identify`, `page`, `screen` by default in core), **When** consent + is missing, **Then** those event types still pass guard checks. +3. **Given** blocking diagnostics callback throws, **When** blocked event reporting occurs, **Then** + callback failure is swallowed and blocked-event signal is still updated. + +--- + +### User Story 3 - Queue and Flush Events with Retry, Backoff, and Offline Support (Priority: P2) + +As a runtime maintainer, I need robust queueing and retry policies for analytics and personalization +stateful products so events can recover from temporary failures and offline periods. + +**Why this priority**: Stateful reliability depends on resilient queue flushing and bounded failure +handling. + +**Independent Test**: Simulate offline mode and send failures; verify queue retention/drop behavior, +retry scheduling, circuit opening, and recovery callbacks. + +**Acceptance Scenarios**: + +1. **Given** analytics events and an active profile, **When** events are queued and flush fails, + **Then** retries follow configured backoff policy and queue remains until success. +2. **Given** personalization events while offline, **When** queue exceeds `maxEvents`, **Then** + oldest events are dropped first and `onDrop` callback receives dropped payload context. +3. **Given** repeated flush failures reaching threshold, **When** retries are scheduled, **Then** + circuit-open delay is applied before next allowed flush. +4. **Given** connectivity returns online, **When** online signal turns true, **Then** pending + retries are cleared and force-flush is attempted. + +--- + +### Edge Cases + +- `destroy()` is idempotent and must safely release singleton ownership only once. +- `reset()` clears blocked/event/changes/profile/personalizations but intentionally preserves + consent. +- `flush({ force: true })` bypasses offline/backoff/circuit gates but not active in-flight flush. +- Analytics queueing without a current profile drops event enqueue attempt (warn only). +- Immediate online personalization send failures do not backfill the offline queue. +- Queue policy callbacks (`onDrop`, `onFlushFailure`, `onCircuitOpen`, `onFlushRecovered`) are + best-effort and callback exceptions are swallowed. + +## Requirements _(mandatory)_ + +### Functional Requirements + +- **FR-001**: `CoreStateful` MUST acquire runtime singleton ownership during construction and reject + parallel stateful instances in the same runtime. +- **FR-002**: `CoreStateful.destroy()` MUST release singleton ownership for its instance and MUST be + safe to call multiple times. +- **FR-003**: `CoreStateful` MUST split scoped `queuePolicy` fields from analytics/personalization + config before constructing shared `ApiClient`. +- **FR-004**: `CoreStateful` MUST construct `AnalyticsStateful` and `PersonalizationStateful` using + shared `api`, `builder`, `interceptors`, and stateful product config. +- **FR-005**: `CoreStateful` MUST expose `states` as observables for `consent`, + `blockedEventStream`, `eventStream`, `flags`, `profile`, and `personalizations`. +- **FR-006**: `CoreStateful.consent(accept)` MUST update consent signal state. +- **FR-007**: `CoreStateful.reset()` MUST clear blocked/event/changes/profile/personalizations and + MUST NOT clear consent. +- **FR-008**: `CoreStateful.flush()` MUST flush analytics queue then personalization queue. +- **FR-009**: `CoreStateful.registerPreviewPanel()` MUST expose mutable `signals` and `signalFns`, + mutating provided preview object when supplied. +- **FR-010**: Stateful products MUST implement consent checks through `guardedBy` using `hasConsent` + and `onBlockedByConsent`. +- **FR-011**: Consent checks MUST allow events when consent is true or when event type appears in + allowed-event list (default `['identify', 'page', 'screen']` in core). +- **FR-012**: Consent checks for `trackComponentView` and `trackFlagView` MUST map method names to + `'component'` for allow-list matching. +- **FR-013**: Analytics stateful queue MUST be grouped by `profile.id` and preserve latest profile + snapshot per profile ID. +- **FR-014**: Analytics stateful queue MUST auto-flush when total queued events reaches `25`. +- **FR-015**: Analytics flush MUST treat both `false` responses and thrown send errors as failures + for retry runtime handling. +- **FR-016**: Personalization stateful offline queue MUST default to `maxEvents: 100` and drop + oldest events first when queue bounds are exceeded. +- **FR-017**: Personalization stateful `onDrop` callback MUST receive dropped events context and + MUST be fault-tolerant (callback errors swallowed). +- **FR-018**: Personalization stateful online path MUST send events immediately via Experience + upsert; offline path MUST queue events and return `undefined`. +- **FR-019**: Personalization stateful upsert MUST prefer `getAnonymousId()` over `profile.id` when + resolving outgoing `profileId`. +- **FR-020**: Personalization stateful MUST run state interceptors before applying returned + `changes/profile/personalizations` to signals. +- **FR-021**: State signal updates after Experience responses MUST be value-aware and avoid + redundant assignments when payloads are deeply equal. +- **FR-022**: Queue flush runtime MUST skip flushes when in-flight and, unless forced, when offline, + backoff window is active, or circuit window is open. +- **FR-023**: Queue flush runtime MUST apply normalized retry policy defaults: `baseBackoffMs=500`, + `maxBackoffMs=30000`, `jitterRatio=0.2`, `maxConsecutiveFailures=8`, `circuitOpenMs=120000`. +- **FR-024**: Queue flush runtime MUST invoke failure/circuit/recovered callbacks with queue and + retry context payloads and MUST schedule retry attempts accordingly. + +### Key Entities _(include if feature involves data)_ + +- **CoreStateful**: Runtime singleton coordinating stateful analytics/personalization products. +- **CoreStates**: Observable contract for consent, blocked events, emitted events, flags, profile, + and selected personalizations. +- **AnalyticsStateful Queue**: Profile-grouped in-memory event map with flush/backoff/circuit + policy. +- **Personalization Offline Queue**: Ordered set of Experience events retained while offline. +- **QueueFlushRuntime**: Shared retry/backoff/circuit state machine used by stateful products. +- **Queue Policy Contexts**: Failure/recovery/drop callback payload contracts for telemetry. + +## Success Criteria _(mandatory)_ + +### Measurable Outcomes + +- **SC-001**: Singleton lifecycle tests confirm only one active stateful instance per runtime until + `destroy()` is called. +- **SC-002**: Consent-blocked calls are emitted via both callback and blocked-event observable + stream. +- **SC-003**: Analytics and personalization queue policies demonstrate retry/backoff/circuit + behavior and recover by clearing queued events after successful flush. +- **SC-004**: Offline personalization queue enforces max-size drop policy with accurate drop-context + callback payloads. diff --git a/specs/012-core-personalized-data-resolution/spec.md b/specs/012-core-personalized-data-resolution/spec.md new file mode 100644 index 00000000..69d51cda --- /dev/null +++ b/specs/012-core-personalized-data-resolution/spec.md @@ -0,0 +1,157 @@ +# Feature Specification: Optimization Core Personalized Data Resolution + +**Feature Branch**: `[012-core-personalized-data-resolution]` +**Created**: 2026-02-26 +**Status**: Draft +**Input**: User description: "Examine the current functionality in `@contentful/optimization-core` +package and derive SpecKit-compatible specifications." + +## User Scenarios & Testing _(mandatory)_ + +### User Story 1 - Resolve Custom Flags from Changes (Priority: P1) + +As a personalization consumer, I need custom flags resolved from Experience API `changes` so I can +read feature and variable values from one flattened lookup map. + +**Why this priority**: Flag lookup is a core personalization consumption pattern. + +**Independent Test**: Resolve flags from undefined and populated change arrays, including wrapped +values, and verify deterministic key-value output. + +**Acceptance Scenarios**: + +1. **Given** undefined changes, **When** flag resolution runs, **Then** an empty flag map is + returned. +2. **Given** change entries with primitive values, **When** flag resolution runs, **Then** keys map + to those values directly. +3. **Given** change entries with wrapped object values (`{ value: { ... } }`), **When** flag + resolution runs, **Then** wrapped payloads are unwrapped to underlying object values. + +--- + +### User Story 2 - Resolve Personalized Entries to Selected Variants (Priority: P1) + +As a Contentful SDK consumer, I need baseline entries resolved to selected personalized variants so +the rendered content matches selected experience treatments. + +**Why this priority**: Entry variant resolution is the primary personalized-content behavior. + +**Independent Test**: Run resolver with matching and non-matching selections and verify baseline +fallback, variant selection, and returned personalization metadata. + +**Acceptance Scenarios**: + +1. **Given** selected personalizations with non-zero variant index and matching replacement variant, + **When** entry resolution runs, **Then** variant entry and selected personalization metadata are + returned. +2. **Given** selected variant index `0`, **When** entry resolution runs, **Then** baseline entry is + returned without personalization metadata. +3. **Given** missing/invalid personalization linkage, **When** entry resolution runs, **Then** + baseline entry is returned and variant resolution failure is logged. + +--- + +### User Story 3 - Resolve Merge Tag Values with Profile Fallbacks (Priority: P2) + +As a runtime rendering personalized rich text, I need merge-tag values resolved from profile data +with fallback values so content remains renderable even when profile fields are missing. + +**Why this priority**: Merge tags are often rendered in user-visible content and need graceful +fallback semantics. + +**Independent Test**: Resolve merge tags using valid and invalid entries/profiles, underscore+dot +selector variants, and fallback paths. + +**Acceptance Scenarios**: + +1. **Given** valid merge-tag entry and matching profile value, **When** merge-tag resolution runs, + **Then** the resolved profile value is returned as a string. +2. **Given** valid merge-tag entry and invalid/missing profile, **When** merge-tag resolution runs, + **Then** entry fallback value is returned. +3. **Given** invalid merge-tag entry, **When** merge-tag resolution runs, **Then** resolution + returns `undefined`. + +--- + +### Edge Cases + +- In stateless usage, missing `changes` causes `getCustomFlag(name)` lookups to return `undefined`. +- Variant indexes are treated as 1-based; index `0` is explicit baseline. +- Hidden baseline components are excluded from replacement-variant selection. +- If selected variant config exists but linked variant entry is absent, resolver returns baseline. +- Merge-tag selector normalization must support mixed underscore and dot path patterns. +- Merge-tag profile resolution only returns primitive string/number/boolean values and stringifies + them. + +## Requirements _(mandatory)_ + +### Functional Requirements + +- **FR-001**: `PersonalizationBase` MUST expose resolver-backed methods `getCustomFlag`, + `personalizeEntry`, and `getMergeTagValue`. +- **FR-002**: `FlagsResolver.resolve` MUST return `{}` when `changes` is undefined. +- **FR-003**: `FlagsResolver.resolve` MUST flatten change entries into a key-value map keyed by + `change.key`. +- **FR-004**: `FlagsResolver.resolve` MUST unwrap wrapped object values when change value is object + containing object-like `value`. +- **FR-005**: `PersonalizationBase.getCustomFlag(name, changes)` MUST resolve from `FlagsResolver` + and return lookup value at `name`. +- **FR-006**: `PersonalizedEntryResolver.getPersonalizationEntry` MUST find personalization entries + by matching selected `experienceId` values against entry `nt_experience_id`. +- **FR-007**: `PersonalizedEntryResolver.getSelectedPersonalization` MUST return selected + personalization matching personalization entry `nt_experience_id`. +- **FR-008**: `PersonalizedEntryResolver.getSelectedVariant` MUST locate relevant replacement + component by baseline entry ID and return variant at `variantIndex - 1`. +- **FR-009**: `PersonalizedEntryResolver.getSelectedVariant` MUST ignore components whose baseline + is marked hidden. +- **FR-010**: `PersonalizedEntryResolver.getSelectedVariantEntry` MUST resolve variant entry by + variant ID from `nt_variants`. +- **FR-011**: `PersonalizedEntryResolver.resolve` MUST return baseline entry when no selected + personalizations are provided. +- **FR-012**: `PersonalizedEntryResolver.resolve` MUST return baseline entry when entry is not a + personalized entry shape. +- **FR-013**: `PersonalizedEntryResolver.resolve` MUST treat selected variant index `0` as baseline. +- **FR-014**: `PersonalizedEntryResolver.resolve` MUST return baseline entry when variant config or + linked variant entry cannot be resolved. +- **FR-015**: `PersonalizedEntryResolver.resolve` MUST return + `{ entry: variantEntry, personalization: selectedPersonalization }` when variant resolution + succeeds. +- **FR-016**: `MergeTagValueResolver.isMergeTagEntry` MUST validate candidate entries using + `MergeTagEntry.safeParse`. +- **FR-017**: `MergeTagValueResolver.normalizeSelectors` MUST produce selector candidates by + splitting merge-tag IDs on underscores and progressively combining dot/underscore segments. +- **FR-018**: `MergeTagValueResolver.getValueFromProfile` MUST return stringified primitive values + from first matching selector path and return `undefined` for missing or non-primitive values. +- **FR-019**: `MergeTagValueResolver.resolve` MUST return `undefined` for invalid merge-tag entries. +- **FR-020**: `MergeTagValueResolver.resolve` MUST return configured merge-tag fallback when profile + is invalid or no profile value is resolved. +- **FR-021**: Stateful personalization overrides for these methods MUST default optional resolver + inputs from current signals (`changes`, `personalizations`, `profile`). +- **FR-022**: Core-level wrapper methods (`CoreBase.getCustomFlag`, `.personalizeEntry`, + `.getMergeTagValue`) MUST delegate to personalization resolver methods without altering resolved + payload shape. + +### Key Entities _(include if feature involves data)_ + +- **FlagsResolver**: Utility mapping `ChangeArray` inputs to flattened flag lookup map. +- **PersonalizedEntryResolver**: Multi-step resolver selecting baseline vs variant Contentful + entries. +- **MergeTagValueResolver**: Utility resolving merge-tag IDs against profile data with fallback + support. +- **ResolvedData**: Resolver output shape containing resolved entry and optional selected + personalization metadata. +- **SelectedPersonalization**: Experience selection metadata with `experienceId` and 1-based + `variantIndex`. + +## Success Criteria _(mandatory)_ + +### Measurable Outcomes + +- **SC-001**: Custom flag resolution returns deterministic outputs for undefined changes, primitive + values, and wrapped object values. +- **SC-002**: Personalized entry resolution returns baseline on all invalid/missing selection paths + and returns variant+metadata on valid selection paths. +- **SC-003**: Merge-tag resolution supports underscore/dot selector normalization and returns + fallback values for invalid profile paths. +- **SC-004**: Core and personalization resolver wrapper methods preserve resolver semantics across + stateless and stateful runtime contexts. diff --git a/specs/013-node-sdk-foundational-and-shared/spec.md b/specs/013-node-sdk-foundational-and-shared/spec.md new file mode 100644 index 00000000..5a61b933 --- /dev/null +++ b/specs/013-node-sdk-foundational-and-shared/spec.md @@ -0,0 +1,149 @@ +# Feature Specification: Optimization Node SDK Foundational and Shared Contracts + +**Feature Branch**: `[013-node-sdk-foundational-and-shared]` +**Created**: 2026-02-26 +**Status**: Draft +**Input**: User description: "Examine the current functionality in `@contentful/optimization-node` +package and derive SpecKit-compatible specifications that could have guided its development. Create +a SpecKit spec for the derived specifications." + +## User Scenarios & Testing _(mandatory)_ + +### User Story 1 - Bootstrap a Stateless Node Runtime with Server Metadata (Priority: P1) + +As a Node/SSR SDK consumer, I need a Node-focused SDK entry point that initializes the stateless +core with server-safe defaults so event metadata is correct without extra setup. + +**Why this priority**: Correct bootstrap defaults are the primary value added by the Node package +over raw core usage. + +**Independent Test**: Construct `Optimization` with minimal config and verify inherited stateless +runtime behavior plus Node default event metadata (`channel`, `library`). + +**Acceptance Scenarios**: + +1. **Given** `Optimization` is constructed with required API config, **When** initialization + completes, **Then** the runtime behaves as `CoreStateless` with no behavior overrides. +2. **Given** no explicit `eventBuilder` configuration, **When** `Optimization` is created, **Then** + event metadata defaults to `channel: 'server'` and Node SDK `library` name/version constants. +3. **Given** top-level `app` metadata is provided, **When** `Optimization` is created, **Then** the + app metadata is available to the event builder context. + +--- + +### User Story 2 - Override Node Event-Builder Defaults Safely (Priority: P1) + +As a platform integrator, I need partial event-builder overrides so I can customize metadata while +keeping sensible Node defaults for unspecified values. + +**Why this priority**: Custom host applications often need channel/library overrides without +redefining full event-builder config. + +**Independent Test**: Initialize with partial `eventBuilder` values and verify deep-merge behavior +against Node defaults, including nested `library` object fields. + +**Acceptance Scenarios**: + +1. **Given** partial event-builder overrides (for example custom `channel`), **When** config is + merged, **Then** supplied fields override Node defaults. +2. **Given** partial nested `library` overrides, **When** config is merged, **Then** unspecified + nested defaults remain intact. +3. **Given** non-event-builder options (`analytics`, `personalization`, `fetchOptions`, etc.), + **When** config is merged, **Then** those options are forwarded unchanged to core/API setup. + +--- + +### User Story 3 - Consume Node SDK as a Stable Package Surface (Priority: P2) + +As an SDK consumer, I need one Node package entry that exports core APIs plus Node constants in both +ESM and CJS-compatible form so integration and upgrades stay predictable. + +**Why this priority**: Packaging and export stability determines install/import ergonomics in mixed +Node ecosystems. + +**Independent Test**: Import from package root in both module systems and verify availability of +default class, named exports, shared constants, and generated type/runtime artifacts. + +**Acceptance Scenarios**: + +1. **Given** imports from `@contentful/optimization-node`, **When** consumers use root exports, + **Then** core public exports remain available alongside Node-specific exports. +2. **Given** Node global constants are imported, **When** build-time define replacements are absent, + **Then** constants fall back to deterministic default literals. +3. **Given** a package build, **When** artifacts are emitted, **Then** ESM/CJS runtime files and + dual declaration files are produced for import/require entry points. + +--- + +### Edge Cases + +- If build-time constant replacement is unavailable, SDK name/version constants must still resolve + to fallback values (`@contentful/optimization-node`, `0.0.0`). +- Omitting top-level `app` must not inject app metadata into built events. +- Providing no `eventBuilder` options must still produce server-channel events with Node SDK library + attribution. +- Partial nested `library` overrides must not erase unspecified default `library` fields. +- The Node package must remain stateless-only and must not introduce stateful runtime requirements. + +## Requirements _(mandatory)_ + +### Functional Requirements + +- **FR-001**: The package MUST provide an `Optimization` class that extends `CoreStateless`. +- **FR-002**: `OptimizationNodeConfig` MUST include all `CoreStatelessConfig` options except + `eventBuilder`, and MUST add top-level optional `app` plus optional partial `eventBuilder` + overrides. +- **FR-003**: The Node SDK MUST build default event-builder config with `channel: 'server'`, + `library.name = OPTIMIZATION_NODE_SDK_NAME`, and + `library.version = OPTIMIZATION_NODE_SDK_VERSION`. +- **FR-004**: The Node SDK MUST map top-level `app` config into default `eventBuilder.app`. +- **FR-005**: The Node SDK MUST deep-merge caller configuration with Node defaults before calling + `CoreStateless`. +- **FR-006**: Provided `eventBuilder` overrides MUST take precedence over Node defaults for supplied + fields. +- **FR-007**: Unspecified event-builder fields MUST retain Node defaults after merge. +- **FR-008**: Non-event-builder configuration fields MUST be forwarded to `CoreStateless` without + Node-specific mutation. +- **FR-009**: The package root MUST re-export the full public API of + `@contentful/optimization-core`. +- **FR-010**: The package root MUST export `Optimization` as both the default export and a named + export. +- **FR-011**: The package root MUST export `OPTIMIZATION_NODE_SDK_NAME` and + `OPTIMIZATION_NODE_SDK_VERSION`. +- **FR-012**: `OPTIMIZATION_NODE_SDK_VERSION` MUST read build-time `__OPTIMIZATION_VERSION__` when + it is a string, otherwise default to `'0.0.0'`. +- **FR-013**: `OPTIMIZATION_NODE_SDK_NAME` MUST read build-time `__OPTIMIZATION_PACKAGE_NAME__` when + it is a string, otherwise default to `'@contentful/optimization-node'`. +- **FR-014**: The Node package MUST re-export `ANONYMOUS_ID_COOKIE` from + `@contentful/optimization-core` via its global constants module. +- **FR-015**: Package build output MUST include bundled ESM and CJS runtime artifacts in `dist` + (including source maps). +- **FR-016**: Package build output MUST include dual type declaration artifacts for import and + require consumers (`.d.mts` and `.d.cts`). +- **FR-017**: Inherited stateless methods (`identify`, `page`, `screen`, `track`, + `trackComponentView`, `trackFlagView`, `getCustomFlag`, `personalizeEntry`, `getMergeTagValue`) + MUST preserve `CoreStateless` semantics. + +### Key Entities _(include if feature involves data)_ + +- **OptimizationNodeConfig**: Node-facing configuration contract that accepts core stateless + options, optional top-level `app`, and partial `eventBuilder` overrides. +- **mergeConfig**: Internal composition step that applies Node defaults and merges caller overrides + before constructing `CoreStateless`. +- **Optimization**: Node SDK class extending `CoreStateless` with Node defaults only. +- **Node SDK Constants**: Build-time/fallback metadata constants for package name/version and shared + anonymous-ID cookie export. +- **Package Surface**: Root export contract combining all core exports with Node-specific additions. + +## Success Criteria _(mandatory)_ + +### Measurable Outcomes + +- **SC-001**: Constructing `Optimization` without explicit event-builder options yields + server-channel metadata and Node library attribution. +- **SC-002**: Partial event-builder overrides are merged correctly, preserving unspecified default + fields. +- **SC-003**: Package root imports expose core APIs, Node constants, and `Optimization` + default/named exports without type errors. +- **SC-004**: A clean package build emits `dist/index.mjs`, `dist/index.cjs`, `dist/index.d.mts`, + and `dist/index.d.cts`. diff --git a/specs/014-web-foundational-and-shared/spec.md b/specs/014-web-foundational-and-shared/spec.md new file mode 100644 index 00000000..0dd7cacd --- /dev/null +++ b/specs/014-web-foundational-and-shared/spec.md @@ -0,0 +1,148 @@ +# Feature Specification: Optimization Web Foundational and Shared Contracts + +**Feature Branch**: `[014-web-foundational-and-shared]` +**Created**: 2026-02-26 +**Status**: Draft +**Input**: User description: "Examine the current functionality in `@contentful/optimization-web` +package and derive SpecKit-compatible specifications." + +## User Scenarios & Testing _(mandatory)_ + +### User Story 1 - Bootstrap a Browser-Wired Runtime (Priority: P1) + +As a Web SDK integrator, I need one browser-oriented Optimization runtime that merges Web defaults +with core stateful behavior so analytics and personalization are ready with minimal setup. + +**Why this priority**: Runtime bootstrap is the entry path for all Web SDK capabilities. + +**Independent Test**: Construct `Optimization` with minimal config and assert merged defaults, +stateful core construction, and singleton guard behavior. + +**Acceptance Scenarios**: + +1. **Given** a config with `clientId`, **When** `Optimization` is constructed, **Then** core + receives merged Web defaults for analytics beacon handler, event builder metadata, and anonymous + ID getter. +2. **Given** a browser runtime where `window.optimization` already exists, **When** a new instance + is created, **Then** construction fails with an "already initialized" error. +3. **Given** a browser runtime with no existing instance, **When** an instance is created, **Then** + it is attached to `window.optimization`. +4. **Given** no `allowedEventTypes` override, **When** Web config is merged, **Then** + `allowedEventTypes` defaults to `['identify', 'page']`; and **Given** caller-provided + `allowedEventTypes`, **Then** caller values override that Web default. + +--- + +### User Story 2 - Wire Lifecycle Listeners and Safe Teardown (Priority: P1) + +As a maintainer, I need online/offline and page-visibility listeners wired to stateful flush/online +controls so event delivery is resilient across browser lifecycle transitions. + +**Why this priority**: Lifecycle wiring directly affects event reliability and runtime safety. + +**Independent Test**: Trigger online/offline and hidden/pagehide transitions, then assert updates to +`online` via setter assignment and calls to `flush()`, and verify `destroy()` removes listeners. + +**Acceptance Scenarios**: + +1. **Given** an initialized Web SDK instance, **When** browser online state changes, **Then** + `online` is set to `true|false` through the online listener callback. +2. **Given** an initialized instance, **When** the page transitions to hidden/pagehide, **Then** + `flush()` is invoked via visibility listener callback. +3. **Given** a destroyed instance, **When** runtime listeners would normally fire, **Then** prior + listener bindings no longer invoke SDK callbacks. + +--- + +### User Story 3 - Consume a Stable Package Surface (Priority: P2) + +As a package consumer, I need consistent module exports and constants so both direct SDK usage and +derived integrations can rely on a stable public surface. + +**Why this priority**: Packaging and exports determine interoperability for all downstream users. + +**Independent Test**: Import from package root and verify core re-exports, Web utilities, default +export, and fallback constant behavior when build-time defines are absent. + +**Acceptance Scenarios**: + +1. **Given** package root imports, **When** importing `@contentful/optimization-web`, **Then** core + exports plus Web event builder, global constants, beacon handler, and `LocalStore` are available. +2. **Given** missing build-time replacements, **When** constants are resolved, **Then** package name + and version fall back to hardcoded defaults. +3. **Given** published artifacts, **When** consumers resolve package entry points, **Then** ESM/CJS + runtime outputs and dual declaration outputs are available. + +--- + +### Edge Cases + +- Listener helpers must degrade to no-op cleanup functions in non-DOM/SSR environments. +- Visibility callbacks must be best-effort: callback exceptions are logged and do not crash runtime. +- Online callbacks must be best-effort: callback exceptions are logged and do not crash runtime. +- Visibility handling must invoke hide callback at most once per hide cycle until reset by + visible/pageshow. +- `destroy()` must remove `window.optimization` only when the global points to the current instance. + +## Requirements _(mandatory)_ + +### Functional Requirements + +- **FR-001**: `OptimizationWebConfig` MUST extend `CoreStatefulConfig` with optional `app`, optional + `autoTrackEntryViews`, and optional cookie attributes (`domain`, `expires`). +- **FR-002**: `Optimization` construction MUST reject initialization when `window.optimization` + already exists in browser environments. +- **FR-003**: Web config merging MUST provide default analytics `beaconHandler`. +- **FR-004**: Web config merging MUST provide default event-builder values: `channel: 'web'`, + `library.name`, `library.version`, `getLocale`, `getPageProperties`, and `getUserAgent`. +- **FR-005**: Web config merging MUST provide default state values from `LocalStore` for `consent`, + `changes`, `profile`, and `personalizations` when not explicitly supplied. +- **FR-006**: Web config merging MUST provide default `getAnonymousId` that reads from + `LocalStore.anonymousId`. +- **FR-007**: Web config merging MUST default runtime log level to `'debug'` when `LocalStore.debug` + is true; otherwise it MUST preserve provided `logLevel`. +- **FR-008**: `Optimization` MUST extend `CoreStateful` and initialize the parent with merged + config. +- **FR-009**: `Optimization` MUST register an online/offline listener that maps browser state to + `online = isOnline`. +- **FR-010**: `Optimization` MUST register a visibility listener that flushes queued events on + hide/pagehide. +- **FR-011**: `Optimization` construction MUST assign `window.optimization` to the created instance + when absent in browser environments. +- **FR-012**: `Optimization.destroy()` MUST stop entry view tracking and run cleanup handlers for + online and visibility listeners. +- **FR-013**: `Optimization.destroy()` MUST remove the global singleton reference only when + `window.optimization === this`. +- **FR-014**: Package root exports MUST include all `@contentful/optimization-core` exports plus + Web-specific exports for event builder helpers, global constants, beacon handler, and + `LocalStore`. +- **FR-015**: Default export of package root MUST be `Optimization`. +- **FR-016**: `OPTIMIZATION_WEB_SDK_NAME` MUST default to `'@contentful/optimization-web'` when + build-time package-name replacement is unavailable. +- **FR-017**: `OPTIMIZATION_WEB_SDK_VERSION` MUST default to `'0.0.0'` when build-time version + replacement is unavailable. +- **FR-018**: Package build outputs MUST include ESM, CJS, UMD, and dual declaration artifacts. +- **FR-019**: Web config merging MUST default `allowedEventTypes` to `['identify', 'page']` when + caller configuration omits it. +- **FR-020**: Caller-provided `allowedEventTypes` MUST override the Web default allow-list. + +### Key Entities _(include if feature involves data)_ + +- **Optimization**: Browser-wired SDK class extending stateful core behavior. +- **OptimizationWebConfig**: Web runtime configuration contract with core plus Web-specific options. +- **Lifecycle Listeners**: Online/offline and visibility/pagehide hooks that drive runtime behavior. +- **Package Surface**: Root exports and constants consumed by downstream SDK integrations. + +## Success Criteria _(mandatory)_ + +### Measurable Outcomes + +- **SC-001**: Initialization tests confirm merged Web defaults are passed into the stateful core. +- **SC-002**: Singleton tests confirm duplicate browser initialization fails until prior instance is + destroyed. +- **SC-003**: Lifecycle listener tests confirm online/visibility transitions invoke SDK handlers and + teardown removes bindings. +- **SC-004**: Import/build checks confirm package root exports and runtime/type artifacts are + present. +- **SC-005**: Merge-config tests confirm Web defaults `allowedEventTypes` to `['identify', 'page']` + and preserves caller-provided `allowedEventTypes`. diff --git a/specs/015-web-state-management/spec.md b/specs/015-web-state-management/spec.md new file mode 100644 index 00000000..959aa0a8 --- /dev/null +++ b/specs/015-web-state-management/spec.md @@ -0,0 +1,140 @@ +# Feature Specification: Optimization Web State Management + +**Feature Branch**: `[015-web-state-management]` +**Created**: 2026-02-26 +**Status**: Draft +**Input**: User description: "Examine the current functionality in `@contentful/optimization-web` +package and derive SpecKit-compatible specifications." + +## User Scenarios & Testing _(mandatory)_ + +### User Story 1 - Initialize Runtime State from Persisted Storage (Priority: P1) + +As a Web SDK integrator, I need consent, profile, changes, personalizations, and anonymous identity +to be restored from browser persistence so user context survives page reloads. + +**Why this priority**: Persisted state continuity is required for stable personalization behavior. + +**Independent Test**: Pre-populate localStorage/cookies, initialize `Optimization`, and verify +defaults and anonymous ID migration/reset behavior. + +**Acceptance Scenarios**: + +1. **Given** persisted localStorage values for consent/profile/changes/personalizations, **When** + `Optimization` is created, **Then** merged defaults use those values. +2. **Given** a legacy anonymous ID cookie, **When** initialization runs, **Then** the legacy cookie + is removed and current anonymous ID is derived from the migrated value. +3. **Given** a persisted cookie anonymous ID that differs from in-memory/local state, **When** + initialization runs, **Then** SDK state is reset and anonymous ID is updated to cookie value. + +--- + +### User Story 2 - Keep Runtime Signals and Persistence in Sync (Priority: P1) + +As a maintainer, I need state signal changes mirrored to localStorage/cookies so runtime mutations +from API responses and consent actions are persisted automatically. + +**Why this priority**: Without synchronization effects, persisted state diverges from runtime state. + +**Independent Test**: Update core signals (`consent`, `profile`, `changes`, `personalizations`) and +assert corresponding LocalStore and cookie writes. + +**Acceptance Scenarios**: + +1. **Given** `signals.changes` updates, **When** effects run, **Then** `LocalStore.changes` reflects + latest value. +2. **Given** `signals.profile` updates with `profile.id`, **When** effects run, **Then** anonymous + ID cookie and LocalStore anonymous ID are updated. +3. **Given** `signals.consent` updates and auto-tracking is enabled, **When** consent changes, + **Then** auto entry tracking starts on consented state and stops otherwise. + +--- + +### User Story 3 - Reset and Persist Safely Under Storage Failures (Priority: P2) + +As a runtime operator, I need storage operations and reset paths to be fault-tolerant so SDK +behavior remains functional even with blocked or malformed browser storage. + +**Why this priority**: Browsers often restrict storage access; SDK must continue operating. + +**Independent Test**: Force localStorage parse/write/remove failures and invoke reset; verify no +throws and expected cleanup behavior. + +**Acceptance Scenarios**: + +1. **Given** malformed JSON or schema-invalid cached values, **When** LocalStore reads occur, + **Then** values resolve as `undefined` and invalid cache entries are removed. +2. **Given** localStorage write/remove exceptions, **When** LocalStore updates occur, **Then** + errors are swallowed and SDK continues. +3. **Given** `optimization.reset()`, **When** invoked, **Then** entry tracking stops, anonymous ID + cookie is removed, LocalStore runtime caches are cleared, and core reset is executed. + +--- + +### Edge Cases + +- `LocalStore.reset()` defaults to preserving consent and debug flags unless explicitly requested. +- `optimization.reset()` must preserve consent by default because LocalStore reset does not clear it + and core reset intentionally retains consent. +- Setting anonymous ID to `undefined` must clear both cookie and localStorage anonymous ID key. +- Cookie expiration defaults to 365 days when not provided. +- LocalStore consent values must map `'accepted' -> true`, `'denied' -> false`, anything else -> + `undefined`. +- Legacy localStorage anonymous ID key must be removed after migration. + +## Requirements _(mandatory)_ + +### Functional Requirements + +- **FR-001**: Web config merging MUST default `defaults.consent`, `.changes`, `.profile`, and + `.personalizations` from LocalStore when these defaults are omitted by the caller. +- **FR-002**: `Optimization` MUST read current and legacy anonymous ID cookies during construction. +- **FR-003**: Initialization MUST remove legacy anonymous ID cookie when present. +- **FR-004**: Initialization MUST reset SDK state and apply cookie anonymous ID when persisted + cookie ID differs from LocalStore anonymous ID. +- **FR-005**: `Optimization` MUST derive cookie attributes from config, using optional `domain` and + default `expires=365` days when not supplied. +- **FR-006**: `setAnonymousId(undefined)` MUST remove anonymous ID cookie and clear + `LocalStore.anonymousId`. +- **FR-007**: `setAnonymousId(value)` MUST persist anonymous ID to cookie and LocalStore. +- **FR-008**: Effects MUST synchronize `signals.changes.value` to `LocalStore.changes`. +- **FR-009**: Effects MUST synchronize `signals.consent.value` to `LocalStore.consent`. +- **FR-010**: Effects MUST synchronize `signals.profile.value` to `LocalStore.profile`. +- **FR-011**: Profile synchronization MUST call anonymous ID persistence with `profile?.id`. +- **FR-012**: Effects MUST synchronize `signals.personalizations.value` to + `LocalStore.personalizations`. +- **FR-013**: Consent effect MUST gate automatic entry view tracking: start when consent truthy and + `autoTrackEntryViews` is enabled; stop otherwise. +- **FR-014**: `Optimization.reset()` MUST stop auto entry tracking, clear anonymous ID cookie, clear + LocalStore runtime caches, and delegate to `CoreStateful.reset()`. +- **FR-015**: `Optimization.destroy()` MUST NOT clear persisted user state by default. +- **FR-016**: `LocalStore.anonymousId` getter MUST prefer legacy key when present and remove legacy + key after read. +- **FR-017**: `LocalStore.getCache()` MUST parse stored JSON with schema validation and return + `undefined` when absent/invalid. +- **FR-018**: `LocalStore.getCache()` MUST clear storage key when parsing or validation fails. +- **FR-019**: `LocalStore.setCache()` MUST remove key when input is `undefined`; otherwise it MUST + persist strings verbatim and non-strings as JSON. +- **FR-020**: `LocalStore.setCache()` MUST swallow storage persistence exceptions and log warnings. +- **FR-021**: `LocalStore.reset()` MUST clear anonymous ID, changes, profile, and personalizations, + and MUST clear consent/debug only when reset options request it. + +### Key Entities _(include if feature involves data)_ + +- **LocalStore**: Browser localStorage abstraction for persisted optimization state. +- **Anonymous ID Persistence**: Cookie + localStorage identity synchronization contract. +- **Signal Synchronization Effects**: Reactive bridges from core signals to browser persistence. +- **Reset Semantics**: Coordinated local and core state cleanup behavior for Web runtime. + +## Success Criteria _(mandatory)_ + +### Measurable Outcomes + +- **SC-001**: Initialization tests confirm persisted localStorage/cookie values are restored and + legacy cookie migration logic applies correctly. +- **SC-002**: Signal synchronization tests confirm consent/profile/changes/personalizations writes + are persisted automatically. +- **SC-003**: Reset tests confirm anonymous ID cleanup, LocalStore cleanup, and core reset + delegation. +- **SC-004**: Fault-injection tests confirm malformed cache values and storage exceptions never + crash runtime behavior. diff --git a/specs/016-web-event-enrichment/spec.md b/specs/016-web-event-enrichment/spec.md new file mode 100644 index 00000000..e30e1aad --- /dev/null +++ b/specs/016-web-event-enrichment/spec.md @@ -0,0 +1,129 @@ +# Feature Specification: Optimization Web Event Enrichment + +**Feature Branch**: `[016-web-event-enrichment]` +**Created**: 2026-02-26 +**Status**: Draft +**Input**: User description: "Examine the current functionality in `@contentful/optimization-web` +package and derive SpecKit-compatible specifications." + +## User Scenarios & Testing _(mandatory)_ + +### User Story 1 - Enrich Events with Web Context by Default (Priority: P1) + +As an integrator, I need events emitted from the Web SDK to include browser context metadata without +writing custom enrichment code for locale, page, and user-agent fields. + +**Why this priority**: Event observability and analysis depend on consistent context enrichment. + +**Independent Test**: Initialize `Optimization` with minimal config, emit events, and verify +event-builder defaults for channel/library/locale/page/user-agent are applied. + +**Acceptance Scenarios**: + +1. **Given** default Web SDK configuration, **When** events are built, **Then** event builder uses + `channel: 'web'` and Web SDK library metadata. +2. **Given** browser context with languages and location data, **When** enrichers run, **Then** + locale, page properties, and user-agent values are provided from browser APIs. +3. **Given** app metadata in configuration, **When** events are built, **Then** app metadata is + included in event builder defaults. + +--- + +### User Story 2 - Preserve Robust Fallbacks for Enrichment Inputs (Priority: P1) + +As a maintainer, I need enrichment helpers to fail safely so event building continues even when +window/document access is unavailable or throws. + +**Why this priority**: Browser APIs can fail under SSR-like, test, or restricted runtime conditions. + +**Independent Test**: Force browser API failures in `getPageProperties` and verify fallback payload +shape and error-handling behavior. + +**Acceptance Scenarios**: + +1. **Given** `getPageProperties` can read browser globals, **When** it executes, **Then** it returns + full page metadata including dimensions/hash/query/title/referrer. +2. **Given** `getPageProperties` throws while reading browser globals, **When** it executes, + **Then** it logs an error and returns a minimal safe fallback payload. +3. **Given** URL query parameters exist, **When** query building runs, **Then** query entries are + flattened into a plain dictionary. + +--- + +### User Story 3 - Support Extensible and Reliable Delivery Metadata (Priority: P2) + +As an SDK extender, I need configurable event-builder overrides and a beacon transport helper so I +can customize enrichment while preserving default Web event-delivery behavior. + +**Why this priority**: Extensibility and lifecycle-safe delivery improve adoption across host apps. + +**Independent Test**: Supply event-builder overrides and invoke beacon handler; verify override +merge behavior and sendBeacon payload format. + +**Acceptance Scenarios**: + +1. **Given** user-supplied event-builder overrides, **When** config merge runs, **Then** defaults + are merged with user values rather than dropped. +2. **Given** a batch insights payload, **When** `beaconHandler` executes, **Then** data is + serialized as JSON blob (`text/plain`) and sent via `navigator.sendBeacon`. +3. **Given** build-time constants are missing, **When** library metadata is read, **Then** fallback + package name/version constants are used. + +--- + +### Edge Cases + +- Locale resolution must prefer `navigator.languages[0]` and fall back to `navigator.language`. +- `getPageProperties` fallback payload omits hash/width/height but must still include stable + string/query keys. +- Query extraction preserves one string value per key based on URLSearchParams iteration behavior. +- Beacon helper returns the browser-provided boolean from `sendBeacon` without additional retries. +- Build-time constant replacements are optional; fallback constants must keep event metadata valid. + +## Requirements _(mandatory)_ + +### Functional Requirements + +- **FR-001**: Web config merging MUST default event-builder channel to `'web'`. +- **FR-002**: Web config merging MUST default event-builder library metadata to + `OPTIMIZATION_WEB_SDK_NAME` and `OPTIMIZATION_WEB_SDK_VERSION`. +- **FR-003**: Web config merging MUST include optional configured `app` metadata in event-builder + defaults. +- **FR-004**: Web config merging MUST default event-builder enrichers to `getLocale`, + `getPageProperties`, and `getUserAgent`. +- **FR-005**: Web config merging MUST allow user-supplied event-builder overrides via deep merge. +- **FR-006**: `getLocale()` MUST return `navigator.languages[0]` when present, otherwise + `navigator.language`. +- **FR-007**: `getPageProperties()` MUST return page metadata with `hash`, `height`, `path`, + `query`, `referrer`, `search`, `title`, `url`, and `width` when browser globals are accessible. +- **FR-008**: `getPageProperties()` MUST derive `query` by iterating URL search params into a plain + dictionary. +- **FR-009**: `getPageProperties()` MUST catch runtime errors, log the error, and return fallback + payload `{ path: '', query: {}, referrer: '', search: '', title: '', url: '' }`. +- **FR-010**: `getUserAgent()` MUST return `navigator.userAgent`. +- **FR-011**: `beaconHandler(url, events)` MUST serialize events as JSON into a Blob with MIME type + `text/plain`. +- **FR-012**: `beaconHandler` MUST return the boolean result of `window.navigator.sendBeacon`. +- **FR-013**: `OPTIMIZATION_WEB_SDK_NAME` MUST resolve to build-time replacement when available, + otherwise `'@contentful/optimization-web'`. +- **FR-014**: `OPTIMIZATION_WEB_SDK_VERSION` MUST resolve to build-time replacement when available, + otherwise `'0.0.0'`. + +### Key Entities _(include if feature involves data)_ + +- **Web Event Builder Defaults**: Channel/library/app/enricher configuration injected at runtime. +- **Page Enrichment Payload**: Browser-derived page context object attached to page-related events. +- **Beacon Transport Payload**: Serialized `BatchInsightsEventArray` sent through Beacon API. +- **SDK Metadata Constants**: Build-time-replaced or fallback library identification values. + +## Success Criteria _(mandatory)_ + +### Measurable Outcomes + +- **SC-001**: Default initialization tests confirm web channel/library metadata and enrichers are + present in event builder config. +- **SC-002**: Enricher tests confirm locale/page/user-agent helpers produce expected browser-derived + values and fallback payloads. +- **SC-003**: Override tests confirm custom event-builder fields can be merged without losing + defaults. +- **SC-004**: Beacon transport tests confirm serialized batch payloads are queued via `sendBeacon`. diff --git a/specs/017-web-automatic-component-view-tracking/spec.md b/specs/017-web-automatic-component-view-tracking/spec.md new file mode 100644 index 00000000..f843a91f --- /dev/null +++ b/specs/017-web-automatic-component-view-tracking/spec.md @@ -0,0 +1,168 @@ +# Feature Specification: Optimization Web Automatic Component View Tracking + +**Feature Branch**: `[017-web-automatic-component-view-tracking]` +**Created**: 2026-02-26 +**Status**: Draft +**Input**: User description: "Examine the current functionality in `@contentful/optimization-web` +package and derive SpecKit-compatible specifications." + +## User Scenarios & Testing _(mandatory)_ + +### User Story 1 - Track Entry Component Views from DOM Signals (Priority: P1) + +As a Web SDK consumer, I need component view events emitted automatically from entry-marked DOM +elements so I do not need to manually call tracking APIs for every viewable component. + +**Why this priority**: Automatic view tracking is a primary Web SDK differentiator. + +**Independent Test**: Enable auto tracking, render elements with `data-ctfl-*` attributes, trigger +intersections, and assert `trackComponentView` payload routing. + +**Acceptance Scenarios**: + +1. **Given** an observed element with valid entry data attributes, **When** dwell and visibility + requirements are met, **Then** one component view event is sent through `trackComponentView`. +2. **Given** callback data passed through manual observation, **When** both callback data and + dataset are present, **Then** callback data takes precedence for payload extraction. +3. **Given** missing entry identifier in callback and dataset, **When** callback executes, **Then** + no event is sent and a warning is logged. + +--- + +### User Story 2 - Observe and Unobserve Dynamic Entry Elements (Priority: P1) + +As a runtime maintainer, I need newly-added/removed entry elements auto-managed so tracking stays +accurate during dynamic DOM updates. + +**Why this priority**: Modern Web apps frequently add, remove, and reorder content nodes. + +**Independent Test**: Use mutation-driven add/remove scenarios and verify automatic +observe/unobserve behavior plus move-coalescing semantics. + +**Acceptance Scenarios**: + +1. **Given** auto-observation is enabled, **When** entry elements are added to DOM, **Then** they + are automatically observed for view tracking. +2. **Given** tracked entry elements are removed, **When** mutation processing runs, **Then** + matching elements are unobserved. +3. **Given** a node move represented as remove+add in same mutation batch, **When** records are + coalesced, **Then** no net add/remove callbacks are delivered. + +--- + +### User Story 3 - Maintain Reliable Dwell/Retry Behavior Across Visibility Changes (Priority: P2) + +As an SDK operator, I need dwell timing and retry handling to be robust across tab visibility +changes, callback failures, and disconnected elements so view tracking remains stable and bounded. + +**Why this priority**: Reliability depends on deterministic retry/timer lifecycle behavior. + +**Independent Test**: Simulate visible/hidden cycles, callback failures, and orphan elements; verify +dwell accumulation, retry backoff behavior, and cleanup semantics. + +**Acceptance Scenarios**: + +1. **Given** intermittent visibility, **When** an element is visible across multiple cycles, + **Then** visible time accumulates until dwell threshold is reached and callback fires once. +2. **Given** callback failures while visible, **When** retries are permitted, **Then** retries are + scheduled with exponential backoff plus jitter and without concurrent duplicate attempts. +3. **Given** tab hidden state, **When** timers/retries are active, **Then** processing pauses and + resumes cleanly when tab becomes visible again. + +--- + +### Edge Cases + +- `autoTrackEntryViews` defaults to `false` and only auto-starts/stops with consent transitions when + enabled. +- `parseSticky(undefined)` resolves to `false` for dataset-based extraction. +- Dataset variant index parsing must accept only digit-only safe integers; invalid values become + `undefined`. +- Manual entry observation APIs are safe no-ops before observers are initialized. +- `startAutoTrackingEntryViews(options)` applies provided options to initially discovered elements; + mutation-added elements use observer defaults. +- Element view callbacks must execute once per element after success or retry exhaustion. +- Mutation callbacks must deliver removals before additions for each processed batch. +- Non-DOM/SSR environments must degrade to no-op observer behavior. + +## Requirements _(mandatory)_ + +### Functional Requirements + +- **FR-001**: `Optimization` MUST default `autoTrackEntryViews` to `false` when omitted. +- **FR-002**: `Optimization` MUST preserve explicit `autoTrackEntryViews: true` configuration. +- **FR-003**: Consent synchronization MUST start auto tracking when consent is truthy and + `autoTrackEntryViews` is enabled. +- **FR-004**: Consent synchronization MUST stop auto tracking when consent is falsy/undefined and + `autoTrackEntryViews` is enabled. +- **FR-005**: `startAutoTrackingEntryViews()` MUST create an `ElementViewObserver` using + `createAutoTrackingEntryViewCallback`. +- **FR-006**: `startAutoTrackingEntryViews()` MUST create an `ElementExistenceObserver` using + `createAutoTrackingEntryExistenceCallback(..., true)`. +- **FR-007**: `startAutoTrackingEntryViews()` MUST query `[data-ctfl-entry-id]` elements and observe + each valid entry element. +- **FR-008**: `stopAutoTrackingEntryViews()` MUST disconnect both element existence and element view + observers. +- **FR-009**: `trackEntryViewForElement()` MUST attempt to observe the provided element through + `ElementViewObserver` when initialized. +- **FR-010**: `untrackEntryViewForElement()` MUST attempt to unobserve the provided element through + `ElementViewObserver` when initialized. +- **FR-011**: `isEntryElement()` MUST return true only for DOM elements with non-empty + `dataset.ctflEntryId`. +- **FR-012**: `createAutoTrackingEntryViewCallback` MUST accept callback data in `EntryData` form or + derive payload data from `data-ctfl-*` attributes. +- **FR-013**: Callback data extraction MUST prioritize explicit callback `info.data` over element + dataset. +- **FR-014**: Dataset sticky parsing MUST treat only case-insensitive `'true'` as true; all other + values MUST resolve to false. +- **FR-015**: Dataset variant index parsing MUST return only non-negative safe integers parsed from + digit-only strings; otherwise `undefined`. +- **FR-016**: Auto-tracking callback MUST call `core.trackComponentView` with + `{ componentId, experienceId, sticky, variantIndex }` when entry ID is available. +- **FR-017**: Auto-tracking callback MUST skip event dispatch when entry ID cannot be resolved. +- **FR-018**: Existence observer callback MUST auto-observe added entry elements when auto-observe + is enabled. +- **FR-019**: Existence observer callback MUST unobserve removed entry elements only when stats + indicate they are currently tracked. +- **FR-020**: `ElementViewObserver` MUST accumulate visible time across multiple visibility cycles. +- **FR-021**: `ElementViewObserver` MUST invoke callback once per observed element after dwell + threshold is met and callback succeeds, then unobserve that element. +- **FR-022**: `ElementViewObserver` MUST retry failed callbacks with per-element exponential backoff + and jitter while respecting `maxRetries`. +- **FR-023**: `ElementViewObserver` MUST avoid duplicate concurrent callback attempts for the same + element. +- **FR-024**: `ElementViewObserver` MUST pause dwell/retry timing when page visibility is hidden and + resume when visible. +- **FR-025**: `ElementViewObserver` MUST expose readonly stats via `getStats()` and return `null` + when state is not tracked. +- **FR-026**: `ElementViewObserver` MUST clear timers and state on `unobserve()` and `disconnect()`. +- **FR-027**: `ElementViewObserver` MUST periodically sweep orphaned/disconnected element states and + stop sweeper when no active states remain. +- **FR-028**: `ElementExistenceObserver` MUST observe childList+subtree mutations, coalesce moves, + filter to elements (including descendants), and batch deliveries in idle time. +- **FR-029**: `ElementExistenceObserver` MUST dispatch removal chunks before addition chunks. +- **FR-030**: `ElementExistenceObserver` MUST route sync/async callback failures to optional + `onError`. + +### Key Entities _(include if feature involves data)_ + +- **EntryElement**: DOM element with `data-ctfl-entry-id` and optional personalization attributes. +- **EntryData**: Normalized callback payload (`entryId`, `personalizationId`, `sticky`, + `variantIndex`). +- **ElementViewObserver**: IntersectionObserver-based dwell/retry tracker for per-element callbacks. +- **ElementExistenceObserver**: MutationObserver-based add/remove detector for dynamic DOM tracking. +- **AutoTracking Callbacks**: Glue logic that transforms observer signals into component view + events. + +## Success Criteria _(mandatory)_ + +### Measurable Outcomes + +- **SC-001**: Auto-tracking tests confirm valid entry elements emit exactly one component view event + per observed element after dwell conditions are satisfied. +- **SC-002**: Mutation tests confirm added elements are observed, removed elements are unobserved, + and move-only mutations produce no net callbacks. +- **SC-003**: Dwell/retry tests confirm accumulation across visibility cycles, exponential retry + behavior, and no duplicate concurrent attempts. +- **SC-004**: Cleanup tests confirm observer disconnect/unobserve paths clear timers/listeners/state + and prevent further callback execution. diff --git a/specs/018-web-preview-panel/spec.md b/specs/018-web-preview-panel/spec.md new file mode 100644 index 00000000..0d5ea618 --- /dev/null +++ b/specs/018-web-preview-panel/spec.md @@ -0,0 +1,154 @@ +# Feature Specification: Optimization Web Preview Panel + +**Feature Branch**: `[018-web-preview-panel]` +**Created**: 2026-02-26 +**Status**: Draft +**Input**: User description: "Examine the current functionality in +`@contentful/optimization-web-preview-panel` package and derive SpecKit-compatible specifications. +Create a SpecKit spec for the derived specifications." + +## User Scenarios & Testing _(mandatory)_ + +### User Story 1 - Attach the Preview Panel and Load Preview Data (Priority: P1) + +As a Web SDK consumer, I need to attach a single preview panel instance and load all relevant +audience and personalization entries so editors can inspect personalization state in-page. + +**Why this priority**: The feature is unusable unless the panel attaches reliably and receives data. + +**Independent Test**: Call `attachOptimizationPreviewPanel(...)` in a browser-like environment and +verify one panel is appended, required elements are defined, and audiences/personalizations are +loaded. + +**Acceptance Scenarios**: + +1. **Given** no existing preview panel and a valid `Optimization` preview registration, **When** + `attachOptimizationPreviewPanel` is called, **Then** the panel initializes and is appended to + `document.body`. +2. **Given** an existing preview panel element in the DOM, **When** attachment is attempted again, + **Then** the function throws and does not append a duplicate panel. +3. **Given** `optimization.registerPreviewPanel()` does not provide required preview signals, + **When** attachment is attempted, **Then** the function throws an error. + +--- + +### User Story 2 - Explore Audiences and Override Variants (Priority: P1) + +As an editor, I need to browse audiences and choose variants from the panel so I can preview +personalization behavior for specific experiences. + +**Why this priority**: Manual variant overrides are the core editor interaction for previewing. + +**Independent Test**: Render the panel with fetched entries and simulate audience toggles and +variant selection events; verify grouping, ordering, and signal updates. + +**Acceptance Scenarios**: + +1. **Given** fetched audiences and personalizations, **When** the panel renders, **Then** + personalizations are grouped by audience and entries without audience are grouped under the + synthetic "All Visitors" audience. +2. **Given** personalizations containing `InlineVariable` components, **When** entries are prepared + for rendering, **Then** those personalizations are excluded from the panel. +3. **Given** a variant radio change in an audience section, **When** a valid + `ctfl-opt-preview-personalization-change` event is emitted, **Then** the selected override is + stored and propagated to optimization personalizations. + +--- + +### User Story 3 - Keep Overrides and Reset in Sync with Optimization Signals (Priority: P2) + +As an editor, I need override changes and reset behavior to stay synchronized with optimization +signals so the preview state is deterministic and reversible. + +**Why this priority**: Correct synchronization prevents stale or conflicting preview assignments. + +**Independent Test**: Trigger optimization state updates, apply overrides, and press reset; verify +default capture, override application, and restoration semantics. + +**Acceptance Scenarios**: + +1. **Given** optimization states are intercepted, **When** states update, **Then** default + personalizations are captured and overrides are applied to returned state values. +2. **Given** one or more active overrides, **When** the panel reset action is triggered, **Then** + overrides are cleared and optimization personalizations revert to captured defaults. +3. **Given** malformed personalization change events, **When** event payload guards fail, **Then** + no override mutation is applied. + +--- + +### Edge Cases + +- Contentful entry fetching must follow cursor pagination until all pages are collected. +- A personalization with no `nt_audience` reference must still appear under an "All Visitors" + fallback. +- Audiences with no qualifying personalizations must render deterministic empty-state messaging. +- Attachment must fail fast if preview signals are unavailable from the Optimization instance. +- Duplicate panel attachment attempts must fail fast to prevent conflicting listeners/state. +- Invalid or non-`CustomEvent` variant-change payloads must be ignored by event guards. + +## Requirements _(mandatory)_ + +### Functional Requirements + +- **FR-001**: `attachOptimizationPreviewPanel` MUST throw when `document` already contains a + `ctfl-opt-preview-panel` element. +- **FR-002**: `attachOptimizationPreviewPanel` MUST assign the provided `cspNonce` to + `window.litNonce` before creating Lit-based elements. +- **FR-003**: `attachOptimizationPreviewPanel` MUST call `optimization.registerPreviewPanel()` and + MUST throw when required `signals` or `signalFns` are missing. +- **FR-004**: Entry loading MUST fetch all `nt_audience` and `nt_experience` entries using + cursor-aware pagination. +- **FR-005**: Initialization MUST define custom elements for indicator, personalization, audience, + and panel before appending the panel instance. +- **FR-006**: Panel data preparation MUST include only valid audience/personalization entries and + MUST exclude personalizations containing `InlineVariable` components. +- **FR-007**: The panel instance MUST receive `signals`, `signalFns`, audiences, personalizations, + and the initial `defaultSelectedPersonalizations`. +- **FR-008**: An Optimization state interceptor MUST capture default personalizations and MUST + return states with personalizations transformed by `applyPersonalizationOverrides(...)`. +- **FR-009**: Handling `ctfl-opt-preview-personalization-change` MUST update the override map and + `signals.personalizations.value` with override-applied personalizations. +- **FR-010**: Handling `ctfl-opt-preview-panel-reset` MUST clear overrides, restore default + personalizations to signals, and refresh panel defaults. +- **FR-011**: The panel MUST group personalizations by audience and MUST use the synthetic "All + Visitors" audience for personalizations without an audience reference. +- **FR-012**: The panel MUST maintain deterministic audience ordering using audience metadata and + associated personalization counts. +- **FR-013**: Audience sections MUST maintain local expanded/collapsed state and update it from + `ctfl_opt_preview_audience_content_toggle` events. +- **FR-014**: Audience sections MUST expose personalization variant choices through radio groups and + MUST emit `ctfl-opt-preview-personalization-change` with personalization id and variant index. +- **FR-015**: Personalization change handlers MUST ignore malformed/non-`CustomEvent` payloads that + fail payload guards. +- **FR-016**: Panel reset UI interaction MUST emit `ctfl-opt-preview-panel-reset`. +- **FR-017**: Audience and personalization rows MUST render qualification/selection indicators based + on profile and override state. +- **FR-018**: Empty personalization collections MUST render explicit empty-state messaging in + audience content. + +### Key Entities _(include if feature involves data)_ + +- **PreviewPanelAttachment**: Host-side initialization flow that validates prerequisites, fetches + entries, installs interceptors/listeners, and appends `ctfl-opt-preview-panel`. +- **AudienceEntry**: Contentful `nt_audience` entry rendered as an expandable audience group. +- **PersonalizationEntry**: Contentful `nt_experience` entry with variant configuration shown as + radio options. +- **PersonalizationOverrideMap**: `Map` used to persist manual + overrides. +- **PreviewSignals**: Optimization `signals` and `signalFns` used to read/update runtime + personalization state. + +## Success Criteria _(mandatory)_ + +### Measurable Outcomes + +- **SC-001**: Attachment tests confirm exactly one preview panel can exist and duplicate attachment + throws. +- **SC-002**: Data loading tests confirm all paginated audiences/personalizations are retrieved and + inline variable personalizations are excluded from panel rendering. +- **SC-003**: Interaction tests confirm valid variant-change events mutate overrides and immediately + update optimization personalization signals. +- **SC-004**: Interceptor tests confirm default personalizations are captured and override + application is reflected in returned optimization state. +- **SC-005**: Reset tests confirm override map is emptied and optimization personalizations revert + to captured defaults. diff --git a/specs/019-react-native-foundational-and-shared/spec.md b/specs/019-react-native-foundational-and-shared/spec.md new file mode 100644 index 00000000..957c51f2 --- /dev/null +++ b/specs/019-react-native-foundational-and-shared/spec.md @@ -0,0 +1,151 @@ +# Feature Specification: Optimization React Native Foundational and Shared Contracts + +**Feature Branch**: `[019-react-native-foundational-and-shared]` +**Created**: 2026-02-26 +**Status**: Draft +**Input**: User description: "Examine the current functionality in +`@contentful/optimization-react-native` package and derive SpecKit-compatible specifications that +could have guided its development." + +## User Scenarios & Testing _(mandatory)_ + +### User Story 1 - Bootstrap a Single Mobile Runtime Instance (Priority: P1) + +As a React Native integrator, I need one canonical SDK instance per JS runtime so configuration, +signals, and listeners are initialized once and reused safely. + +**Why this priority**: SDK bootstrap is the entry point for all personalization and analytics +behavior. + +**Independent Test**: Call `Optimization.create(...)` twice in one runtime and verify first creation +succeeds while second creation fails until the first instance is destroyed. + +**Acceptance Scenarios**: + +1. **Given** no active SDK instance, **When** `Optimization.create(config)` is called, **Then** a + new initialized `Optimization` instance is returned. +2. **Given** an active SDK instance, **When** `Optimization.create(config)` is called again, + **Then** creation fails with an "already initialized" error. +3. **Given** a destroyed active instance, **When** `Optimization.create(config)` is called, **Then** + a replacement instance can be created. + +--- + +### User Story 2 - Wire Mobile Lifecycle and Connectivity Signals (Priority: P1) + +As a maintainer, I need app-state and network listeners wired to SDK lifecycle APIs so queueing and +flushing behavior remain resilient as connectivity and app focus change. + +**Why this priority**: Event reliability depends on online/offline transitions and background +flushes. + +**Independent Test**: Simulate AppState and NetInfo transitions and verify updates to `online` via +setter assignment, `flush()`, callback error handling, and cleanup behavior. + +**Acceptance Scenarios**: + +1. **Given** the app transitions to `background` or `inactive`, **When** the AppState listener + fires, **Then** SDK `flush()` is invoked. +2. **Given** NetInfo emits connectivity state changes, **When** the listener receives updates, + **Then** SDK `online` is set with `online = isOnline` using internet reachability fallback logic. +3. **Given** listener callbacks throw or reject, **When** listeners run, **Then** errors are logged + and runtime execution continues. + +--- + +### User Story 3 - Consume a Stable Package Surface and Runtime Setup (Priority: P2) + +As a package consumer, I need a stable export surface, build artifacts, constants, and runtime +polyfills so the SDK can be imported consistently across React Native toolchains. + +**Why this priority**: Compatibility and developer adoption depend on predictable packaging. + +**Independent Test**: Import package root in ESM/CJS and verify exported APIs, fallback constants, +and runtime polyfill side effects. + +**Acceptance Scenarios**: + +1. **Given** package root imports, **When** consumers import + `@contentful/optimization-react-native`, **Then** core exports plus React Native-specific + classes/components/hooks are available. +2. **Given** build-time constant replacement is unavailable, **When** SDK name/version constants are + read, **Then** fallback values are returned. +3. **Given** environments without `crypto.randomUUID`, **When** package entry executes, **Then** the + polyfill provides a `randomUUID` implementation. + +--- + +### Edge Cases + +- NetInfo module loading can fail (missing dependency or invalid shape); listener setup must degrade + to warning + no-op cleanup. +- Cleanup can be called before asynchronous NetInfo import resolves; late subscription must be + prevented. +- AppState and NetInfo callback exceptions must never crash the runtime. +- `destroy()` must clear singleton ownership only when called on the active instance. +- Global `crypto` polyfill setup must be idempotent across repeated imports. + +## Requirements _(mandatory)_ + +### Functional Requirements + +- **FR-001**: `Optimization.create(config)` MUST enforce a single active SDK instance per JS + runtime. +- **FR-002**: `Optimization.create(config)` MUST throw when an active instance already exists. +- **FR-003**: `Optimization.create(config)` MUST asynchronously resolve merged configuration before + constructing the runtime. +- **FR-004**: `Optimization` MUST extend `CoreStateful`. +- **FR-005**: Construction MUST register an online/offline listener that maps connectivity state to + `online = isOnline`. +- **FR-006**: Construction MUST register an AppState listener that invokes `flush()` on `background` + and `inactive` transitions. +- **FR-007**: `createOnlineChangeListener` MUST dynamically import `@react-native-community/netinfo` + and validate module shape before subscription. +- **FR-008**: `createOnlineChangeListener` MUST determine connectivity using + `isInternetReachable ?? isConnected ?? true`. +- **FR-009**: If NetInfo cannot be loaded or validated, `createOnlineChangeListener` MUST log a + warning and return a safe cleanup function. +- **FR-010**: Online listener callback failures (sync or async) MUST be logged and swallowed. +- **FR-011**: AppState listener callback failures (sync or async) MUST be logged and swallowed. +- **FR-012**: `Optimization.destroy()` MUST invoke cleanup handlers for both online and AppState + listeners. +- **FR-013**: `Optimization.destroy()` MUST clear singleton tracking only when + `activeOptimizationInstance === this`. +- **FR-014**: `Optimization.destroy()` MUST delegate to `CoreStateful.destroy()`. +- **FR-015**: Package root exports MUST include all `@contentful/optimization-core` exports plus + React Native APIs (`OptimizationProvider`, `OptimizationRoot`, `Personalization`, `Analytics`, + `ScrollProvider`, context hooks, tracking hooks, navigation container, and preview exports). +- **FR-016**: Package default export MUST be the `Optimization` class. +- **FR-017**: `OPTIMIZATION_REACT_NATIVE_SDK_NAME` MUST resolve from build-time replacement when + available, otherwise `'@contentful/optimization-react-native'`. +- **FR-018**: `OPTIMIZATION_REACT_NATIVE_SDK_VERSION` MUST resolve from build-time replacement when + available, otherwise `'0.0.0'`. +- **FR-019**: Package entry MUST load runtime setup modules for image typing declarations and crypto + polyfills. +- **FR-020**: Crypto polyfill MUST ensure `global.crypto` exists and provide `crypto.randomUUID` + when missing. +- **FR-021**: Package build outputs MUST expose ESM (`index.mjs`), CJS (`index.cjs`), and dual + declaration artifacts (`index.d.mts`, `index.d.cts`). +- **FR-022**: Build configuration MUST treat React/React Native runtime dependencies as externals. + +### Key Entities _(include if feature involves data)_ + +- **Optimization (React Native)**: Stateful SDK runtime with mobile lifecycle wiring. +- **Lifecycle Listener Contracts**: Online and AppState handlers that bridge device state to SDK + behavior. +- **Package Surface**: Public exports, constants, and bundle artifacts consumed by integrators. +- **Runtime Polyfills**: Import-time compatibility setup for iterator helpers and + `crypto.randomUUID`. + +## Success Criteria _(mandatory)_ + +### Measurable Outcomes + +- **SC-001**: Runtime initialization tests confirm singleton enforcement and re-creation after + destruction. +- **SC-002**: Listener tests confirm online/AppState transitions invoke SDK handlers and cleanup + prevents later callbacks. +- **SC-003**: Fault-injection tests confirm NetInfo absence and callback failures do not crash SDK + execution. +- **SC-004**: Import/build checks confirm root exports, default export, constants fallback behavior, + and runtime/type artifacts are present. diff --git a/specs/020-react-native-state-management/spec.md b/specs/020-react-native-state-management/spec.md new file mode 100644 index 00000000..e44ab8ca --- /dev/null +++ b/specs/020-react-native-state-management/spec.md @@ -0,0 +1,156 @@ +# Feature Specification: Optimization React Native State Management + +**Feature Branch**: `[020-react-native-state-management]` +**Created**: 2026-02-26 +**Status**: Draft +**Input**: User description: "Examine the current functionality in +`@contentful/optimization-react-native` package and derive SpecKit-compatible specifications that +could have guided its development." + +## User Scenarios & Testing _(mandatory)_ + +### User Story 1 - Hydrate SDK State from AsyncStorage on Startup (Priority: P1) + +As a mobile SDK integrator, I need persisted consent/profile/changes/personalizations restored at +startup so personalization and analytics continuity survives app restarts. + +**Why this priority**: Persistent state hydration is foundational for consistent user experience. + +**Independent Test**: Pre-populate AsyncStorage keys, run `Optimization.create(...)`, and verify +merged defaults and log-level behavior use stored values when caller defaults are omitted. + +**Acceptance Scenarios**: + +1. **Given** stored values for consent/profile/changes/personalizations, **When** SDK initializes, + **Then** those values are used as effective defaults when not overridden. +2. **Given** a stored debug flag set to true, **When** config is merged, **Then** runtime log level + defaults to `'debug'`. +3. **Given** caller-provided defaults, **When** config is merged, **Then** caller values override + storage-derived defaults. +4. **Given** no `allowedEventTypes` override, **When** React Native config is merged, **Then** + `allowedEventTypes` defaults to `['identify', 'screen']`. +5. **Given** caller-provided `allowedEventTypes`, **When** config is merged, **Then** caller values + override the React Native default allow-list. + +--- + +### User Story 2 - Synchronize Runtime Signals Back to Persistence (Priority: P1) + +As a maintainer, I need reactive synchronization from core signals to AsyncStorage so runtime state +mutations are persisted without manual write calls. + +**Why this priority**: Signal/storage drift would break profile continuity and personalization. + +**Independent Test**: Mutate core signals (`changes`, `consent`, `profile`, `personalizations`) and +verify matching AsyncStorageStore writes. + +**Acceptance Scenarios**: + +1. **Given** `signals.changes.value` changes, **When** effects run, **Then** + `AsyncStorageStore.changes` is updated. +2. **Given** `signals.profile.value` changes, **When** effects run, **Then** + `AsyncStorageStore.profile` is updated and anonymous ID is synchronized from `profile.id` with + fallback to stored anonymous ID. +3. **Given** `signals.consent.value` or `signals.personalizations.value` changes, **When** effects + run, **Then** corresponding AsyncStorageStore keys are updated. + +--- + +### User Story 3 - Survive Malformed Cache Data and Storage Failures (Priority: P2) + +As an operator, I need storage parsing and persistence to fail safely so the SDK remains functional +when AsyncStorage values are corrupted or storage operations fail. + +**Why this priority**: Mobile storage can be unavailable or contain invalid payloads. + +**Independent Test**: Inject malformed JSON, schema-invalid values, and rejected AsyncStorage +operations; verify invalid values are removed and runtime avoids throws. + +**Acceptance Scenarios**: + +1. **Given** malformed JSON in structured cache keys, **When** initialization parses values, + **Then** invalid keys are removed and treated as undefined. +2. **Given** schema-invalid structured values, **When** initialization or getter validation runs, + **Then** invalid keys are removed and treated as undefined. +3. **Given** AsyncStorage `setItem`/`removeItem` failures, **When** cache writes or invalidations + occur, **Then** errors are logged and execution continues. + +--- + +### Edge Cases + +- Store initialization may fail entirely (`multiGet` rejection); SDK bootstrap must continue with + best-effort behavior. +- Structured cache getters must re-validate in-memory values and invalidate stale/invalid entries. +- Consent string mapping must be strict: `'accepted' -> true`, `'denied' -> false`, everything else + -> `undefined`. +- Destroying the SDK instance must not implicitly clear persisted AsyncStorage values. +- Anonymous ID synchronization must preserve prior stored ID when profile updates do not include an + `id`. + +## Requirements _(mandatory)_ + +### Functional Requirements + +- **FR-001**: AsyncStorage-backed state MUST be initialized before merged runtime config is + computed. +- **FR-002**: Initialization MUST load known cache keys for anonymous ID, consent, debug, changes, + profile, and personalizations. +- **FR-003**: Initialization MUST treat string keys (`anonymousId`, `consent`, `debug`) as raw + string values. +- **FR-004**: Initialization MUST parse structured keys (`changes`, `profile`, `personalizations`) + from JSON and validate them with schema parsers. +- **FR-005**: Malformed structured JSON MUST trigger cache invalidation and AsyncStorage key + removal. +- **FR-006**: Schema-invalid structured values MUST trigger cache invalidation and AsyncStorage key + removal. +- **FR-007**: Initialization failures MUST be logged without throwing. +- **FR-008**: Merged config MUST default `defaults.consent` from AsyncStorage when caller value is + omitted. +- **FR-009**: Merged config MUST default `defaults.profile` from AsyncStorage when caller value is + omitted. +- **FR-010**: Merged config MUST default `defaults.changes` from AsyncStorage when caller value is + omitted. +- **FR-011**: Merged config MUST default `defaults.personalizations` from AsyncStorage when caller + value is omitted. +- **FR-012**: Merged config MUST default `logLevel` to `'debug'` when persisted debug flag is + truthy; otherwise it MUST preserve caller-provided log level. +- **FR-013**: Runtime effects MUST synchronize `signals.changes.value` to persisted changes state. +- **FR-014**: Runtime effects MUST synchronize `signals.consent.value` to persisted consent state. +- **FR-015**: Runtime effects MUST synchronize `signals.profile.value` to persisted profile state. +- **FR-016**: Profile synchronization MUST update anonymous ID persistence using + `profile?.id ?? storedAnonymousId`. +- **FR-017**: Runtime effects MUST synchronize `signals.personalizations.value` to persisted + personalizations state. +- **FR-018**: Consent setter MUST translate booleans to storage strings (`accepted`/`denied`) and + clear storage when undefined. +- **FR-019**: `getCache` MUST validate in-memory structured values and invalidate/remove keys that + fail schema validation. +- **FR-020**: `setCache(key, undefined)` MUST remove key from in-memory cache and AsyncStorage. +- **FR-021**: `setCache(key, value)` MUST persist strings verbatim and non-string values as JSON. +- **FR-022**: AsyncStorage write/remove failures during `setCache` or invalidation MUST be logged + and swallowed. +- **FR-023**: React Native merged config MUST default `allowedEventTypes` to + `['identify', 'screen']` when caller configuration omits it. +- **FR-024**: Caller-provided `allowedEventTypes` MUST override the React Native default allow-list. + +### Key Entities _(include if feature involves data)_ + +- **AsyncStorageStore**: In-memory + AsyncStorage write-through cache for SDK runtime state. +- **State Hydration Contract**: Startup behavior that merges persisted values into SDK defaults. +- **Signal Persistence Effects**: Reactive synchronizers from core signals to AsyncStorage keys. +- **Structured Cache Parsers**: Schema validators for `changes`, `profile`, and `personalizations` + data integrity. + +## Success Criteria _(mandatory)_ + +### Measurable Outcomes + +- **SC-001**: Startup tests confirm omitted defaults are hydrated from AsyncStorage-backed state. +- **SC-002**: Effect tests confirm changes/consent/profile/personalizations are persisted when + signals update. +- **SC-003**: Invalid-cache tests confirm malformed or schema-invalid values are removed and + resolved as undefined. +- **SC-004**: Fault tests confirm AsyncStorage operation failures do not crash runtime execution. +- **SC-005**: Merge-config tests confirm React Native defaults `allowedEventTypes` to + `['identify', 'screen']` and preserves caller-provided `allowedEventTypes`. diff --git a/specs/021-react-native-event-enrichment/spec.md b/specs/021-react-native-event-enrichment/spec.md new file mode 100644 index 00000000..9fdc0a8c --- /dev/null +++ b/specs/021-react-native-event-enrichment/spec.md @@ -0,0 +1,135 @@ +# Feature Specification: Optimization React Native Event Enrichment + +**Feature Branch**: `[021-react-native-event-enrichment]` **Created**: 2026-02-26 **Status**: Draft +**Input**: User description: "Examine the current functionality in +`@contentful/optimization-react-native` package and derive SpecKit-compatible specifications that +could have guided its development." + +## User Scenarios & Testing _(mandatory)_ + +### User Story 1 - Apply Mobile Event Metadata Defaults (Priority: P1) + +As an SDK integrator, I need default mobile event metadata set automatically so emitted events carry +stable source attribution without extra configuration. + +**Why this priority**: Event metadata consistency is required for downstream attribution and +analysis. + +**Independent Test**: Initialize `Optimization` with minimal config and assert merged +`eventBuilder.channel` and `eventBuilder.library` defaults. + +**Acceptance Scenarios**: + +1. **Given** SDK initialization with no event-builder overrides, **When** merged config is resolved, + **Then** `eventBuilder.channel` is `'mobile'`. +2. **Given** SDK initialization with no event-builder library override, **When** merged config is + resolved, **Then** `eventBuilder.library.name` and `eventBuilder.library.version` are populated + from SDK metadata constants. +3. **Given** build-time metadata replacement is unavailable, **When** defaults are resolved, + **Then** fallback package name/version values are used. + +--- + +### User Story 2 - Preserve Consumer Event Builder Overrides (Priority: P1) + +As an SDK consumer, I need to override event-builder fields while retaining unspecified React Native +defaults so enrichment behavior can be customized incrementally. + +**Why this priority**: Real integrations frequently customize only part of event-builder behavior. + +**Independent Test**: Provide partial and full `eventBuilder` overrides and verify deep-merge +behavior preserves non-overridden defaults. + +**Acceptance Scenarios**: + +1. **Given** a custom `eventBuilder.channel`, **When** config is merged, **Then** the custom channel + overrides the default. +2. **Given** a partial `eventBuilder.library` override (for example, name only), **When** config is + merged, **Then** unspecified library fields retain default values. +3. **Given** additional event-builder fields supplied by the consumer, **When** config is merged, + **Then** those fields are preserved in the final event-builder configuration. + +--- + +### User Story 3 - Align RN Enrichment Scope with Core Support Boundaries (Priority: P2) + +As a maintainer, I need React Native enrichment defaults scoped to metadata only because currently +Core's built-in function enrichers are intended for server-side and Web environments, not mobile. + +**Why this priority**: This avoids implying first-class React Native support for enrichment +functions that are outside current Core support boundaries. + +**Independent Test**: Initialize SDK without function-based event-builder overrides and verify the +React Native merge layer contributes only channel/library defaults; then provide explicit function +overrides and verify they pass through unchanged. + +**Acceptance Scenarios**: + +1. **Given** no consumer-supplied function-based event-builder overrides, **When** merge runs, + **Then** the React Native merge layer only contributes channel/library defaults. +2. **Given** consumer-supplied function-based event-builder values, **When** merge runs, **Then** + those values are preserved unchanged. +3. **Given** React Native SDK usage where `page(...)` is technically callable via inherited Core + APIs, **When** defining React Native enrichment scope, **Then** `page` enrichment is treated as a + non-explicit feature with no RN-specific enrichment helper defaults. + +--- + +### Edge Cases + +- Partial nested overrides (for example, only `eventBuilder.library.name`) must keep missing nested + defaults (for example, default `library.version`). +- Explicit consumer channel overrides must replace `'mobile'`. +- Fallback metadata constants must remain valid when build-time define replacement is absent. +- React Native event enrichment scope intentionally omits built-in function enrichers because + current Core built-in enrichers are server/web-oriented. +- `page` event emission remains technically possible through inherited Core APIs but is outside + explicitly supported React Native enrichment behavior. + +## Requirements _(mandatory)_ + +### Functional Requirements + +- **FR-001**: React Native config merging MUST set default `eventBuilder.channel` to `'mobile'`. +- **FR-002**: React Native config merging MUST set default `eventBuilder.library.name` to + `OPTIMIZATION_REACT_NATIVE_SDK_NAME`. +- **FR-003**: React Native config merging MUST set default `eventBuilder.library.version` to + `OPTIMIZATION_REACT_NATIVE_SDK_VERSION`. +- **FR-004**: React Native config merging MUST deep-merge caller config with defaults. +- **FR-005**: Caller-provided `eventBuilder.channel` MUST override the default channel value. +- **FR-006**: Caller-provided partial `eventBuilder.library` values MUST override only specified + fields while preserving unspecified default library fields. +- **FR-007**: Caller-provided additional `eventBuilder` fields MUST be preserved in merged config. +- **FR-008**: React Native config merging MUST omit built-in function-based event-builder enrichers + by default because current Core built-in function enrichers target server-side and Web contexts. +- **FR-009**: Consumer-supplied function-based `eventBuilder` values MUST be preserved unchanged by + merge behavior. +- **FR-010**: `OPTIMIZATION_REACT_NATIVE_SDK_NAME` MUST resolve to build-time replacement when + available, otherwise `'@contentful/optimization-react-native'`. +- **FR-011**: `OPTIMIZATION_REACT_NATIVE_SDK_VERSION` MUST resolve to build-time replacement when + available, otherwise `'0.0.0'`. +- **FR-012**: Event-builder library defaults MUST always remain non-empty and valid through + build-time and fallback constant resolution. +- **FR-013**: `page(...)` event emission MAY still occur through inherited Core APIs, but React + Native event enrichment contracts MUST NOT define dedicated RN helper defaults for `page` + enrichment. + +### Key Entities _(include if feature involves data)_ + +- **Event Builder Defaults**: React Native-provided channel/library baseline metadata. +- **Event Builder Overrides**: Caller-supplied event-builder fields merged into runtime config. +- **SDK Metadata Constants**: Build-time or fallback package identity values used for library + metadata. + +## Success Criteria _(mandatory)_ + +### Measurable Outcomes + +- **SC-001**: Initialization tests confirm merged defaults include `channel='mobile'` and library + name/version metadata. +- **SC-002**: Override tests confirm deep-merge behavior for channel/library and preservation of + additional event-builder fields. +- **SC-003**: Regression tests confirm React Native merge logic applies metadata defaults only and + does not inject built-in function enrichers. +- **SC-004**: Constant-resolution tests confirm build-time replacements and fallback values both + produce valid library metadata. diff --git a/specs/022-react-native-contexts-and-providers/spec.md b/specs/022-react-native-contexts-and-providers/spec.md new file mode 100644 index 00000000..6bc06b7d --- /dev/null +++ b/specs/022-react-native-contexts-and-providers/spec.md @@ -0,0 +1,141 @@ +# Feature Specification: Optimization React Native Contexts and Providers + +**Feature Branch**: `[022-react-native-contexts-and-providers]` +**Created**: 2026-02-26 +**Status**: Draft +**Input**: User description: "Examine the current functionality in +`@contentful/optimization-react-native` package and derive SpecKit-compatible specifications that +could have guided its development." + +## User Scenarios & Testing _(mandatory)_ + +### User Story 1 - Access the SDK Instance Through React Context (Priority: P1) + +As a component author, I need reliable access to the active SDK instance via context so child +components and hooks can call personalization and analytics APIs without prop drilling. + +**Why this priority**: Most package APIs depend on context-resolved SDK access. + +**Independent Test**: Render components with and without `OptimizationProvider` and verify +`useOptimization` returns instance inside provider and throws outside provider. + +**Acceptance Scenarios**: + +1. **Given** an `OptimizationProvider` with `instance`, **When** `useOptimization()` is called in + descendants, **Then** the provided instance is returned. +2. **Given** no `OptimizationProvider`, **When** `useOptimization()` is called, **Then** an error is + thrown with setup guidance. +3. **Given** provider mount, **When** initialization occurs, **Then** provider initialization is + logged. + +--- + +### User Story 2 - Provide Shared Scroll and Live-Updates Runtime Signals (Priority: P1) + +As a tracking-component maintainer, I need scroll and live-updates context values exposed across the +component tree so visibility tracking and personalization update behavior are coordinated. + +**Why this priority**: Scroll/live-update context directly drives tracking and rendering behavior. + +**Independent Test**: Render within `ScrollProvider` and `LiveUpdatesProvider`, simulate layout and +scroll events, and verify context values and forwarding callbacks. + +**Acceptance Scenarios**: + +1. **Given** `ScrollProvider` receives layout/scroll events, **When** handlers execute, **Then** + `scrollY` and `viewportHeight` context values are updated. +2. **Given** caller passes `onLayout`/`onScroll` to `ScrollProvider`, **When** provider handlers + run, **Then** caller handlers are invoked. +3. **Given** `LiveUpdatesProvider`, **When** consumers call `useLiveUpdates()`, **Then** they + receive `globalLiveUpdates`, `previewPanelVisible`, and `setPreviewPanelVisible`. + +--- + +### User Story 3 - Compose a Recommended Root Tree with Optional Preview Overlay (Priority: P2) + +As an app integrator, I need a single top-level wrapper that combines providers and optional preview +panel wiring so setup stays consistent and minimal. + +**Why this priority**: Root composition reduces setup errors and enables preview-driven behavior. + +**Independent Test**: Render `OptimizationRoot` with and without preview panel config and verify +provider hierarchy plus preview visibility synchronization. + +**Acceptance Scenarios**: + +1. **Given** `OptimizationRoot` without preview panel, **When** rendered, **Then** children are + wrapped by `OptimizationProvider` and `LiveUpdatesProvider` only. +2. **Given** `OptimizationRoot` with `previewPanel.enabled=true`, **When** rendered, **Then** + children are wrapped in `PreviewPanelOverlay` with forwarded panel props. +3. **Given** preview panel opens or closes, **When** overlay visibility changes, **Then** + `LiveUpdatesContext.previewPanelVisible` is synchronized. + +--- + +### Edge Cases + +- `useScrollContext()` and `useLiveUpdates()` may return `null` outside providers and must be safe + for optional consumption. +- `ScrollProvider` must initialize viewport tracking on layout so children can track before first + scroll event. +- `OptimizationRoot.liveUpdates` defaults to `false` when omitted. +- Preview overlay should preserve override state across modal open/close cycles via dedicated + override provider. +- `usePreviewOverrides()` must throw when used outside its provider boundary. + +## Requirements _(mandatory)_ + +### Functional Requirements + +- **FR-001**: `OptimizationContext` MUST store `{ instance: Optimization }` or `null` by default. +- **FR-002**: `useOptimization()` MUST throw when context is missing. +- **FR-003**: `OptimizationProvider` MUST provide the passed `instance` via `OptimizationContext`. +- **FR-004**: `OptimizationProvider` MUST log provider initialization on mount. +- **FR-005**: `ScrollContext` MUST expose `scrollY` and `viewportHeight` values. +- **FR-006**: `useScrollContext()` MUST return `ScrollContext` value or `null` outside provider. +- **FR-007**: `ScrollProvider` MUST render a `ScrollView` wrapped by `ScrollContext.Provider`. +- **FR-008**: `ScrollProvider` layout handling MUST initialize viewport height from first layout + event when current value is zero. +- **FR-009**: `ScrollProvider` scroll handling MUST update `scrollY` and `viewportHeight` from + native scroll event payload. +- **FR-010**: `ScrollProvider` MUST forward caller-provided `onLayout` and `onScroll` callbacks. +- **FR-011**: `ScrollProvider` MUST set `scrollEventThrottle={16}` on the underlying `ScrollView`. +- **FR-012**: `LiveUpdatesContext` MUST expose `globalLiveUpdates`, `previewPanelVisible`, and + `setPreviewPanelVisible`. +- **FR-013**: `LiveUpdatesProvider` MUST default `globalLiveUpdates` to `false` when omitted. +- **FR-014**: `LiveUpdatesProvider` MUST maintain `previewPanelVisible` state internally. +- **FR-015**: `useLiveUpdates()` MUST return `LiveUpdatesContext` value or `null` outside provider. +- **FR-016**: `OptimizationRoot` MUST wrap children with `OptimizationProvider` and + `LiveUpdatesProvider`. +- **FR-017**: `OptimizationRoot` MUST default `liveUpdates` to `false`. +- **FR-018**: When `previewPanel.enabled` is true, `OptimizationRoot` MUST render + `PreviewPanelOverlay` and forward preview props (`contentfulClient`, `fabPosition`, + `onVisibilityChange`, `showHeader`). +- **FR-019**: When `previewPanel` is absent or disabled, `OptimizationRoot` MUST render children + without preview overlay. +- **FR-020**: `PreviewPanelOverlay` MUST synchronize open/close state to + `LiveUpdatesContext.setPreviewPanelVisible`. +- **FR-021**: `PreviewPanelOverlay` MUST host children inside `PreviewOverrideProvider` so override + state survives modal visibility changes. +- **FR-022**: `usePreviewOverrides()` MUST throw when accessed outside `PreviewOverrideProvider`. + +### Key Entities _(include if feature involves data)_ + +- **OptimizationContext**: React context contract for SDK instance propagation. +- **ScrollContext**: Shared viewport metrics (`scrollY`, `viewportHeight`) for visibility tracking. +- **LiveUpdatesContext**: Global and preview-driven live-update control state. +- **OptimizationRoot Composition**: Canonical provider tree with optional preview overlay. +- **PreviewOverrideContext**: Override state container used by preview panel internals. + +## Success Criteria _(mandatory)_ + +### Measurable Outcomes + +- **SC-001**: Context-access tests confirm `useOptimization` succeeds inside provider and fails with + clear guidance outside provider. +- **SC-002**: Scroll provider tests confirm context updates from layout/scroll events and user + callbacks are preserved. +- **SC-003**: Root composition tests confirm provider hierarchy and conditional preview overlay + behavior. +- **SC-004**: Preview visibility tests confirm overlay open/close updates `previewPanelVisible` in + live-updates context. diff --git a/specs/023-react-native-hooks/spec.md b/specs/023-react-native-hooks/spec.md new file mode 100644 index 00000000..fe9a4397 --- /dev/null +++ b/specs/023-react-native-hooks/spec.md @@ -0,0 +1,140 @@ +# Feature Specification: Optimization React Native Hooks + +**Feature Branch**: `[023-react-native-hooks]` +**Created**: 2026-02-26 +**Status**: Draft +**Input**: User description: "Examine the current functionality in +`@contentful/optimization-react-native` package and derive SpecKit-compatible specifications that +could have guided its development." + +## User Scenarios & Testing _(mandatory)_ + +### User Story 1 - Track Component Views from Viewport Visibility (Priority: P1) + +As an SDK consumer, I need a hook that tracks when a Contentful entry stays sufficiently visible for +a configured dwell time so component view analytics can be emitted automatically. + +**Why this priority**: Automatic visibility-based component tracking is core package behavior. + +**Independent Test**: Attach `useViewportTracking` to a view, simulate layout + viewport updates, +and verify `trackComponentView` dispatch when threshold and dwell requirements are met. + +**Acceptance Scenarios**: + +1. **Given** an entry becomes visible above threshold, **When** it remains visible for `viewTimeMs`, + **Then** `analytics.trackComponentView` is called with derived tracking metadata. +2. **Given** an entry becomes invisible before dwell timeout completes, **When** visibility drops, + **Then** pending timer is cancelled. +3. **Given** component unmount, **When** cleanup runs, **Then** active timers are cleared. + +--- + +### User Story 2 - Resolve Visibility Across Scroll and Non-Scroll Layouts (Priority: P1) + +As a component maintainer, I need viewport calculations to work both inside and outside +`ScrollProvider` so tracking can run in scrollable and fixed layouts. + +**Why this priority**: Tracking must adapt to different screen structures without extra logic. + +**Independent Test**: Run `useViewportTracking` with and without `ScrollProvider` context and verify +viewport calculations and metadata extraction. + +**Acceptance Scenarios**: + +1. **Given** `ScrollProvider` context exists, **When** visibility checks run, **Then** calculations + use context `scrollY` and `viewportHeight`. +2. **Given** no scroll context, **When** visibility checks run, **Then** calculations use screen + dimensions and update on dimension changes. +3. **Given** personalization metadata is provided, **When** tracking metadata is derived, **Then** + `componentId`, `experienceId`, and `variantIndex` reflect personalization mapping. + +--- + +### User Story 3 - Track Screens Automatically or Manually (Priority: P2) + +As a screen developer, I need a hook that can auto-track screen views on mount and also expose +manual tracking so I can align screen events with lifecycle/data loading constraints. + +**Why this priority**: Screen analytics flexibility is required across navigation patterns. + +**Independent Test**: Use `useScreenTracking` with both `trackOnMount=true` and `false`, invoke +`trackScreen`, and verify success/error return behavior. + +**Acceptance Scenarios**: + +1. **Given** `trackOnMount=true`, **When** hook mounts and has not tracked yet, **Then** it triggers + one automatic screen event. +2. **Given** `trackOnMount=false`, **When** `trackScreen()` is called, **Then** SDK `screen(...)` is + invoked with current name/properties. +3. **Given** SDK screen call throws, **When** `trackScreen()` resolves, **Then** it logs an error + and returns `undefined`. + +--- + +### Edge Cases + +- Visibility checks should no-op until both element layout dimensions and non-zero viewport height + are available. +- Dimension listener cleanup must run on unmount. +- `useViewportTracking` may emit multiple view events across repeated visible/invisible cycles + because it is transition-based, not permanently one-shot. +- `useViewportTracking.isVisible` is ref-backed and does not itself trigger re-render updates. +- `useScreenTracking` stores `name`/`properties` in refs for stable callback identity. + +## Requirements _(mandatory)_ + +### Functional Requirements + +- **FR-001**: `useViewportTracking` MUST require an `entry` input and MAY accept optional + `personalization`, `threshold`, and `viewTimeMs`. +- **FR-002**: `useViewportTracking` MUST default `threshold` to `0.8` and `viewTimeMs` to `2000`. +- **FR-003**: `useViewportTracking` MUST derive tracking metadata from entry/personalization data. +- **FR-004**: With personalization input, metadata extraction MUST attempt to resolve `componentId` + from `personalization.variants` mapping and fall back to `entry.sys.id` when unmatched. +- **FR-005**: Without personalization input, metadata extraction MUST set + `componentId=entry.sys.id`, `experienceId=undefined`, and `variantIndex=0`. +- **FR-006**: `useViewportTracking` MUST read `scrollY`/`viewportHeight` from `useScrollContext()` + when available. +- **FR-007**: Without scroll context, `useViewportTracking` MUST use window height from + `Dimensions.get('window')` and subscribe to dimension change events. +- **FR-008**: `useViewportTracking` MUST expose an `onLayout` handler that stores element layout + dimensions and triggers immediate visibility evaluation. +- **FR-009**: Visibility evaluation MUST compute intersection ratio between element bounds and + viewport bounds. +- **FR-010**: On transition from invisible to visible-above-threshold, hook MUST start a dwell + timer. +- **FR-011**: On transition from visible to below-threshold, hook MUST cancel pending dwell timer. +- **FR-012**: When dwell timer completes and element remains visible, hook MUST call + `optimization.analytics.trackComponentView` with derived metadata. +- **FR-013**: `useViewportTracking` MUST re-check visibility whenever scroll position or viewport + height changes. +- **FR-014**: `useViewportTracking` MUST clear active timers on unmount. +- **FR-015**: `useViewportTracking` MUST return `{ isVisible, onLayout }`. +- **FR-016**: `useScreenTracking` MUST accept `{ name, properties?, trackOnMount? }` options. +- **FR-017**: `useScreenTracking` MUST default `properties` to an empty object. +- **FR-018**: `useScreenTracking` MUST default `trackOnMount` to `true`. +- **FR-019**: `useScreenTracking.trackScreen()` MUST call + `optimization.screen({ name, properties })`. +- **FR-020**: `trackScreen()` MUST return optimization data on success and `undefined` on failure. +- **FR-021**: `trackScreen()` failures MUST be logged. +- **FR-022**: `useScreenTracking` MUST avoid duplicate auto-tracking within the same mount cycle by + using an internal "has tracked" guard. + +### Key Entities _(include if feature involves data)_ + +- **Viewport Tracking Metadata**: `{ componentId, experienceId?, variantIndex }` payload for + component-view analytics. +- **Viewport Geometry State**: Element layout + viewport bounds used to compute visibility ratio. +- **Dwell Timer State**: Timeout state controlling delayed tracking dispatch. +- **Screen Tracking Contract**: Hook return API and behavior for automatic/manual screen events. + +## Success Criteria _(mandatory)_ + +### Measurable Outcomes + +- **SC-001**: Visibility tests confirm `trackComponentView` fires only after threshold and dwell + criteria are met. +- **SC-002**: Scroll/non-scroll tests confirm viewport calculations remain correct in both layout + modes. +- **SC-003**: Cleanup tests confirm timers and dimension listeners are removed on unmount. +- **SC-004**: Screen tracking tests confirm auto/manual behavior and failure-path return semantics. diff --git a/specs/024-react-native-personalization-and-analytics-components/spec.md b/specs/024-react-native-personalization-and-analytics-components/spec.md new file mode 100644 index 00000000..8c81f009 --- /dev/null +++ b/specs/024-react-native-personalization-and-analytics-components/spec.md @@ -0,0 +1,151 @@ +# Feature Specification: Optimization React Native Personalization and Analytics Components + +**Feature Branch**: `[024-react-native-personalization-and-analytics-components]` +**Created**: 2026-02-26 +**Status**: Draft +**Input**: User description: "Examine the current functionality in +`@contentful/optimization-react-native` package and derive SpecKit-compatible specifications that +could have guided its development." + +## User Scenarios & Testing _(mandatory)_ + +### User Story 1 - Render Personalized Entries and Track Their Views (Priority: P1) + +As an app developer, I need a component that resolves personalized entry variants and tracks +component views so personalized content rendering and analytics are coupled by default. + +**Why this priority**: Personalization rendering is a primary value path of the package. + +**Independent Test**: Render `Personalization` with a baseline entry, drive personalization state +updates, and verify resolved entry rendering plus viewport tracking integration. + +**Acceptance Scenarios**: + +1. **Given** a baseline entry with matching personalization data, **When** component resolves, + **Then** render prop receives resolved variant entry. +2. **Given** no applicable personalization, **When** component resolves, **Then** render prop + receives baseline entry. +3. **Given** resolved entry is visible per tracking thresholds, **When** dwell criteria are met, + **Then** component view tracking is dispatched through viewport hook integration. + +--- + +### User Story 2 - Control Live-Update vs Locking Behavior for Personalization (Priority: P1) + +As a product owner, I need predictable control over whether personalized components update live or +lock to first resolved value so UI stability can be tuned per screen and preview workflows. + +**Why this priority**: Live-update policy directly affects runtime UX and preview workflows. + +**Independent Test**: Exercise combinations of preview-panel visibility, global liveUpdates, and +per-component `liveUpdates` prop; verify update vs lock behavior. + +**Acceptance Scenarios**: + +1. **Given** preview panel is visible, **When** personalization state updates, **Then** component + uses live updates regardless of other settings. +2. **Given** preview panel is hidden and component `liveUpdates` is defined, **When** state updates, + **Then** component-level setting overrides global setting. +3. **Given** live updates are disabled, **When** first non-undefined personalizations value is + received, **Then** component locks to that value and ignores subsequent updates. + +--- + +### User Story 3 - Track Non-Personalized Components and Navigation Screens (Priority: P2) + +As an analytics integrator, I need dedicated helpers for non-personalized entry tracking and +navigation-driven screen tracking so I can instrument both component and screen events with minimal +boilerplate. + +**Why this priority**: Analytics parity requires both component-level and navigation-level coverage. + +**Independent Test**: Render `Analytics` and `OptimizationNavigationContainer`, simulate visibility +and route changes, and verify expected tracking payloads. + +**Acceptance Scenarios**: + +1. **Given** an `Analytics` component with entry data, **When** visibility thresholds are met, + **Then** component view tracking fires with baseline metadata. +2. **Given** navigation container becomes ready with current route, **When** ready callback runs, + **Then** initial screen event is tracked. +3. **Given** route changes to a different name, **When** state change callback runs, **Then** a new + screen event is tracked and user callback is invoked afterwards. + +--- + +### Edge Cases + +- `Personalization` subscribes to personalization state updates and must unsubscribe on unmount. +- When live updates are disabled, components must ignore updates until first non-undefined + personalizations value is captured, then remain locked. +- `Analytics` and `Personalization` rely on `useViewportTracking`; usage outside provider boundaries + inherits provider-related hook constraints. +- `OptimizationNavigationContainer` includes route params only when `includeParams=true` and params + are provided. +- Route params are JSON-coerced through `JSON.parse(JSON.stringify(...))` + schema parsing and may + throw for non-serializable payloads. + +## Requirements _(mandatory)_ + +### Functional Requirements + +- **FR-001**: `Personalization` MUST accept `baselineEntry`, render-prop `children`, and optional + tracking/live-update props. +- **FR-002**: `Personalization` MUST compute `shouldLiveUpdate` using this priority order: + preview-panel visibility, component `liveUpdates` prop, global live updates context, default + `false`. +- **FR-003**: `Personalization` MUST subscribe to `optimization.states.personalizations`. +- **FR-004**: When `shouldLiveUpdate` is true, subscription updates MUST always replace local + personalization state. +- **FR-005**: When `shouldLiveUpdate` is false, component MUST lock on first non-undefined + personalization value and ignore later updates. +- **FR-006**: `Personalization` MUST unsubscribe from personalization state updates on unmount. +- **FR-007**: `Personalization` MUST resolve display content via + `optimization.personalization.personalizeEntry(baselineEntry, lockedPersonalizations)`. +- **FR-008**: `Personalization` MUST pass resolved entry and resolved personalization metadata to + `useViewportTracking`. +- **FR-009**: `Personalization` MUST render `children(resolvedEntry)` inside a wrapper `View` + carrying `onLayout`, optional `style`, and optional `testID`. +- **FR-010**: `Analytics` MUST accept a non-personalized `entry`, `children`, and optional tracking + props. +- **FR-011**: `Analytics` MUST invoke `useViewportTracking` with `{ entry, threshold, viewTimeMs }` + (without personalization metadata). +- **FR-012**: `Analytics` MUST render `children` inside a wrapper `View` carrying `onLayout`, + optional `style`, and optional `testID`. +- **FR-013**: `OptimizationNavigationContainer` MUST use a render-prop child API that provides + `ref`, `onReady`, and `onStateChange` handlers. +- **FR-014**: On `onReady`, container MUST track the current route if available and then invoke user + `onReady` callback. +- **FR-015**: On `onStateChange`, container MUST track only when current route name differs from + previously tracked route name. +- **FR-016**: On each state change, container MUST update stored current route name. +- **FR-017**: `OptimizationNavigationContainer` MUST invoke user `onStateChange` callback after + internal tracking logic. +- **FR-018**: `OptimizationNavigationContainer` MUST default `includeParams` to `false`. +- **FR-019**: When `includeParams=true` and route params exist, screen tracking MUST include + `properties.params` as JSON-safe data. +- **FR-020**: Screen tracking payload from navigation container MUST call `optimization.screen` with + `{ name, properties, screen: { name } }`. + +### Key Entities _(include if feature involves data)_ + +- **Personalization Component State**: Local locked/live personalization snapshot used for entry + resolution. +- **Resolved Personalization Output**: `{ entry, personalization }` tuple from + `personalizeEntry(...)` consumed by rendering and tracking. +- **Analytics Component Contract**: Non-personalized entry wrapper for viewport-triggered component + view analytics. +- **Navigation Tracking State**: Previous/current route name refs used to suppress duplicate screen + events. + +## Success Criteria _(mandatory)_ + +### Measurable Outcomes + +- **SC-001**: Personalization tests confirm resolved entry rendering and subscription cleanup. +- **SC-002**: Live-updates tests confirm precedence behavior (preview > component prop > global > + default lock). +- **SC-003**: Analytics component tests confirm viewport-based tracking wiring for non-personalized + entries. +- **SC-004**: Navigation tests confirm initial screen tracking, route-change tracking order, and + duplicate suppression by route name comparison. diff --git a/universal/core/README.md b/universal/core/README.md index 7d589c35..1ef6788b 100644 --- a/universal/core/README.md +++ b/universal/core/README.md @@ -122,12 +122,12 @@ exposed externally as read-only observables. The following configuration options apply only in stateful environments: -| Option | Required? | Default | Description | -| ------------------- | --------- | ---------------------- | ---------------------------------------------------------- | -| `allowedEventTypes` | No | `['identify', 'page']` | Allow-listed event types permitted when consent is not set | -| `defaults` | No | `undefined` | Set of default state values applied on initialization | -| `getAnonymousId` | No | `undefined` | Function used to obtain an anonymous user identifier | -| `onEventBlocked` | No | `undefined` | Callback invoked when an event call is blocked by guards | +| Option | Required? | Default | Description | +| ------------------- | --------- | -------------------------------- | ---------------------------------------------------------- | +| `allowedEventTypes` | No | `['identify', 'page', 'screen']` | Allow-listed event types permitted when consent is not set | +| `defaults` | No | `undefined` | Set of default state values applied on initialization | +| `getAnonymousId` | No | `undefined` | Function used to obtain an anonymous user identifier | +| `onEventBlocked` | No | `undefined` | Callback invoked when an event call is blocked by guards | Configuration method signatures: diff --git a/universal/core/src/CoreStateful.test.ts b/universal/core/src/CoreStateful.test.ts index 2009022d..80e0e8d1 100644 --- a/universal/core/src/CoreStateful.test.ts +++ b/universal/core/src/CoreStateful.test.ts @@ -54,11 +54,31 @@ const getPersonalizationFlushPolicyBaseBackoffMs = (core: CoreStateful): number return typeof baseBackoffMs === 'number' ? baseBackoffMs : undefined } +const getPersonalizationAllowedEventTypes = (core: CoreStateful): string[] | undefined => { + const allowedEventTypes = Reflect.get(core.personalization, 'allowedEventTypes') + + if (!Array.isArray(allowedEventTypes)) { + return + } + + return allowedEventTypes.filter((eventType): eventType is string => typeof eventType === 'string') +} + const config: CoreStatefulConfig = { clientId: 'key_123', environment: 'main', } +class CoreStatefulTestHarness extends CoreStateful { + getOnlineState(): boolean { + return this.online + } + + setOnlineState(isOnline: boolean): void { + this.online = isOnline + } +} + describe('CoreStateful blocked event handling', () => { const createdCores: CoreStateful[] = [] const createCoreStateful = (overrides: Partial = {}): CoreStateful => { @@ -71,6 +91,18 @@ describe('CoreStateful blocked event handling', () => { return core } + const createCoreStatefulHarness = ( + overrides: Partial = {}, + ): CoreStatefulTestHarness => { + const core = new CoreStatefulTestHarness({ + ...config, + ...overrides, + }) + + createdCores.push(core) + + return core + } beforeEach(() => { batch(() => { @@ -78,6 +110,7 @@ describe('CoreStateful blocked event handling', () => { signals.changes.value = undefined signals.consent.value = undefined signals.event.value = undefined + signals.online.value = true signals.personalizations.value = undefined signals.profile.value = undefined }) @@ -136,6 +169,12 @@ describe('CoreStateful blocked event handling', () => { expect(signals.blockedEvent.value).toBeUndefined() }) + it('defaults allowedEventTypes to identify/page/screen in core', () => { + const core = createCoreStateful() + + expect(getPersonalizationAllowedEventTypes(core)).toEqual(['identify', 'page', 'screen']) + }) + it('uses analytics.queuePolicy when provided', () => { const core = createCoreStateful({ analytics: { @@ -176,4 +215,23 @@ describe('CoreStateful blocked event handling', () => { createCoreStateful() }).not.toThrow() }) + + it('exposes online state through protected accessor pair', () => { + const core = createCoreStatefulHarness() + + expect(core.getOnlineState()).toBe(true) + + core.setOnlineState(false) + + expect(core.getOnlineState()).toBe(false) + expect(signals.online.value).toBe(false) + }) + + it('returns false when online signal is undefined', () => { + const core = createCoreStatefulHarness() + + signals.online.value = undefined + + expect(core.getOnlineState()).toBe(false) + }) }) diff --git a/universal/core/src/CoreStateful.ts b/universal/core/src/CoreStateful.ts index f3fa06d3..954b6e13 100644 --- a/universal/core/src/CoreStateful.ts +++ b/universal/core/src/CoreStateful.ts @@ -356,15 +356,29 @@ class CoreStateful extends CoreBase implements ConsentController { } /** - * Update online state + * Read current online state. * - * @param isOnline - `true` if the browser is online; `false` otherwise. * @example * ```ts - * this.online(navigator.onLine) + * if (this.online) { + * await this.flush() + * } * ``` */ - protected online(isOnline: boolean): void { + protected get online(): boolean { + return online.value ?? false + } + + /** + * Update online state. + * + * @param isOnline - `true` if the runtime is online; `false` otherwise. + * @example + * ```ts + * this.online = navigator.onLine + * ``` + */ + protected set online(isOnline: boolean) { online.value = isOnline } diff --git a/universal/core/src/ProductBase.ts b/universal/core/src/ProductBase.ts index 26d1621d..760537c4 100644 --- a/universal/core/src/ProductBase.ts +++ b/universal/core/src/ProductBase.ts @@ -25,7 +25,7 @@ export type EventType = AnalyticsEventType | PersonalizationEventType * @privateRemarks These defaults are only applied when a consumer does not provide * {@link ProductConfig.allowedEventTypes}. */ -const defaultAllowedEvents: EventType[] = ['page', 'identify'] +const defaultAllowedEvents: EventType[] = ['identify', 'page', 'screen'] /** * Common configuration for all product implementations. @@ -37,7 +37,7 @@ export interface ProductConfig { * The set of event type strings that are allowed to be sent even if consent is * not granted. * - * @defaultValue `['page', 'identify']` + * @defaultValue `['identify', 'page', 'screen']` * @remarks These types are compared against the `type` property of events. */ allowedEventTypes?: EventType[]