Complete bounded browser and API product behavior

This commit is contained in:
ms2k 2026-07-19 01:12:29 +09:00
commit ea55108f44
5 changed files with 2117 additions and 399 deletions

View file

@ -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
View 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';
}

View 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/);
});

View file

@ -5,7 +5,7 @@ export default defineConfig({
plugins: [svelte()],
build: {
manifest: true,
sourcemap: true
sourcemap: false
},
server: {
proxy: {