Si development study 01
Capturing request-time state in an asynchronous browser workspace store
Reusing a persistence foundation without inheriting its mutable-reference boundary.
Engineering report · Source comparison, controlled reproduction and synthetic workload measurements
Abstract
Asynchronous persistence must preserve both the order of save requests and the state associated with each request. These properties are distinct. We examine a workspace store adapted from Photoshot for the Si vector editor. The reference implementation retains an input object and defers its write through a Promise queue. A caller-side mutation can therefore change the content of an earlier save request. Si instead validates and clones the data before queueing the write. In a controlled IndexedDB reproduction, the reference stored a recovery revision of 99 where revision 1 was expected; Si preserved revision 1, including a nested document field. A separate synthetic workload experiment retained 160 observations across four input sizes. For Si, median synchronous save-call time increased from 0.10 ms at 3,756 JSON bytes to 10.40 ms at 763,864 bytes. These measurements characterize this implementation and fixture, not whole-editor performance. Together, source inspection and observed values identify the snapshot boundary, its benefit and its synchronous cost.
Introduction
The first persistence requirement for slohero illust (Si) was specific: preserve the document supplied to a save request while allowing the caller to continue editing. A second requirement was to retain the immediately preceding saved workspace as a recovery record. This recovery slot represents one earlier completed save; it is neither an undo history nor a multi-version archive.
The starting point was an existing Photoshot workspace store. It already serialized requests with a Promise chain and updated the current workspace and its recovery record within a read–write transaction. These mechanisms were useful to reuse. They did not, by themselves, define when the input state became fixed. Reusing the transaction pattern therefore required a separate decision about the lifetime of the caller’s mutable object.
We express the acceptance condition with two requests. Request S₁ receives revision 1. Before its asynchronous work executes, the caller changes the same object to revision 99. Request S₂ receives a distinct object containing revision 2. After both requests complete, the expected current record is 2 and the expected recovery record is 1. A nested document revision is changed at the same time to distinguish deep separation from a top-level copy.
This article follows that requirement through the reference code, the failure mechanism, the adapted implementation and the observed result. The scope is the persistence foundation. The fixture is a serializable ordinary-object data transfer object, not a complete vector document or a demonstration of drawing tools.
A queue preserves order, but not request-time content
The initial implementation
The comparison reference is app/studio/workspace-store.ts from Photoshot commit 460b49f152162e7613a95bc60a1c445135e15692. Its saveWorkspace function assigns const snapshot = value, then appends an asynchronous job to a queue. Once a database connection and transaction are available, the job reads the existing workspace, promotes it to the recovery slot and writes the retained object. The active document is also written to a separate current slot.
The identifier snapshot does not make a copy. It retains an alias to the caller’s object. The queue controls job execution, while the object remains live and mutable until the later write consumes it. Full source is provided in the code supplement, with its fixed revision and SHA-256 identity.
The failing input
The caller requests a save and immediately changes both first.revision and first.tabs[0].doc.revision to 99. In the archived browser execution, the reference subsequently produced current revision 2 and recovery revision 99. Inspecting only the final current record would miss the defect: the latest request was stored, but the prior request’s content had not been preserved.
This behavior does not require concurrent JavaScript threads. A fulfilled Promise’s reaction is enqueued rather than executed inside the call to then.1 The synchronous caller mutation can therefore precede the deferred database write. If t₀ denotes the save call, t₁ the mutation and t₂ the later write, the relevant order is t₀ < t₁ < t₂. Retaining a reference at t₀ does not preserve its contents at t₀.
Moving state capture ahead of queue registration
Why the boundary belongs in the wrapper
IndexedDB is not missing a copy operation. Its put() algorithm includes cloning the supplied value.2 The application wrapper, however, postpones the call to put. A clone performed there can only capture the value supplied at that later time. It cannot recover the earlier state after the caller has changed the shared object. The application must capture the intended state before relinquishing control to later caller code.
Si performs input validation and structuredClone before appending the asynchronous job. For the ordinary objects used here, the clone detaches nested data as well as the top-level object. The HTML Standard defines the structured-cloning API and its failure for non-serializable input.3 The observed property is consequently scoped to suitable data objects; it is not a promise to preserve arbitrary class behavior, accessors or shared-memory semantics.
What was reused and what changed
The Promise queue and current/recovery transaction pattern were adapted from Photoshot. The capture step was strengthened, the storage namespace was separated and document validation was injected through createWorkspaceStore<T>(validate). The current Si format is a { format, version, savedAt, data } envelope. Its database is slohero-vector-workspace-v1, and its format identifier is slohero-vector-workspace. The experiments use isolated database names instead of that product database.
No external GitHub patch was newly imported to solve this problem. The direct source is the user’s Photoshot project, identified by a Git commit; that does not establish a public GitHub origin. React, Vite and TypeScript provide the project’s runtime or build foundation, but are not credited as the source of this snapshot fix. The standards cited here were checked during preparation of this expanded report, rather than retrospectively described as historical implementation research.
let snapshot: { format: string; version: number; savedAt: number; data: T };
try {
snapshot = {
format: PROFILE.workspaceFormat,
version: 1,
savedAt: Date.now(),
data: structuredClone(validate(value))
};
} catch (e) {
return Promise.reject(e);
}Preserving the recovery pattern and the document boundary
A rejected validation or clone returns a rejected Promise before a new database job is queued. A successful capture is followed by the existing sequential write pattern. The caller’s completion signal remains transaction completion, not the completion of the synchronous clone. Each request still receives its own job Promise and can observe its own failure.
When an older workspace exists, Si passes it through envelope before promoting it to workspace-recovery. That path checks format, version, finite save time and the injected document validator. If validation throws, the code skips promotion and proceeds with the new current record. This is a source-level property of the implementation. Corrupt-record fault injection was not part of the recorded experiments.
The current and recovery writes share a read–write transaction over documents. This groups their updates logically, but it is a separate concern from request-time state capture. Neither the screenshot reproduction nor the workload measurements establish power-loss durability. Similarly, the queue exists per store instance; it is not a global ordering mechanism across tabs or independently created stores.
The implementation also closes a database connection on versionchange and rejects a blocked open. Those event paths were inspected in source but not deliberately induced here. Its load() waits for the instance’s queue and validates the loaded envelope. Our observations read the raw IndexedDB slots directly, so they do not constitute full validation of that public load path.
Controlled reproduction preserves the requested revision
The archived comparison was performed on 1 November 2026 using the actual browser database, not a mocked result. The same input sequence was connected to the fixed-commit Photoshot function and to the preserved Si source. Their database names were independently injected and unique. The expected values were compared with records read back after both save jobs completed.
The reference’s recovery record contained revision 99 at both the top level and inside the nested document. Si’s recovery record contained revision 1 in both locations. Both current records contained revision 2. These observations support the proposed distinction: the queue can produce the correct latest record while failing to preserve the contents of the preceding request.
The two implementations differ in more than the clone call, including envelopes, validation and written slots. This comparison is therefore not a single-line ablation study. The explanation combines a directly inspected aliasing path with matching observed outputs. The archived evidence contains one execution per implementation, not a distribution of repeated trials or a success-rate estimate.
The accompanying chart plots revision identifiers solely to expose the mismatch. Their numeric distance is arbitrary fixture encoding, not a performance or quality score. Exact values are retained in Table 1 and the linked source data; no percentage improvement or statistical significance is inferred from this pair of observations.
| Read-back field | Expected | Reference | Si |
|---|---|---|---|
| Current workspace revision | 2 | 2 | 2 |
| Recovery workspace revision | 1 | 99 | 1 |
| Nested document revision in recovery | 1 | 99 | 1 |
Both top-level and nested recovery fields must match the first request, not its later caller-side mutation.
The stronger boundary has a synchronous preprocessing cost
We added a separate measurement experiment for this revision of the report. Four synthetic point counts—100, 1,000, 5,000 and 20,000—produced UTF-8 JSON sizes of 3,756, 36,927, 187,439 and 763,864 bytes. Each size and implementation received three warm-up requests followed by 20 retained requests. The order of implementations alternated between trials, giving 160 retained observations in total.
We measured the interval from entering save(value) to its synchronous return, with fixture generation and JSON sizing outside that interval. For Si, median recorded call times were 0.10, 0.55, 2.65 and 10.40 ms as point count increased. The reference median was 0 ms at every size; 12 of its 80 individual readings were approximately 0.10 ms. The zero medians must be read as a measurement floor, not evidence of literally cost-free execution.
The measured interval contains the complete synchronous preprocessing of each wrapper. In Si this includes validation, envelope preparation and cloning; in the reference it largely retains an alias and queues work. The experiment is not an isolated structuredClone microbenchmark. Figure 3 shows descriptive medians and interquartile ranges rather than confidence intervals. We did not perform a significance test or remove outliers.
Transaction-completion durations were also retained in the raw data and supplementary table. They showed substantial storage-path variability at the larger sizes. In addition, the reference writes an active-document slot that Si does not write in the same form. Those differences prevent an end-to-end speedup claim. The study measures this synthetic fixture on one browser and machine, not interactive frame rate or real-document usability.
| Points | JSON bytes | Reference median (ms) | Si median [IQR] (ms) |
|---|---|---|---|
| 100 | 3,756 | 0.00* | 0.10 [0.10–0.10] |
| 1,000 | 36,927 | 0.00* | 0.55 [0.50–0.60] |
| 5,000 | 187,439 | 0.00* | 2.65 [2.47–2.90] |
| 20,000 | 763,864 | 0.00* | 10.40 [9.80–10.60] |
20 retained requests per implementation and size. *Reference medians and quartiles were recorded as 0 ms; individual values can exceed zero. JSON bytes are a serialized size, not heap usage.
Discussion
The implementation lesson is a boundary decision rather than a replacement database. Reusing the Promise queue and recovery transaction was compatible with Si’s requirements, provided that the document was detached before the caller could resume mutation. The controlled comparison demonstrates this distinction with both top-level and nested values. The workload study adds the associated synchronous budget that the earlier report did not measure.
That budget is a reason to evaluate capture behavior explicitly when documents grow. Possible future designs include immutable document snapshots, coalescing superseded save requests or moving suitable serialization work away from the interaction path. None was implemented or benchmarked here. Their correctness would still need to define which request-time state is promised, rather than merely making a later write faster.
Several limits remain. Quota exhaustion, clone-incompatible values, transaction cancellation, corrupt stored envelopes, version-change blocking and browser termination were not tested as fault scenarios. Multiple tabs and multiple store instances were not coordinated. The benchmark used an isolated headless Chromium profile, serial requests and ordinary JavaScript objects. Browser caching, operating-system I/O and unrelated machine activity were not experimentally controlled.
The source provenance also has a historical limit. The working-file hash recorded in the early reuse record differs from the tracked file extracted from the stated commit, even after checking LF and CRLF variants. The cause is unresolved. Our reference is the exact tracked commit file, not a claimed reconstruction of the original uncommitted working tree. The current reproduction is presented as current evidence, not as footage captured during the original development.
Methods
Source selection and preparation
The reference was extracted from the fixed Photoshot commit. The Si implementation was preserved with SHA-256 909d1922b675f3522a56343b71b275a267e9915fc750e65b09236470faff2685; the reference SHA-256 is 4e3772a27163de353192fea8e233dc9f340c16b2c4d417ac7b0e10ca2f9b3a8e. The formatted full listings were compared with the source TypeScript syntax trees. Source files and qualifications are supplied in the source manifest.
For execution, TypeScript 5.9.3 transpiled the two sources as ES2022 modules. The reference’s database-name expression was replaced by an injected test name. Its document.ts import was connected to a stub that throws if document validation is invoked; the reference save method does not invoke it. Si received an experimental PROFILE with the same workspace format and an isolated database name. We did not call the reference loader or claim a full raster/vector schema comparison.
Archived mutation protocol
The two-request input in Listing 2 was executed through a browser interface. Both requests completed before the raw workspace and workspace-recovery slots were read. Si’s results were unwrapped from data. The reference result was recorded at 23:02:19.713 UTC on 31 October 2026 and the Si result at 23:02:29.491 UTC, corresponding to 08:02 on 1 November in Korea. These timestamps are recording times, not latency measurements.
Synthetic workload protocol
Each point was a plain object { x: i/8, y: (i%127)/16, pressure: (i%11)/10 } inside the nested document. A fresh payload was created outside the timing interval. The validator checked an integer top-level revision and string label, then returned the same object. After the synchronous timing window, both the top-level and nested revisions were changed from 1 to 99. The save job was awaited and the current slot was read outside the timed interval. Each retained reference record contained 99 and each Si record contained 1 at both locations.
Each implementation used a separate store instance and database per size. Requests were serial within the experiment; this is not a backlog or concurrent-writer workload. Repeated requests exercise the existing current/recovery path after warm-up. Timing used performance.now() from entry to synchronous return and from entry to the returned job’s completion. Input construction, JSON byte counting and the independent read-back were excluded. Eight experiment-owned databases were deleted after the run.
The workload run began at 2026-11-01T01:15:40.504Z and ended at 2026-11-01T01:16:25.568Z. The environment reported Windows and Headless Chrome 156. The browser profile was isolated from the user’s product data. The retained table includes all 160 observations; no samples were discarded after retention. Quantiles were computed with linear interpolation. The IQR summarizes repeated requests in this run and is not an uncertainty interval for other machines or documents.
Other verification
A separately retained foundation log reports seven passing unit checks for command registration, keyboard handling, handler lifecycle and shortcut preferences. These are not seven document-snapshot tests. Earlier browser-test records were inspected but are not counted as newly rerun checks here. The archived reproduction, the new workload observations and the inspected code paths are reported as separate evidence classes.
function payload(revision, label) {
return {
version: 1,
activeId: 'demo',
tabs: [{ id: 'demo', doc: { revision, label } }],
savedAt: 1,
revision,
label
};
}
const first = payload(1, 'first');
const a = save(first);
first.revision = 99;
first.tabs[0].doc.revision = 99;
const b = save(payload(2, 'second'));
await Promise.all([a, b]);Code and supplementary evidence
The complete source listings remain available below. Original screenshots retain their Korean interface labels; their English captions identify the recorded values and scope.
Supplementary code 1 · complete reference save function
export function saveWorkspace(value: SavedWorkspace) {
const snapshot = value;
const job = queue.catch(() => {}).then(async () => {
const db = await database();
try {
await new Promise<void>((resolve, reject) => {
const t = db.transaction('documents', 'readwrite'),
s = t.objectStore('documents'),
r = s.get('workspace');
r.onsuccess = () => {
if (r.result) s.put(r.result, 'workspace-recovery');
s.put(snapshot, 'workspace');
const current = snapshot.tabs.find(t => t.id === snapshot.activeId);
if (current) s.put(current.doc, 'current');
};
t.oncomplete = () => resolve();
t.onabort = () => reject(t.error ?? Error('Saving was cancelled.'));
t.onerror = () => reject(t.error);
});
} finally {
db.close();
}
});
queue = job;
return job;
}Download the byte-preserved reference source. Definitions for the workspace types, queue and database function are included in the linked file.
Supplementary code 2 · complete Si workspace store
import {PROFILE} from '../config/profile.ts';
// Transaction/recovery pattern adapted from Photoshot workspace-store.ts.
// Domain validation is injected: no raster document imports or shared database.
export function createWorkspaceStore<T>(validate: (value: unknown) => T) {
let queue: Promise<void> = Promise.resolve();
function database() {
return new Promise<IDBDatabase>((resolve, reject) => {
const r = indexedDB.open(PROFILE.workspaceDatabase, 1);
r.onupgradeneeded = () => r.result.createObjectStore('documents');
r.onsuccess = () => {
r.result.onversionchange = () => r.result.close();
resolve(r.result);
};
r.onerror = () => reject(r.error);
r.onblocked = () => reject(Error('다른 창에서 저장소를 사용하고 있습니다.'));
});
}
function envelope(value: unknown): T {
if (!value || typeof value !== 'object') throw Error('잘못된 작업공간');
const v = value as {
format?: unknown; version?: unknown; savedAt?: unknown; data?: unknown
};
if (v.format !== PROFILE.workspaceFormat || v.version !== 1 ||
typeof v.savedAt !== 'number' || !Number.isFinite(v.savedAt))
throw Error('지원하지 않는 작업공간 형식');
return validate(v.data);
}
return {
save(value: T): Promise<void> {
let snapshot: {format: string; version: number; savedAt: number; data: T};
try {
snapshot = {
format: PROFILE.workspaceFormat,
version: 1,
savedAt: Date.now(),
data: structuredClone(validate(value))
};
} catch (e) { return Promise.reject(e); }
const job = queue.catch(() => {}).then(async () => {
const db = await database();
try {
await new Promise<void>((resolve, reject) => {
const t = db.transaction('documents', 'readwrite'),
s = t.objectStore('documents'),
r = s.get('workspace');
r.onsuccess = () => {
if (r.result !== undefined) {
try {
envelope(r.result);
s.put(r.result, 'workspace-recovery');
} catch { /* Preserve the last known good recovery. */ }
}
s.put(snapshot, 'workspace');
};
t.oncomplete = () => resolve();
t.onabort = () => reject(t.error ?? Error('저장 취소'));
t.onerror = () => reject(t.error);
});
} finally { db.close(); }
});
queue = job;
return job;
},
async load(recovery = false): Promise<T | null> {
await queue.catch(() => {});
const db = await database();
try {
const value = await new Promise<unknown>((resolve, reject) => {
const t = db.transaction('documents'),
r = t.objectStore('documents').get(
recovery ? 'workspace-recovery' : 'workspace'
);
r.onsuccess = () => resolve(r.result);
r.onerror = () => reject(r.error);
t.onabort = () => reject(t.error);
});
return value === undefined ? null : envelope(value);
} finally { db.close(); }
}
};
}Download the byte-preserved Si source. The application injects PROFILE and the document validator.
Supplementary evidence 1 · archived failure and success screens


