Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
/.parcel-cache/
*.DS_Store
dist/**/*.test.*
dist/**/test-utils.*
dist/**/kv-height-harness.*
/.agent/
/.tldr*
/.agents
Expand Down
57 changes: 54 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ Dual ESM/CJS output with TypeScript declarations:

## Code Organization

All source lives in `src/`. The largest files are `internals.ts` and `chelonia.ts`.
All source lives in `src/`. The largest files are `kv.ts`, `chelonia.ts` and `internals.ts`.

```
src/
Expand All @@ -78,11 +78,16 @@ src/
├── functions.ts # CID creation, blake32Hash, base64 utilities
├── utils.ts # Key lookup, permissions, event stream utilities
├── errors.ts # Custom error classes via ChelErrorGenerator factory
├── error-names.ts # Name-based error check and `.cause` reader behind the realm-safe predicates (internal)
├── internal-errors.ts # Internal KV errors (`ChelErrorKvMaxAttempts`, `isKvMaxAttempts`)
├── events.ts # Event name constants
├── constants.ts # INVITE_STATUS enum
├── persistent-actions.ts # PersistentAction queue with retry
├── journal.ts # Per-contract state-change journal (diff + snapshots)
├── kv.ts # KV slots — declarative typed key/value store API
├── kv-constants.ts # KV constants (`KV_NOOP`, statuses, reasons, key-name defaults)
├── kv-height.ts # KV contract-height awareness (waits, recovery; internal)
├── kv-runtime.ts # KV runtime maps: created by _init, replaced by reset (internal)
├── presets.ts # Server preset for configuring Chelonia
├── time-sync.ts # Server time synchronization via monotonic offsets
├── chelonia-utils.ts # Optional utility selectors (e.g., chelonia/kv/queuedSet)
Expand Down Expand Up @@ -131,7 +136,8 @@ Often the source file's default export is an array of the selector names it regi
| `chelonia/journal/*` | `journal.ts` | Public journal API — get, reconstruct, clear |
| `chelonia/private/journal/*` | `journal.ts` | Internal journal recorder — recordEvent |
| `chelonia/kv/queuedSet` | `chelonia-utils.ts` | Optional queued raw KV setter |
| `chelonia/kv/{defineSlot,update,read,sync,clear,status,refreshFilters}` | `kv.ts` | KV slots — declarative typed key/value API |
| `chelonia/kv/{defineSlot,update,read,sync,clear,status,settledStatus,whenSettled,refreshFilters}` | `kv.ts` | KV slots — declarative typed key/value API |
| `chelonia/kv/withHeightRecovery` | `kv.ts` | Height recovery (sync and retry) around raw `kv/get` / `kv/set` calls |
| `chelonia/externalStateSetup` | `local-selectors/` | External state synchronization |

#### Key Public Selectors
Expand Down Expand Up @@ -220,7 +226,38 @@ Full documentation:
- Selector reference and events: [docs/api.md](docs/api.md#kv-slots)
- Public selectors: `chelonia/kv/defineSlot`, `chelonia/kv/update`,
`chelonia/kv/read`, `chelonia/kv/sync`, `chelonia/kv/clear`,
`chelonia/kv/status`, `chelonia/kv/refreshFilters`
`chelonia/kv/status`, `chelonia/kv/settledStatus`,
`chelonia/kv/whenSettled`, `chelonia/kv/refreshFilters`, and
`chelonia/kv/withHeightRecovery` (the slots' height recovery, for raw
`kv/get` / `kv/set` calls)
- Contract heights: a KV value can only be verified once the local
contract has reached the height it was written at, and the server only
accepts writes stamped with its current height. Never treat an
unverifiable value as absent; see
[docs/kv.md](docs/kv.md#contract-heights) and
[KV-REVAMPED.md §3.4](docs/specs/KV-REVAMPED.md#34-height-stamps-and-verifiability).
Detect the resulting error with `isKvHeightAhead(e)` (`src/errors.ts`),
not with `instanceof` and not by comparing `e.name` yourself. Detect the
error for a value whose stamp is missing or malformed (it can never be
verified; syncing doesn't help) with `isKvHeightMalformed(e)`. The KV
selectors never reject with the old `'ChelErrorInvalidMessageHeight'`
name any more; lower-level parsing
(`chelonia/parseEncryptedOrUnencryptedDetachedMessage`) still throws it,
both for a value that is ahead and for a malformed stamp. Likewise
`isKvConflict(e)` for a write that ran out of attempts, or got a `412`
without `onconflict`.
- Never await `chelonia/kv/update`, `clear`, `queuedSet`, single-key
`chelonia/kv/sync`, `whenSettled` or `withHeightRecovery` for a
contract from code that contract's lanes await (a `sideEffect`, a
`queueInvocation` body, an `onconflict`, an `onUpdate`): it never
settles (a *synchronous* call from a same-contract `onUpdate` rejects
with `ChelErrorKvReentrant` instead). (Raw `chelonia/kv/set` and
`kv/get` queue on no lane, so they settle: `kv/set`'s passive waits
time out, and `kv/get` has none, rejecting at once.) From another
contract's `sideEffect` or `queueInvocation` body, it holds that
contract's lane for the whole recovery. Raw pubsub handlers and event
listeners aren't awaited, so they may await writes. See
[docs/kv.md](docs/kv.md#how-height-recovery-runs).

### Contract State Structure

Expand Down Expand Up @@ -324,6 +361,20 @@ describe('Feature name', () => {
})
```

Shared helpers live in plain modules that register no selectors and are
excluded from the CJS build: `src/test-utils.ts` (incl. a simulated KV
server, `makeKvServer`) and `src/kv-height-harness.ts` (the harness of
every `kv-height*.test.ts` file except `kv-height-events.test.ts`, with
the helpers they share). Test files may import those, never each other.
A test must fail rather than hang when the behaviour it checks regresses:
poll with `until(cond, label)` and bound a promise that might never settle
with `within(promise, label)` (both in `src/test-utils.ts`).
`npm test` runs with `--test-timeout=60000`. On newer Node lines (checked on
26) that bounds each test; on Node 22, which CI uses, it bounds each test
*file*, and only `until` / `within` bound a single test. Keep every test
file under about 25 s when run alone on Node 22; split one that grows
past that.

Test files use `.test.ts` suffix and are discovered automatically: `npm test` runs every
`src/**/*.test.ts` file via the Node test runner, each in its own process (fresh SBP
registrations per file). Never import one test file from another: SBP keeps the first
Expand Down
89 changes: 78 additions & 11 deletions dist/cjs/chelonia-utils.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,14 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
};
Object.defineProperty(exports, "__esModule", { value: true });
const sbp_1 = __importDefault(require("@sbp/sbp"));
const errors_js_1 = require("./errors.cjs");
const internal_errors_js_1 = require("./internal-errors.cjs");
const kv_height_js_1 = require("./kv-height.cjs");
// This file contains non-core parts of Chelonia, i.e., functionality that is
// useful but optional. The threshold for something being 'optional' generally
// is something that can be implemented externally using only public Chelonia
// selectors.
// selectors (here plus the library's error predicates and internal helpers:
// `queueAbortable`, and the `maxAttempts` check).
// Optional functionality can make certain assumptions about contracts or
// actions to make things simpler or easier to implement.
// Currently, a single selector is defined: 'chelonia/kv/queuedSet'.
Expand All @@ -21,16 +25,79 @@ exports.default = (0, sbp_1.default)('sbp/selectors/register', {
// `chelonia/kv/set`. However, the `chelonia/kv/set` primitive is needed if
// the queueing logic needs to be more advanced, the key to use requires
// custom logic or _if the `onconflict` callback also needs to be queued_.
'chelonia/kv/queuedSet': ({ contractID, key, data, onconflict, ifMatch, maxAttempts, signal, encryptionKeyName = 'cek', signingKeyName = 'csk' }) => {
return (0, sbp_1.default)('chelonia/queueInvocation', contractID, () => {
return (0, sbp_1.default)('chelonia/kv/set', contractID, key, data, {
ifMatch,
encryptionKeyId: (0, sbp_1.default)('chelonia/contract/currentKeyIdByName', contractID, encryptionKeyName),
signingKeyId: (0, sbp_1.default)('chelonia/contract/currentKeyIdByName', contractID, signingKeyName),
onconflict,
maxAttempts,
signal
});
// Raw `chelonia/kv/set` never syncs the contract, though: wrap such a call
// in `chelonia/kv/withHeightRecovery`, as this selector does (see
// docs/kv.md).
//
// When the server value is ahead of the local contract
// (`ChelErrorKvHeightAhead`), the write waits (holding no lane) for a sync
// of the contract, which runs on the contract's internal lane, and is
// retried (`onHeightAhead: 'sync'`, the default), up to
// `maxHeightRecoveries` times, without `kv/set`'s passive wait for the
// height, except on the last attempt, where that wait is the write's only
// chance. Pass `onHeightAhead: 'reject'` to get the error at once, without
// waiting or syncing: nothing retries the write then (a deferred reload
// only refreshes a slot's mirror, and only for a value that is ahead, not
// after a stale-stamp `409`), so use it only when the caller re-issues the
// write itself.
// Pass `maxHeightRecoveries: 0` to wait passively without syncing. See
// KV-REVAMPED.md §4.2 step 5a. A write that recovers is enqueued again,
// so a later write to the same key can commit before it (as with an
// app-level retry). `signal` ends the write at once while it is queued
// (it then sends nothing). Awaited from the contract's own lanes (one of
// its `sideEffect`s, a raw pubsub handler or a `queueInvocation` body for
// it, an `onUpdate` of one of its slots), it never settles; a synchronous
// call from such an `onUpdate` rejects with `ChelErrorKvReentrant` (see
// docs/kv.md, "How height recovery runs").
// `allowUnverifiedConflict` is passed on to `chelonia/kv/set`.
//
// Running out of `maxAttempts` resolving `412` conflicts, or a `412`
// without `onconflict`, rejects with `ChelErrorKvConflict`, like
// `chelonia/kv/update` and `chelonia/kv/clear`; match it with
// `isKvConflict(e)`. Its `.cause` is `kv/set`'s: `{ currentStatus,
// currentData, etag }` from the last server response, with `currentData`
// only when that value is known (`'present'` and decodable, or
// `'absent'`), and `currentStatus` only when the last response could be
// read and decoded. The exception is a conflict whose last value is ahead of
// the local contract (with an `onconflict`, and without
// `allowUnverifiedConflict`): `kv/set` rejects with
// `ChelErrorKvHeightAhead`, which is recovered from as above (and is the
// rejection once the recoveries run out). A blind write
// (`allowUnverifiedConflict`) doesn't depend on the value, so that is a
// conflict too.
'chelonia/kv/queuedSet': ({ contractID, key, data, onconflict, allowUnverifiedConflict, ifMatch, maxAttempts, signal, onHeightAhead, maxHeightRecoveries, encryptionKeyName = 'cek', signingKeyName = 'csk' }) => {
const label = `queuedSet: ${contractID}::${key}`;
if (!(0, kv_height_js_1.isValidMaxAttempts)(maxAttempts)) {
return Promise.reject(new errors_js_1.ChelErrorKvUpdateInvalid(`[chelonia/kv] ${label}: \`maxAttempts\` must be an integer >= 1`));
}
return (0, sbp_1.default)('chelonia/kv/withHeightRecovery', contractID, ({ heightWaitMs }) => {
// `signal` also ends the wait while the write is still queued.
return (0, kv_height_js_1.queueAbortable)(contractID, [signal], () => {
return (0, sbp_1.default)('chelonia/kv/set', contractID, key, data, {
ifMatch,
encryptionKeyId: (0, sbp_1.default)('chelonia/contract/currentKeyIdByName', contractID, encryptionKeyName),
signingKeyId: (0, sbp_1.default)('chelonia/contract/currentKeyIdByName', contractID, signingKeyName),
onconflict,
allowUnverifiedConflict,
maxAttempts,
heightWaitMs,
signal
});
}).result;
}, {
onHeightAhead, maxHeightRecoveries, signal, label
})
.catch((e) => {
// `kv/set`'s internal error for running out of attempts, or for a
// `412` without `onconflict` (only `ChelErrorKvHeightAhead` is
// retried above, so this is outside the recovery loop). The cause
// keeps `kv/set`'s shape: there is no slot to normalize it against.
if ((0, internal_errors_js_1.isKvMaxAttempts)(e)) {
throw new errors_js_1.ChelErrorKvConflict(`[chelonia/kv] queuedSet: ${contractID}::${key} ` + (typeof onconflict === 'function'
? 'ran out of attempts resolving conflicts'
: 'conflicts with the server value, and no onconflict handler was provided'), { cause: e.cause });
}
throw e;
});
}
});
Loading
Loading