Skip to content

v1.10.1 — Per-environment ids block in page.meta.json + path-first pushPage#3

Draft
PeterKnightDigital wants to merge 3 commits into
cursor/page-assets-sync-2d5bfrom
cursor/per-env-ids-2d5b
Draft

v1.10.1 — Per-environment ids block in page.meta.json + path-first pushPage#3
PeterKnightDigital wants to merge 3 commits into
cursor/page-assets-sync-2d5bfrom
cursor/per-env-ids-2d5b

Conversation

@PeterKnightDigital

Copy link
Copy Markdown
Owner

Stacked on top of #2 (v1.10.0). Base branch is cursor/page-assets-sync-2d5b. Merging this PR after #2 lands.

Summary

Closes the meta format gap that v1.10.0 explicitly deferred. Prior to v1.10.1, page.meta.json had a single top-level pageId slot:

  • Pull from local first → meta.pageId = 1234 (local id).
  • Re-pull from remote → meta.pageId silently switches to 5678 (remote id).
  • Now pw_page_push calls $wire->pages->get($meta['pageId']) and addresses the wrong page on local. Or fails with "Page not found". Or — worst case — addresses an unrelated page that happened to have id 5678 locally.

The meta — your persistent record of the page — also had nowhere to record the local id and the remote id side by side, so an at-a-glance "which physical id corresponds to this page on each environment?" required re-resolving on both sides every time.

The format change

page.meta.json gains an ids block alongside the existing top-level pageId (kept as a back-compat mirror):

{
  "pageId": 1234,
  "canonicalPath": "/about/",
  "ids": {
    "local":  { "id": 1234, "lastSeenAt": "2026-04-30T15:00:00+00:00" },
    "remote": { "id": 5678, "lastSeenAt": "2026-04-30T15:30:00+00:00" }
  }
}

