# Project data moved to the master catalogue (2026-09-25)

> 📍 Part of the project catalogue doc set — the map and routing table is
> [start-here.md](/docs/modules_handbook/shared/project-catalogue/start-here.md). Where every
> table lives is in [databases-and-distribution.md](/docs/modules_handbook/shared/project-catalogue/databases-and-distribution.md) §3.

## What it does

The owner ruled on 2026-09-25 (Lee Jie, on PR #186) that data describing a **project** is the same
on every platform, so it lives on the shared master catalogue, not in each site's database. Six
data sets moved:

| Data set | Table on the master | Model | Who writes it |
|---|---|---|---|
| Rent-basis rulings | `catalog_floor_plan_rent_bases` | `CatalogFloorPlanRentBasis` | The Rental tab's "Save as this layout's basis" — from a sales project's Property Preview, or the public page / catalogue Live preview (`CatalogRentBasisController`). Catalogue admin domain only |
| Key-size estimates | `catalog_floor_plan_key_sizes` | `CatalogFloorPlanKeySize` | `catalog:estimate-key-sizes`, catalogue admin domain only |
| Analysis snapshots | `catalog_floor_plan_analytics`, rows of kind `unit_analysis` (was the site table `catalog_analysis_snapshots`) | `CatalogAnalysisSnapshot` | `catalogue:precompute-analysis --apply`, the admin Save on the project page, and page views on the admin domain |
| Layout Analysis projection | `layout_analyses` | `LayoutAnalysis` | `layouts:project`, catalogue admin domain only |
| Property Research archive | the seven `propertylab_*` reference tables | `ResearchSnapshot` (+ `ResearchSnapshot::db()`) | `propertylab:import-research`, catalogue admin domain only |
| VR360 bakes | `catalog_vr_bakes`, artefacts in the master's `media` | `CatalogVrBake`, `MasterMedia` | Catalogue admin → VR tab (already behind `catalogue.edit`) |

What did **not** move, and why: `site_catalog_projects`, `sites` and `catalog_project_highlights`
(which projects THIS website lists or pins), the publish-review workflow, the legacy crosswalk
ledger, the Property Research advisor tables (`propertylab_conversations`, `_turn_runs`,
`_messages`, `_research_results` — a member's own work), and all member data. The rule, from
[databases-and-distribution.md](/docs/modules_handbook/shared/project-catalogue/databases-and-distribution.md):
the master holds what is true about the world, the deployment holds what is true about itself.

## How it works

**Children follow their parent.** Every moved model pins `CatalogProject::CONNECTION` and uses
`KeepsRelatedConnections`, and every read and write is asked on the connection of the project or
floor plan it belongs to (`$plan->getConnectionName()`). So a master layout's ruling, estimate,
snapshot and bake are on the master; a project this platform created itself keeps its own in the
site database's copy of the same table. Helpers that do this: `CatalogFloorPlanRentBasis::forPlan()`
/ `forProject()`, `CatalogFloorPlan::preloadKeySizes()` (groups by connection),
`CatalogAnalysisSnapshot::currentBodyFor()`, `CatalogVrBake::forProject()` / `findByUuid()` /
`findOn()`.

**Writes are the catalogue admin domain's.** Platforms hold SELECT-only master credentials, and
master writes are allowed only from `config('site.catalogue_edit_domains')`
(`CatalogueFederationService::canEdit`). The moved write paths carry the same gate:

- the sales-project rent-basis routes and the project page's analysis Save check `canEdit()`, and the catalogue rent-basis route carries `catalogue.edit`; all return 403;
  the Save panel and the rent-basis control are not offered where they would be refused;
- the four console writers (`catalog:estimate-key-sizes`, `catalogue:precompute-analysis --apply`,
  `layouts:project`, `propertylab:import-research`) refuse unless
  `CatalogueFederationService::consoleMayWriteMaster()` — the domain in `config('app.url')`;
- a page view that computes an analysis on a read-only platform no longer tries to save it
  (`CatalogAnalysisSnapshotRepository::mayWrite()` probes the connection once per process).

**Snapshots share the analytics table by kind.** `catalog_floor_plan_analytics.kind` is
`room_rental` (every row that existed) or `unit_analysis` (a snapshot). Each model sees only its
own kind through a global scope, and each kind keeps its own current row per layout, so saving a
snapshot never demotes the room rents. Raw queries filter on `kind` too:
`ProjectCatalogueMergeService` (a snapshot never blocks a merge), `AuditRedesign` (one current row
per layout per kind), `CatalogFloorPlanRepository::moveAnalytics` (a ghost layout's snapshots are
dropped, not carried to the keeper). The engine's run time is `calculated_at`; the snapshot model
keeps `computed_at` as an accessor over it. The old site table stays in place, unread.

**Bake media is master media.** `MediaContext` gained a third scope, `catalogue()`
(`MediaContext::forOwner($bake)` picks it for a record on the catalogue connection). Bake jobs
store artefacts through it, the relations are `MasterMedia`, and the master gets its own
`media_cleanup_tasks` outbox so a failed delete is retried where the row lives. Queued VR jobs
carry the bake's connection (`$bakeConnection`); a job queued before the deploy has none and looks
on the master.

**Existing rows are copied, not recomputed** (owner's ruling). `App\Actions\Catalogue\MoveSiteDataToCatalogue`
copies one box's site rows into the catalogue connection:

- integer ids are re-resolved from the master by uuid; a row whose layout or project the master
  does not hold stays behind (it belongs to a platform-created record);
- the master's own row always wins (`insertOrIgnore` on each table's unique key), so it is safe
  to run twice and from several boxes;
- a copied Property Research snapshot never becomes active over one the master already serves;
- a copied bake's media rows are inserted into the master's `media`, owned by the new bake, and
  re-pointed. The stored OBJECTS do not move: their disk and path must name a bucket every
  platform can read, the same assumption the catalogue's images already make.

The five catalogue migrations of 2026-09-25 each call it for their own data set. It only copies
when the migration targets a database other than the site's — the Hub's
`migrate --database=catalogue`. On a site's own `migrate` source and target are the same database
and nothing moves, except the snapshot merge, which moves rows between two different tables.

## Deploying it

1. **The Hub first**, straight after its deploy:
   ```bash
   php artisan migrate --path=database/migrations/catalogue --database=catalogue
   php artisan migrate
   ```
   The first line creates the tables on the master and copies the Hub's own rows up; the second is
   the Hub's local copy. The master's database user must be able to `CREATE TABLE`.
2. **Every other box that holds rows** pushes them with credentials that may write the master, for
   the length of the run. wk holds the research archive and the rent-basis rulings; any box that
   ran the estimator or served analysis pages holds key sizes and snapshots. The dry run says what
   a box has:
   ```bash
   php artisan catalogue:push-site-data                 # dry run: what would be copied
   php artisan catalogue:push-site-data --apply
   php artisan catalogue:push-site-data --set=property_research --apply
   ```
   ⚠️ On a COPY-mode box (`CATALOGUE_USE_DEFAULT_CONNECTION` set) the catalogue connection IS the
   site database, so the command has nowhere to push to and says so. For that one run, delete the
   line (never `=false` — this project's `env()` treats it as truthy), point `MASTER_DB_*` at an
   account that may write, and `php artisan config:clear` first; restore both afterwards. Push
   BEFORE the box's next `catalogue:mirror-from-master`, which replaces these tables with the
   master's rows.
3. **Sites** deploy normally. A COPY-mode site (`CATALOGUE_USE_DEFAULT_CONNECTION` set) then runs
   `catalogue:mirror-from-master --apply` to pull the master's new tables; the mirror also shifts
   the bakes' media ids when it has to protect a site upload.
4. **Drain the `vr-bake` queue before deploying**, so no bake job straddles the move.
5. On the Hub, AFTER `catalogue:precompute-analysis --apply` has finished (hours on a cold run), rebuild
   the projection: `php artisan layouts:project --country=MY`. ⚠️ Not `--all`: that adds every
   published project, subsales included, and the Layout Analysis page lists every row the table
   holds — it would change what members see.
6. On a box whose `APP_URL` is not a catalogue admin domain (the Hub serves `app.propertylab.com.my`
   too, and only `propertylabglobal.com` may write the master), run the four master-writing commands
   as `php artisan config:clear`, then `APP_URL=https://propertylabglobal.com php artisan …`, then
   `php artisan config:cache`. Never widen `CATALOGUE_EDIT_DOMAINS` to get past the refusal.

## Traps

- **A platform that cannot write the master no longer saves the analyses its visitors compute.**
  Before the move, the first page view of a layout saved its snapshot in the site database and
  every later view read it. Now a read-only platform computes a layout the master has no
  snapshot for on EVERY view (the "PropertyLab AI is analysing…" wait the snapshot table was built
  to end). The fix is upstream: run `catalogue:precompute-analysis --apply` on the catalogue admin
  domain after catalogue changes, then `layouts:project`.
- **A COPY-mode site that writes one of these tables loses the write at the next mirror.** The
  mirror replaces each table wholesale with the master's rows. That is why the writers are gated to
  the catalogue admin domain; do not work around the gate on a COPY box.
- **Never read one of these models without the parent's connection.** `CatalogVrBake::where(...)`
  or `CatalogFloorPlanRentBasis::query()` asks the master only, and a platform-created project's
  rows are in the site database. Go through the helpers above.
- **`CatalogFloorPlanAnalytics` no longer sees every row.** Its global scope hides unit analyses;
  a raw `DB::connection('catalogue')->table('catalog_floor_plan_analytics')` sees both kinds and
  must filter `kind` itself.
- **A copy is batched by BYTES, not rows.** A saved analysis body is up to ~265 KB; the first Hub
  run inserted 500 per statement and MySQL dropped the connection ("2006 MySQL server has gone
  away"). `MoveSiteDataToCatalogue::insertInBatches()` keeps every INSERT under `MAX_INSERT_BYTES`
  (1 MB) and snapshots are read `SNAPSHOT_CHUNK` (20) at a time. A failed catalogue migration is
  not recorded as run, and every step is guarded, so re-running it is the recovery.
- **The single-database test suite cannot see any of this.** `tests/Unit/Property/MoveSiteDataToCatalogueTest`
  and `CatalogueFederationSplitTest` run on two SQLite files; everything else runs collapsed.

## Related files

- `app/Actions/Catalogue/MoveSiteDataToCatalogue.php` — the copy
- `app/Console/Commands/PushSiteDataToCatalogue.php` — `catalogue:push-site-data`
- `database/migrations/catalogue/2026_09_23_100000_create_catalog_floor_plan_rent_bases.php` (moved from `database/migrations/`)
- `database/migrations/catalogue/2026_09_25_1000*_…` — the five moves
- `src/Analysis/Reference/CatalogFloorPlanRentBasis.php`, `CatalogFloorPlanKeySize.php`, `CatalogAnalysisSnapshot.php`, `CatalogFloorPlanAnalytics.php`, `CatalogVrBake.php`, `src/Analysis/LayoutAnalysis.php`, `src/PropertyLab/Research/ResearchSnapshot.php`
- `src/Common/Support/MediaContext.php` — the `catalogue` scope
- `app/Services/Property/CatalogueFederationService.php` — `canEdit()`, `consoleMayWriteMaster()`
- `tests/Unit/Property/MoveSiteDataToCatalogueTest.php`
