60fd322b49
## Introduction Closes twentyhq/core-team-issues#2635 and twentyhq/core-team-issues#2642 and twentyhq/core-team-issues#2589 Object system fields (`searchVector` + its GIN index + `searchFieldMetadata`, the reserved system fields, default relations) were provisioned through several scattered, path-specific code paths. As a result the **app-manifest sync path** authored objects with an empty/`NULL` `searchVector` and **zero `searchFieldMetadata`**, so app-owned objects shipped a broken generated search column (see #22657). The generation logic also lived partly in imperative services rather than in the metadata side-effect engine, and relied on non-deterministic (`v4`) universal identifiers that `twenty apply` could not converge, destroying manually backfilled rows. This PR centralizes every object-creation system side effect into the **metadata side-effect engine**, extends the engine to keep search metadata consistent on field delete and object relabel, makes the standard app's search identifiers deterministic, and ships upgrade commands to reconcile existing workspaces. ## What changed ### Side effects moved into the metadata side-effect engine New dedicated, self-contained handlers — so every write path (API and app manifest) gets identical results, and side effects never trigger other side effects. **Object create / delete** (`handlers/object-metadata`) * **`objectSystemFieldsOnCreate`** — generates the 7 reserved system fields (`id`, `createdAt`, `updatedAt`, `deletedAt`, `createdBy`, `updatedBy`, `position`). * **`objectSearchVectorOnCreate`** — provisions the full-text search surface as one unit: the `searchVector` `TS_VECTOR` field, its backing GIN index, and the `searchFieldMetadata` row (for searchable objects whose label identifier is a searchable field) that keeps `searchVector` populated instead of `NULL`. * **`objectSystemSideEffectsOnDelete`** — tears the above down on object deletion. **Search-metadata consistency on relabel / field delete** (new — these are what close the manifest-path gaps) * **`objectSearchVectorOnUpdate`** (`handlers/object-metadata`) — when a searchable object is relabeled onto a new searchable field, provisions the `searchFieldMetadata` row that indexes it. Relabeling is **additive**: existing rows (e.g. the provisioned `name` row) are preserved, so the previous label identifier stays searchable. Mirrors the API update path so a manifest re-sync that changes the label identifier reaches search parity. No-ops for junction objects (`id` label identifier) and non-searchable field types. * **`fieldSearchFieldMetadataOnDelete`** (`handlers/field-metadata`) — when a field is deleted, cascade-deletes every `searchFieldMetadata` row that indexes it. `searchFieldMetadata` is excluded from manifest deletion inference, so this explicit cascade is what covers **both the API and manifest paths** (the object-scoped DB cascade only fires on object deletion). Uses the `searchFieldMetadataUniversalIdentifiers` aggregator on the flat field for an O(k) lookup instead of scanning all rows. The **default `name` field and default relations are now caller-provided default fields** (SDK autocomplete on the manifest path, input transpiler on the API path) rather than system side effects — removing duplicate name generation, the imperative `build-default-*-for-custom-object` utilities, and the ad-hoc system-field integrity validator. ### Deterministic identifiers for the standard app The twenty-standard search GIN index and `searchFieldMetadata` now derive deterministic universal identifiers (`getIndexUniversalIdentifier` / `getSearchFieldUniversalIdentifier`) instead of `v4`, so `twenty apply` converges instead of recreating. ### Upgrade commands (`2-20`) to reconcile existing workspaces **Instance commands** (run once per instance; ordered fast → slow → workspace): 1. **`AddIsSystemSideEffectToSearchFieldMetadata`** (fast) — adds the `isSystemSideEffect` column to `core.searchFieldMetadata`. Defaults to `true`, which also correctly backfills every existing row since `searchFieldMetadata` is always system-derived (never user-authored). 2. **`BackfillNameFieldIsSystemSideEffect`** (slow) — re-flags existing `name` fields from `isSystemSideEffect: true` → `false`, since the default `name` field is now a caller-provided default like any other user-owned field (it was provisioned as `true` in 2.15 → 2.19). This is a pure data backfill, so the bulk `UPDATE` lives in `runDataMigration()` rather than `up()` — keeping it out of the fast schema transaction avoids holding an `ACCESS EXCLUSIVE` lock that could stall reads during the deploy. Slow instance commands still run before every workspace command of the version, so the fresh value is in place before the search-reconcile workspace commands recompute the `fieldMetadata` flat-entity cache. Scoping by name alone is safe (no engine-owned field is named `name`); `down()` is best-effort (pre-2.15 `false` rows are indistinguishable from flipped ones). **Workspace commands** (idempotent, dry-run supported): 1. **`reconcile-search-vector-gin-index-universal-identifier`** — re-owns every searchVector GIN index UID to its deterministic value (all applications), then backfills the missing GIN index for installed-app objects. 2. **`reconcile-search-field-metadata`** — re-owns every `searchFieldMetadata` UID (all applications), then backfills the missing rows for installed-app searchable objects. 3. **`rebuild-installed-app-search-vectors`** — rebuilds the `searchVector` column of every installed-app `TS_VECTOR` field, once the index and rows exist. Design notes: * **Re-own is global** (twenty-standard, workspace-custom, installed) — a UID convergence keyed on each row's own application. * **Backfill is installed-app only** — standard/custom objects already have these rows via the manifest funnel. * Re-own runs **before** backfill and is transaction-guarded; a failure aborts that workspace to avoid a unique-identifier collision. ## Tests * Integration: app manifest sync now asserts system fields + searchable objects (searchVector, GIN index, searchFieldMetadata) are created; a new relabel suite drives three manifest syncs and asserts records stay searchable through the old + new label identifiers and lose searchability when a field is removed; removed the obsolete system-fields-integrity suite/snapshots. * Unit: per-handler side-effect specs (including the new `objectSearchVectorOnUpdate` and `fieldSearchFieldMetadataOnDelete` handlers), and per-util specs for the re-own / backfill operation builders and the GIN-index classifier. ## Upgrade / migration notes * Existing workspaces converge on the next upgrade run via the `2-20` instance + workspace commands (idempotent, dry-run supported). * Backfill and rebuild go through the workspace-migration runner (automatic cache invalidation); the re-own step invalidates only the affected flat-entity maps directly. * The cross-version upgrade CI now flushes the cache before running the upgrade, so the new version recomputes every flat-entity map from the database instead of reading blobs the old version serialized in an older shape. ## Follow-up * `object-metadata.service.ts` still carries a `TODO: remove once default view fields move to the metadata side effect engine` — default view fields are the next candidate to move into the engine. * A single manifest sync cannot yet both create a field and relabel the object onto it, because `objectMetadata.update` is ordered before `fieldMetadata.create` in the migration runner. Tracked in twentyhq/core-team-issues#2655; to be fixed in a follow-up.
366 lines
15 KiB
YAML
366 lines
15 KiB
YAML
name: CI - Cross-Version Upgrade
|
|
run-name: cross-version-upgrade ${{ inputs.from_version || 'v1.22' }} → current (from source)
|
|
|
|
on:
|
|
workflow_call:
|
|
inputs:
|
|
skip:
|
|
type: boolean
|
|
required: false
|
|
default: false
|
|
description: 'Set to true to skip the upgrade check and report success immediately'
|
|
from_version:
|
|
type: string
|
|
required: false
|
|
description: 'Old Twenty version to upgrade from (defaults to oldest supported: v1.22)'
|
|
workflow_dispatch:
|
|
inputs:
|
|
from_version:
|
|
type: string
|
|
required: false
|
|
default: ''
|
|
description: 'Old Twenty version to upgrade from (defaults to oldest supported: v1.22)'
|
|
|
|
permissions:
|
|
contents: read
|
|
|
|
defaults:
|
|
run:
|
|
shell: bash --noprofile --norc -euo pipefail {0}
|
|
|
|
env:
|
|
SERVER_BUILD_CACHE_KEY: server-build
|
|
OLDEST_SUPPORTED_VERSION: v1.22
|
|
# Local CI containers only — not real secrets. APP_SECRET is generated fresh
|
|
# per run (see "Compute run config") so nothing reconstructible is committed.
|
|
OLD_PG_DATABASE_URL: postgres://postgres:postgres@postgres:5432/default
|
|
OLD_REDIS_URL: redis://redis:6379
|
|
# Standard dev-seed workspace id (workspace:seed:dev) — public, not a secret.
|
|
SEED_WORKSPACE_ID: 20202020-1c25-4d02-bf25-6aeccf7ea419
|
|
|
|
jobs:
|
|
no-op:
|
|
if: format('{0}', inputs.skip) == 'true'
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- run: echo "Cross-version upgrade check skipped (upgrade path not touched)"
|
|
|
|
cross-version-upgrade:
|
|
if: format('{0}', inputs.skip) != 'true'
|
|
timeout-minutes: 45
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- name: Compute run config
|
|
env:
|
|
INPUT_FROM_VERSION: ${{ inputs.from_version }}
|
|
run: |
|
|
FROM_VERSION="${INPUT_FROM_VERSION:-}"
|
|
if [ -z "$FROM_VERSION" ]; then
|
|
FROM_VERSION="$OLDEST_SUPPORTED_VERSION"
|
|
fi
|
|
printf 'FROM_VERSION=%s\n' "$FROM_VERSION" >> "$GITHUB_ENV"
|
|
# Random per-run secret shared by the old container and the from-source
|
|
# server within this job. Never committed, never reused across runs.
|
|
GENERATED_APP_SECRET="$(openssl rand -hex 32)"
|
|
echo "::add-mask::${GENERATED_APP_SECRET}"
|
|
printf 'APP_SECRET=%s\n' "$GENERATED_APP_SECRET" >> "$GITHUB_ENV"
|
|
echo "Testing upgrade from ${FROM_VERSION} to current (built from source)"
|
|
|
|
- name: Create Docker network
|
|
run: docker network create twenty-net
|
|
|
|
- name: Start Postgres
|
|
run: |
|
|
docker run -d \
|
|
--name postgres \
|
|
--network twenty-net \
|
|
-e POSTGRES_USER=postgres \
|
|
-e POSTGRES_PASSWORD=postgres \
|
|
-e POSTGRES_DB=default \
|
|
-p 5432:5432 \
|
|
postgres:16
|
|
|
|
for i in $(seq 1 30); do
|
|
if docker exec postgres pg_isready -U postgres 2>/dev/null; then
|
|
echo "Postgres is ready"
|
|
exit 0
|
|
fi
|
|
echo "Waiting for Postgres… ($i/30)"
|
|
sleep 2
|
|
done
|
|
echo "::error::Postgres did not become ready in time"
|
|
docker logs postgres 2>&1 | tail -50 || true
|
|
exit 1
|
|
|
|
- name: Start Redis
|
|
run: |
|
|
docker run -d \
|
|
--name redis \
|
|
--network twenty-net \
|
|
-p 6379:6379 \
|
|
redis:7-alpine
|
|
|
|
for i in $(seq 1 10); do
|
|
if docker exec redis redis-cli ping 2>/dev/null | grep -q PONG; then
|
|
echo "Redis is ready"
|
|
exit 0
|
|
fi
|
|
echo "Waiting for Redis… ($i/10)"
|
|
sleep 1
|
|
done
|
|
echo "::error::Redis did not become ready in time"
|
|
docker logs redis 2>&1 | tail -50 || true
|
|
exit 1
|
|
|
|
# ================================================================
|
|
# Phase 1 — Old version (from Docker Hub): seed and sanity check
|
|
# ================================================================
|
|
|
|
- name: Old version / Start container
|
|
run: |
|
|
docker run -d \
|
|
--name twenty-old \
|
|
--network twenty-net \
|
|
-p 3000:3000 \
|
|
-e NODE_PORT=3000 \
|
|
-e SERVER_URL=http://localhost:3000 \
|
|
-e PG_DATABASE_URL=${OLD_PG_DATABASE_URL} \
|
|
-e REDIS_URL=${OLD_REDIS_URL} \
|
|
-e APP_SECRET=${APP_SECRET} \
|
|
"twentycrm/twenty:${FROM_VERSION}"
|
|
|
|
- name: Old version / Wait for API ready
|
|
run: |
|
|
TIMEOUT=600
|
|
CONTAINER="twenty-old"
|
|
ELAPSED=0
|
|
echo "Waiting for Twenty on port 3000 (timeout ${TIMEOUT}s)…"
|
|
while true; do
|
|
healthz=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:3000/healthz" 2>/dev/null || echo "000")
|
|
graphql=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:3000/graphql" \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"query":"{ __typename }"}' 2>/dev/null || echo "000")
|
|
if [ "$healthz" = "200" ] && [ "$graphql" != "000" ] && [ "$graphql" != "404" ]; then
|
|
echo "Twenty is ready! healthz=${healthz} graphql=${graphql} (took ~${ELAPSED}s)"
|
|
break
|
|
fi
|
|
container_status=$(docker inspect --format='{{.State.Status}}' "$CONTAINER" 2>/dev/null || echo "unknown")
|
|
if [ "$container_status" = "exited" ]; then
|
|
echo "::error::Container ${CONTAINER} exited unexpectedly"
|
|
docker logs "$CONTAINER"
|
|
exit 1
|
|
fi
|
|
ELAPSED=$((ELAPSED + 5))
|
|
if [ "$ELAPSED" -ge "$TIMEOUT" ]; then
|
|
echo "::error::Twenty did not become ready within ${TIMEOUT}s (healthz=${healthz} graphql=${graphql})"
|
|
docker logs "$CONTAINER" 2>&1 | tail -100
|
|
exit 1
|
|
fi
|
|
echo " … waited ${ELAPSED}s [healthz=${healthz} graphql=${graphql}]"
|
|
sleep 5
|
|
done
|
|
|
|
- name: Old version / Seed dev data
|
|
run: |
|
|
docker exec twenty-old sh -c "cd /app/packages/twenty-server && yarn command:prod workspace:seed:dev"
|
|
|
|
- name: Old version / Sanity check seeded data
|
|
# Verify the seed populated the database directly via psql — avoids
|
|
# needing an API token on the old version (v1.22 has no token-mint command).
|
|
run: |
|
|
COUNT=$(docker exec -e PGPASSWORD=postgres postgres \
|
|
psql -U postgres -d default -tAc \
|
|
"SELECT count(*) FROM core.workspace WHERE id = '${SEED_WORKSPACE_ID}';")
|
|
echo "Seed workspaces found: ${COUNT}"
|
|
if [ "${COUNT:-0}" -lt 1 ]; then
|
|
echo "Sanity check failed: seed workspace ${SEED_WORKSPACE_ID} not found"
|
|
docker logs twenty-old
|
|
exit 1
|
|
fi
|
|
|
|
- name: Old version / Stop container
|
|
run: |
|
|
mkdir -p upgrade-logs
|
|
docker logs twenty-old > upgrade-logs/twenty-old.log 2>&1 || true
|
|
docker stop twenty-old
|
|
docker rm twenty-old
|
|
|
|
# ================================================================
|
|
# Phase 2 — Current version (from source): upgrade on same database
|
|
# ================================================================
|
|
|
|
- name: Fetch custom Github Actions and base branch history
|
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
|
with:
|
|
fetch-depth: 10
|
|
|
|
- name: Install dependencies
|
|
uses: ./.github/actions/yarn-install
|
|
|
|
- name: Restore server build cache
|
|
# Best-effort: a hit skips the cold rebuild; a miss simply builds from scratch below.
|
|
uses: ./.github/actions/restore-cache
|
|
with:
|
|
key: ${{ env.SERVER_BUILD_CACHE_KEY }}
|
|
|
|
- name: Build twenty-shared
|
|
run: npx nx build twenty-shared
|
|
|
|
- name: Server / Write .env
|
|
run: npx nx reset:env twenty-server
|
|
|
|
- name: Server / Align .env with seeded database
|
|
run: |
|
|
ENV_FILE="packages/twenty-server/.env"
|
|
# Must match the old container's APP_SECRET (same DB, same run-generated secret).
|
|
sed -i "s|^APP_SECRET=.*|APP_SECRET=${APP_SECRET}|" "$ENV_FILE"
|
|
# PG_DATABASE_URL / REDIS_URL come from .env.example (postgres@localhost:5432/default,
|
|
# redis@localhost:6379). APP_SECRET is intentionally not echoed.
|
|
grep -E '^(PG_DATABASE_URL|REDIS_URL)=' "$ENV_FILE"
|
|
|
|
- name: Server / Build
|
|
run: npx nx build twenty-server
|
|
|
|
- name: Server / Flush cache
|
|
run: npx nx run twenty-server:command-no-deps -- cache:flush
|
|
|
|
- name: Server / Run upgrade command
|
|
run: npx nx run twenty-server:command-no-deps -- upgrade --verbose
|
|
|
|
- name: Server / Start
|
|
run: |
|
|
mkdir -p upgrade-logs
|
|
npx nx start:ci twenty-server > upgrade-logs/twenty-new.log 2>&1 &
|
|
|
|
- name: Waiting for server starting...
|
|
run: |
|
|
for i in {1..30}; do
|
|
if curl -f -s http://localhost:3000/healthz > /dev/null; then
|
|
echo "Server ready!"
|
|
exit 0
|
|
fi
|
|
echo "Waiting..."
|
|
sleep 5
|
|
done
|
|
echo "::error::Server did not become healthy in time"
|
|
tail -100 upgrade-logs/twenty-new.log || true
|
|
exit 1
|
|
|
|
# ================================================================
|
|
# Phase 3 — Smoke tests on upgraded instance
|
|
# ================================================================
|
|
# Health is already asserted by the "Waiting for server starting..." loop.
|
|
|
|
- name: Smoke / Generate API token
|
|
# Mint a token against the running upgraded server (signed with this run's
|
|
# APP_SECRET). No token is committed to the repo.
|
|
run: |
|
|
OUTPUT=$(NO_COLOR=1 npx nx run twenty-server:command-no-deps -- \
|
|
workspace:generate-api-key -w "${SEED_WORKSPACE_ID}" -n "cross-version-upgrade-ci")
|
|
echo "$OUTPUT"
|
|
TOKEN=$(echo "$OUTPUT" | grep -o 'TOKEN:[A-Za-z0-9._-]*' | head -1 | cut -d: -f2-)
|
|
if [ -z "$TOKEN" ]; then
|
|
echo "::error::Failed to generate API token"
|
|
exit 1
|
|
fi
|
|
echo "::add-mask::$TOKEN"
|
|
printf 'API_KEY=%s\n' "$TOKEN" >> "$GITHUB_ENV"
|
|
|
|
- name: Smoke / Upgrade status
|
|
run: |
|
|
RAW=$(FORCE_COLOR=0 NO_COLOR=1 npx nx run twenty-server:command-no-deps -- upgrade:status 2>&1)
|
|
# chalk colorizes even with NO_COLOR under nx — strip ANSI codes before asserting.
|
|
OUTPUT=$(printf '%s\n' "$RAW" | sed -E 's/\x1b\[[0-9;]*m//g')
|
|
echo "$OUTPUT"
|
|
echo "$OUTPUT" > upgrade-logs/upgrade-status.log
|
|
echo "$OUTPUT" | grep -q "Instance: Up to date" \
|
|
|| { echo "FAIL: Instance is not up to date"; exit 1; }
|
|
echo "$OUTPUT" | grep -q "0 behind, 0 failed" \
|
|
|| { echo "FAIL: Some workspaces are behind or failed"; exit 1; }
|
|
|
|
- name: Smoke / Query companies
|
|
run: |
|
|
HTTP_CODE=$(curl -s -o /tmp/response.json -w '%{http_code}' http://localhost:3000/graphql \
|
|
-H "Authorization: Bearer ${API_KEY}" \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"query":"{ companies { edges { node { id name } } } }"}' || true)
|
|
echo "HTTP status: ${HTTP_CODE}"
|
|
jq . /tmp/response.json 2>/dev/null || cat /tmp/response.json
|
|
if [ -z "$HTTP_CODE" ] || [ "$HTTP_CODE" -lt 200 ] || [ "$HTTP_CODE" -ge 300 ]; then
|
|
echo "FAIL: API returned HTTP ${HTTP_CODE}"; exit 1
|
|
fi
|
|
jq -e '.data.companies.edges | length > 0' /tmp/response.json \
|
|
|| { echo "FAIL: expected companies with edges"; exit 1; }
|
|
|
|
- name: Smoke / Query people
|
|
run: |
|
|
HTTP_CODE=$(curl -s -o /tmp/response.json -w '%{http_code}' http://localhost:3000/graphql \
|
|
-H "Authorization: Bearer ${API_KEY}" \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"query":"{ people { edges { node { id } } } }"}' || true)
|
|
echo "HTTP status: ${HTTP_CODE}"
|
|
jq . /tmp/response.json 2>/dev/null || cat /tmp/response.json
|
|
if [ -z "$HTTP_CODE" ] || [ "$HTTP_CODE" -lt 200 ] || [ "$HTTP_CODE" -ge 300 ]; then
|
|
echo "FAIL: API returned HTTP ${HTTP_CODE}"; exit 1
|
|
fi
|
|
jq -e '.data.people.edges != null' /tmp/response.json \
|
|
|| { echo "FAIL: expected people edges to be non-null"; exit 1; }
|
|
|
|
- name: Smoke / Query metadata objects
|
|
run: |
|
|
HTTP_CODE=$(curl -s -o /tmp/response.json -w '%{http_code}' http://localhost:3000/metadata \
|
|
-H "Authorization: Bearer ${API_KEY}" \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"query":"{ objects { edges { node { id nameSingular } } } }"}' || true)
|
|
echo "HTTP status: ${HTTP_CODE}"
|
|
jq . /tmp/response.json 2>/dev/null || cat /tmp/response.json
|
|
if [ -z "$HTTP_CODE" ] || [ "$HTTP_CODE" -lt 200 ] || [ "$HTTP_CODE" -ge 300 ]; then
|
|
echo "FAIL: API returned HTTP ${HTTP_CODE}"; exit 1
|
|
fi
|
|
jq -e '.data.objects.edges | length > 0' /tmp/response.json \
|
|
|| { echo "FAIL: expected metadata objects"; exit 1; }
|
|
|
|
# ================================================================
|
|
# Phase 4 — Collect logs & cleanup
|
|
# ================================================================
|
|
|
|
- name: Upload upgrade logs
|
|
if: always()
|
|
id: upload-logs
|
|
uses: actions/upload-artifact@v4
|
|
with:
|
|
name: cross-version-upgrade-logs
|
|
path: upgrade-logs/
|
|
if-no-files-found: ignore
|
|
|
|
- name: Job summary
|
|
if: always()
|
|
env:
|
|
RESULT: ${{ job.status }}
|
|
ARTIFACT_URL: ${{ steps.upload-logs.outputs.artifact-url }}
|
|
run: |
|
|
{
|
|
echo "## Cross-Version Upgrade: ${FROM_VERSION} → current (from source)"
|
|
echo ""
|
|
echo "| | |"
|
|
echo "|---|---|"
|
|
echo "| **Result** | \`${RESULT}\` |"
|
|
echo "| **Old version** | \`${FROM_VERSION}\` (Docker Hub) |"
|
|
echo "| **New version** | current branch (built from source) |"
|
|
if [ -n "${ARTIFACT_URL:-}" ]; then
|
|
echo ""
|
|
echo "**[Download full logs](${ARTIFACT_URL})**"
|
|
fi
|
|
} >> "$GITHUB_STEP_SUMMARY"
|
|
|
|
- name: Cleanup
|
|
if: always()
|
|
run: |
|
|
docker stop twenty-old 2>/dev/null || true
|
|
docker rm twenty-old 2>/dev/null || true
|
|
docker stop postgres 2>/dev/null || true
|
|
docker rm postgres 2>/dev/null || true
|
|
docker stop redis 2>/dev/null || true
|
|
docker rm redis 2>/dev/null || true
|
|
docker network rm twenty-net 2>/dev/null || true
|