Each meta-write site populates only its own slot. The other side's slot is preserved verbatim. Five write paths cover every code route in / out of the meta:

  • SyncManager::pullPage (local pull): writes ids.local, preserves any pre-existing ids.remote.
  • SyncManager::exportPageYaml (remote-pull payload): emits ids.remote (from the receiver's perspective, this id IS the remote one — labelling it correctly at source means the receiver doesn't need to know which environment it pulled from).
  • SyncManager::initPageMeta: writes ids.local for "linked" (existing page) scaffolds.
  • SyncManager::publishPage: writes ids.local after the page is actually created on this site by a pw_page_publish writeback. Preserves any pre-existing ids.remote.
  • SyncManager::pushPage writeback: refreshes ids.local after a successful save (lastSeenAt advances; new id recorded if it differs from the meta's last-seen).

Two more write paths handle the closing-the-loop case for remote pushes from the MCP side:

  • MCP pushPage (mcp-server/src/pages/pusher.ts): after a successful live remote push, reads the pageId from the API response and writes ids.remote into the local meta. Best-effort, dry-run-aware, never fails the push.
  • MCP publishPage remote branch: same recording for page:create responses.

And the critical merge fix on the puller side:

  • MCP pullPageFromRemote (mcp-server/src/pages/puller.ts): merges the remote-emitted payload with any existing local meta — preserving ids.local, stripping any stray ids.local an older payload might include, and promoting legacy top-level pageId to ids.local for pre-v1.10.1 metas. Without this merge a pw_page_pull source: "remote" would still wipe the local id slot, defeating the whole point.

The pushPage refactor

This is the critical write-path change v1.10.0 deliberately deferred:

$page = $this->wire->pages->get($meta['canonicalPath']);
if ((!$page || !$page->id) && $localId) {
    $page = $this->wire->pages->get($localId);  // legacy fallback
}

$expected = $meta['ids']['local']['id'] ?? null;
if ($expected && $page->id !== $expected && !$force) {
    return [
        'error'         => "Path/id mismatch: meta last saw id $expected for $canonicalPath but it now resolves to id {$page->id} on this site.",
        'hint'          => 'Either the page was deleted and recreated, the slug was reused, or the meta is from a different site. Re-pull, or pass force:true to push to the new id anyway.',
        'expectedId'    => $expected,
        'currentId'     => (int) $page->id,
        'canonicalPath' => $canonicalPath,
    ];
}
  • Lookup is path-first — same primary key the page-assets work in v1.10.0 already uses, applied uniformly to page content.
  • Id is a sanity check, not a primary key. If the canonical path now resolves to a different id than the last-seen ids.local.id, the push is refused with a structured error pointing the operator at force:true. Catches: page deleted-and-recreated at the same path, slug rebound to an unrelated page, meta from a different site entirely.
  • Legacy pageId is the fallback when a meta has no canonicalPath (covers older pulls and the rare moved-without-reconcile case).

getPageSyncStatus (powering pw_sync_status) and sync:reconcile are updated with the same path-first / id-fallback rule so all three read sites give identical results regardless of which side last wrote the meta. pw_sync_status results now also include the per-environment ids block when present.

Migration

Zero manual steps. Older page.meta.json files without the ids block keep working:

  • readEnvId(meta, 'local') falls back to top-level pageId (the dominant historical case — almost every existing meta on disk was last written by a local pull).
  • readEnvId(meta, 'remote') returns null for legacy metas — callers that need a remote id fall back to a path lookup, which is what the new code path does anyway.
  • The first write to the meta on v1.10.1+ adds the ids block.
  • Remote pulls promote any pre-existing top-level pageId to ids.local before merging in the remote payload.

Any caller that was passing a now-misaddressed meta.pageId and getting "Page not found" will instead get a successful path-based resolution OR a clear id-mismatch diagnostic — both improvements over the silent breakage they were getting before.

Validation

  • 16 reflection-driven PHP tests covering the new helpers:
    • buildIdsBlock: fresh writes, preserve other side, defensive bad env names, key-stable output for byte-identical writes.
    • readEnvId: new format, legacy fallback for local, null for remote when only legacy data exists, malformed / empty / zero inputs.
  • 9 Node tests covering the TS merge logic in pullPageFromRemote:
    • No-existing-meta passes payload through.
    • Existing v1.10.1 ids.local is preserved across the merge.
    • Legacy top-level pageId is promoted to ids.local.
    • Stray payload ids.local is stripped (defensive).
    • No-local-available case leaves ids.local absent rather than guessing.
  • npm run build (TypeScript strict) passes.
  • php -l clean on every modified PHP file.

All 25 tests pass.

Files changed

  • src/Sync/SyncManager.php — new buildIdsBlock / readEnvId helpers; pullPage, exportPageYaml, initPageMeta, publishPage and pushPage writes wired through them; pushPage lookup refactored to path-first with id verification; getPageSyncStatus and sync:reconcile updated to the same rule.
  • mcp-server/src/pages/puller.tspullPageFromRemote now merges existing meta with the remote payload (preserves ids.local, strips stray local from payload, promotes legacy pageId).
  • mcp-server/src/pages/pusher.tspushPage and publishPage now record ids.remote in the local meta after a successful live remote operation, via a new recordRemoteIdInMeta() helper.
  • src/Cli/CommandRouter.php, mcp-server/src/index.ts, PromptWire.module.php, mcp-server/package.json — version bump 1.10.0 → 1.10.1.
  • CHANGELOG.md, ROADMAP.md — release entry; the deferred section that lived inside the v1.10.0 entry has been removed (replaced by this release).
Open in Web Open in Cursor 

cursoragent and others added 3 commits April 30, 2026 16:09
Adds new helpers in SyncManager — buildIdsBlock(env, pageId, existing?)
and readEnvId(meta, env) — and threads them through every PHP write
site that touches page.meta.json.

The ids block:

  "ids": {
    "local":  { "id": 1234, "lastSeenAt": "..." },
    "remote": { "id": 5678, "lastSeenAt": "..." }
  }

Each write populates only its own slot, never overwrites the other.
Top-level meta.pageId is kept as a back-compat mirror for tooling we
don't own that reads it directly. The mirror always equals the slot
just written by the current operation — same behaviour the legacy
single-slot had.

Write sites updated:

  - pullPage:        writes ids.local, preserves any pre-existing
                     ids.remote. Reads any existing meta first to
                     do the merge.
  - exportPageYaml:  emits ids.remote in the export payload. From the
                     receiver's perspective this id IS the remote id —
                     labelling it correctly at source means the
                     receiver doesn't need to know which environment
                     it pulled from.
  - initPageMeta:    writes ids.local in the 'linked' (existing page)
                     branch. The 'new' scaffold branch keeps pageId:
                     null (no real id yet) and gets the slot
                     populated by the publishPage writeback after
                     the page is actually created.
  - publishPage:     writes ids.local after a successful create,
                     preserving any pre-existing ids.remote.
  - pushPage:        on successful save, refreshes ids.local
                     (lastSeenAt advances; new id recorded if it
                     differs from the meta's last-seen).

Path-first refactor of pushPage:

   = $wire->pages->get($meta['canonicalPath']);
  $expected = $meta['ids']['local']['id'] ?? null;

  if ($expected && $page->id !== $expected && !$force) {
      // refuse with structured error: expectedId / currentId /
      // canonicalPath / hint pointing at force:true
  }

Same path-first / id-as-sanity-check pattern the page-assets work in
v1.10.0 already uses. Catches: page deleted-and-recreated at the same
path, slug rebound to an unrelated page, meta from a different site.
Falls back to id when the path lookup fails (covers legacy metas
without canonicalPath, plus the rare moved-without-reconcile case).

getPageSyncStatus and sync:reconcile updated with the same rule so
pw_sync_status and pw_sync_reconcile give identical results regardless
of which side last wrote the meta. pw_sync_status results now also
include the ids block when present.

Validated with 16 reflection-driven PHP tests covering buildIdsBlock
(fresh writes, preserve other side, defensive bad env names) and
readEnvId (new format, legacy fallback for local, null for remote
when only legacy data exists, malformed inputs).

Co-authored-by: PKDigital <PeterKnightDigital@users.noreply.github.com>
Two TS-side changes that close the v1.10.1 loop:

pullPageFromRemote (mcp-server/src/pages/puller.ts):

  Previously wrote the entire remote-emitted meta over any existing
  page.meta.json, which would have wiped the new ids.local slot from
  a prior local pull. Now reads the existing meta first, merges it
  with the payload by:

    - preserving existing ids.local
    - stripping any stray ids.local from the remote payload
      (defensive — page:export-yaml on v1.10.1+ only emits
      ids.remote, but an older or misconfigured endpoint could
      include local; we don't trust the remote to know about our
      local slot)
    - promoting legacy top-level pageId to ids.local for pre-v1.10.1
      metas so the value isn't lost on this remote pull
    - falling through to writing the payload as-is when the existing
      meta is missing or malformed (rather than failing the pull)

pushPage / publishPage (mcp-server/src/pages/pusher.ts):

  After a successful live remote push, reads the pageId from the API
  response (page:update / page:create both return it) and writes
  ids.remote into the local meta. Best-effort, dry-run-aware, never
  fails the push. The local meta now learns the remote id from a
  remote push without needing a follow-up pull — closing the symmetric
  case of pullPageFromRemote learning the local id from disk.

Validated with 9 Node tests covering the merge logic: no-existing-meta
passes payload through; existing v1.10.1 ids.local is preserved; legacy
top-level pageId is promoted to ids.local; stray payload ids.local is
stripped; the no-local-available case leaves ids.local absent rather
than guessing.

Co-authored-by: PKDigital <PeterKnightDigital@users.noreply.github.com>
Module + MCP server bumped 1.10.0 → 1.10.1 so the version Cursor
reports matches the new ids-block + path-first pushPage feature set.

ROADMAP: removes the deferred 'follow-up planned for v1.10.1' section
from inside the v1.10.0 entry, replaces it with a full v1.10.1 release
entry covering the format change, the pushPage refactor, the legacy
migration semantics, and the validation results.

CHANGELOG: new v1.10.1 entry above v1.10.0 covering the same ground in
release-note form.

Co-authored-by: PKDigital <PeterKnightDigital@users.noreply.github.com>
cursor Bot pushed a commit that referenced this pull request Apr 30, 2026
…st pushPage

Closes the meta format gap deliberately deferred from v1.10.0. Replaces
the single top-level meta.pageId slot with an additive ids block
(local / remote, each with id + lastSeenAt). Each meta-write site
populates only its own slot; the other side is preserved verbatim.
Top-level pageId is kept as a back-compat mirror.

Refactors SyncManager::pushPage to resolve pages by canonical path with
the last-seen ids.local.id used as a sanity check (refuses path/id
mismatches with a structured error pointing at force:true). Same
path-first cross-environment safety the page-assets work in v1.10.0
already gave us, applied uniformly to page content. getPageSyncStatus
and sync:reconcile updated to the same rule.

MCP-side pullPageFromRemote now MERGES the remote payload with any
existing local meta — preserving ids.local, stripping stray payload
ids.local, promoting legacy top-level pageId for pre-v1.10.1 metas.
pushPage and publishPage record ids.remote in the local meta after a
successful live remote operation, closing the loop.

Migration is automatic and additive: older metas keep working;
readEnvId falls back to legacy pageId for local; the first write on
v1.10.1+ adds the ids block.

Validated with 25 tests (16 PHP, 9 Node), all pass.

See PR #3 for the full release notes.

Co-authored-by: PKDigital <PeterKnightDigital@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants