Complete bounded browser and API product behavior
This commit is contained in:
parent
619c2911d2
commit
ea55108f44
5 changed files with 2117 additions and 399 deletions
|
|
@ -11,7 +11,8 @@
|
|||
"scripts": {
|
||||
"build": "vite build",
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json",
|
||||
"dev": "vite"
|
||||
"dev": "vite",
|
||||
"test": "node --test test/*.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"svelte": "5.56.6"
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
128
web/src/mutations.ts
Normal file
128
web/src/mutations.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
export type Root = 'personal' | 'shared';
|
||||
|
||||
export type MutationResult =
|
||||
| 'succeeded'
|
||||
| 'pending'
|
||||
| 'failed'
|
||||
| 'rejected'
|
||||
| 'conflict';
|
||||
|
||||
export type DeleteItem = {
|
||||
path: string;
|
||||
operation_id: string;
|
||||
};
|
||||
|
||||
export type DeleteItemResult = DeleteItem & {
|
||||
result: MutationResult | null;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export type PendingDelete = {
|
||||
root: Root;
|
||||
prefix: string;
|
||||
items: DeleteItem[];
|
||||
};
|
||||
|
||||
export type MutationFollowup = 'complete' | 'replay' | 'restart';
|
||||
|
||||
export type CapturedOperation = {
|
||||
root: Root;
|
||||
path: string;
|
||||
operationId: string;
|
||||
};
|
||||
|
||||
export function captureOperation(
|
||||
root: Root,
|
||||
path: string,
|
||||
operationId: string
|
||||
): CapturedOperation {
|
||||
return { root, path, operationId };
|
||||
}
|
||||
|
||||
export function mutationFollowup(result: MutationResult): MutationFollowup {
|
||||
if (result === 'succeeded') return 'complete';
|
||||
if (result === 'pending') return 'replay';
|
||||
return 'restart';
|
||||
}
|
||||
|
||||
export function unfinishedDeleteItems(
|
||||
results: DeleteItemResult[],
|
||||
nextOperationId: () => string
|
||||
): DeleteItem[] {
|
||||
return results.flatMap((result) => {
|
||||
if (result.error !== null || result.result === null) {
|
||||
return [{ path: result.path, operation_id: result.operation_id }];
|
||||
}
|
||||
const followup = mutationFollowup(result.result);
|
||||
if (followup === 'complete') return [];
|
||||
return [
|
||||
{
|
||||
path: result.path,
|
||||
operation_id:
|
||||
followup === 'replay' ? result.operation_id : nextOperationId()
|
||||
}
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
export function encodePendingDelete(
|
||||
root: Root,
|
||||
prefix: string,
|
||||
items: DeleteItem[]
|
||||
): string {
|
||||
return JSON.stringify({ root, prefix, items });
|
||||
}
|
||||
|
||||
export function decodePendingDelete(
|
||||
encoded: string | null,
|
||||
root: Root,
|
||||
prefix: string
|
||||
): DeleteItem[] {
|
||||
const saved = readPendingDelete(encoded);
|
||||
if (saved === null || saved.root !== root || saved.prefix !== prefix) return [];
|
||||
return saved.items;
|
||||
}
|
||||
|
||||
export function readPendingDelete(encoded: string | null): PendingDelete | null {
|
||||
if (encoded === null) return null;
|
||||
try {
|
||||
const saved = JSON.parse(encoded) as {
|
||||
root?: string;
|
||||
prefix?: string;
|
||||
items?: unknown;
|
||||
};
|
||||
if (
|
||||
(saved.root !== 'personal' && saved.root !== 'shared') ||
|
||||
typeof saved.prefix !== 'string' ||
|
||||
!Array.isArray(saved.items) ||
|
||||
!saved.items.every(validDeleteItem)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
root: saved.root,
|
||||
prefix: saved.prefix,
|
||||
items: saved.items
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function sharedUploadWarning(root: Root, path: string): string | null {
|
||||
if (root !== 'shared') return null;
|
||||
return `Upload “${path}” to shared storage? This may replace a file for every user.`;
|
||||
}
|
||||
|
||||
export function deleteWarning(root: Root, path: string, isPrefix: boolean): string {
|
||||
if (root === 'shared') {
|
||||
return `Delete shared ${isPrefix ? 'folder' : 'object'} “${path}” for every user?`;
|
||||
}
|
||||
return `Delete ${isPrefix ? 'folder' : 'object'} “${path}”?`;
|
||||
}
|
||||
|
||||
function validDeleteItem(value: unknown): value is DeleteItem {
|
||||
if (typeof value !== 'object' || value === null) return false;
|
||||
const item = value as Record<string, unknown>;
|
||||
return typeof item.path === 'string' && typeof item.operation_id === 'string';
|
||||
}
|
||||
96
web/test/mutations.test.mjs
Normal file
96
web/test/mutations.test.mjs
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
captureOperation,
|
||||
deleteWarning,
|
||||
decodePendingDelete,
|
||||
encodePendingDelete,
|
||||
mutationFollowup,
|
||||
readPendingDelete,
|
||||
sharedUploadWarning,
|
||||
unfinishedDeleteItems
|
||||
} from '../src/mutations.ts';
|
||||
|
||||
test('pending mutations replay while terminal failures restart', () => {
|
||||
assert.equal(mutationFollowup('succeeded'), 'complete');
|
||||
assert.equal(mutationFollowup('pending'), 'replay');
|
||||
assert.equal(mutationFollowup('failed'), 'restart');
|
||||
assert.equal(mutationFollowup('rejected'), 'restart');
|
||||
assert.equal(mutationFollowup('conflict'), 'restart');
|
||||
});
|
||||
|
||||
test('partial delete recovery preserves only unfinished identities', () => {
|
||||
const ids = ['new-failed', 'new-conflict'];
|
||||
const pending = unfinishedDeleteItems(
|
||||
[
|
||||
{
|
||||
path: 'done',
|
||||
operation_id: 'done-id',
|
||||
result: 'succeeded',
|
||||
error: null
|
||||
},
|
||||
{
|
||||
path: 'waiting',
|
||||
operation_id: 'waiting-id',
|
||||
result: 'pending',
|
||||
error: null
|
||||
},
|
||||
{
|
||||
path: 'failed',
|
||||
operation_id: 'failed-id',
|
||||
result: 'failed',
|
||||
error: null
|
||||
},
|
||||
{
|
||||
path: 'conflict',
|
||||
operation_id: 'conflict-id',
|
||||
result: 'conflict',
|
||||
error: null
|
||||
},
|
||||
{
|
||||
path: 'uncertain',
|
||||
operation_id: 'uncertain-id',
|
||||
result: null,
|
||||
error: 'transport interrupted'
|
||||
}
|
||||
],
|
||||
() => ids.shift()
|
||||
);
|
||||
assert.deepEqual(pending, [
|
||||
{ path: 'waiting', operation_id: 'waiting-id' },
|
||||
{ path: 'failed', operation_id: 'new-failed' },
|
||||
{ path: 'conflict', operation_id: 'new-conflict' },
|
||||
{ path: 'uncertain', operation_id: 'uncertain-id' }
|
||||
]);
|
||||
});
|
||||
|
||||
test('persisted delete batches survive reload with the same captured root', () => {
|
||||
const items = [{ path: 'folder/file', operation_id: 'operation-1' }];
|
||||
const encoded = encodePendingDelete('shared', 'folder', items);
|
||||
assert.deepEqual(readPendingDelete(encoded), {
|
||||
root: 'shared',
|
||||
prefix: 'folder',
|
||||
items
|
||||
});
|
||||
assert.deepEqual(decodePendingDelete(encoded, 'shared', 'folder'), items);
|
||||
assert.deepEqual(decodePendingDelete(encoded, 'personal', 'folder'), []);
|
||||
assert.deepEqual(decodePendingDelete('{"items":[null]}', 'shared', 'folder'), []);
|
||||
});
|
||||
|
||||
test('logical mutations capture their root before navigation changes', () => {
|
||||
let root = 'personal';
|
||||
const captured = captureOperation(root, 'folder/file', 'operation-1');
|
||||
root = 'shared';
|
||||
assert.deepEqual(captured, {
|
||||
root: 'personal',
|
||||
path: 'folder/file',
|
||||
operationId: 'operation-1'
|
||||
});
|
||||
});
|
||||
|
||||
test('shared replacement confirmation is explicit', () => {
|
||||
assert.equal(sharedUploadWarning('personal', 'file'), null);
|
||||
assert.match(sharedUploadWarning('shared', 'file'), /replace a file for every user/);
|
||||
assert.match(deleteWarning('shared', 'folder', true), /for every user/);
|
||||
assert.doesNotMatch(deleteWarning('personal', 'file', false), /every user/);
|
||||
});
|
||||
|
|
@ -5,7 +5,7 @@ export default defineConfig({
|
|||
plugins: [svelte()],
|
||||
build: {
|
||||
manifest: true,
|
||||
sourcemap: true
|
||||
sourcemap: false
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
|
|
|
|||
Loading…
Reference in a new issue