From 46c770ed72f0429f805782b5ccf694943cc32f49 Mon Sep 17 00:00:00 2001
From: Akhilesh Thite
Date: Fri, 11 Sep 2026 12:11:12 -0700
Subject: [PATCH 1/8] feat: password change, account deletion, and writes while
offline
---
AGENTS.md | 9 ++
README.md | 7 +-
docs/data.md | 13 +++
docs/login.md | 35 +++++++
package.json | 2 +-
src/chelonia/auth.js | 177 +++++++++++++++++++++++++++++++-
src/chelonia/connection.js | 6 +-
src/chelonia/index.js | 6 +-
src/chelonia/offline.js | 75 ++++++++++++++
src/chelonia/todos.js | 72 ++++++++++---
src/components/AccountPanel.vue | 77 ++++++++++++++
src/components/App.vue | 4 +
src/components/TodoApp.vue | 30 +++---
src/contracts/identity.js | 23 ++++-
src/style.css | 26 ++++-
test/e2e/chelonia.spec.mjs | 32 +++---
vite.config.js | 7 +-
17 files changed, 534 insertions(+), 67 deletions(-)
create mode 100644 src/chelonia/offline.js
create mode 100644 src/components/AccountPanel.vue
diff --git a/AGENTS.md b/AGENTS.md
index 89d4f4d..5288d62 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -13,6 +13,7 @@ src/chelonia/lists.js creating a list, inviting, joining
src/chelonia/lists-model.js the lists schema and its one reducer, both pure
src/chelonia/todos.js the todos slot and the six writes
src/chelonia/todos-model.js the schema and the reducers, both pure
+src/chelonia/offline.js the queue for writes made while the server is away
src/components/ Vue, and nothing else
scripts/build-contracts.mjs chel manifest -> chel pin -> manifest CID
scripts/chel.mjs runs chel from node_modules, see below
@@ -45,9 +46,17 @@ database backend, and a `server_id` the server refuses to start without).
`chel init` generates it with the in-memory backend, which loses every account
on restart, so the script switches it to sqlite under `data/`.
+`npm run serve` runs the real server as a child of `scripts/chel.mjs`, so
+killing the node process alone leaves it up. Stop it through the port:
+`lsof -ti:8000 | xargs kill`.
+
After a full rebuild, restart `npm run serve`. Vite empties `dist/` and a
server that was already running answers 404 until it is restarted.
+The app is built with `LIGHTWEIGHT_CLIENT=true` (see `vite.config.js`), the
+same as Group Income: the browser keeps no message log, and Chelonia reads each
+contract's HEAD from the saved state.
+
The contract version comes from `version` in `package.json`. Editing a contract
without bumping it makes the build stop, since the app would then be built
against a manifest the accounts already on the server do not have.
diff --git a/README.md b/README.md
index 3ad8eeb..030b0d8 100644
--- a/README.md
+++ b/README.md
@@ -38,7 +38,12 @@ scripts, and the files the first run creates, are listed in
of the contract.
7. Share a list: press **Share**, open the link in a private window, sign up
there and join. Keep the first window open, it is the one that answers.
-8. Look at what the server actually has:
+8. Change your password from the **account** link at the bottom, then log out
+ and in with the new one. The same panel deletes the account, along with
+ the lists it created.
+9. Stop the server with Ctrl-C and keep adding todos. They show up straight
+ away and wait; start the server again and they go through.
+10. Look at what the server actually has:
```bash
chel eventsAfter 0
diff --git a/docs/data.md b/docs/data.md
index eed41b2..62414a6 100644
--- a/docs/data.md
+++ b/docs/data.md
@@ -46,3 +46,16 @@ declared slot at `rootState._kv[contractID][key]` and updates it from four
places: the first load, a push from another client, our own write, and a
refetch after the socket reconnects. That state object is a Vue `reactive()`,
so a `computed` over it reruns on all four and the list redraws by itself.
+
+## Offline
+
+`chelonia/kv/update` needs the server, so while the socket is down a write goes
+into Chelonia's persistent action queue instead (`src/chelonia/offline.js`).
+The queue stores `[selector, ...args]` as JSON, which is why writes are named
+(`'addTodo'`, `'setTitle'`, ...) and the reducer is looked up when the write
+runs. The queue lives under one localStorage key, so it survives a reload, and
+`retryAll` is called as soon as the socket is back.
+
+Until a write lands, `currentTodos` applies it on top of the mirror value, so
+the list looks the same offline as it will once the server has it. A write that
+lands is removed from that overlay on `PERSISTENT_ACTION_SUCCESS`.
diff --git a/docs/login.md b/docs/login.md
index 83260aa..dbfeb6b 100644
--- a/docs/login.md
+++ b/docs/login.md
@@ -44,3 +44,38 @@ server answered at all.
None of the above. The secrets and the contract state are already in the saved
blob, so it only re-syncs.
+
+## Changing the password
+
+1. Prove the current password against `/zkpp/:contractID/auth_hash`, as at
+ login.
+2. `POST /zkpp/:contractID/updatePasswordHash` with that proof and the new
+ password's hash, encrypted to the proof's shared secret
+ (`buildUpdateSaltRequestEc`). The answer is the old contract salt and a
+ one-time token.
+3. Derive the old `ipk` and `iek` from the old password and salt, and the new
+ ones from the new password and the new salt.
+4. `chelonia/out/keyUpdate`, signed by the old `ipk`, with the token in the
+ `shelter-salt-update-token` header. `ipk` and `iek` are replaced. `csk`,
+ `cek` and `#sak` keep their keys and only get their secrets encrypted again
+ to the new `iek`, so nothing already on the contract has to be rewritten.
+5. Publish the deletion token again, encrypted to the new `iek`.
+6. Discard all four password keys.
+
+## Deleting the account
+
+Signup sends `shelter-deletion-token-digest`, the hash of a random token, and
+keeps the token itself in the contract encrypted to the `iek`. So deleting
+takes the password.
+
+1. Prove the password and derive the `iek`, as at login.
+2. Decrypt the token out of `attributes.encryptedDeletionToken`.
+3. `chelonia/out/deleteContract` with the token. The server answers 202 and
+ deletes the contract in the background, together with every list this
+ account created. Lists joined through an invite belong to whoever made them
+ and stay.
+4. Log out locally.
+
+The username is not freed. chel 3.4.0 keeps the name pointing at the deleted
+contract and only lists it as orphaned, so signing up with the same name again
+is refused as taken until the server cleans those up.
diff --git a/package.json b/package.json
index 4ed6a14..07bafe2 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "chelonia-todomvc",
- "version": "0.1.0",
+ "version": "0.2.0",
"private": true,
"type": "module",
"engines": {
diff --git a/src/chelonia/auth.js b/src/chelonia/auth.js
index aeb7c49..57ddc6a 100644
--- a/src/chelonia/auth.js
+++ b/src/chelonia/auth.js
@@ -7,12 +7,13 @@
import sbp from '@sbp/sbp'
import { Secret } from '@chelonia/lib/Secret'
-import { encryptedOutgoingDataWithRawKey } from '@chelonia/lib/encryptedData'
-import { bytesToB64 } from '@chelonia/lib/functions'
+import { encryptedIncomingData, encryptedOutgoingDataWithRawKey } from '@chelonia/lib/encryptedData'
+import { blake32Hash, bytesToB64 } from '@chelonia/lib/functions'
import {
base64ToBase64url,
boxKeyPair,
buildRegisterSaltRequest,
+ buildUpdateSaltRequestEc,
computeCAndHc,
decryptContractSalt,
hash,
@@ -23,12 +24,14 @@ import {
CURVE25519XSALSA20POLY1305,
EDWARDS25519SHA512BATCH,
deriveKeyFromPassword,
+ generateSalt,
keyId,
keygen,
serializeKey
} from '@chelonia/crypto'
import { API_URL, CONTRACT_NAME } from './config.js'
-import { createList, loadLists, retainOrSync } from './lists.js'
+import { createList, currentLists, loadLists, retainOrSync } from './lists.js'
+import { dropPendingWrites, loadOfflineQueue } from './offline.js'
import { clearSavedState, persistState, state } from './state.js'
const DEFAULT_LIST_TITLE = 'My todos'
@@ -154,6 +157,10 @@ export async function signup ({ username, password }) {
const CSK = keygen(EDWARDS25519SHA512BATCH)
const CEK = keygen(CURVE25519XSALSA20POLY1305)
const SAK = keygen(EDWARDS25519SHA512BATCH)
+ // Lets the account delete itself later. The server keeps only the hash, and
+ // the token sits in the contract encrypted to the IEK, so deleting takes the
+ // password.
+ const deletionToken = generateSalt()
// Transient, so neither of the password-derived keys reaches the saved state.
sbp('chelonia/storeSecretKeys', new Secret([
@@ -170,7 +177,8 @@ export async function signup ({ username, password }) {
// this first message.
headers: {
'shelter-namespace-registration': username,
- 'shelter-salt-registration-token': saltRegistrationToken
+ 'shelter-salt-registration-token': saltRegistrationToken,
+ 'shelter-deletion-token-digest': blake32Hash(deletionToken)
}
},
signingKeyId: keyId(IPK),
@@ -231,7 +239,13 @@ export async function signup ({ username, password }) {
data: serializeKey(SAK, false)
}
],
- data: { attributes: { username } }
+ data: {
+ attributes: {
+ username,
+ encryptedDeletionToken: encryptedOutgoingDataWithRawKey(IEK, deletionToken)
+ .serialize('encryptedDeletionToken')
+ }
+ }
})
} catch (e) {
// TODO: BEGIN REMOVEME (okTurtles/libcheloniajs#94)
@@ -279,6 +293,12 @@ export async function login ({ username, password }) {
// Syncing is the recovery step: processing OP_CONTRACT decrypts the CSK,
// CEK and SAK with the IEK and stores them persistently.
await sbp('chelonia/contract/retain', [identityContractID])
+ // After a password change those three are only readable from the key
+ // update onwards, so the first pass could not open anything before it.
+ // Go through the log once more now that they are known.
+ if (state.contracts[identityContractID]?.missingDecryptionKeyIds?.length) {
+ await sbp('chelonia/contract/sync', [identityContractID], { resync: true })
+ }
} finally {
sbp('chelonia/clearTransientSecretKeys', [keyId(IEK)])
}
@@ -294,6 +314,7 @@ export async function restoreSession () {
await retainOrSync(identityContractID)
sbp('chelonia/kv/refreshFilters')
await loadLists(identityContractID)
+ await loadOfflineQueue((contractID) => currentLists().includes(contractID))
return identityContractID
}
@@ -303,6 +324,149 @@ async function enterSession (identityContractID) {
sbp('chelonia/kv/refreshFilters')
await sbp('chelonia/contract/wait', [identityContractID])
await loadLists(identityContractID)
+ await loadOfflineQueue((contractID) => currentLists().includes(contractID))
+}
+
+function currentIdentity () {
+ const identityContractID = state.loggedIn?.identityContractID
+ if (!identityContractID) throw new AuthError('Not logged in.')
+ return identityContractID
+}
+
+const keyIdByName = (contractState, name) =>
+ sbp('chelonia/contract/currentKeyIdByName', contractState, name)
+
+export async function changePassword ({ oldPassword, newPassword }) {
+ const identityContractID = currentIdentity()
+ const identityState = state[identityContractID]
+ const contract = encodeURIComponent(identityContractID)
+
+ // Same proof as login. The new password travels encrypted to that proof's
+ // shared secret, and the answer is the old salt plus a one-time token that
+ // lets the next message swap the salts on the server.
+ let oldContractSalt, newContractSalt, updateToken
+ try {
+ const r = randomNonce()
+ const { authSalt, s, sig } = await request(
+ `/zkpp/${contract}/auth_hash?b=${encodeURIComponent(hash(r))}`
+ ).then((r) => r.json())
+ const [c, hc] = computeCAndHc(r, s, await hashPassword(oldPassword, authSalt))
+ const [salt, Ea] = await buildUpdateSaltRequestEc(newPassword, c)
+ newContractSalt = salt
+ const encrypted = await request(
+ `/zkpp/${contract}/updatePasswordHash`,
+ form({ r, s, sig, hc: toBase64url(hc), Ea })
+ ).then((r) => r.json())
+ ;[oldContractSalt, updateToken] = JSON.parse(decryptContractSalt(c, encrypted))
+ } catch (e) {
+ if (e instanceof AuthError && e.exact) throw e
+ throw new AuthError('Incorrect password.', { cause: e })
+ }
+
+ const oldIPK = await deriveKeyFromPassword(EDWARDS25519SHA512BATCH, oldPassword, oldContractSalt)
+ const oldIEK = await deriveKeyFromPassword(CURVE25519XSALSA20POLY1305, oldPassword, oldContractSalt)
+ const IPK = await deriveKeyFromPassword(EDWARDS25519SHA512BATCH, newPassword, newContractSalt)
+ const IEK = await deriveKeyFromPassword(CURVE25519XSALSA20POLY1305, newPassword, newContractSalt)
+
+ // Read while the old IEK is still the current key.
+ const encryptedToken = identityState.attributes?.encryptedDeletionToken
+ const deletionToken = encryptedToken && encryptedIncomingData(
+ identityContractID, identityState, encryptedToken, NaN,
+ { [keyId(oldIEK)]: oldIEK }, 'encryptedDeletionToken'
+ ).valueOf()
+
+ sbp('chelonia/storeSecretKeys', new Secret(
+ [oldIPK, oldIEK, IPK, IEK].map((key) => ({ key, transient: true }))
+ ))
+ try {
+ // Only the two password keys are replaced. The everyday keys stay and get
+ // their secrets encrypted again to the new IEK, so nothing already on the
+ // contract has to be rewritten. The same id and public key go back in,
+ // since Chelonia checks the decrypted secret against the entry's id.
+ const keep = (name) => {
+ const id = keyIdByName(identityState, name)
+ return {
+ id,
+ name,
+ oldKeyId: id,
+ data: identityState._vm.authorizedKeys[id].data,
+ meta: { private: { content: encryptedOutgoingDataWithRawKey(IEK, state.secretKeys[id]) } }
+ }
+ }
+ await sbp('chelonia/out/keyUpdate', {
+ contractID: identityContractID,
+ contractName: CONTRACT_NAME,
+ data: [
+ {
+ id: keyId(IPK),
+ name: 'ipk',
+ oldKeyId: keyId(oldIPK),
+ meta: { private: { transient: true } },
+ data: serializeKey(IPK, false)
+ },
+ {
+ id: keyId(IEK),
+ name: 'iek',
+ oldKeyId: keyId(oldIEK),
+ meta: { private: { transient: true } },
+ data: serializeKey(IEK, false)
+ },
+ keep('csk'),
+ keep('cek'),
+ keep('#sak')
+ ],
+ signingKeyId: keyId(oldIPK),
+ // The server swaps the salts while it accepts this message.
+ publishOptions: { headers: { 'shelter-salt-update-token': updateToken } }
+ })
+ if (deletionToken) {
+ await sbp('chelonia/out/actionEncrypted', {
+ action: `${CONTRACT_NAME}/setDeletionToken`,
+ contractID: identityContractID,
+ data: {
+ encryptedDeletionToken: encryptedOutgoingDataWithRawKey(IEK, deletionToken)
+ .serialize('encryptedDeletionToken')
+ },
+ signingKeyId: keyIdByName(identityState, 'csk'),
+ encryptionKeyId: keyIdByName(identityState, 'cek')
+ })
+ }
+ await sbp('chelonia/contract/wait', [identityContractID])
+ } finally {
+ sbp('chelonia/clearTransientSecretKeys', [oldIPK, oldIEK, IPK, IEK].map(keyId))
+ }
+}
+
+export async function deleteAccount ({ password }) {
+ const identityContractID = currentIdentity()
+ const identityState = state[identityContractID]
+ const encryptedToken = identityState?.attributes?.encryptedDeletionToken
+ if (!encryptedToken) {
+ throw new AuthError('This account was made before deleting was possible.')
+ }
+
+ let token
+ try {
+ const contractSalt = await retrieveSalt(identityContractID, password)
+ const IEK = await deriveKeyFromPassword(CURVE25519XSALSA20POLY1305, password, contractSalt)
+ token = encryptedIncomingData(
+ identityContractID, identityState, encryptedToken, NaN,
+ { [keyId(IEK)]: IEK }, 'encryptedDeletionToken'
+ ).valueOf()
+ } catch (e) {
+ if (e instanceof AuthError && e.exact) throw e
+ throw new AuthError('Incorrect password.', { cause: e })
+ }
+
+ // The server takes it from here and also deletes the lists this account
+ // created. Lists it only joined belong to whoever made them.
+ const [result] = await sbp('chelonia/out/deleteContract', identityContractID, {
+ [identityContractID]: { token: new Secret(token) }
+ })
+ if (result.status === 'rejected') {
+ throw new AuthError('Could not delete the account.', { cause: result.reason })
+ }
+ await logout()
}
// Read from the contract state rather than kept alongside the session, so
@@ -313,6 +477,9 @@ export function currentUsername () {
}
export async function logout () {
+ // Unsent writes cannot go out without this account's keys.
+ await dropPendingWrites()
+ sbp('chelonia.persistentActions/unload')
// Stop saving before reset churns through the state, then start again for
// whoever logs in next.
clearSavedState()
diff --git a/src/chelonia/connection.js b/src/chelonia/connection.js
index 05bd68a..476d8e5 100644
--- a/src/chelonia/connection.js
+++ b/src/chelonia/connection.js
@@ -6,10 +6,11 @@ import {
PUBSUB_RECONNECTION_SCHEDULED,
PUBSUB_RECONNECTION_SUCCEEDED
} from '@chelonia/lib/pubsub'
+import { retryPendingWrites } from './offline.js'
// The socket is the only thing that says the server went away mid-session.
-// Reads keep working off the mirror, so without this the app looks fine while
-// every write fails.
+// Reads keep working off the mirror, so without this the app would not know
+// to queue writes instead of sending them.
export const connection = reactive({ online: true })
export function watchConnection () {
@@ -23,5 +24,6 @@ export function watchConnection () {
// Also fires on the first open, not just on a reconnect.
sbp('okTurtles.events/on', PUBSUB_RECONNECTION_SUCCEEDED, () => {
connection.online = true
+ retryPendingWrites()
})
}
diff --git a/src/chelonia/index.js b/src/chelonia/index.js
index 7f7ec2c..cbb9e9d 100644
--- a/src/chelonia/index.js
+++ b/src/chelonia/index.js
@@ -3,17 +3,21 @@ import { watchConnection } from './connection.js'
import { persistState } from './state.js'
import { restoreSession } from './auth.js'
import { defineListsSlot } from './lists.js'
+import { setupOfflineQueue } from './offline.js'
import { defineTodosSlot } from './todos.js'
export async function startChelonia () {
watchConnection()
await configureChelonia()
+ setupOfflineQueue()
defineListsSlot()
defineTodosSlot()
persistState()
return restoreSession()
}
-export { AuthError, currentUsername, login, logout, signup } from './auth.js'
+export {
+ AuthError, changePassword, currentUsername, deleteAccount, login, logout, signup
+} from './auth.js'
export { connection } from './connection.js'
export { state } from './state.js'
diff --git a/src/chelonia/offline.js b/src/chelonia/offline.js
new file mode 100644
index 0000000..2627ef6
--- /dev/null
+++ b/src/chelonia/offline.js
@@ -0,0 +1,75 @@
+// Writes made while the server is unreachable.
+//
+// They go into Chelonia's persistent action queue, which retries them until
+// the server takes them, and until then they are shown on top of the last
+// value the server sent. The queue keeps `[selector, ...args]` as plain JSON,
+// so a write is described by name and its reducer is looked up when it runs.
+
+import sbp from '@sbp/sbp'
+import {
+ PERSISTENT_ACTION_SUCCESS,
+ PERSISTENT_ACTION_TOTAL_FAILURE
+} from '@chelonia/lib/events'
+import { state } from './state.js'
+
+const QUEUE_KEY = 'todomvc/pending-writes'
+const NO_WRITES = Object.freeze([])
+
+export const pendingWrites = () => state.pendingWrites ?? NO_WRITES
+
+export function setupOfflineQueue () {
+ keepQueueInLocalStorage()
+ sbp('chelonia.persistentActions/configure', {
+ databaseKey: QUEUE_KEY,
+ options: { retrySeconds: 15 }
+ })
+ const forget = ({ id }) => {
+ state.pendingWrites = pendingWrites().filter((w) => w.id !== id)
+ }
+ sbp('okTurtles.events/on', PERSISTENT_ACTION_SUCCESS, forget)
+ sbp('okTurtles.events/on', PERSISTENT_ACTION_TOTAL_FAILURE, ({ id, error }) => {
+ console.error('[todomvc] gave up on a queued write', error)
+ forget({ id })
+ })
+}
+
+// chelonia.db is an in-memory map in this app, and the queue has to outlive a
+// reload, so this one key goes to localStorage instead.
+function keepQueueInLocalStorage () {
+ const get = sbp('sbp/selectors/fn', 'chelonia.db/get')
+ const set = sbp('sbp/selectors/fn', 'chelonia.db/set')
+ // Both return a promise, as the originals do and as their callers expect.
+ sbp('sbp/selectors/overwrite', {
+ 'chelonia.db/get': async (key) =>
+ key === QUEUE_KEY ? localStorage.getItem(QUEUE_KEY) : get(key),
+ 'chelonia.db/set': async (key, value) =>
+ key === QUEUE_KEY ? localStorage.setItem(QUEUE_KEY, value) : set(key, value)
+ })
+ sbp('sbp/selectors/lock', ['chelonia.db/get', 'chelonia.db/set'])
+}
+
+// Called once a session is open. Writes for lists this account is not in
+// (another account used this browser and never logged out) are dropped.
+export async function loadOfflineQueue (isOurs) {
+ await sbp('chelonia.persistentActions/load')
+ const queued = sbp('chelonia.persistentActions/status')
+ for (const action of queued) {
+ if (!isOurs(action.invocation[1])) await sbp('chelonia.persistentActions/cancel', action.id)
+ }
+ const kept = new Set(sbp('chelonia.persistentActions/status').map((a) => a.id))
+ state.pendingWrites = pendingWrites().filter((w) => kept.has(w.id))
+}
+
+export function queueWrite (invocation, write) {
+ const [id] = sbp('chelonia.persistentActions/enqueue', invocation)
+ state.pendingWrites = [...pendingWrites(), { id, ...write }]
+}
+
+export const retryPendingWrites = () => sbp('chelonia.persistentActions/retryAll')
+
+export async function dropPendingWrites () {
+ for (const { id } of sbp('chelonia.persistentActions/status')) {
+ await sbp('chelonia.persistentActions/cancel', id)
+ }
+ state.pendingWrites = []
+}
diff --git a/src/chelonia/todos.js b/src/chelonia/todos.js
index 2bf9e05..0a5412b 100644
--- a/src/chelonia/todos.js
+++ b/src/chelonia/todos.js
@@ -1,7 +1,10 @@
import sbp from '@sbp/sbp'
import { CHELONIA_KV_VALIDATION_ERROR } from '@chelonia/lib/events'
+import { KV_NOOP } from '@chelonia/lib/kv-constants'
import { LIST_CONTRACT_NAME } from './config.js'
+import { connection } from './connection.js'
import { currentLists } from './lists.js'
+import { pendingWrites, queueWrite } from './offline.js'
import { state } from './state.js'
import {
addTodo,
@@ -16,6 +19,10 @@ import {
const TODOS_KEY = 'todos'
const NO_TODOS = Object.freeze({})
+// Reducers by name, because a queued write is stored as JSON and cannot carry
+// a function.
+const OPS = { addTodo, setCompleted, setTitle, removeTodo, setAllCompleted, removeCompleted }
+
// One declaration covers the first fetch, the pubsub subscription, the local
// mirror, schema validation and the conflict retries.
export function defineTodosSlot () {
@@ -24,14 +31,27 @@ export function defineTodosSlot () {
key: TODOS_KEY,
defaultValue: {},
schema: todosSchema,
- // Every list we are in, but only once we hold its keys: /kv is authorized
- // with the contract's own #sak. Nothing re-runs this when they arrive.
- // OP_KEY_SHARE resyncs the contract, and that reconciles the slots.
+ // Attaches to every list this account is in, but only once we hold that
+ // list's keys. Between accepting an invite and the owner answering it there
+ // is nothing here we could read or write: /kv/:contractID/:key is
+ // authorized with the contract's own #sak.
+ //
+ // Nothing re-runs this by hand when the keys finally arrive. Chelonia marks
+ // the contract dirty on OP_KEY_SHARE and resyncs it, and a resync drops and
+ // re-adds the subscription, which is what reconciles the slots again.
match: (contractID, contractState) =>
currentLists().includes(contractID) &&
!!sbp('chelonia/contract/currentKeyIdByName', contractState, '#sak', true)
})
+ sbp('sbp/selectors/register', {
+ 'todomvc/todos/write': (contractID, op, ...args) => sbp('chelonia/kv/update', {
+ contractID,
+ key: TODOS_KEY,
+ updater: OPS[op](...args)
+ })
+ })
+
// A value that fails the schema never reaches the app: the mirror keeps the
// last good one and the slot goes to 'error'. The UI reads that status; this
// is here so the reason is visible while developing.
@@ -44,10 +64,19 @@ export function defineTodosSlot () {
// Reading `entry.value` is what makes a Vue computed re-run when Chelonia
// updates the mirror. The value itself comes from the selector, which
// substitutes the declared default. See "Consumer caveats" in docs/kv.md.
+//
+// Writes still waiting for the server are applied on top, in the order they
+// were made, so the list looks the same offline as it will once they land.
export function currentTodos (contractID) {
const entry = mirrorEntry(contractID)
if (!entry) return NO_TODOS
- return entry.value ?? sbp('chelonia/kv/read', contractID, TODOS_KEY)
+ const saved = entry.value ?? sbp('chelonia/kv/read', contractID, TODOS_KEY)
+ return pendingWrites()
+ .filter((w) => w.contractID === contractID)
+ .reduce((todos, w) => {
+ const next = OPS[w.op](...w.args)(todos)
+ return next === KV_NOOP ? todos : next
+ }, saved)
}
// 'non-init' | 'loading' | 'loaded' | 'error'
@@ -55,13 +84,24 @@ export function todosStatus (contractID) {
return mirrorEntry(contractID)?.status ?? 'non-init'
}
+export const pendingCount = (contractID) =>
+ pendingWrites().filter((w) => w.contractID === contractID).length
+
const mirrorEntry = (contractID) => contractID && state._kv?.[contractID]?.[TODOS_KEY]
-const write = (contractID, updater) => sbp('chelonia/kv/update', {
- contractID,
- key: TODOS_KEY,
- updater
-})
+// Goes straight to the server when it is there. Otherwise, or when the request
+// fails before an answer, the write is queued and sent later.
+async function write (contractID, op, ...args) {
+ const invocation = ['todomvc/todos/write', contractID, op, ...args]
+ if (!connection.online) return queueWrite(invocation, { contractID, op, args })
+ try {
+ await sbp(...invocation)
+ } catch (e) {
+ // fetch rejects with a TypeError when the server never answered.
+ if (!(e instanceof TypeError)) throw e
+ queueWrite(invocation, { contractID, op, args })
+ }
+}
// Not crypto.randomUUID: that needs a secure context, and opening the demo
// from another machine on http://192.168.x.x is not one.
@@ -69,17 +109,17 @@ const newId = () =>
Array.from(crypto.getRandomValues(new Uint8Array(16)), (b) =>
b.toString(16).padStart(2, '0')).join('')
-export const createTodo = (contractID, title) => write(contractID, addTodo({
+export const createTodo = (contractID, title) => write(contractID, 'addTodo', {
id: newId(),
// Server time, so a tab with a wrong clock sorts the same as everyone else.
createdDate: new Date(sbp('chelonia/time')).toISOString(),
title
-}))
+})
export const setTodoCompleted = (contractID, id, completed) =>
- write(contractID, setCompleted(id, completed))
-export const renameTodo = (contractID, id, title) => write(contractID, setTitle(id, title))
-export const destroyTodo = (contractID, id) => write(contractID, removeTodo(id))
+ write(contractID, 'setCompleted', id, completed)
+export const renameTodo = (contractID, id, title) => write(contractID, 'setTitle', id, title)
+export const destroyTodo = (contractID, id) => write(contractID, 'removeTodo', id)
export const completeAllTodos = (contractID, completed) =>
- write(contractID, setAllCompleted(completed))
-export const clearCompletedTodos = (contractID) => write(contractID, removeCompleted())
+ write(contractID, 'setAllCompleted', completed)
+export const clearCompletedTodos = (contractID) => write(contractID, 'removeCompleted')
diff --git a/src/components/AccountPanel.vue b/src/components/AccountPanel.vue
new file mode 100644
index 0000000..0a83e7d
--- /dev/null
+++ b/src/components/AccountPanel.vue
@@ -0,0 +1,77 @@
+
+
+
+
+
+
+
+
+
{{ error }}
+
{{ message }}
+
+
+
diff --git a/src/components/App.vue b/src/components/App.vue
index c84f601..f57d602 100644
--- a/src/components/App.vue
+++ b/src/components/App.vue
@@ -2,6 +2,7 @@
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { currentUsername, logout, state } from '../chelonia/index.js'
import { currentLists, listIsPending, readInvite } from '../chelonia/lists.js'
+import AccountPanel from './AccountPanel.vue'
import AuthView from './AuthView.vue'
import JoinView from './JoinView.vue'
import ListsBar from './ListsBar.vue'
@@ -16,6 +17,7 @@ const username = computed(() => currentUsername())
const lists = computed(() => currentLists())
const invite = ref(readInvite())
const selectedListId = ref(null)
+const showAccount = ref(false)
// A list that was just joined has no keys yet, so nothing about it can be read.
const pending = computed(() => !!selectedListId.value && listIsPending(selectedListId.value))
@@ -57,6 +59,7 @@ async function onLogout () {
+
@@ -69,6 +72,7 @@ async function onLogout () {
diff --git a/src/components/TodoApp.vue b/src/components/TodoApp.vue
index 5c8cebf..d4d448d 100644
--- a/src/components/TodoApp.vue
+++ b/src/components/TodoApp.vue
@@ -6,6 +6,7 @@ import {
createTodo,
currentTodos,
destroyTodo,
+ pendingCount,
renameTodo,
setTodoCompleted,
todosStatus
@@ -42,10 +43,9 @@ const remaining = computed(() => todos.value.filter((todo) => !todo.completed).l
const loading = computed(() => todosStatus(props.listId) === 'loading')
// The mirror still holds the last good value, so the list stays on screen.
const stale = computed(() => todosStatus(props.listId) === 'error')
-// Nothing is queued while the server is unreachable, so a change made now is
-// simply lost. Rather than let people pile up work that gets thrown away, the
-// list goes read only and stays readable.
-const readOnly = computed(() => !connection.online)
+const offline = computed(() => !connection.online)
+// Writes queued for the server, shown on top of the list until they land.
+const waiting = computed(() => pendingCount(props.listId))
function readFilter () {
const name = window.location.hash.replace(/^#\/?/, '')
@@ -78,13 +78,12 @@ async function run (write) {
function add () {
const title = newTitle.value.trim()
- if (!title || readOnly.value) return
+ if (!title) return
newTitle.value = ''
run(() => createTodo(props.listId, title))
}
function startEditing (todo) {
- if (readOnly.value) return
editingId.value = todo.id
editTitle.value = todo.title
nextTick(() => editInput.value?.[0]?.focus())
@@ -92,9 +91,7 @@ function startEditing (todo) {
function finishEditing () {
const id = editingId.value
- // Disabling the input fires blur. Keep the edit open and the typing intact
- // until the connection is back, instead of discarding it.
- if (id === null || readOnly.value) return
+ if (id === null) return
const title = editTitle.value.trim()
editingId.value = null
run(() => (title ? renameTodo(props.listId, id, title) : destroyTodo(props.listId, id)))
@@ -110,15 +107,17 @@ function finishEditing () {
class="new-todo"
placeholder="What needs to be done?"
:maxlength="MAX_TITLE_LENGTH"
- :disabled="readOnly"
autofocus
@keyup.enter="add"
>
-
- Not connected to the server, so the list is read only right now. It will
- be editable again once the connection is back.
+
+ Not connected to the server. Changes are kept here and sent once it is
+ back, {{ waiting }} waiting so far.
+