mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 13:16:16 -08:00
fix(server-storage): keep edits as ordered steps, treat probe refusals as transient, read the cache through memory
- A queued write keeps its edits as steps: an edit continuing the last one joins it, one made behind a travelling write or on another tab's change stays apart, and a conflict replays them in order. Saved writes and early edits store the steps too. - The instance probe treats any refusal but 404 (a proxy's 429 or 403) as unreachable, so a browser keeps its remembered server. - Cache reads that browser storage refuses fall back to memory, so a re-read and later edits still work. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b1b05600d6
commit
72b0d2102a
9 files changed
+213
-129
No files matched your search
@@ -74,10 +74,12 @@ describe('probeServerInstance', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('reports an unreachable server for failures and 5xx', async () => {
|
||||
it('reports an unreachable server for failures, 5xx and refusals', async () => {
|
||||
for (const fetchFn of [
|
||||
jest.fn().mockRejectedValue(new TypeError('Failed to fetch')),
|
||||
answer('Bad Gateway', 502),
|
||||
answer('Too Many Requests', 429),
|
||||
answer('Forbidden', 403),
|
||||
// Headers arrived, then the body was cut off.
|
||||
jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
|
||||
@@ -6,9 +6,10 @@ const PROBE_TIMEOUT_MS = 2_500;
|
||||
|
||||
/**
|
||||
* - `skipped`: no runtime backend URL, or Electron.
|
||||
* - `server` / `none`: the backend answered.
|
||||
* - `unreachable`: no answer, a timeout or a 5xx (nginx answers 502 while
|
||||
* the backend restarts), so the result says nothing about storage.
|
||||
* - `server` / `none`: the backend answered (a 404 is an older backend).
|
||||
* - `unreachable`: no answer, a timeout, a 5xx (nginx answers 502 while
|
||||
* the backend restarts) or another refusal such as a proxy's 429, so the
|
||||
* result says nothing about storage.
|
||||
*/
|
||||
export type ServerInstanceProbe =
|
||||
| { readonly kind: 'skipped' | 'none' | 'unreachable' }
|
||||
@@ -52,13 +53,13 @@ export async function probeServerInstance(
|
||||
} catch {
|
||||
return { kind: 'unreachable' };
|
||||
}
|
||||
if (response.status >= 500) {
|
||||
return { kind: 'unreachable' };
|
||||
}
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
// A backend from before server storage has no /v1 routes.
|
||||
return { kind: 'none' };
|
||||
}
|
||||
if (!response.ok) {
|
||||
return { kind: 'unreachable' };
|
||||
}
|
||||
try {
|
||||
const body: unknown = await response.json();
|
||||
return isServerInstance(body) && body.storage === 'server'
|
||||
|
||||
@@ -179,8 +179,8 @@ deployment wrote a runtime `BACKEND_URL` into `window.__IPTVNATOR_CONFIG__`
|
||||
`storage: 'server'` it imports `apps/web/src/app/server-storage/` lazily and
|
||||
merges `provideServerStorage()` into the app config, and remembers the
|
||||
instance under `iptvnator-server:instance`. When the probe gets no answer
|
||||
(network error, timeout, or a 5xx such as nginx's 502 while the backend
|
||||
restarts), a browser that remembers this backend still starts in server
|
||||
(network error, timeout, a 5xx such as nginx's 502 while the backend
|
||||
restarts, or a refusal other than 404, such as a proxy's 429), a browser that remembers this backend still starts in server
|
||||
mode, offline, and reloads once the server answers; it never falls back to
|
||||
an empty browser library. Until that reload it sends no writes
|
||||
(`holdWrites`): the address may lead to another database by then, which
|
||||
@@ -220,11 +220,15 @@ settings and guards read them in every build.
|
||||
as offline. Each write is sent on the tag of the entry it was made
|
||||
against (`UserDataWriteQueue`); after a `412` the edit (before → after) is
|
||||
replayed on the server's current value, so another device's change to the
|
||||
same playlist's entry is kept. An edit queued behind a travelling write
|
||||
keeps its own before → after and carries the travelling one as `prior`
|
||||
(also in the saved writes): if that write was lost, a conflict replays
|
||||
both in order, and if it landed, adding and then removing an item still
|
||||
removes it. An edit made on another tab's unsent change, and an entry a
|
||||
same playlist's entry is kept. A queued write keeps its edits as steps
|
||||
(before → after, also in the saved writes): an edit continuing the last
|
||||
one joins it, one made behind a travelling write or on another tab's
|
||||
change is kept apart, and a conflict replays them in order. So a lost
|
||||
write is not dropped, and adding and then removing an item still removes
|
||||
it when the addition landed. When two tabs edit one entry before either
|
||||
write lands, a removal of the other tab's unsent item holds if that tab's
|
||||
write lands first, as it does when a tab is left (its writes go out on
|
||||
hide); otherwise the item can come back. An edit made on another tab's unsent change, and an entry a
|
||||
re-read keeps from the cache, are sent on a version no entry has
|
||||
(`UNKNOWN_TAG`), so they conflict and are replayed too. Page-hide writes report their
|
||||
new tag or conflict like any other, and one behind a travelling write is
|
||||
@@ -238,9 +242,9 @@ settings and guards read them in every build.
|
||||
registry in `localStorage` (`iptvnator-server:unconfirmed:<instanceId>`,
|
||||
one record per page load, renewed every 30 s while it has unsent writes,
|
||||
stale after ten minutes) lets a re-read in one tab keep the cache values
|
||||
another tab is still sending. A value too big for
|
||||
`localStorage` stays readable from memory, so callers and the diff
|
||||
baseline agree. A re-read
|
||||
another tab is still sending. A value too big for `localStorage`, or any
|
||||
value when the browser refuses storage access, stays readable from
|
||||
memory, so callers and the diff baseline agree. A re-read
|
||||
updates the cache; views already open show another device's changes on
|
||||
their next load (no live push). Start-up waits
|
||||
up to 3 s for the first read.
|
||||
|
||||
@@ -63,17 +63,18 @@ export function claimCache(local: UserDataStorage, instanceId: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Saved edits: JSON drops an undefined side, which reads back as such. */
|
||||
type Steps = readonly { readonly before: unknown; readonly after: unknown }[];
|
||||
|
||||
export interface UnsentWrite {
|
||||
readonly namespace: ServerUserStateNamespace;
|
||||
readonly playlistId: string;
|
||||
/** `undefined` deletes the entry. */
|
||||
readonly value: unknown;
|
||||
/** The entry the edit was made against, to replay it on a conflict. */
|
||||
readonly before?: unknown;
|
||||
/** The server version it is based on (see `PendingEntry.tag`). */
|
||||
readonly tag?: string | null;
|
||||
/** A write sent before it, maybe saved (see `PendingEntry.prior`). */
|
||||
readonly prior?: { readonly before: unknown; readonly after: unknown };
|
||||
/** The edits since that version (see `PendingEntry.steps`). */
|
||||
readonly steps: Steps;
|
||||
}
|
||||
|
||||
interface StoredWrite {
|
||||
@@ -81,12 +82,24 @@ interface StoredWrite {
|
||||
readonly playlistId: string;
|
||||
readonly value?: unknown;
|
||||
readonly remove?: true;
|
||||
readonly before?: unknown;
|
||||
readonly tag?: string | null;
|
||||
/** JSON drops an undefined side: absent reads back as undefined. */
|
||||
readonly prior?: { readonly before?: unknown; readonly after?: unknown };
|
||||
readonly steps?: unknown;
|
||||
}
|
||||
|
||||
function readSteps(stored: unknown): Steps {
|
||||
return Array.isArray(stored)
|
||||
? stored
|
||||
.filter((step) => step && typeof step === 'object')
|
||||
.map((step: { before?: unknown; after?: unknown }) => ({
|
||||
before: step.before,
|
||||
after: step.after,
|
||||
}))
|
||||
: [];
|
||||
}
|
||||
|
||||
const saveSteps = (steps: Steps) =>
|
||||
steps.map(({ before, after }) => ({ before, after }));
|
||||
|
||||
/**
|
||||
* Records `page` as the tab's current page load and returns the one
|
||||
* before it: a reload, whose saved writes this page takes over.
|
||||
@@ -160,15 +173,8 @@ export function readUnsentWrites(
|
||||
namespace: write.namespace,
|
||||
playlistId: write.playlistId,
|
||||
value: write.remove ? undefined : write.value,
|
||||
before: write.before,
|
||||
tag: write.tag,
|
||||
prior:
|
||||
write.prior && typeof write.prior === 'object'
|
||||
? {
|
||||
before: write.prior.before,
|
||||
after: write.prior.after,
|
||||
}
|
||||
: undefined,
|
||||
steps: readSteps(write.steps),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -187,15 +193,12 @@ export function saveUnsentWrites(
|
||||
return;
|
||||
}
|
||||
const stored: StoredWrite[] = writes.map(
|
||||
({ namespace, playlistId, value, before, tag, prior }) => ({
|
||||
({ namespace, playlistId, value, tag, steps }) => ({
|
||||
namespace,
|
||||
playlistId,
|
||||
...(value === undefined ? { remove: true } : { value }),
|
||||
...(before === undefined ? {} : { before }),
|
||||
...(tag === undefined ? {} : { tag }),
|
||||
...(prior === undefined
|
||||
? {}
|
||||
: { prior: { before: prior.before, after: prior.after } }),
|
||||
steps: saveSteps(steps),
|
||||
})
|
||||
);
|
||||
local.setItem(writesKey(instanceId, page), JSON.stringify(stored));
|
||||
@@ -222,8 +225,7 @@ export function newestWrites<T extends UnsentWrite & { readonly edit: number }>(
|
||||
export interface SavedDeferredEdit {
|
||||
readonly namespace: ServerUserStateNamespace;
|
||||
readonly playlistId: string;
|
||||
readonly before: unknown;
|
||||
readonly after: unknown;
|
||||
readonly steps: Steps;
|
||||
}
|
||||
|
||||
export function readDeferredEdits(
|
||||
@@ -250,12 +252,10 @@ export function readDeferredEdits(
|
||||
isServerUserStateNamespace(namespace) &&
|
||||
typeof playlistId === 'string'
|
||||
) {
|
||||
// JSON has no undefined: absent sides are flagged.
|
||||
edits.push({
|
||||
namespace,
|
||||
playlistId,
|
||||
before: edit['noBefore'] ? undefined : edit['before'],
|
||||
after: edit['noAfter'] ? undefined : edit['after'],
|
||||
steps: readSteps(edit['steps']),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -279,12 +279,7 @@ export function saveDeferredEdits(
|
||||
edits.map((edit) => ({
|
||||
namespace: edit.namespace,
|
||||
playlistId: edit.playlistId,
|
||||
...(edit.before === undefined
|
||||
? { noBefore: true }
|
||||
: { before: edit.before }),
|
||||
...(edit.after === undefined
|
||||
? { noAfter: true }
|
||||
: { after: edit.after }),
|
||||
steps: saveSteps(edit.steps),
|
||||
}))
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { ServerUserStateNamespace } from '@iptvnator/shared/interfaces';
|
||||
import { itemKey } from './server-migration-merge';
|
||||
import type { EditStep } from './server-user-data-entries';
|
||||
|
||||
/** An edit made before the first read, applied once the server's value is known. */
|
||||
/** Edits made before the first read, applied once the server's value is known. */
|
||||
export interface DeferredEdit {
|
||||
readonly namespace: ServerUserStateNamespace;
|
||||
readonly playlistId: string;
|
||||
readonly before: unknown;
|
||||
readonly after: unknown;
|
||||
readonly steps: readonly EditStep[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,11 +29,7 @@ export function refreshedNamespace(
|
||||
}
|
||||
}
|
||||
for (const edit of deferred) {
|
||||
const merged = applyEdit(
|
||||
server[edit.playlistId],
|
||||
edit.before,
|
||||
edit.after
|
||||
);
|
||||
const merged = replaySteps(server[edit.playlistId], edit.steps);
|
||||
if (merged === undefined) {
|
||||
delete value[edit.playlistId];
|
||||
} else {
|
||||
@@ -115,17 +111,14 @@ function isListOrMissing(value: unknown): value is unknown[] | undefined {
|
||||
return value === undefined || Array.isArray(value);
|
||||
}
|
||||
|
||||
/** A saved write's edits, its `prior` first, applied to `server`. */
|
||||
export function replayWrite(
|
||||
/** Edits replayed on `server` in order. */
|
||||
export function replaySteps(
|
||||
server: unknown,
|
||||
write: {
|
||||
readonly before?: unknown;
|
||||
readonly value: unknown;
|
||||
readonly prior?: { readonly before: unknown; readonly after: unknown };
|
||||
}
|
||||
steps: readonly { readonly before: unknown; readonly after: unknown }[]
|
||||
): unknown {
|
||||
const base = write.prior
|
||||
? applyEdit(server, write.prior.before, write.prior.after)
|
||||
: server;
|
||||
return applyEdit(base, write.before, write.value);
|
||||
let value = server;
|
||||
for (const step of steps) {
|
||||
value = applyEdit(value, step.before, step.after);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -8,26 +8,47 @@ export interface PendingEntry {
|
||||
readonly value: unknown;
|
||||
/** The edit this value came from; a retry never replaces a newer one. */
|
||||
readonly edit: number;
|
||||
/** The entry as the edit found it, to replay the edit on a conflict. */
|
||||
readonly before: unknown;
|
||||
/**
|
||||
* The server version the edit is based on: a tag, `null` for an entry
|
||||
* The server version `value` is based on: a tag, `null` for an entry
|
||||
* seen absent, `undefined` when unknown (an unconditional write).
|
||||
*/
|
||||
readonly tag?: string | null;
|
||||
/**
|
||||
* The edit of the write this one was queued behind, which may or may
|
||||
* not have reached the server: a conflict replays it first. Kept apart
|
||||
* from this edit, so adding and then removing an item is not one no-op.
|
||||
* The edits since that version, oldest first: a conflict replays them
|
||||
* on the server's value in order. Kept apart where they cannot be one
|
||||
* before → after (another tab's change in between, or a write that may
|
||||
* or may not have landed), so adding and then removing an item is
|
||||
* never a no-op.
|
||||
*/
|
||||
readonly prior?: PriorEdit;
|
||||
readonly steps: readonly EditStep[];
|
||||
}
|
||||
|
||||
export interface PriorEdit {
|
||||
/** One edit as it was made: the entry before and after it. */
|
||||
export interface EditStep {
|
||||
readonly before: unknown;
|
||||
readonly after: unknown;
|
||||
/** The write it was sent with; `0` after a reload. */
|
||||
readonly edit: number;
|
||||
/** The travelling write that carries it, until that one is answered. */
|
||||
readonly sent?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* `steps` plus the edit `before` → `after`: joined with the last step when
|
||||
* it continues it, kept apart otherwise.
|
||||
*/
|
||||
export function addStep(
|
||||
steps: readonly EditStep[],
|
||||
before: unknown,
|
||||
after: unknown
|
||||
): EditStep[] {
|
||||
const last = steps[steps.length - 1];
|
||||
if (
|
||||
last &&
|
||||
last.sent === undefined &&
|
||||
JSON.stringify(last.after) === JSON.stringify(before)
|
||||
) {
|
||||
return [...steps.slice(0, -1), { before: last.before, after }];
|
||||
}
|
||||
return [...steps, { before, after }];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,12 +7,13 @@ import { ServerApiError, type ServerApiClient } from './server-api-client';
|
||||
import {
|
||||
entryKey,
|
||||
entryPath,
|
||||
addStep,
|
||||
type EditStep,
|
||||
type PendingEntry,
|
||||
preconditionHeaders,
|
||||
type PriorEdit,
|
||||
UNKNOWN_TAG,
|
||||
} from './server-user-data-entries';
|
||||
import { replayWrite } from './server-user-data-edit';
|
||||
import { replaySteps } from './server-user-data-edit';
|
||||
import { newestWrites } from './server-unsent-writes';
|
||||
|
||||
const FLUSH_DELAY_MS = 500;
|
||||
@@ -118,10 +119,9 @@ export class UserDataWriteQueue {
|
||||
|
||||
/**
|
||||
* Queues an edit (`before` → `value`). A newer edit of an entry still
|
||||
* queued joins it; one behind a travelling write keeps its own edit,
|
||||
* carries the write's as `prior` and is based on the write's tag. A
|
||||
* `foreign` edit was made on another tab's unsent change: its write
|
||||
* must conflict and be replayed.
|
||||
* queued joins it; one behind a travelling write keeps that write's
|
||||
* steps (marked as sent) and tag. A `foreign` edit was made on another
|
||||
* tab's unsent change: its write must conflict and be replayed.
|
||||
*/
|
||||
queue(
|
||||
namespace: ServerUserStateNamespace,
|
||||
@@ -130,7 +130,7 @@ export class UserDataWriteQueue {
|
||||
before: unknown,
|
||||
base: {
|
||||
readonly tag?: string | null;
|
||||
readonly prior?: Omit<PriorEdit, 'edit'>;
|
||||
readonly steps?: readonly EditStep[];
|
||||
readonly foreign?: boolean;
|
||||
} = {}
|
||||
): void {
|
||||
@@ -140,32 +140,37 @@ export class UserDataWriteQueue {
|
||||
const edit = ++this.editCount;
|
||||
let entry: PendingEntry;
|
||||
if (queued) {
|
||||
entry = { ...queued, value, edit };
|
||||
entry = {
|
||||
...queued,
|
||||
value,
|
||||
edit,
|
||||
steps: addStep(queued.steps, before, value),
|
||||
};
|
||||
} else if (travelling) {
|
||||
const sent = travelling.steps.map((step) => ({
|
||||
...step,
|
||||
sent: travelling.edit,
|
||||
}));
|
||||
entry = {
|
||||
namespace,
|
||||
playlistId,
|
||||
value,
|
||||
edit,
|
||||
before,
|
||||
tag: travelling.tag,
|
||||
prior: {
|
||||
before: travelling.prior
|
||||
? travelling.prior.before
|
||||
: travelling.before,
|
||||
after: travelling.value,
|
||||
edit: travelling.edit,
|
||||
},
|
||||
steps: [...sent, { before, after: value }],
|
||||
};
|
||||
} else {
|
||||
const tag =
|
||||
base.tag !== undefined
|
||||
? base.tag
|
||||
: this.tagFor(namespace, playlistId);
|
||||
entry = { namespace, playlistId, value, edit, before, tag };
|
||||
if (base.prior) {
|
||||
entry = { ...entry, prior: { ...base.prior, edit: 0 } };
|
||||
}
|
||||
entry = {
|
||||
namespace,
|
||||
playlistId,
|
||||
value,
|
||||
edit,
|
||||
tag:
|
||||
base.tag !== undefined
|
||||
? base.tag
|
||||
: this.tagFor(namespace, playlistId),
|
||||
steps: base.steps ?? [{ before, after: value }],
|
||||
};
|
||||
}
|
||||
if (base.foreign) {
|
||||
entry = { ...entry, tag: UNKNOWN_TAG };
|
||||
@@ -367,7 +372,7 @@ export class UserDataWriteQueue {
|
||||
));
|
||||
this.retireInFlight(entry);
|
||||
if (outcome.status === 'saved') {
|
||||
// Without a tag a newer edit keeps this one as `prior`.
|
||||
// Without a tag a newer edit keeps this one's steps.
|
||||
if (outcome.tag !== undefined) {
|
||||
this.confirmed(entry, outcome.tag);
|
||||
}
|
||||
@@ -422,11 +427,11 @@ export class UserDataWriteQueue {
|
||||
this.setTag(entry.namespace, entry.playlistId, tag);
|
||||
const key = entryKey(entry);
|
||||
const next = this.pending.get(key);
|
||||
if (next?.prior?.edit === entry.edit) {
|
||||
if (next?.steps.some((step) => step.sent === entry.edit)) {
|
||||
// A foreign edit stays unknown: its baseline is not this value.
|
||||
this.pending.set(key, {
|
||||
...next,
|
||||
prior: undefined,
|
||||
steps: next.steps.filter((step) => step.sent !== entry.edit),
|
||||
tag: next.tag === UNKNOWN_TAG ? UNKNOWN_TAG : tag,
|
||||
});
|
||||
}
|
||||
@@ -434,9 +439,8 @@ export class UserDataWriteQueue {
|
||||
|
||||
/**
|
||||
* Another device changed the entry and `entry` did not land: replay its
|
||||
* edits on the server's current value (with the newer queued edit that
|
||||
* carries it as `prior`, if any), show the result and send it on that
|
||||
* version.
|
||||
* steps on the server's current value (with the newer queued edit that
|
||||
* carries them, if any), show the result and send it on that version.
|
||||
*/
|
||||
private replayOnConflict(
|
||||
entry: PendingEntry,
|
||||
@@ -450,7 +454,7 @@ export class UserDataWriteQueue {
|
||||
if (!latest) {
|
||||
return;
|
||||
}
|
||||
const merged = replayWrite(current.value, latest);
|
||||
const merged = replaySteps(current.value, latest.steps);
|
||||
this.hooks.show(entry.namespace, entry.playlistId, merged);
|
||||
if (
|
||||
latest === entry &&
|
||||
@@ -461,9 +465,8 @@ export class UserDataWriteQueue {
|
||||
this.pending.set(key, {
|
||||
...latest,
|
||||
value: merged,
|
||||
before: current.value,
|
||||
tag: current.tag,
|
||||
prior: undefined,
|
||||
steps: [{ before: current.value, after: merged }],
|
||||
});
|
||||
this.scheduleFlush();
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import type { UserDataStorage } from '../user-data-storage';
|
||||
import type { ServerApiClient } from './server-api-client';
|
||||
import {
|
||||
addStep,
|
||||
parseMap,
|
||||
splitEditKey,
|
||||
UNKNOWN_TAG,
|
||||
@@ -14,7 +15,7 @@ import {
|
||||
import {
|
||||
type DeferredEdit,
|
||||
refreshedNamespace,
|
||||
replayWrite,
|
||||
replaySteps,
|
||||
} from './server-user-data-edit';
|
||||
import { UserDataWriteQueue } from './server-user-data-queue';
|
||||
import {
|
||||
@@ -50,9 +51,9 @@ export class ServerUserDataStorage implements UserDataStorage {
|
||||
private readonly writes: UserDataWriteQueue;
|
||||
private unsentSaved = false;
|
||||
/**
|
||||
* Edits made before the first successful read, as before → after: the
|
||||
* cache they were made against may be empty or old, so they are
|
||||
* applied to the server's value once it is known (`applyEdit`).
|
||||
* Edits made before the first successful read: the cache they were
|
||||
* made against may be empty or old, so they are replayed on the
|
||||
* server's value once it is known (`applyEdit`).
|
||||
*/
|
||||
private readonly deferred = new Map<string, DeferredEdit>();
|
||||
private hydrated = false;
|
||||
@@ -102,8 +103,8 @@ export class ServerUserDataStorage implements UserDataStorage {
|
||||
write.namespace,
|
||||
write.playlistId,
|
||||
write.value,
|
||||
write.before,
|
||||
{ tag: write.tag, prior: write.prior }
|
||||
undefined,
|
||||
{ tag: write.tag, steps: write.steps }
|
||||
);
|
||||
}
|
||||
for (const edit of readDeferredEdits(
|
||||
@@ -131,15 +132,13 @@ export class ServerUserDataStorage implements UserDataStorage {
|
||||
this.tabId
|
||||
)) {
|
||||
const edits = [
|
||||
...readDeferredEdits(this.local, this.instanceId, page).map(
|
||||
(edit) => ({ ...edit, value: edit.after })
|
||||
),
|
||||
...readDeferredEdits(this.local, this.instanceId, page),
|
||||
...readUnsentWrites(this.local, this.instanceId, page),
|
||||
];
|
||||
for (const edit of edits) {
|
||||
const before = this.baseline(edit.namespace);
|
||||
const after = { ...before };
|
||||
const value = replayWrite(before[edit.playlistId], edit);
|
||||
const value = replaySteps(before[edit.playlistId], edit.steps);
|
||||
if (value === undefined) {
|
||||
delete after[edit.playlistId];
|
||||
} else {
|
||||
@@ -323,11 +322,17 @@ export class ServerUserDataStorage implements UserDataStorage {
|
||||
this.adoptClosedTabs();
|
||||
}
|
||||
|
||||
/** Memory first; browser storage that refuses access reads as empty. */
|
||||
private readCache(namespace: ServerUserStateNamespace): string | null {
|
||||
return (
|
||||
this.overflow.get(namespace) ??
|
||||
this.local.getItem(SERVER_CACHE_PREFIX + namespace)
|
||||
);
|
||||
const kept = this.overflow.get(namespace);
|
||||
if (kept !== undefined) {
|
||||
return kept;
|
||||
}
|
||||
try {
|
||||
return this.local.getItem(SERVER_CACHE_PREFIX + namespace);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** `null` empties the entry. Browser storage first, memory if full. */
|
||||
@@ -398,13 +403,14 @@ export class ServerUserDataStorage implements UserDataStorage {
|
||||
return;
|
||||
}
|
||||
const key = `${namespace}|${playlistId}`;
|
||||
const earlier = this.deferred.get(key);
|
||||
this.deferred.set(key, {
|
||||
namespace,
|
||||
playlistId,
|
||||
// An earlier edit's baseline stays, even when it was empty.
|
||||
before: earlier ? earlier.before : previous[playlistId],
|
||||
after: value,
|
||||
steps: addStep(
|
||||
this.deferred.get(key)?.steps ?? [],
|
||||
previous[playlistId],
|
||||
value
|
||||
),
|
||||
});
|
||||
this.saveDeferred();
|
||||
this.publishUnconfirmed();
|
||||
|
||||
@@ -534,4 +534,63 @@ describe('ServerUserDataStorage with other devices', () => {
|
||||
)
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps a removal of another tab's unsent item", async () => {
|
||||
const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } });
|
||||
const shared = memoryStorage();
|
||||
const tab = () =>
|
||||
new ServerUserDataStorage(
|
||||
server.api as never,
|
||||
shared,
|
||||
jest.fn(),
|
||||
'default',
|
||||
memoryStorage()
|
||||
);
|
||||
const tabA = tab();
|
||||
const tabB = tab();
|
||||
await tabA.hydrate();
|
||||
await tabB.hydrate();
|
||||
|
||||
tabA.setItem('xtream-favorites', JSON.stringify({ p1: ['S', 'X'] }));
|
||||
tabB.setItem(
|
||||
'xtream-favorites',
|
||||
JSON.stringify({ p1: [...favorites(tabB).p1, 'Y'] })
|
||||
);
|
||||
// Tab A removes the Y it sees from tab B before sending anything;
|
||||
// tab B's write lands first (leaving a tab sends its writes).
|
||||
tabA.setItem(
|
||||
'xtream-favorites',
|
||||
JSON.stringify({
|
||||
p1: favorites(tabA).p1.filter((id: string) => id !== 'Y'),
|
||||
})
|
||||
);
|
||||
await tabB.flush();
|
||||
await tabB.flush();
|
||||
await tabA.flush();
|
||||
await tabA.flush();
|
||||
|
||||
expect(tabA.hasUnsentWrites() || tabB.hasUnsentWrites()).toBe(false);
|
||||
expect(
|
||||
[...(server.state['xtream-favorites']['p1'] as string[])].sort()
|
||||
).toEqual(['S', 'X']);
|
||||
});
|
||||
|
||||
it('reads and writes through memory when browser storage refuses', async () => {
|
||||
const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } });
|
||||
const denied = () => {
|
||||
throw new DOMException('denied', 'SecurityError');
|
||||
};
|
||||
const phone = new ServerUserDataStorage(
|
||||
server.api as never,
|
||||
{ getItem: denied, setItem: denied, removeItem: denied },
|
||||
jest.fn()
|
||||
);
|
||||
|
||||
await phone.hydrate();
|
||||
expect(favorites(phone)).toEqual({ p1: ['S'] });
|
||||
phone.setItem('xtream-favorites', JSON.stringify({ p1: ['S', 'X'] }));
|
||||
await phone.flush();
|
||||
|
||||
expect(server.state['xtream-favorites']['p1']).toEqual(['S', 'X']);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user