arcane-os 0.34.1 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/README.md +1 -1
- package/docs/reference/inventory/runtime-modules.json +1 -1
- package/docs/reference/runtime-components.md +9 -0
- package/docs/reference/runtime-modules.md +33 -0
- package/package.json +2 -2
- package/runtime/arcane/components/chat.html +29 -25
- package/runtime/arcane/modules/DBOPFS.js +212 -148
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.35.0
|
|
4
|
+
|
|
5
|
+
- Add `DBOPFS.createCompressedPNG({tableNames, additionalTables, signal})` to
|
|
6
|
+
return a PNG Blob without downloading or writing storage. Omitted table names
|
|
7
|
+
select all saved tables; an empty list selects none. Caller-supplied tables
|
|
8
|
+
replace matching saved tables in the export only.
|
|
9
|
+
- Preserve `downloadCompressedPNG(name)` and accept the same optional export
|
|
10
|
+
options. Cancellation stops further preparation and prevents a later download;
|
|
11
|
+
browser file reads and canvas encoding already underway finish normally.
|
|
12
|
+
- Add optional async `restoreFromPNG(file, {selectTables})` selection over the
|
|
13
|
+
complete decoded table/file map before any writes. The callback owns selection
|
|
14
|
+
and projection; existing one-argument restore remains supported.
|
|
15
|
+
- Report rejected restore writes after all selected table batches settle through
|
|
16
|
+
`DBOPFS_RESTORE_WRITE_FAILED`, retaining original errors and table/file details.
|
|
17
|
+
Successful writes remain saved; restore is not an atomic transaction. Release
|
|
18
|
+
decoded image resources on both success and extraction failure.
|
|
19
|
+
|
|
20
|
+
## 0.34.2
|
|
21
|
+
|
|
22
|
+
- Remove temporary `persist:false` Chat cards on completion, failure,
|
|
23
|
+
cancellation, destruction and session replacement. Temporary input is not
|
|
24
|
+
restored into the composer after a rejected request.
|
|
25
|
+
- Track each submission's exact created cards, preserving earlier persistent
|
|
26
|
+
cards and newer submissions even when operation identifiers are reused.
|
|
27
|
+
Persistent transcript and draft behavior remain unchanged; existing saved
|
|
28
|
+
history is not migrated or rewritten.
|
|
29
|
+
|
|
3
30
|
## 0.34.1
|
|
4
31
|
|
|
5
32
|
- Honor an optional request-local `model` in `AI.fetchRequest()` and
|
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ version-locked SDK runtime, while an integrated Arcane checkout uses its live
|
|
|
19
19
|
`arcane/` runtime. Both profiles share the theme, packaging, event, cancellation,
|
|
20
20
|
and browser run contracts while retaining their selected application layout.
|
|
21
21
|
|
|
22
|
-
This checkout defines the `0.
|
|
22
|
+
This checkout defines the `0.35.0` SDK contract. Applications pin one exact npm
|
|
23
23
|
version and lockfile; registry state is deliberately not baked into application
|
|
24
24
|
artifacts.
|
|
25
25
|
|
|
@@ -568,7 +568,7 @@
|
|
|
568
568
|
"availability": "Browser / native WebView",
|
|
569
569
|
"protocol": "OPFS, DBOPFSWorker, Compression Streams, and per-realm globalThis.arcaneEvents authority",
|
|
570
570
|
"normalization": "App scope and recognized file parsing are normalized; nonblank unreadable JSONL rows remain complete raw strings, empty-table removal reports removed, absent, or not-empty after a create:false directory lookup and without scanning or recursively clearing a directory, same-named files are preserved, aliases and cached handles are invalidated only when the directory is gone, and unexpected DOM/storage errors are preserved.",
|
|
571
|
-
"surface": "default `DBOPFS`; installs `window.dbopfs`, emits `dbopfs-ready`; table/file/backup APIs including removeEmptyTable(tableName)."
|
|
571
|
+
"surface": "default `DBOPFS`; installs `window.dbopfs`, emits `dbopfs-ready`; table/file/backup APIs including removeEmptyTable(tableName). createCompressedPNG({tableNames,additionalTables,signal}) returns a PNG Blob without download or record writes; omitted names select all, [] none, and supplied tables replace matching saved tables in the export. downloadCompressedPNG(name,options) reuses the same preparation and initiates one download; cancellation prevents it. restoreFromPNG(file,{selectTables}) closes the decoded image after pixel extraction, awaits the optional caller selection/projection before any writes, and rejects DBOPFS_RESTORE_WRITE_FAILED AggregateError after all selected table batches settle when records fail, preserving original errors and failures {tableName,fileName,reason}; successful writes remain. No-options restore and setMany retain their contracts."
|
|
572
572
|
},
|
|
573
573
|
{
|
|
574
574
|
"file": "runtime/arcane/modules/DBOPFSDocumentLibrary.js",
|
|
@@ -413,6 +413,15 @@ the public result term; `disposition` remains an accepted input spelling.
|
|
|
413
413
|
When a submitted turn uses `persist:false`, Chat may show its cards while that
|
|
414
414
|
one operation is active, then removes them when the operation settles; neither
|
|
415
415
|
the input nor response remains in the retained transcript or model context.
|
|
416
|
+
This cleanup also applies to failed, cancelled, detached, or replaced-session
|
|
417
|
+
requests. Temporary input is not restored into the composer, and a temporary
|
|
418
|
+
failure does not become an assistant history card. The request still rejects
|
|
419
|
+
normally and keeps its diagnostic/status reporting. Cleanup owns the exact
|
|
420
|
+
operation's nodes so a completion listener can start another turn safely.
|
|
421
|
+
Submission code passes those card references directly when it creates them;
|
|
422
|
+
reusing an operation identifier never selects earlier conversation cards for
|
|
423
|
+
cleanup or timestamp updates.
|
|
424
|
+
Persistent-turn recovery and existing saved history remain unchanged.
|
|
416
425
|
|
|
417
426
|
For streaming sessions, every provisional structural card and the terminal
|
|
418
427
|
result must preserve the same choice, ordered call position, exact ID, type,
|
|
@@ -1986,6 +1986,39 @@ default `DBOPFS`; installs `window.dbopfs`, emits `dbopfs-ready`; table/file/bac
|
|
|
1986
1986
|
|
|
1987
1987
|
Exact exports: `DBOPFS_EVENT_TYPES`, `DBOPFS_REASONS`, `default`.
|
|
1988
1988
|
|
|
1989
|
+
`createCompressedPNG({tableNames, additionalTables, signal})` returns an
|
|
1990
|
+
`image/png` Blob without downloading or writing records. Omitted `tableNames`
|
|
1991
|
+
selects all discovered saved tables; `[]` selects none. Named absent tables
|
|
1992
|
+
are encoded as empty tables without creating OPFS directories. The existing
|
|
1993
|
+
`memories`/`memory` alias is retained. `additionalTables` is an application-owned
|
|
1994
|
+
`{tableName:{fileName:value}}` map. Each supplied table replaces the matching
|
|
1995
|
+
saved table in this export only, rather than inheriting records the caller did
|
|
1996
|
+
not select. Values retain the existing JSON serialization contract. Applications
|
|
1997
|
+
own recipient policy, profile projections, and any product format marker.
|
|
1998
|
+
|
|
1999
|
+
`downloadCompressedPNG(name, options)` uses that same method and its options,
|
|
2000
|
+
then initiates one timestamp-named download. Existing `downloadCompressedPNG(name)`
|
|
2001
|
+
calls retain full-database behavior. An optional `AbortSignal` stops further
|
|
2002
|
+
record preparation and prevents a later download. File reads and browser canvas
|
|
2003
|
+
encoding already in progress cannot be interrupted; their result is discarded
|
|
2004
|
+
on cancellation. No object URL or download is created by `createCompressedPNG`.
|
|
2005
|
+
|
|
2006
|
+
`restoreFromPNG(file, {selectTables})` preserves the existing deflate/RGB backup format and
|
|
2007
|
+
restores through `setMany()` without changing its settled-result API or per-key
|
|
2008
|
+
write ordering. The optional callback receives the complete decoded table/file
|
|
2009
|
+
map exactly once and may return a map or promise of a map. Only its returned
|
|
2010
|
+
tables and records are written, allowing application-owned selection and
|
|
2011
|
+
projection of older full backups without a second decoder or temporary storage
|
|
2012
|
+
writes. Callback failure rejects before any writes. With no callback, all
|
|
2013
|
+
decoded tables are restored as before.
|
|
2014
|
+
|
|
2015
|
+
Restore resolves only when every selected record write succeeds. It
|
|
2016
|
+
continues the table batches after individual rejected writes, then rejects an
|
|
2017
|
+
`AggregateError` with code `DBOPFS_RESTORE_WRITE_FAILED`, original reasons in
|
|
2018
|
+
`errors`, and `failures` entries `{tableName, fileName, reason}`. Successful
|
|
2019
|
+
writes remain saved; restore is not an all-or-nothing transaction. The decoded
|
|
2020
|
+
image is closed after pixel extraction, including canvas failures.
|
|
2021
|
+
|
|
1989
2022
|
### Availability and normalization
|
|
1990
2023
|
|
|
1991
2024
|
**Browser / native WebView.** App scope and recognized JSON/JSONL file parsing
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "arcane-os",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.0",
|
|
4
4
|
"description": "Arcane OS JavaScript SDK, project-local CLI, browser runtime, and repository-portable application packager.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.mjs",
|
|
@@ -86,7 +86,7 @@
|
|
|
86
86
|
"test": "npm run test:unit && npm run test:functional && npm run test:integration && npm run test:regression",
|
|
87
87
|
"test:release": "node ./bin/arcane-test.mjs test/npm-release.test.mjs",
|
|
88
88
|
"test:unit": "node ./bin/arcane-test.mjs test/app-descriptor.test.mjs test/app-schema.test.mjs test/app-selection.test.mjs test/contracts.test.mjs test/doctor.test.mjs test/mail-credentials.test.mjs test/mail-outbox.test.mjs test/mail-public-api.test.mjs test/mail-send.test.mjs test/mail-transport.test.mjs test/targets.test.mjs test/workspace-operation-lock.test.mjs",
|
|
89
|
-
"test:functional": "node ./bin/arcane-test.mjs test/ai-request-model.test.mjs test/ai-request-retry.test.mjs test/ai-stream-cancellation.test.mjs test/browser-speech-providers.test.mjs test/browser-wasm-gpu-notice.test.mjs test/browser-wasm-download-resume.test.mjs test/chat-stream-speech.test.mjs test/chat-transcript-scroll.test.mjs test/cli.test.mjs test/dbopfs-document-library.test.mjs test/dbopfs.test.mjs test/dev-server.test.mjs test/dev-pwa.test.mjs test/dom-event-instrumentation.test.mjs test/event-manager.test.mjs test/events.test.mjs test/import-map.test.mjs test/mail-cli.test.mjs test/mail-runtime.test.mjs test/mail-server.test.mjs test/modal.test.mjs test/packaging.test.mjs test/pwa-packaging.test.mjs test/pwa-client.test.mjs test/pwa-install.test.mjs test/pwa-worker.test.mjs test/persistent-ai-chat-session.test.mjs test/reference-completeness.test.mjs test/runtime-api-behavior.test.mjs test/runtime.test.mjs test/scaffold.test.mjs test/speech-playback.test.mjs test/site.test.mjs test/twin-cloud.test.mjs test/update-check.test.mjs test/user-explicit-updates.test.mjs",
|
|
89
|
+
"test:functional": "node ./bin/arcane-test.mjs test/ai-request-model.test.mjs test/ai-request-retry.test.mjs test/ai-stream-cancellation.test.mjs test/browser-speech-providers.test.mjs test/browser-wasm-gpu-notice.test.mjs test/browser-wasm-download-resume.test.mjs test/chat-stream-speech.test.mjs test/chat-transcript-scroll.test.mjs test/chat-transient-retention.test.mjs test/cli.test.mjs test/dbopfs-document-library.test.mjs test/dbopfs-png.test.mjs test/dbopfs.test.mjs test/dev-server.test.mjs test/dev-pwa.test.mjs test/dom-event-instrumentation.test.mjs test/event-manager.test.mjs test/events.test.mjs test/import-map.test.mjs test/mail-cli.test.mjs test/mail-runtime.test.mjs test/mail-server.test.mjs test/modal.test.mjs test/packaging.test.mjs test/pwa-packaging.test.mjs test/pwa-client.test.mjs test/pwa-install.test.mjs test/pwa-worker.test.mjs test/persistent-ai-chat-session.test.mjs test/reference-completeness.test.mjs test/runtime-api-behavior.test.mjs test/runtime.test.mjs test/scaffold.test.mjs test/speech-playback.test.mjs test/site.test.mjs test/twin-cloud.test.mjs test/update-check.test.mjs test/user-explicit-updates.test.mjs",
|
|
90
90
|
"test:integration": "node ./bin/arcane-test.mjs test/installed-package-runtime.test.mjs test/root-app-layout.test.mjs test/integrated-shared.test.mjs test/integrated-workspace.test.mjs test/mail-browser.test.mjs test/native-plan.test.mjs test/native-provider-loader.test.mjs test/npm-release.test.mjs test/release-bundle.test.mjs test/release-capability-smoke.test.mjs test/shared-payload-batch.test.mjs test/tarball.test.mjs test/browser-wasm-cpu.test.mjs test/wllama-webgpu-runtime.test.mjs",
|
|
91
91
|
"test:regression": "node ./bin/arcane-test.mjs test/channel-workflows.test.mjs test/html-import-registration.test.mjs test/logging-regression.test.mjs test/markdown-speech.test.mjs test/prepared-speech.test.mjs test/native-provider-generation.test.mjs test/speech-queue-regression.test.mjs test/testing.test.mjs test/test-sets.test.mjs",
|
|
92
92
|
"check": "node tools/check-source.mjs && npm test",
|
|
@@ -3188,15 +3188,12 @@
|
|
|
3188
3188
|
].includes(error?.code);
|
|
3189
3189
|
}
|
|
3190
3190
|
|
|
3191
|
-
function restoreRejectedStructuralDraft(messageId,
|
|
3191
|
+
function restoreRejectedStructuralDraft(messageId,requestMessages,text){
|
|
3192
3192
|
const response=[...chatOutput.children].find(
|
|
3193
3193
|
candidate=>candidate.id===`message-${messageId}`
|
|
3194
3194
|
)||null;
|
|
3195
|
-
const request=[...chatOutput.children].find(
|
|
3196
|
-
candidate=>candidate.dataset.operationId===operationId
|
|
3197
|
-
)||null;
|
|
3198
3195
|
response?.remove();
|
|
3199
|
-
request
|
|
3196
|
+
for(const request of requestMessages) request.remove();
|
|
3200
3197
|
const existing=textArea.value;
|
|
3201
3198
|
if(existing!==text){
|
|
3202
3199
|
textArea.value=existing
|
|
@@ -3206,14 +3203,15 @@
|
|
|
3206
3203
|
textArea.style.height='auto';
|
|
3207
3204
|
textArea.style.height=`${textArea.scrollHeight}px`;
|
|
3208
3205
|
scrollTranscriptToBottom();
|
|
3209
|
-
return Boolean(response||
|
|
3206
|
+
return Boolean(response||requestMessages.length);
|
|
3210
3207
|
}
|
|
3211
3208
|
|
|
3212
3209
|
async function sendMessageThroughBoundSession(
|
|
3213
3210
|
text,
|
|
3214
3211
|
context,
|
|
3215
3212
|
sessionRequest={content:text,role:'user'},
|
|
3216
|
-
perTurnRequest=null
|
|
3213
|
+
perTurnRequest=null,
|
|
3214
|
+
requestMessages=[]
|
|
3217
3215
|
){
|
|
3218
3216
|
const session=boundChatSession;
|
|
3219
3217
|
const bindingGeneration=sessionBindingGeneration;
|
|
@@ -3248,12 +3246,21 @@
|
|
|
3248
3246
|
const streamedStructuralToolCalls=[];
|
|
3249
3247
|
const messageId=`session-${++sessionMessageSequence}`;
|
|
3250
3248
|
const sessionMessageToken=Symbol(messageId);
|
|
3249
|
+
const transientCards=retainTurn?[]:[...requestMessages];
|
|
3250
|
+
function removeTransientSessionCards(){
|
|
3251
|
+
if(!transientCards.length) return;
|
|
3252
|
+
// The submitter supplies only nodes created for this submission.
|
|
3253
|
+
for(const card of transientCards) card.remove();
|
|
3254
|
+
transientCards.length=0;
|
|
3255
|
+
scrollTranscriptToBottom();
|
|
3256
|
+
}
|
|
3251
3257
|
activeSessionMessageToken=sessionMessageToken;
|
|
3252
3258
|
sessionMessagePending=true;
|
|
3253
3259
|
applyAIAvailability(host.aiAvailability);
|
|
3254
3260
|
setSessionStatus('sending','AI is responding…');
|
|
3255
3261
|
try{
|
|
3256
|
-
receivedMessage(host.aiName, '', messageId);
|
|
3262
|
+
const responseCard=receivedMessage(host.aiName, '', messageId);
|
|
3263
|
+
if(!retainTurn) transientCards.push(responseCard);
|
|
3257
3264
|
await streamMessage('',messageId,true);
|
|
3258
3265
|
setMessageProgress(
|
|
3259
3266
|
messageId,
|
|
@@ -3337,9 +3344,6 @@
|
|
|
3337
3344
|
result.message.content,
|
|
3338
3345
|
committedTurn?.response?.timestamp??result.message.timestamp
|
|
3339
3346
|
);
|
|
3340
|
-
const requestMessages=[...chatOutput.children].filter(
|
|
3341
|
-
candidate=>candidate.dataset.operationId===context.operationId
|
|
3342
|
-
);
|
|
3343
3347
|
for(const [index,requestMessage] of requestMessages.entries()){
|
|
3344
3348
|
setTranscriptMessageTimestamp(
|
|
3345
3349
|
requestMessage,
|
|
@@ -3365,10 +3369,8 @@
|
|
|
3365
3369
|
}
|
|
3366
3370
|
setPendingStructuralToolCalls(terminalToolCalls);
|
|
3367
3371
|
}else{
|
|
3368
|
-
|
|
3369
|
-
message.remove();
|
|
3372
|
+
removeTransientSessionCards();
|
|
3370
3373
|
setPendingStructuralToolCalls(previousPendingToolCalls);
|
|
3371
|
-
scrollTranscriptToBottom();
|
|
3372
3374
|
}
|
|
3373
3375
|
setSessionStatus(
|
|
3374
3376
|
pendingStructuralToolMessage?'tool':'ready',
|
|
@@ -3417,16 +3419,13 @@
|
|
|
3417
3419
|
const response=[...chatOutput.children].find(
|
|
3418
3420
|
candidate=>candidate.id===`message-${messageId}`
|
|
3419
3421
|
)||null;
|
|
3420
|
-
const requestMessages=[...chatOutput.children].filter(
|
|
3421
|
-
candidate=>candidate.dataset.operationId===context.operationId
|
|
3422
|
-
);
|
|
3423
3422
|
response?.remove();
|
|
3424
3423
|
for(const requestMessage of requestMessages){
|
|
3425
3424
|
requestMessage.remove();
|
|
3426
3425
|
}
|
|
3427
3426
|
scrollTranscriptToBottom();
|
|
3428
|
-
}else{
|
|
3429
|
-
restoreRejectedStructuralDraft(messageId,
|
|
3427
|
+
}else if(retainTurn){
|
|
3428
|
+
restoreRejectedStructuralDraft(messageId,requestMessages,text);
|
|
3430
3429
|
}
|
|
3431
3430
|
arcaneLogging.error('Arcane structural tool protocol failure.',error);
|
|
3432
3431
|
setSessionStatus(
|
|
@@ -3436,14 +3435,11 @@
|
|
|
3436
3435
|
}else if(!destroyed&&bindingGeneration===sessionBindingGeneration){
|
|
3437
3436
|
arcaneLogging.error('Arcane chat request failed.',error);
|
|
3438
3437
|
if(sessionRequestMessages.every(message=>message.role==='tool')){
|
|
3439
|
-
const requestMessages=[...chatOutput.children].filter(
|
|
3440
|
-
candidate=>candidate.dataset.operationId===context.operationId
|
|
3441
|
-
);
|
|
3442
3438
|
for(const requestMessage of requestMessages){
|
|
3443
3439
|
requestMessage.remove();
|
|
3444
3440
|
}
|
|
3445
3441
|
}
|
|
3446
|
-
renderSessionMessageFailure(messageId,error);
|
|
3442
|
+
if(retainTurn) renderSessionMessageFailure(messageId,error);
|
|
3447
3443
|
setSessionStatus(
|
|
3448
3444
|
'error',
|
|
3449
3445
|
visibleErrorMessage(error,'The AI request failed.')
|
|
@@ -3471,6 +3467,7 @@
|
|
|
3471
3467
|
}
|
|
3472
3468
|
throw error;
|
|
3473
3469
|
}finally{
|
|
3470
|
+
removeTransientSessionCards();
|
|
3474
3471
|
if(activeSessionMessageToken===sessionMessageToken){
|
|
3475
3472
|
activeSessionMessageToken=null;
|
|
3476
3473
|
sessionMessagePending=false;
|
|
@@ -3625,6 +3622,7 @@
|
|
|
3625
3622
|
return false;
|
|
3626
3623
|
}
|
|
3627
3624
|
try{
|
|
3625
|
+
const requestMessages=[];
|
|
3628
3626
|
for(const result of orderedResults){
|
|
3629
3627
|
const visibleMessage=appendTranscriptMessage(
|
|
3630
3628
|
'tool',
|
|
@@ -3633,12 +3631,14 @@
|
|
|
3633
3631
|
);
|
|
3634
3632
|
visibleMessage.dataset.operationId=operationId;
|
|
3635
3633
|
visibleMessage.dataset.toolCallId=result.tool_call_id;
|
|
3634
|
+
requestMessages.push(visibleMessage);
|
|
3636
3635
|
}
|
|
3637
3636
|
const result=sendMessageThroughBoundSession(
|
|
3638
3637
|
orderedResults.map(message=>message.content).join('\n'),
|
|
3639
3638
|
eventContext,
|
|
3640
3639
|
orderedResults,
|
|
3641
|
-
perTurnRequest
|
|
3640
|
+
perTurnRequest,
|
|
3641
|
+
requestMessages
|
|
3642
3642
|
);
|
|
3643
3643
|
return observeHostSubmission(result,eventContext,ownership);
|
|
3644
3644
|
}catch(error){
|
|
@@ -3824,6 +3824,7 @@
|
|
|
3824
3824
|
return false;
|
|
3825
3825
|
}
|
|
3826
3826
|
try{
|
|
3827
|
+
const requestMessages=[];
|
|
3827
3828
|
if(!eventContext.preserveDraft){
|
|
3828
3829
|
textArea.value='';
|
|
3829
3830
|
}
|
|
@@ -3834,6 +3835,7 @@
|
|
|
3834
3835
|
hostSubmissionGeneration++;
|
|
3835
3836
|
const visibleMessage=appendTranscriptMessage('user',text,host.name);
|
|
3836
3837
|
visibleMessage.dataset.operationId=operationId;
|
|
3838
|
+
requestMessages.push(visibleMessage);
|
|
3837
3839
|
}
|
|
3838
3840
|
scrollTranscriptToBottom(!eventContext.synthetic);
|
|
3839
3841
|
|
|
@@ -3854,7 +3856,9 @@
|
|
|
3854
3856
|
let result;
|
|
3855
3857
|
try{
|
|
3856
3858
|
result=boundChatSession
|
|
3857
|
-
?sendMessageThroughBoundSession(
|
|
3859
|
+
?sendMessageThroughBoundSession(
|
|
3860
|
+
text,eventContext,undefined,null,requestMessages
|
|
3861
|
+
)
|
|
3858
3862
|
:host.sendMessage(text,eventContext);
|
|
3859
3863
|
}catch(error){
|
|
3860
3864
|
result=Promise.reject(error);
|
|
@@ -1049,165 +1049,194 @@ class DBOPFS {
|
|
|
1049
1049
|
}
|
|
1050
1050
|
|
|
1051
1051
|
/**
|
|
1052
|
-
*
|
|
1052
|
+
* Creates a PNG backup without downloading or writing database records.
|
|
1053
|
+
* JSON is deflated and framed with the existing little-endian payload
|
|
1054
|
+
* length, then encoded in RGB channels with opaque alpha for restoration.
|
|
1053
1055
|
*
|
|
1054
|
-
*
|
|
1055
|
-
*
|
|
1056
|
-
*
|
|
1057
|
-
* Each
|
|
1058
|
-
*
|
|
1059
|
-
*
|
|
1060
|
-
*
|
|
1061
|
-
*
|
|
1062
|
-
*
|
|
1063
|
-
*
|
|
1064
|
-
* @param {string} name
|
|
1065
|
-
* Base name used for the generated backup filename.
|
|
1066
|
-
*
|
|
1067
|
-
* @returns {Promise<void>}
|
|
1056
|
+
* @param {Object} options
|
|
1057
|
+
* @param {string[]} [options.tableNames] Saved tables; omitted selects all.
|
|
1058
|
+
* @param {Object<string,Object<string,*>>} [options.additionalTables]
|
|
1059
|
+
* Caller-owned tables. Each replaces the matching saved table in this
|
|
1060
|
+
* export only. Values must be JSON-serializable; no storage write occurs.
|
|
1061
|
+
* @param {AbortSignal} [options.signal]
|
|
1062
|
+
* Stops further preparation and prevents a subsequent download. Browser
|
|
1063
|
+
* file reads and canvas encoding already in progress cannot be interrupted.
|
|
1064
|
+
* @returns {Promise<Blob>} An image/png Blob in the existing backup format.
|
|
1068
1065
|
*/
|
|
1069
|
-
async
|
|
1070
|
-
|
|
1071
|
-
|
|
1066
|
+
async createCompressedPNG({tableNames, additionalTables = {}, signal} = {}) {
|
|
1067
|
+
signal?.throwIfAborted();
|
|
1068
|
+
if (!('CompressionStream' in window)) {
|
|
1069
|
+
throw new Error('CompressionStream not supported.');
|
|
1072
1070
|
}
|
|
1073
1071
|
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
const jsonStream=new ReadableStream({
|
|
1080
|
-
start:async controller=>{
|
|
1081
|
-
controller.enqueue(encoder.encode('{'))
|
|
1072
|
+
await this.readyPromise;
|
|
1073
|
+
signal?.throwIfAborted();
|
|
1074
|
+
await this.getTableNames(true);
|
|
1075
|
+
signal?.throwIfAborted();
|
|
1082
1076
|
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
if(t>0){
|
|
1088
|
-
controller.enqueue(encoder.encode(','))
|
|
1089
|
-
}
|
|
1090
|
-
|
|
1091
|
-
controller.enqueue(
|
|
1092
|
-
encoder.encode(`${JSON.stringify(tableName)}:{`)
|
|
1093
|
-
)
|
|
1094
|
-
|
|
1095
|
-
let first=true
|
|
1077
|
+
function logicalTableName(name) {
|
|
1078
|
+
return tableNameForDirectory(directoryNameForTable(name));
|
|
1079
|
+
}
|
|
1096
1080
|
|
|
1097
|
-
|
|
1098
|
-
|
|
1081
|
+
const selectedNames = new Set(
|
|
1082
|
+
(tableNames === undefined ? Object.keys(this.#tableHandles) : tableNames)
|
|
1083
|
+
.map(logicalTableName)
|
|
1084
|
+
);
|
|
1085
|
+
const suppliedTables = new Map();
|
|
1086
|
+
for (const [name, records] of Object.entries(additionalTables)) {
|
|
1087
|
+
const tableName = logicalTableName(name);
|
|
1088
|
+
selectedNames.add(tableName);
|
|
1089
|
+
suppliedTables.set(tableName, records);
|
|
1090
|
+
}
|
|
1099
1091
|
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
}
|
|
1092
|
+
const database = this;
|
|
1093
|
+
const encoder = new TextEncoder();
|
|
1103
1094
|
|
|
1104
|
-
|
|
1095
|
+
async function* tableRecords(tableName) {
|
|
1096
|
+
if (suppliedTables.has(tableName)) {
|
|
1097
|
+
yield* Object.entries(suppliedTables.get(tableName));
|
|
1098
|
+
return;
|
|
1099
|
+
}
|
|
1105
1100
|
|
|
1106
|
-
|
|
1101
|
+
const table = database.#tableHandles[tableName];
|
|
1102
|
+
if (!table) return;
|
|
1103
|
+
for await (const [fileName] of table.entries()) {
|
|
1104
|
+
signal?.throwIfAborted();
|
|
1105
|
+
const value = await database.get(tableName, fileName);
|
|
1106
|
+
signal?.throwIfAborted();
|
|
1107
|
+
yield [fileName, value];
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1107
1110
|
|
|
1108
|
-
|
|
1111
|
+
async function* encodeDatabase() {
|
|
1112
|
+
yield encoder.encode('{');
|
|
1113
|
+
let firstTable = true;
|
|
1114
|
+
for (const tableName of selectedNames) {
|
|
1115
|
+
signal?.throwIfAborted();
|
|
1116
|
+
if (!firstTable) yield encoder.encode(',');
|
|
1117
|
+
firstTable = false;
|
|
1118
|
+
yield encoder.encode(`${JSON.stringify(tableName)}:{`);
|
|
1119
|
+
|
|
1120
|
+
let firstRecord = true;
|
|
1121
|
+
for await (const [fileName, value] of tableRecords(tableName)) {
|
|
1122
|
+
signal?.throwIfAborted();
|
|
1123
|
+
if (!firstRecord) yield encoder.encode(',');
|
|
1124
|
+
firstRecord = false;
|
|
1125
|
+
const serialized = JSON.stringify(value);
|
|
1126
|
+
if (serialized === undefined) {
|
|
1127
|
+
throw new TypeError(`PNG record ${tableName}/${fileName} is not JSON-serializable.`);
|
|
1109
1128
|
}
|
|
1110
|
-
|
|
1111
|
-
controller.enqueue(encoder.encode('}'))
|
|
1129
|
+
yield encoder.encode(`${JSON.stringify(fileName)}:${serialized}`);
|
|
1112
1130
|
}
|
|
1113
|
-
|
|
1114
|
-
controller.enqueue(encoder.encode('}'))
|
|
1115
|
-
controller.close()
|
|
1116
|
-
}
|
|
1117
|
-
})
|
|
1118
|
-
|
|
1119
|
-
const gzipStream=jsonStream.pipeThrough(
|
|
1120
|
-
new CompressionStream('deflate')
|
|
1121
|
-
)
|
|
1122
|
-
|
|
1123
|
-
const reader=gzipStream.getReader()
|
|
1124
|
-
|
|
1125
|
-
const chunks=[]
|
|
1126
|
-
let totalLength=0
|
|
1127
|
-
|
|
1128
|
-
while(true){
|
|
1129
|
-
const {done,value}=await reader.read()
|
|
1130
|
-
|
|
1131
|
-
if(done){
|
|
1132
|
-
break
|
|
1131
|
+
yield encoder.encode('}');
|
|
1133
1132
|
}
|
|
1134
|
-
|
|
1135
|
-
chunks.push(value)
|
|
1136
|
-
totalLength+=value.length
|
|
1133
|
+
yield encoder.encode('}');
|
|
1137
1134
|
}
|
|
1138
1135
|
|
|
1139
|
-
const
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1136
|
+
const encoded = encodeDatabase();
|
|
1137
|
+
const jsonStream = new ReadableStream(
|
|
1138
|
+
{
|
|
1139
|
+
async pull(controller) {
|
|
1140
|
+
signal?.throwIfAborted();
|
|
1141
|
+
const {done, value} = await encoded.next();
|
|
1142
|
+
if (done) controller.close();
|
|
1143
|
+
else controller.enqueue(value);
|
|
1144
|
+
},
|
|
1145
|
+
async cancel() {
|
|
1146
|
+
await encoded.return();
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
);
|
|
1150
|
+
const compressedStream = jsonStream.pipeThrough(
|
|
1151
|
+
new CompressionStream('deflate'),
|
|
1152
|
+
{signal}
|
|
1153
|
+
);
|
|
1154
|
+
const reader = compressedStream.getReader();
|
|
1155
|
+
const chunks = [];
|
|
1156
|
+
let totalLength = 0;
|
|
1157
|
+
try {
|
|
1158
|
+
while (true) {
|
|
1159
|
+
const {done, value} = await reader.read();
|
|
1160
|
+
signal?.throwIfAborted();
|
|
1161
|
+
if (done) break;
|
|
1162
|
+
chunks.push(value);
|
|
1163
|
+
totalLength += value.length;
|
|
1164
|
+
}
|
|
1165
|
+
} finally {
|
|
1166
|
+
reader.releaseLock();
|
|
1145
1167
|
}
|
|
1146
1168
|
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
const view=new DataView(payload.buffer)
|
|
1150
|
-
view.setUint32(0,totalLength,true)
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
const totalPixels=size*size
|
|
1157
|
-
|
|
1158
|
-
if(totalPixels*3 < payload.length){
|
|
1159
|
-
throw new Error('PNG canvas too small for payload.')
|
|
1169
|
+
// Length and channel offsets belong only to the existing PNG format.
|
|
1170
|
+
const payload = new Uint8Array(totalLength + 4);
|
|
1171
|
+
const view = new DataView(payload.buffer);
|
|
1172
|
+
view.setUint32(0, totalLength, true);
|
|
1173
|
+
let offset = 4;
|
|
1174
|
+
for (const chunk of chunks) {
|
|
1175
|
+
payload.set(chunk, offset);
|
|
1176
|
+
offset += chunk.length;
|
|
1160
1177
|
}
|
|
1161
1178
|
|
|
1162
|
-
const
|
|
1163
|
-
canvas.
|
|
1164
|
-
canvas.
|
|
1165
|
-
|
|
1166
|
-
const ctx=canvas.getContext('2d')
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
throw new Error('Canvas 2D context unavailable.')
|
|
1179
|
+
const size = Math.ceil(Math.sqrt(payload.length / 3));
|
|
1180
|
+
const canvas = document.createElement('canvas');
|
|
1181
|
+
canvas.width = size;
|
|
1182
|
+
canvas.height = size;
|
|
1183
|
+
const ctx = canvas.getContext('2d');
|
|
1184
|
+
if (!ctx) {
|
|
1185
|
+
throw new Error('Canvas 2D context unavailable.');
|
|
1170
1186
|
}
|
|
1171
1187
|
|
|
1172
|
-
const imgData=ctx.createImageData(size,size)
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
imgData.data[i+1] = payload[p+1] ?? 0;
|
|
1181
|
-
imgData.data[i+2] = payload[p+2] ?? 0;
|
|
1182
|
-
imgData.data[i+3] = 255;
|
|
1183
|
-
|
|
1184
|
-
p+=3;
|
|
1188
|
+
const imgData = ctx.createImageData(size, size);
|
|
1189
|
+
let position = 0;
|
|
1190
|
+
for (let index = 0; index < imgData.data.length; index += 4) {
|
|
1191
|
+
imgData.data[index] = payload[position] ?? 0;
|
|
1192
|
+
imgData.data[index + 1] = payload[position + 1] ?? 0;
|
|
1193
|
+
imgData.data[index + 2] = payload[position + 2] ?? 0;
|
|
1194
|
+
imgData.data[index + 3] = 255;
|
|
1195
|
+
position += 3;
|
|
1185
1196
|
}
|
|
1197
|
+
ctx.putImageData(imgData, 0, 0);
|
|
1186
1198
|
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1199
|
+
const blob = await new Promise(
|
|
1200
|
+
function encodeBackupPNG(resolve) {
|
|
1201
|
+
canvas.toBlob(resolve, 'image/png');
|
|
1202
|
+
}
|
|
1203
|
+
);
|
|
1204
|
+
signal?.throwIfAborted();
|
|
1205
|
+
if (!blob) {
|
|
1206
|
+
throw new Error('PNG export failed.');
|
|
1193
1207
|
}
|
|
1208
|
+
return blob;
|
|
1209
|
+
}
|
|
1194
1210
|
|
|
1195
|
-
|
|
1211
|
+
/**
|
|
1212
|
+
* Downloads one PNG backup using the same options as createCompressedPNG.
|
|
1213
|
+
* Existing one-argument callers still export the whole database.
|
|
1214
|
+
* @param {string} name Base filename; an ISO timestamp is appended.
|
|
1215
|
+
* @param {Object} options PNG table selection and optional signal.
|
|
1216
|
+
* @returns {Promise<void>}
|
|
1217
|
+
*/
|
|
1218
|
+
async downloadCompressedPNG(name = 'DBOPFS-backup', options = {}) {
|
|
1219
|
+
const blob = await this.createCompressedPNG(options);
|
|
1220
|
+
options.signal?.throwIfAborted();
|
|
1221
|
+
const stamp = new Date()
|
|
1196
1222
|
.toISOString()
|
|
1197
|
-
.slice(0,19)
|
|
1198
|
-
.replace(/[:T]/g,'-')
|
|
1199
|
-
|
|
1200
|
-
const url=URL.createObjectURL(blob)
|
|
1201
|
-
|
|
1202
|
-
const a=document.createElement('a')
|
|
1203
|
-
a.href=url
|
|
1204
|
-
a.download
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1223
|
+
.slice(0, 19)
|
|
1224
|
+
.replace(/[:T]/g, '-');
|
|
1225
|
+
|
|
1226
|
+
const url = URL.createObjectURL(blob);
|
|
1227
|
+
|
|
1228
|
+
const a = document.createElement('a');
|
|
1229
|
+
a.href = url;
|
|
1230
|
+
a.download = `${name}-${stamp}.png`;
|
|
1231
|
+
|
|
1232
|
+
try {
|
|
1233
|
+
document.body.appendChild(a);
|
|
1234
|
+
options.signal?.throwIfAborted();
|
|
1235
|
+
a.click();
|
|
1236
|
+
} finally {
|
|
1237
|
+
a.remove();
|
|
1238
|
+
URL.revokeObjectURL(url);
|
|
1239
|
+
}
|
|
1211
1240
|
}
|
|
1212
1241
|
|
|
1213
1242
|
/**
|
|
@@ -1229,30 +1258,39 @@ class DBOPFS {
|
|
|
1229
1258
|
*
|
|
1230
1259
|
* @param {File|Blob} file
|
|
1231
1260
|
* PNG backup file generated by DBOPFS.
|
|
1261
|
+
* @param {Object} options
|
|
1262
|
+
* @param {function(Object): Object|Promise<Object>} [options.selectTables]
|
|
1263
|
+
* Receives the complete decoded table/file map once, before writes. Only
|
|
1264
|
+
* its returned map is restored; the application owns selection/projection.
|
|
1232
1265
|
*
|
|
1233
1266
|
* @returns {Promise<void>}
|
|
1234
|
-
* Resolves
|
|
1267
|
+
* Resolves after every write succeeds. Rejected writes are collected after
|
|
1268
|
+
* all table batches settle and reported in an AggregateError with the
|
|
1269
|
+
* original reasons and table/file associations; successful writes remain.
|
|
1235
1270
|
*/
|
|
1236
|
-
async restoreFromPNG(file){
|
|
1271
|
+
async restoreFromPNG(file, {selectTables} = {}) {
|
|
1237
1272
|
const img=await createImageBitmap(
|
|
1238
1273
|
file,
|
|
1239
1274
|
{premultiplyAlpha:'none'}
|
|
1240
1275
|
)
|
|
1241
1276
|
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1277
|
+
let data;
|
|
1278
|
+
try {
|
|
1279
|
+
const canvas = document.createElement('canvas');
|
|
1280
|
+
canvas.width = img.width;
|
|
1281
|
+
canvas.height = img.height;
|
|
1245
1282
|
|
|
1246
|
-
|
|
1283
|
+
const ctx = canvas.getContext('2d');
|
|
1284
|
+
if (!ctx) {
|
|
1285
|
+
throw new Error('Canvas 2D context unavailable.');
|
|
1286
|
+
}
|
|
1247
1287
|
|
|
1248
|
-
|
|
1249
|
-
|
|
1288
|
+
ctx.drawImage(img, 0, 0);
|
|
1289
|
+
data = ctx.getImageData(0, 0, img.width, img.height).data;
|
|
1290
|
+
} finally {
|
|
1291
|
+
img.close();
|
|
1250
1292
|
}
|
|
1251
1293
|
|
|
1252
|
-
ctx.drawImage(img,0,0)
|
|
1253
|
-
|
|
1254
|
-
const data=ctx.getImageData(0,0,img.width,img.height).data
|
|
1255
|
-
|
|
1256
1294
|
if(data.length < 4){
|
|
1257
1295
|
throw new Error('Invalid PNG backup.')
|
|
1258
1296
|
}
|
|
@@ -1283,12 +1321,38 @@ class DBOPFS {
|
|
|
1283
1321
|
|
|
1284
1322
|
const json=await new Response(stream).text()
|
|
1285
1323
|
|
|
1286
|
-
const
|
|
1324
|
+
const decodedTables = JSON.parse(json);
|
|
1325
|
+
const db = selectTables === undefined
|
|
1326
|
+
? decodedTables
|
|
1327
|
+
: await selectTables(decodedTables);
|
|
1287
1328
|
|
|
1288
1329
|
const tables=Object.keys(db)
|
|
1289
1330
|
|
|
1290
|
-
|
|
1291
|
-
|
|
1331
|
+
const failures = [];
|
|
1332
|
+
for (const tableName of tables) {
|
|
1333
|
+
const fileNames = Object.keys(db[tableName]);
|
|
1334
|
+
const results = await this.setMany(tableName, db[tableName]);
|
|
1335
|
+
for (const [index, result] of results.entries()) {
|
|
1336
|
+
if (result.status === 'rejected') {
|
|
1337
|
+
failures.push(
|
|
1338
|
+
{tableName, fileName: fileNames[index], reason: result.reason}
|
|
1339
|
+
);
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
if (failures.length > 0) {
|
|
1345
|
+
const error = new AggregateError(
|
|
1346
|
+
failures.map(
|
|
1347
|
+
function restoreWriteReason(failure) {
|
|
1348
|
+
return failure.reason;
|
|
1349
|
+
}
|
|
1350
|
+
),
|
|
1351
|
+
'PNG restore could not save every record.'
|
|
1352
|
+
);
|
|
1353
|
+
error.code = 'DBOPFS_RESTORE_WRITE_FAILED';
|
|
1354
|
+
error.failures = failures;
|
|
1355
|
+
throw error;
|
|
1292
1356
|
}
|
|
1293
1357
|
}
|
|
1294
1358
|
}
|