Supplementary table 1 · transaction-completion durations
| Points | Implementation | Median (ms) | IQR (ms) |
|---|---|---|---|
| 100 | Reference | 2.45 | 2.20–3.20 |
| 100 | Si | 2.05 | 1.80–2.23 |
| 1,000 | Reference | 4.60 | 4.20–5.23 |
| 1,000 | Si | 4.35 | 4.20–5.12 |
| 5,000 | Reference | 234.65 | 196.95–367.72 |
| 5,000 | Si | 225.40 | 196.33–327.42 |
| 20,000 | Reference | 242.90 | 220.22–427.15 |
| 20,000 | Si | 243.35 | 225.22–298.25 |
20 retained requests per row. These durations include different write paths and variable storage I/O. They do not establish a speedup or isolate cloning cost.
Reproduction files and workload source data
For the archived two-request protocol, download the server source, the interface source, both preserved source files and the source manifest. The supplied interface retains its original labels. Its outputs are new local results and do not overwrite the archived records.
For the synthetic workload, save the portable workload runner as benchmark-snapshots.mjs, alongside source-before.ts.txt, source-after.ts.txt and source-manifest.json. The public runner changes path and browser selection for portability; the payload, sequencing and timing body are retained from the recorded experiment. A different browser installation is a different measurement environment.
npm install [email protected] [email protected]
npx playwright install chromium
node benchmark-snapshots.mjsData downloads: raw observations and protocol (JSON) · raw observations (CSV) · summary statistics (JSON) · summary statistics (CSV).
Data and code availability
Archived reference and Si observations, complete saved source files, source identifiers and the newly collected workload data are linked above. The fixture data are synthetic and contain no user artwork. The experiments used isolated temporary databases. Source-level paths that were not exercised are described as code analysis rather than measured behavior.
This report describes an adaptation of Photoshot’s transaction and recovery pattern. It introduces no newly imported external GitHub fix. The public provenance record explicitly retains the mismatch between the historical working-file hash and the tracked reference. The report’s scope does not extend to crash durability, multi-tab ordering or every save call site in the complete editor.
References
- ECMA International / TC39. ECMAScript Language Specification: PerformPromiseThen. Accessed 1 November 2026.
- W3C. Indexed Database API 3.0: IDBObjectStore.put and add-or-put. Editor’s Draft. Accessed 1 November 2026.
- WHATWG. HTML Standard: Structured cloning API. Accessed 1 November 2026.
- Photoshot.
app/studio/workspace-store.ts, commit460b49f152162e7613a95bc60a1c445135e15692. Preserved source. A public remote repository URL was not established in this record. - Si.
src/core/workspace-store.ts;docs/provenance.json;tests/browser.mjs;src/config/profile.ts. Preserved store source.