Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 0 additions & 8 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -333,14 +333,6 @@ const resolveInitialLayout = async (
// daily-note:date backfill silently never synced.
repo.scheduleWorkspaceBackfills(workspaceId)

// One-time post-upgrade recovery for the deterministic-id shadow: clients that
// skip-staled the server's authoritative row under the old reconcile gate
// consumed its change-queue entry, so a normal startup never re-evaluates it.
// Re-scan the workspace's staged rows once (marker-gated, deferred) so those
// shadows heal on disk; visible on the next reload (the live cache LWW still
// holds the default this session).
repo.scheduleReconcileRescan(workspaceId)

// Freshly inserted personal workspace: install the starter tutorial
// as its own parent-less page. The [[Tutorial]] bullet on today's
// daily note (added below) makes it discoverable from the landing
Expand Down
17 changes: 0 additions & 17 deletions src/data/internals/clientSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1108,23 +1108,6 @@ export const RECORD_WORKSPACE_BACKFILL_MARKER_SQL = `
VALUES (?, strftime('%s', 'now') * 1000)
`

/** One-time post-upgrade marker: this client has re-scanned a workspace's
* staged `blocks_synced` rows under the relaxed reconcile gate, to heal
* deterministic-id shadows the old gate skip-staled (and whose change-queue
* entry it then consumed, so a normal queue-driven drain never re-evaluates
* them). Keyed `reconcile_rescan_v1:<workspaceId>` — once per workspace per
* client. */
export const RECONCILE_RESCAN_MARKER_PREFIX = 'reconcile_rescan_v1:'

export const SELECT_RECONCILE_RESCAN_MARKER_SQL = `
SELECT key FROM client_schema_state WHERE key = ?
`

export const RECORD_RECONCILE_RESCAN_MARKER_SQL = `
INSERT OR REPLACE INTO client_schema_state (key, completed_at)
VALUES (?, strftime('%s', 'now') * 1000)
`

// ============================================================================
// Bulk-apply ordered list. Run after `blocks` exists (PowerSync's schema
// initialization creates it). Idempotent (`IF NOT EXISTS`).
Expand Down
26 changes: 12 additions & 14 deletions src/data/internals/invalidation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -643,16 +643,14 @@ describe('sync observer: sync-applied invalidation', () => {
// reprocessing across restarts, coalescing re-deliveries — is pinned in
// observer.test.ts. Local writes never enter the queue, covered above.)

it('ack-to-echo replay reverts disk transiently but the cache masks it (no flash), then converges with no freeze loop', async () => {
// QuickFind-freeze / stale-echo canary, post-split. A local write to A is
// acked (not pending). The server-monotonic DISK gate APPLIES a strictly-
// newer-local replay (a transient disk revert) and relies on the echo — the
// server's authoritative row, stamp floored >= local — to re-assert truth.
// The CACHE write is LWW, so it REJECTS the older replay: the transient
// stays on disk and never surfaces as a new→old→new UI flash. This pins:
// (a) the replay reverts disk transiently while the cache keeps the local
// value, and the echo converges disk + cache within the same settle,
// and
it('skip-stales an ack-to-echo replay (no disk revert, no flash), then the echo converges with no freeze loop', async () => {
// QuickFind-freeze / stale-echo canary, post-hardening. A local write to A is
// acked (not pending). A strictly-newer-local replay (older stamp) is now
// skip-staled by the gate on BOTH disk and cache — no transient revert at
// all — and the echo (the server's authoritative row, stamp floored >= local)
// re-asserts truth through the normal queue. This pins:
// (a) the replay neither reverts disk nor flashes the cache, and the echo
// converges within the same settle, and
// (b) no repeated handle-wake loop (the freeze signature: cache-rejected
// sync rows kicking handles to re-read SQL in a burst).
await create('A', {parentId: null, orderKey: 'a0', content: 'local-new'})
Expand All @@ -677,14 +675,14 @@ describe('sync observer: sync-applied invalidation', () => {
env.repo.startSyncObserver({throttleMs: 0})
await env.repo.flushSyncObserver()

// 1) Stale in-flight replay (older stamp). Disk reverts transiently; the
// cache LWW rejects it, so the user-visible value never flashes to stale.
// 1) Stale in-flight replay (older stamp). Skip-staled: disk is NOT reverted
// and the cache is NOT flashed — the strictly-newer nonzero local row wins.
await syncApply({id: 'A', parentId: null, orderKey: 'a0', content: 'server-stale', updatedAt: 1})
await env.repo.flushSyncObserver()
await Promise.resolve()
const diskRow = await env.h.db.getAll<{content: string}>('SELECT content FROM blocks WHERE id = ?', ['A'])
expect(diskRow[0]?.content).toBe('server-stale') // transient revert on disk
expect(env.cache.getSnapshot('A')?.content).toBe('local-new') // cache masks it — no flash
expect(diskRow[0]?.content).toBe('local-new') // disk protected — no revert
expect(env.cache.getSnapshot('A')?.content).toBe('local-new') // cache protected — no flash

// 2) The echo: server's authoritative row, stamp floored >= local. Converges.
await syncApply({id: 'A', parentId: null, orderKey: 'a0', content: 'local-new', updatedAt: localStamp + 1})
Expand Down
27 changes: 14 additions & 13 deletions src/data/internals/syncObserver/materialize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,8 +284,8 @@ describe('materializeStagingRows — batched gate (mixed outcomes, chunked)', ()

// apply: strictly-newer staging snapshot, no local row.
await stageRow(blockData({ id: 'apply', content: 'fresh', updatedAt: 300 }))
// apply: a strictly-newer NONZERO local row yields to the older server row
// (no strictly-newer protection — the echo converges).
// skip: a strictly-newer NONZERO local row is protected from the older
// delivery (a stale in-flight replay; the echo re-asserts the local value).
await seedLocalBlock(blockData({
id: 'newer', content: 'local newer', updatedAt: 500,
}))
Expand All @@ -305,10 +305,10 @@ describe('materializeStagingRows — batched gate (mixed outcomes, chunked)', ()
{ readChunkSize: 2 }, // 3 ids → 2 read chunks, so the bulk maps span a boundary
)

expect([...out.applied].sort()).toEqual(['apply', 'newer'])
expect(out.skippedStale).toEqual(['pending'])
expect([...out.applied].sort()).toEqual(['apply'])
expect([...out.skippedStale].sort()).toEqual(['newer', 'pending'])
const byId = Object.fromEntries((await allBlocks()).map(b => [b.id, b.content]))
expect(byId).toEqual({ apply: 'fresh', newer: 'stale server', pending: 'local pending' })
expect(byId).toEqual({ apply: 'fresh', newer: 'local newer', pending: 'local pending' })
})
})

Expand All @@ -331,12 +331,12 @@ describe('materializeStagingRows — stamp-0 sentinel (deterministic-id shadow)'
expect((await allBlocks())[0]!.content).toBe('real synced config')
})

it('applies the server row over a strictly-newer NONZERO local row (echo converges)', async () => {
// No more strictly-newer protection. A nonzero local row strictly newer than
// an in-flight older delivery is either pending (guarded) or acked; this
// non-pending one is an acked edit facing a stale replay. The gate applies
// the older server row — a transient revert the upload echo (server stamp >=
// local, via the floor+bump) converges. Pre-split this was "protected".
it('protects a strictly-newer NONZERO local row from a stale older delivery', async () => {
// A nonzero local row strictly newer than an in-flight older delivery is
// authoritative under server monotonicity (the older stamp can't carry newer
// content). The gate skip-stales the delivery — keeping the acked edit on
// disk and off the UI; the real echo (stamp >= local) re-asserts it. The
// 0-stamped sentinel (not this protection) is what heals a real shadow.
await seedLocalBlock(blockData({ content: 'my edit', updatedAt: 500 }))
await stageRow(blockData({ content: 'stale server', updatedAt: 200 }))

Expand All @@ -346,8 +346,9 @@ describe('materializeStagingRows — stamp-0 sentinel (deterministic-id shadow)'
{ getMaterializability: constMat('copy'), getCek: noKey },
)

expect(out.applied).toEqual(['b1'])
expect((await allBlocks())[0]!.content).toBe('stale server')
expect(out.applied).toEqual([])
expect(out.skippedStale).toEqual(['b1'])
expect((await allBlocks())[0]!.content).toBe('my edit')
})
})

Expand Down
33 changes: 8 additions & 25 deletions src/data/internals/syncObserver/observer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ const waitFor = async (cond: () => Promise<boolean>, ms = 3000): Promise<void> =
}
}

describe('blocksSyncedObserver — server overrides a non-pending local row (disk + live heal)', () => {
describe('blocksSyncedObserver — server heals a 0-stamped default; a nonzero local row is protected', () => {
it('overwrites a 0-stamped pristine default with the older server row, on disk AND in the cache', async () => {
// A deterministic-id default minted on read-as-absent: 0-stamped (pristine
// sentinel), non-pending (no ps_crud), read into the cache at app start.
Expand All @@ -145,28 +145,12 @@ describe('blocksSyncedObserver — server overrides a non-pending local row (dis
expect(cache.getSnapshot('b1')).toMatchObject({ content: 'real synced config' })
})

it('also overwrites a strictly-newer NON-pending local row ON DISK (replay transient, echo converges)', async () => {
// No strictly-newer DISK protection. A nonzero local row strictly newer than
// an older delivery, with no pending upload, is an acked edit facing a stale
// in-flight replay. The gate applies the older server row on disk — a
// transient revert the upload echo (server stamp >= local via the floor+bump)
// converges. A genuinely-unsent edit would be pending and is still guarded.
const localEdit = data({ content: 'my edit', updatedAt: 9000 })
await seedLocalBlock(localEdit)
const { observer } = start({ getMaterializability: constMat('copy') })

await put(data({ content: 'stale server', updatedAt: 3000 }))
await observer.flush()

expect(await blocks()).toEqual([{ id: 'b1', content: 'stale server' }])
})

it('does NOT surface that disk transient in the live cache (no stale-echo flash)', async () => {
// Same replay as above, but with the real edit live in the cache (the UI is
// showing it). The disk gate still transiently reverts, but the cache write
// is LWW: it rejects the older server row, so the cache keeps the edit and no
// handle is woken — no new→old→new flash. The echo re-converges disk; a
// reload would rehydrate from the (by-then healed) disk.
it('protects a strictly-newer NON-pending nonzero local row on disk AND in cache (no clobber, no flash)', async () => {
// A nonzero local row strictly newer than an older delivery, no pending
// upload — an acked edit facing a stale in-flight replay. The hardened gate
// skip-stales it: disk keeps the edit (no transient revert) and the cache LWW
// keeps it too (no new→old→new flash). The real echo (server stamp >= local
// via the floor+bump) re-asserts it through the normal queue.
const localEdit = data({ content: 'my edit', updatedAt: 9000 })
await seedLocalBlock(localEdit)
const { observer, cache } = start({ getMaterializability: constMat('copy') })
Expand All @@ -175,8 +159,7 @@ describe('blocksSyncedObserver — server overrides a non-pending local row (dis
await put(data({ content: 'stale server', updatedAt: 3000 }))
await observer.flush()

// Disk took the transient revert; the live cache did not.
expect(await blocks()).toEqual([{ id: 'b1', content: 'stale server' }])
expect(await blocks()).toEqual([{ id: 'b1', content: 'my edit' }])
expect(cache.getSnapshot('b1')).toMatchObject({ content: 'my edit' })
})
})
Expand Down
15 changes: 8 additions & 7 deletions src/data/internals/syncObserver/reconcile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,15 @@ describe('decideStagingRow — local-edit reconciliation', () => {
expect(action).toEqual({ kind: 'skip-stale' })
})

it('applies the server row over a strictly-newer NON-pending local row', () => {
// No more strictly-newer protection: with server-enforced monotonicity, a
// genuinely-newer local edit is either pending (caught above) or acked, and
// its echo (server stamp >= local) re-asserts it. A strictly-older delivery
// here is an in-flight replay; applying it is a transient revert that the
// echo converges. The only deliberate hold is the equal-nonzero guard below.
it('skips a strictly-newer NON-pending nonzero local row (no clobber / no flash)', () => {
// Server-enforced monotonicity makes an older-stamped delivery a stale
// in-flight replay (the server floors+bumps on content change, so it can't
// hand back an older stamp for newer content). Protect the local row: keep
// the acked edit on disk and off the UI; the real echo (stamp >= local)
// re-asserts it through the normal queue. New shadows can't form (0-stamped
// mints) and legacy ones were swept, so this strands nothing.
const action = decideStagingRow('copy', 100, local(200))
expect(action).toEqual({ kind: 'apply', decrypt: false })
expect(action).toEqual({ kind: 'skip-stale' })
})

it('applies when the staging row is newer than the local row', () => {
Expand Down
62 changes: 32 additions & 30 deletions src/data/internals/syncObserver/reconcile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,12 @@
* materializable (copy-through, no key) — "no WK" is NOT the defer
* test, or plaintext rows would strand in staging forever.
*
* - SKIP_STALE: the workspace IS materializable, but a pending local
* edit is newer than this staging snapshot. Applying would clobber an
* unsynced local edit; instead let the upload echo reconcile when it
* returns. This is the ps_crud / updated_at gate the doc calls out.
* - SKIP_STALE: the workspace IS materializable, but the local row wins —
* it has a pending upload, or a nonzero `updated_at` at-or-above this
* staging snapshot's (a stale/equal-stamp delivery under server-enforced
* monotonicity). Applying would clobber an authoritative local row on disk
* and flash the UI; instead let the upload echo / a strictly-newer server
* row reconcile. This is the ps_crud / updated_at gate the doc calls out.
*/

// `Materializability` is sync-seam vocabulary shared with the §6 resolver;
Expand Down Expand Up @@ -87,37 +89,37 @@ export const decideStagingRow = (
}
if (
local.localUpdatedAt !== null &&
local.localUpdatedAt === stagingUpdatedAt &&
local.localUpdatedAt >= stagingUpdatedAt &&

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Allow server-clamped echoes to replace fast-clock stamps

When the editing device's clock is ahead, the upload echo can come back with a lower updated_at than the local row: local txs stamp from Date.now(), while blocks_clamp_updated_at future-clamps NEW.updated_at to server time before flooring/bumping (supabase/migrations/20260612000000_add_user_updated_at_monotonic_clamp.sql lines 31-37). With this >= check, that authoritative echo is consumed as skip-stale, leaving the local row pinned at the future stamp; subsequent collaborator/server edits whose server stamps are below that future value will also be ignored until wall-clock time catches up.

Useful? React with 👍 / 👎.

local.localUpdatedAt !== 0
) {
// EQUAL NONZERO stamps ⟺ identical content (invariant I1): the server
// floor+bump strictly advances the stamp on any content change, so two
// rows can share a nonzero stamp only if neither changed content. The one
// deliberate skip — a stale in-flight server read carrying different
// content under the same ms-stamp would otherwise clobber a local edit on
// disk and resurface after reload (the in-memory cache gate can't guard the
// persistent write). See commit 429fd4b2.
// NEWER-OR-EQUAL nonzero local row wins. Under server-enforced updated_at
// monotonicity (an unconditional floor + a +1 bump on any content change),
// the server can never hand back a stamp <= a nonzero local one for newer
// content — so a staging stamp at-or-below a nonzero local stamp is, by
// definition, a stale delivery, and the local row is authoritative:
// - strictly-newer (local > staging): an acked local edit facing a stale
// in-flight replay, or a value already materialized from a newer server
// delivery. Protecting it keeps the edit on disk AND off the UI — no
// transient revert, no stale-echo flash; the real echo (stamp >= local)
// re-asserts it through the normal queue.
// - equal nonzero (local == staging): identical content by invariant I1;
// the one guard against an equal-ms stale read carrying DIFFERENT
// content clobbering a local edit on disk and resurfacing on reload
// (the in-memory cache gate can't guard the persistent write — 429fd4b2).
//
// The `!== 0` exemption (invariant I2) is required, not cosmetic: two
// devices that minted the same deterministic id both sit at 0; without the
// exemption the insert-or-skip loser would equal-stamp-skip forever and
// never converge to the server's created_at/created_by/user_updated_at (or
// even content, if the default template changed between the mints). A
// 0-stamped local row always yields.
// The `!== 0` exemption (invariant I2) is load-bearing: a 0-stamped pristine
// local default ALWAYS yields to the server (falls through to apply) — the
// deterministic-id heal and the cross-device convergence path (two devices
// minting the same id both sit at 0). This protection is safe to hold now
// that new shadows can't form (mints are 0-stamped via the systemMint
// 0-hold) and the legacy nonzero-shadow population was reconciled by the
// recovery rollout before it shipped — so protecting nonzero local rows
// strands nothing. (This is the protection e7fc79b2 had to relax back when
// speculative defaults were minted with a real `now` stamp.)
return { kind: 'skip-stale' }
}

// Otherwise apply: the server row is newer truth, or this is a 0-stamped
// pristine default yielding to the server. Strictly-newer-local protection
// is intentionally gone — a genuinely-newer local edit is either pending
// (caught above) or already acked, and an acked edit's echo (server stamp
// >= local via the floor+bump) re-asserts it. The only cost is a transient
// revert in rescan paths (drainWorkspace) during the ack-to-echo window;
// steady-state queue-driven drains can't hit it (the next delivery for the
// id IS the echo). That disk transient stays OFF the UI: the cache write is
// LWW (`applySyncInvalidation` → `applyIfNewer`), which rejects the older
// value, so the row self-heals on the echo without a visible flash. (A
// permanently-rejected edit rolls back on the next reload, when the cache
// rehydrates from the server-healed disk.)
// Otherwise apply: the server row is strictly newer (real new truth), or a
// 0-stamped pristine default yielding to the server.
return { kind: 'apply', decrypt: materializability === 'decrypt' }
}
Loading
Loading