@hearthlog/ledger 0.0.0-stage → 0.1.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/LICENSE +235 -0
- package/README.md +173 -2
- package/contracts/append-hash.json +30 -0
- package/docs/database-structure.md +176 -0
- package/docs/event-store-service-design.md +124 -0
- package/docs/event-store-service.http +182 -0
- package/docs/event-store-service.openapi.yaml +1651 -0
- package/package.json +43 -3
- package/scripts/logical-backup.ts +656 -0
- package/src/action-batches.ts +65 -0
- package/src/app-registry.ts +479 -0
- package/src/auth.ts +186 -0
- package/src/contracts.ts +251 -0
- package/src/domain.ts +245 -0
- package/src/errors.ts +50 -0
- package/src/event-validation.ts +181 -0
- package/src/fence.ts +76 -0
- package/src/http.ts +656 -0
- package/src/index.ts +3 -0
- package/src/ledger-app.ts +2057 -0
- package/src/rpc.ts +42 -0
- package/src/schema.ts +236 -0
- package/src/schemas.ts +429 -0
- package/src/sqlite-export.ts +136 -0
- package/src/transfer.ts +219 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# Ledger service design
|
|
2
|
+
|
|
3
|
+
Ledger is the durable write-side service for the offline-first Hearthlog application.
|
|
4
|
+
Clients keep local read models, submit domain events when online, replay
|
|
5
|
+
missing events after reconnecting, and receive ordered realtime events through
|
|
6
|
+
gateway-authenticated WebSockets.
|
|
7
|
+
|
|
8
|
+
## Runtime boundary
|
|
9
|
+
|
|
10
|
+
The deployed runtime is a TypeScript Cloudflare Worker:
|
|
11
|
+
|
|
12
|
+
- Elysia owns stateless HTTP routing and validation through the experimental
|
|
13
|
+
`CloudflareAdapter`.
|
|
14
|
+
- Schemas in `src/schemas.ts` define HTTP request DTOs and registered response
|
|
15
|
+
models.
|
|
16
|
+
- Route-scoped Elysia resolvers authenticate administrator and app routes and
|
|
17
|
+
inject verified claims.
|
|
18
|
+
- Durable Object RPC carries plain structured-clone payloads and a
|
|
19
|
+
discriminated `RpcResult` for expected domain failures.
|
|
20
|
+
- `LedgerApp.fetch()` is reserved for the exact WebSocket upgrade route.
|
|
21
|
+
|
|
22
|
+
Unknown fields and non-canonical query names are rejected. `afterVersion` and
|
|
23
|
+
`limit` accept non-negative or positive safe integers as appropriate. Page
|
|
24
|
+
sizes above 512 are clamped to 512.
|
|
25
|
+
|
|
26
|
+
Invalid input returns an explicit `LedgerError`, usually `400
|
|
27
|
+
validation_error`. Every other exception is a server fault and returns `500`,
|
|
28
|
+
so clients retry it instead of discarding a change.
|
|
29
|
+
|
|
30
|
+
## Durable Object topology
|
|
31
|
+
|
|
32
|
+
`AppRegistry` is addressed deterministically as `registry-v1`. It owns
|
|
33
|
+
application names, provisioning state, and credential lifecycle metadata. It
|
|
34
|
+
calls the target `LedgerApp` through its namespace binding; namespace access is
|
|
35
|
+
the internal capability boundary.
|
|
36
|
+
|
|
37
|
+
Each application UUID addresses one `LedgerApp`. That object owns the
|
|
38
|
+
application metadata, logs, events, entity claims, idempotency records, and
|
|
39
|
+
active hibernating WebSockets. This makes an application the coordination atom
|
|
40
|
+
while avoiding a global event-write bottleneck.
|
|
41
|
+
|
|
42
|
+
Both classes extend `DurableObject<Env>`, use generated Wrangler environment
|
|
43
|
+
types, and initialize ordered SQLite migrations inside
|
|
44
|
+
`blockConcurrencyWhile`.
|
|
45
|
+
|
|
46
|
+
## Authentication
|
|
47
|
+
|
|
48
|
+
The operator endpoints use `ADMIN_TOKEN`, compared in constant time after both
|
|
49
|
+
values are hashed to a fixed length.
|
|
50
|
+
|
|
51
|
+
Creating or rotating an application issues an HMAC-signed app token containing
|
|
52
|
+
the application UUID, principal UUID, generation, and signing-key ID. Every app
|
|
53
|
+
operation verifies the signature at the HTTP boundary and checks the claims
|
|
54
|
+
against `app_metadata` inside the application object. Rotation and revocation
|
|
55
|
+
therefore invalidate old tokens without a distributed token list.
|
|
56
|
+
|
|
57
|
+
The production browser never receives the long-lived app token. It calls the
|
|
58
|
+
same-origin `/api/ledger` gateway, whose Worker injects
|
|
59
|
+
`LEDGER_AUTH_TOKEN` and forwards through a service binding. Cloudflare Access
|
|
60
|
+
remains in front of the public deployment.
|
|
61
|
+
|
|
62
|
+
## Append and replay
|
|
63
|
+
|
|
64
|
+
An append request contains a UUID `requestId` and 1 to 512 events. Ledger:
|
|
65
|
+
|
|
66
|
+
1. validates event envelopes, including unique event IDs within the request
|
|
67
|
+
and timestamps that a JavaScript `Date` can represent;
|
|
68
|
+
2. hashes a canonical representation of the log ID and event content;
|
|
69
|
+
3. checks any existing idempotency record;
|
|
70
|
+
4. rejects an action or event ID the log already accepted
|
|
71
|
+
(`409 action_already_accepted` or `409 event_already_accepted`) and
|
|
72
|
+
validates each entity's version chain;
|
|
73
|
+
5. assigns contiguous log and entity versions and rejects a response too large
|
|
74
|
+
for one SQLite row (`400 request_too_large`);
|
|
75
|
+
6. inserts events and updates entity claims, Gallery photo references, and the log head;
|
|
76
|
+
7. persists the successful response for exact replay.
|
|
77
|
+
|
|
78
|
+
Steps 3 through 7 run in one synchronous SQLite transaction. A matching retry
|
|
79
|
+
returns the stored response after runtime validation. A different request body
|
|
80
|
+
under the same request ID returns `409 idempotency_mismatch`.
|
|
81
|
+
|
|
82
|
+
Realtime delivery runs after the transaction commits and is best effort. A
|
|
83
|
+
delivery failure is logged and never changes the append response; sockets
|
|
84
|
+
recover through HTTP replay.
|
|
85
|
+
|
|
86
|
+
HTTP replay is ordered and authoritative. `GET /logs/{logId}/events` pages by
|
|
87
|
+
global log version.
|
|
88
|
+
|
|
89
|
+
File originals (today Gallery's photos) are immutable app-scoped objects outside Ledger. An entity
|
|
90
|
+
whose definition declares a `fileReference` names its file version in a payload key (generated into
|
|
91
|
+
`FILE_REFERENCES`); a valid add, or an update that names a new version, grants only its own id and
|
|
92
|
+
file version to the current log. The reference is copied into a fork only when the event is
|
|
93
|
+
within the accepted fork prefix. The private file gateway checks the exact
|
|
94
|
+
reference through `GET /logs/{logId}/gallery-photos/{photoId}/files/{fileVersionId}/reference`
|
|
95
|
+
before accessing R2; an application or log check alone is insufficient.
|
|
96
|
+
|
|
97
|
+
## Realtime delivery
|
|
98
|
+
|
|
99
|
+
The browser opens the same-origin socket URL with its last applied log
|
|
100
|
+
version. The frontend Worker enforces same-origin access, injects its
|
|
101
|
+
server-side app token, and forwards the upgrade through a service binding.
|
|
102
|
+
Elysia verifies the token once, overwrites the internal claims header, and
|
|
103
|
+
routes the request to the application object. Durable Object namespace access
|
|
104
|
+
is the capability boundary for those internal claims.
|
|
105
|
+
|
|
106
|
+
The socket uses `ledger.v1` and is server-to-client only. Like HTTP replay, it
|
|
107
|
+
refuses a fork that is still being prepared (`409 fork_preparing`) and a client
|
|
108
|
+
whose replay version is too old for the log (`409 client_upgrade_required`). Catch-up emits
|
|
109
|
+
ordered `event` frames and then `ready`. A gap greater than 512 or a cursor
|
|
110
|
+
ahead of the log head produces `resync_required`; the client returns to HTTP
|
|
111
|
+
replay and reconnects from the new cursor. Attachments persist the cursor,
|
|
112
|
+
principal, and generation across hibernation.
|
|
113
|
+
|
|
114
|
+
## Recovery
|
|
115
|
+
|
|
116
|
+
The logical backup utility snapshots each log head, exports ordered NDJSON
|
|
117
|
+
pages, and records SHA-256 checksums in a versioned manifest. Restore validates
|
|
118
|
+
all files before provisioning a fresh application, replays and verifies every
|
|
119
|
+
page, rotates the bootstrap token, and revokes a partially restored target on
|
|
120
|
+
failure.
|
|
121
|
+
|
|
122
|
+
Deployment validation separates generated-type checks, Worker and Node
|
|
123
|
+
TypeScript checks, Durable Object contracts, bundle dry-run, startup profiling,
|
|
124
|
+
and browser-level HTTP/WebSocket/backup contracts.
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
@host = http://127.0.0.1:8787
|
|
2
|
+
@appToken = replace-with-app-token
|
|
3
|
+
@adminToken = replace-with-admin-token
|
|
4
|
+
@appId = 11111111-1111-1111-1111-111111111111
|
|
5
|
+
@logId = 22222222-2222-2222-2222-222222222222
|
|
6
|
+
|
|
7
|
+
###
|
|
8
|
+
# Public database readiness check
|
|
9
|
+
GET {{host}}/health
|
|
10
|
+
|
|
11
|
+
###
|
|
12
|
+
# Runtime: resolve the current app and principal identity
|
|
13
|
+
GET {{host}}/session
|
|
14
|
+
Authorization: Bearer {{appToken}}
|
|
15
|
+
|
|
16
|
+
###
|
|
17
|
+
# Admin: create an app. Save the returned token; it is shown only once.
|
|
18
|
+
POST {{host}}/admin/v1/apps
|
|
19
|
+
Content-Type: application/json
|
|
20
|
+
Authorization: Bearer {{adminToken}}
|
|
21
|
+
|
|
22
|
+
{
|
|
23
|
+
"name": "stor"
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
###
|
|
27
|
+
# Admin: list apps
|
|
28
|
+
GET {{host}}/admin/v1/apps
|
|
29
|
+
Authorization: Bearer {{adminToken}}
|
|
30
|
+
|
|
31
|
+
###
|
|
32
|
+
# Admin: read, then replace, an app's event policy
|
|
33
|
+
GET {{host}}/admin/v1/apps/{{appId}}/policy
|
|
34
|
+
Authorization: Bearer {{adminToken}}
|
|
35
|
+
|
|
36
|
+
###
|
|
37
|
+
PUT {{host}}/admin/v1/apps/{{appId}}/policy
|
|
38
|
+
Authorization: Bearer {{adminToken}}
|
|
39
|
+
Content-Type: application/json
|
|
40
|
+
|
|
41
|
+
{ "contracts": ["org.stor.health"], "dynamicEvents": false, "validation": "enforce" }
|
|
42
|
+
|
|
43
|
+
###
|
|
44
|
+
# Admin: rotate an app token
|
|
45
|
+
POST {{host}}/admin/v1/apps/{{appId}}/rotate-token
|
|
46
|
+
Authorization: Bearer {{adminToken}}
|
|
47
|
+
|
|
48
|
+
###
|
|
49
|
+
# Admin: revoke an app
|
|
50
|
+
POST {{host}}/admin/v1/apps/{{appId}}/revoke
|
|
51
|
+
Authorization: Bearer {{adminToken}}
|
|
52
|
+
|
|
53
|
+
###
|
|
54
|
+
# Runtime: list logs in the token's app
|
|
55
|
+
GET {{host}}/logs
|
|
56
|
+
Authorization: Bearer {{appToken}}
|
|
57
|
+
|
|
58
|
+
###
|
|
59
|
+
# Runtime: create an empty log
|
|
60
|
+
POST {{host}}/logs
|
|
61
|
+
Content-Type: application/json
|
|
62
|
+
Authorization: Bearer {{appToken}}
|
|
63
|
+
|
|
64
|
+
{
|
|
65
|
+
"name": "personal-ledger"
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
###
|
|
69
|
+
# Runtime: fetch log metadata
|
|
70
|
+
GET {{host}}/logs/{{logId}}
|
|
71
|
+
Authorization: Bearer {{appToken}}
|
|
72
|
+
|
|
73
|
+
###
|
|
74
|
+
# Runtime: create an entity with its first event
|
|
75
|
+
POST {{host}}/logs/{{logId}}/events
|
|
76
|
+
Content-Type: application/json
|
|
77
|
+
Authorization: Bearer {{appToken}}
|
|
78
|
+
|
|
79
|
+
{
|
|
80
|
+
"requestId": "33333333-3333-3333-3333-333333333333",
|
|
81
|
+
"events": [
|
|
82
|
+
{
|
|
83
|
+
"id": "44444444-4444-4444-4444-444444444444",
|
|
84
|
+
"type": "TRANSACTION_ADDED",
|
|
85
|
+
"entityId": "transaction-1",
|
|
86
|
+
"timestamp": 1717323612903,
|
|
87
|
+
"payload": {
|
|
88
|
+
"id": "55555555-5555-5555-5555-555555555555",
|
|
89
|
+
"name": "Rent",
|
|
90
|
+
"amount": 1200,
|
|
91
|
+
"transaction_status": "active",
|
|
92
|
+
"transaction_category": "housing"
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
]
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
###
|
|
99
|
+
# Runtime: update an existing entity currently at entityVersion 1
|
|
100
|
+
POST {{host}}/logs/{{logId}}/events
|
|
101
|
+
Content-Type: application/json
|
|
102
|
+
Authorization: Bearer {{appToken}}
|
|
103
|
+
|
|
104
|
+
{
|
|
105
|
+
"requestId": "66666666-6666-6666-6666-666666666666",
|
|
106
|
+
"events": [
|
|
107
|
+
{
|
|
108
|
+
"type": "TRANSACTION_UPDATED",
|
|
109
|
+
"entityId": "transaction-1",
|
|
110
|
+
"baseEntityVersion": 1,
|
|
111
|
+
"payload": {
|
|
112
|
+
"transaction_status": "completed"
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
]
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
###
|
|
119
|
+
# Runtime: append several events to one entity in a single atomic version chain
|
|
120
|
+
POST {{host}}/logs/{{logId}}/events
|
|
121
|
+
Content-Type: application/json
|
|
122
|
+
Authorization: Bearer {{appToken}}
|
|
123
|
+
|
|
124
|
+
{
|
|
125
|
+
"requestId": "88888888-8888-4888-8888-888888888888",
|
|
126
|
+
"events": [
|
|
127
|
+
{
|
|
128
|
+
"type": "TRANSACTION_UPDATED",
|
|
129
|
+
"entityId": "transaction-1",
|
|
130
|
+
"baseEntityVersion": 2,
|
|
131
|
+
"payload": { "amount": 1250 }
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
"type": "TRANSACTION_COMPLETED",
|
|
135
|
+
"entityId": "transaction-1",
|
|
136
|
+
"baseEntityVersion": 3,
|
|
137
|
+
"payload": { "completed": true }
|
|
138
|
+
}
|
|
139
|
+
]
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
###
|
|
143
|
+
# Runtime: append atomically to two existing entities
|
|
144
|
+
POST {{host}}/logs/{{logId}}/events
|
|
145
|
+
Content-Type: application/json
|
|
146
|
+
Authorization: Bearer {{appToken}}
|
|
147
|
+
|
|
148
|
+
{
|
|
149
|
+
"requestId": "77777777-7777-7777-7777-777777777777",
|
|
150
|
+
"events": [
|
|
151
|
+
{
|
|
152
|
+
"type": "TRANSACTION_UPDATED",
|
|
153
|
+
"entityId": "transaction-1",
|
|
154
|
+
"baseEntityVersion": 2,
|
|
155
|
+
"payload": { "amount": 1250 }
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
"type": "TRANSACTION_UPDATED",
|
|
159
|
+
"entityId": "transaction-2",
|
|
160
|
+
"baseEntityVersion": 1,
|
|
161
|
+
"payload": { "amount": 75 }
|
|
162
|
+
}
|
|
163
|
+
]
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
###
|
|
167
|
+
# Runtime: replay the log after a log version
|
|
168
|
+
GET {{host}}/logs/{{logId}}/events?afterVersion=0
|
|
169
|
+
Authorization: Bearer {{appToken}}
|
|
170
|
+
|
|
171
|
+
###
|
|
172
|
+
# Runtime: upgrade to a ledger.v1 WebSocket after the local log head
|
|
173
|
+
GET {{host}}/logs/{{logId}}/events/socket?afterVersion=0
|
|
174
|
+
Authorization: Bearer {{appToken}}
|
|
175
|
+
Connection: Upgrade
|
|
176
|
+
Upgrade: websocket
|
|
177
|
+
Sec-WebSocket-Protocol: ledger.v1
|
|
178
|
+
|
|
179
|
+
###
|
|
180
|
+
# Runtime: delete a log and all child records
|
|
181
|
+
DELETE {{host}}/logs/{{logId}}
|
|
182
|
+
Authorization: Bearer {{appToken}}
|