libp2r2p 0.11.0 → 0.11.1
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/README.md +6 -0
- package/package.json +3 -1
- package/private-messenger/event-store/README.md +110 -0
- package/private-messenger/event-store/helpers/codec.js +136 -0
- package/private-messenger/event-store/index.js +176 -0
- package/private-messenger/file/README.md +27 -2
- package/private-messenger/file/helpers/cache.js +159 -0
- package/private-messenger/file/helpers/catalog.js +23 -1
- package/private-messenger/file/helpers/stream.js +31 -0
- package/private-messenger/file/index.js +37 -17
- package/private-messenger/index.js +27 -10
- package/private-messenger/services/storage-maintenance.js +1 -0
- package/private-messenger/session/README.md +39 -0
- package/private-messenger/session/helpers/publication.js +40 -0
- package/private-messenger/session/helpers/signer.js +13 -0
- package/private-messenger/session/helpers/work-storage.js +32 -0
- package/private-messenger/session/index.js +307 -0
package/README.md
CHANGED
|
@@ -907,3 +907,9 @@ capture outgoing ordinary-message ciphertext before publishing, even without rel
|
|
|
907
907
|
echo. For files, seeders instead keep expiring recipient authorizations and serve
|
|
908
908
|
verified local chunks as `irfsChunk_v1`; watchtowers retain ciphertext seeds.
|
|
909
909
|
No group derivation or multi-device seed replication is introduced.
|
|
910
|
+
|
|
911
|
+
Private-message consumers can now substitute recovery persistence through
|
|
912
|
+
[`private-messenger/event-store`](private-messenger/event-store/README.md), or use
|
|
913
|
+
local defaults, including a bounded streaming file cache. The optional
|
|
914
|
+
[`private-messenger/session`](private-messenger/session/README.md) coordinator
|
|
915
|
+
provides reusable DM inbox/outbox and attachment lifecycle without UI coupling.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "libp2r2p",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.1",
|
|
4
4
|
"description": "Peer-to-relay-to-peer",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"p2r2p",
|
|
@@ -86,8 +86,10 @@
|
|
|
86
86
|
"./private-channel": "./private-channel/index.js",
|
|
87
87
|
"./private-message": "./private-message/index.js",
|
|
88
88
|
"./private-messenger": "./private-messenger/index.js",
|
|
89
|
+
"./private-messenger/event-store": "./private-messenger/event-store/index.js",
|
|
89
90
|
"./private-messenger/file": "./private-messenger/file/index.js",
|
|
90
91
|
"./private-messenger/recovery": "./private-messenger/recovery/index.js",
|
|
92
|
+
"./private-messenger/session": "./private-messenger/session/index.js",
|
|
91
93
|
"./relay": "./relay/index.js",
|
|
92
94
|
"./temporary-storage": "./temporary-storage/index.js",
|
|
93
95
|
"./url": "./url/index.js",
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# Event-store adapters
|
|
2
|
+
|
|
3
|
+
`createPersonalCopyRecoveryStorage({ eventStore, signer })` returns `seeds`,
|
|
4
|
+
`authorizations`, and `close()`. Pass these to `PrivateMessenger` (`seedStorage`)
|
|
5
|
+
and `createPrivateFileTransfer` (`seedStorage`, `authorizationStorage`). The owner
|
|
6
|
+
signer and event store must address the same identity. There is no `window` access
|
|
7
|
+
and no launcher-specific private database API.
|
|
8
|
+
|
|
9
|
+
The signer exposes `getPublicKey`, `obfuscate(value, kind, scope)`, and byte-based
|
|
10
|
+
`nip44v3.decrypt(peer, kind, scope, ciphertext)`. The store exposes the existing
|
|
11
|
+
`query`, `addPersonalCopy`, and `remove` API. Queries require `&tags`, `!ids`,
|
|
12
|
+
`search:sort:desc`, and the personal-copy mirrors used by 44billion. Returned
|
|
13
|
+
wrappers remain encrypted until the adapter decrypts them. The adapter bounds memoized obfuscations (256) and decrypted grants (128); every
|
|
14
|
+
grant use still queries the store, so removal is immediately effective. Permission, lock,
|
|
15
|
+
read-only, quota and operational failures propagate; they are not empty results.
|
|
16
|
+
Expiry cleanup uses the signed wrapper expiration preserved by personal copies,
|
|
17
|
+
without decrypting the archive; the store may also purge expired wrappers itself.
|
|
18
|
+
|
|
19
|
+
## Contracts and ownership
|
|
20
|
+
|
|
21
|
+
- Seeds: `put(record)`, `iterate(query)`, `has(record)`, `removeLocal(query)`,
|
|
22
|
+
`prune({ now })`, and `revoke(records)`.
|
|
23
|
+
- Authorizations: the same operations plus `find(query)`, choosing the newest
|
|
24
|
+
valid share, then the shorter expiry and lexicographically smaller record ID.
|
|
25
|
+
- `iterate` is an async generator. Queries can name `recordId`, `channelPubkey`,
|
|
26
|
+
`receiverPubkey`, `since`/`until`, or the file tuple `controlChannelPubkey`,
|
|
27
|
+
`fileChannelPubkey`, `receiverPubkey`, and `indices`. `signal` cancels reads.
|
|
28
|
+
- The messenger/coordinator never closes caller-owned adapters. Close their
|
|
29
|
+
owner factory after the consumers. No ciphertext is duplicated into a local
|
|
30
|
+
library queue. Limits and storage admission belong to the event store.
|
|
31
|
+
- `createEventStoreChunkStorage({ eventStore })` implements `read`/`save` for
|
|
32
|
+
IRFS chunks. Reconstruction saves an owner template, not a forged announcer
|
|
33
|
+
identity. NostrDB keeps the usual deduplicated payloads and root references.
|
|
34
|
+
- `createEventStoreMessageStorage({ eventStore })` implements
|
|
35
|
+
`save(event, { peerPubkey, hearsay })`, preserving the DM context/provenance.
|
|
36
|
+
|
|
37
|
+
Local defaults remain available when the corresponding adapter is omitted.
|
|
38
|
+
Registration, cache reads and recovery requests never grant file access. A grant
|
|
39
|
+
neither pins chunks nor contains signer material. Synced timestamps never renew
|
|
40
|
+
retention. Missing channel configuration is not a distributed revocation.
|
|
41
|
+
|
|
42
|
+
## Immutable inner events
|
|
43
|
+
|
|
44
|
+
Recovery data uses self-authored kind 30078 templates, `content: ''`, personal
|
|
45
|
+
copy context `''`, and no hearsay. Coordinates are
|
|
46
|
+
`libp2r2p:recovery:seed:<id>` / `libp2r2p:recovery:grant:<id>` (no schema version
|
|
47
|
+
in `d`). Data changes create another snapshot. Imports preserve authored times;
|
|
48
|
+
identical writes query their coordinate and do not re-encrypt/revise it.
|
|
49
|
+
|
|
50
|
+
`id` is lowercase SHA-256 of UTF-8 `JSON.stringify([type, bodyTags])`, where
|
|
51
|
+
`type` is `seed` or `grant` and body tags follow the fixed encoder order below.
|
|
52
|
+
`d`, `t`, `s`, `D`, and CRDT decorations are derived, not hash inputs. The body
|
|
53
|
+
includes expiration. Original strings starting with `%` or `~` escape that first
|
|
54
|
+
character as `%25` or `%7E`; decoding reverses exactly one prefix. This protects
|
|
55
|
+
preserved strings from CRDT metadata interpretation. Numbers are canonical
|
|
56
|
+
nonnegative decimal strings. The decoder rebuilds and verifies all tags and the
|
|
57
|
+
coordinate, tolerating only the store's reserved CRDT decorations.
|
|
58
|
+
|
|
59
|
+
Grant body, in order: `c` (control channel), `channel` (data channel), `peer`,
|
|
60
|
+
`p` (recipient), `root`, optional `size`, `shared`, `expiration`. There is no `r`
|
|
61
|
+
root reference: a grant must not retain file bytes. Creation time is `shared`.
|
|
62
|
+
|
|
63
|
+
Router seed body: `c` (data/ordinary channel), `type=routerEnvelopeRow_v1`, `p`,
|
|
64
|
+
`router` (pubkey, original timestamp), numbered `router-tag` entries (original
|
|
65
|
+
transport `c` removed), `payload`, `key`, optional `iykc` (remaining envelope
|
|
66
|
+
fields), optional `event` (known inner ID), `range` (first/last), optional `file`
|
|
67
|
+
(control channel, peer, root) plus `received`, and `expiration`. Kind 26300 is
|
|
68
|
+
implied by the record type. The original router's `i` supplies the file index.
|
|
69
|
+
The wire reply still reconstructs the existing JSONL rows; no network codec was
|
|
70
|
+
replaced. File seed creation time is `received`; ordinary seed time is range end.
|
|
71
|
+
|
|
72
|
+
Carrier seed body: `c`, `type=nymCarrier_v1`, numbered `carrier` entries containing
|
|
73
|
+
kind/pubkey/timestamp/ID/signature/content, their numbered `carrier-tag` entries
|
|
74
|
+
with `carrierIndex:tagIndex` identities, `range`, `expiration`. Signatures and
|
|
75
|
+
original tag order are verified/preserved. Range/creation time uses the maximum
|
|
76
|
+
carrier timestamp, as the existing recovery codec does.
|
|
77
|
+
|
|
78
|
+
All snapshots have `t=libp2r2p:recovery:<type>` and selector `s`. Selector hashes
|
|
79
|
+
use the same UTF-8 JSON/SHA-256 operation on:
|
|
80
|
+
|
|
81
|
+
- `['grant', control, data, recipient]`
|
|
82
|
+
- `['seed', channel, recipientOrEmpty]`
|
|
83
|
+
- `['file', control, data, recipient, chunkIndex]`
|
|
84
|
+
|
|
85
|
+
Ordinary seeds also carry every UTC Unix day in their interval (`D`, 86400 seconds
|
|
86
|
+
per day). More than 32 days uses the literal `D='*'`. Queries include their days
|
|
87
|
+
and `*`, then verify exact interval overlap. Wide/unbounded queries skip the day
|
|
88
|
+
prefilter. They never filter wrapper `created_at` with the recovery interval.
|
|
89
|
+
Snapshots do not invent an interval between independent observations. The local
|
|
90
|
+
IDB implementation retains its existing logical min/max compaction.
|
|
91
|
+
|
|
92
|
+
## Deletion and synchronization
|
|
93
|
+
|
|
94
|
+
`revoke(records)` writes owner-authored kind-5 personal copies in context `''`,
|
|
95
|
+
with `a` targets for the **inner** 30078 coordinates and advisory `k=30078`.
|
|
96
|
+
Batches contain at most 100 targets. Their timestamp covers stored versions and
|
|
97
|
+
expiration is the latest target expiry. The normal NostrDB private-deletion path
|
|
98
|
+
applies local removal and synced tombstones. There is no custom `state` tag.
|
|
99
|
+
Local eviction/removal and expiry do not publish a deletion. Once a tombstone
|
|
100
|
+
expires, its original targets are expired too. Do not reauthor synced snapshots.
|
|
101
|
+
|
|
102
|
+
Sync transports must keep **their own recovery seeds local**. Persisting seeds
|
|
103
|
+
of the transport that synchronizes seeds into that same event store creates a
|
|
104
|
+
recursive stream of new synchronization data. Consumer chat sessions may use
|
|
105
|
+
these adapters while the vault's synchronization messenger keeps its default.
|
|
106
|
+
|
|
107
|
+
No legacy seed migration or automatic local-to-event-store copying is performed.
|
|
108
|
+
A newly configured adapter records new captures/shares. Paired devices still
|
|
109
|
+
need the same channel configuration, identity keys, mode and locally available
|
|
110
|
+
chunks to serve a synced grant. Watchtowers need only their preserved envelopes.
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { sha256 } from '@noble/hashes/sha2.js'
|
|
2
|
+
import { bytesToHex } from '../../../base16/index.js'
|
|
3
|
+
import { ValidationError } from '../../../error/index.js'
|
|
4
|
+
import { isValidEvent } from '../../../event/index.js'
|
|
5
|
+
|
|
6
|
+
const encoder = new TextEncoder()
|
|
7
|
+
const HEX = /^[0-9a-f]{64}$/
|
|
8
|
+
const DAY = 86400
|
|
9
|
+
export const PREFIX = 'libp2r2p:recovery:'
|
|
10
|
+
export const digest = value => bytesToHex(sha256(encoder.encode(JSON.stringify(value))))
|
|
11
|
+
const fail = () => { throw new ValidationError('INVALID_RECOVERY_RECORD') }
|
|
12
|
+
const hex = value => { if (!HEX.test(value || '')) fail(); return value }
|
|
13
|
+
const integer = value => { const n = Number(value); if (!Number.isSafeInteger(n) || n < 0 || String(n) !== String(value)) fail(); return n }
|
|
14
|
+
// Protect original values from NostrDB's reserved CRDT metadata syntax.
|
|
15
|
+
const escape = value => String(value).replace(/^%/, '%25').replace(/^~/, '%7E')
|
|
16
|
+
const unescape = value => value.startsWith('%25') ? `%${value.slice(3)}` : value.startsWith('%7E') ? `~${value.slice(3)}` : value
|
|
17
|
+
export function days (since, until) {
|
|
18
|
+
const first = Math.floor(since / DAY), last = Math.floor(until / DAY)
|
|
19
|
+
return last - first >= 32 ? ['*'] : Array.from({ length: Math.max(0, last - first + 1) }, (_, i) => String(first + i))
|
|
20
|
+
}
|
|
21
|
+
export function selector (type, row) {
|
|
22
|
+
return digest(type === 'grant'
|
|
23
|
+
? ['grant', row.controlChannelPubkey, row.fileChannelPubkey, row.receiverPubkey]
|
|
24
|
+
: row.fileChannelPubkey
|
|
25
|
+
? ['file', row.controlChannelPubkey, row.fileChannelPubkey, row.receiverPubkey, row.chunkIndex]
|
|
26
|
+
: ['seed', row.channelPubkey, row.receiverPubkey || ''])
|
|
27
|
+
}
|
|
28
|
+
function body (type, row) {
|
|
29
|
+
const tags = []
|
|
30
|
+
const add = (name, ...values) => tags.push([name, ...values.map(escape)])
|
|
31
|
+
const expires = integer(row.expiresAt)
|
|
32
|
+
if (type === 'grant') {
|
|
33
|
+
add('c', hex(row.controlChannelPubkey)); add('channel', hex(row.fileChannelPubkey))
|
|
34
|
+
add('peer', hex(row.peerPubkey)); add('p', hex(row.receiverPubkey)); add('root', hex(row.root))
|
|
35
|
+
if (row.size !== undefined) { if (!integer(row.size)) fail(); add('size', row.size) }
|
|
36
|
+
add('shared', integer(row.sharedAt))
|
|
37
|
+
if (expires <= row.sharedAt) fail()
|
|
38
|
+
} else if (type === 'seed') {
|
|
39
|
+
add('c', hex(row.fileChannelPubkey || row.channelPubkey))
|
|
40
|
+
add('type', row.recordType)
|
|
41
|
+
if (row.recordType === 'routerEnvelopeRow_v1') {
|
|
42
|
+
const payload = JSON.parse(row.payloadRow), envelope = JSON.parse(row.row)
|
|
43
|
+
if (!Array.isArray(payload) || payload.length !== 1 || typeof payload[0] !== 'string' || !Array.isArray(envelope) || envelope.length < 2 || envelope.length > 4 || !envelope.every(v => typeof v === 'string') || envelope[0] !== row.receiverPubkey) fail()
|
|
44
|
+
add('p', hex(row.receiverPubkey))
|
|
45
|
+
if (row.router?.kind !== 26300) fail()
|
|
46
|
+
add('router', hex(row.router.pubkey), integer(row.router.created_at))
|
|
47
|
+
if (!Array.isArray(row.router.tags)) fail()
|
|
48
|
+
row.router.tags.filter(tag => tag[0] !== 'c').forEach((tag, i) => {
|
|
49
|
+
if (!Array.isArray(tag) || !tag.length || !tag.every(v => typeof v === 'string')) fail()
|
|
50
|
+
add('router-tag', i, ...tag)
|
|
51
|
+
})
|
|
52
|
+
add('payload', payload[0]); add('key', envelope[1])
|
|
53
|
+
if (envelope.length > 2) add('iykc', ...envelope.slice(2))
|
|
54
|
+
if (row.innerEventId) add('event', hex(row.innerEventId))
|
|
55
|
+
const first = integer(row.firstSeenAt ?? row.router.created_at), last = integer(row.lastSeenAt ?? row.router.created_at)
|
|
56
|
+
if (first > last) fail()
|
|
57
|
+
add('range', first, last)
|
|
58
|
+
if (row.fileChannelPubkey) {
|
|
59
|
+
add('file', hex(row.controlChannelPubkey), hex(row.peerPubkey), hex(row.root))
|
|
60
|
+
add('received', integer(row.receivedAt))
|
|
61
|
+
const index = row.router.tags.find(tag => tag[0] === 'i')?.[1]
|
|
62
|
+
if (integer(index) !== row.chunkIndex) fail()
|
|
63
|
+
}
|
|
64
|
+
} else if (row.recordType === 'nymCarrier_v1') {
|
|
65
|
+
if (!row.carriers?.length) fail()
|
|
66
|
+
row.carriers.forEach((carrier, i) => {
|
|
67
|
+
if (!isValidEvent(carrier) || carrier.kind !== 26400) fail()
|
|
68
|
+
add('carrier', i, carrier.kind, carrier.pubkey, carrier.created_at, carrier.id, carrier.sig, carrier.content)
|
|
69
|
+
carrier.tags.forEach((tag, j) => add('carrier-tag', `${i}:${j}`, ...tag))
|
|
70
|
+
})
|
|
71
|
+
const at = Math.max(...row.carriers.map(c => c.created_at))
|
|
72
|
+
add('range', at, at)
|
|
73
|
+
} else fail()
|
|
74
|
+
} else fail()
|
|
75
|
+
add('expiration', expires)
|
|
76
|
+
return tags
|
|
77
|
+
}
|
|
78
|
+
export function encodeRecord (type, row) {
|
|
79
|
+
try {
|
|
80
|
+
const tags = body(type, row)
|
|
81
|
+
const id = digest([type, tags])
|
|
82
|
+
const range = tags.find(t => t[0] === 'range')
|
|
83
|
+
const recordedAt = type === 'grant' ? row.sharedAt : row.fileChannelPubkey ? row.receivedAt : Number(range[2])
|
|
84
|
+
return { kind: 30078, created_at: recordedAt, tags: [['d', `${PREFIX}${type}:${id}`], ['t', `${PREFIX}${type}`], ['s', selector(type, row)], ...tags, ...(type === 'seed' && !row.fileChannelPubkey ? days(Number(range[1]), Number(range[2])).map(day => ['D', day]) : [])], content: '' }
|
|
85
|
+
} catch (error) { if (error instanceof ValidationError) throw error; fail() }
|
|
86
|
+
}
|
|
87
|
+
export function decodeRecord (event) {
|
|
88
|
+
try {
|
|
89
|
+
if (event?.kind !== 30078 || event.content !== '' || !Array.isArray(event.tags)) fail()
|
|
90
|
+
const tags = event.tags.filter(t => !['~', 'z', 'zz'].includes(t[0])).map(t => {
|
|
91
|
+
if (!Array.isArray(t) || !t.every(v => typeof v === 'string')) fail()
|
|
92
|
+
return t.filter((v, i) => i === 0 || !v.startsWith('~')).map((v, i) => i ? unescape(v) : v)
|
|
93
|
+
})
|
|
94
|
+
const one = (name, required = true) => {
|
|
95
|
+
const matches = tags.filter(t => t[0] === name)
|
|
96
|
+
if (matches.length > 1 || (required && !matches.length)) fail()
|
|
97
|
+
return matches[0]?.slice(1)
|
|
98
|
+
}
|
|
99
|
+
const coordinate = one('d')[0]
|
|
100
|
+
const type = coordinate?.startsWith(`${PREFIX}grant:`) ? 'grant' : coordinate?.startsWith(`${PREFIX}seed:`) ? 'seed' : fail()
|
|
101
|
+
const row = { expiresAt: integer(one('expiration')[0]), recordId: coordinate }
|
|
102
|
+
if (type === 'grant') {
|
|
103
|
+
Object.assign(row, { controlChannelPubkey: one('c')[0], fileChannelPubkey: one('channel')[0], peerPubkey: one('peer')[0], receiverPubkey: one('p')[0], root: one('root')[0], sharedAt: integer(one('shared')[0]) })
|
|
104
|
+
const size = one('size', false); if (size) row.size = integer(size[0])
|
|
105
|
+
} else {
|
|
106
|
+
row.channelPubkey = one('c')[0]; row.recordType = one('type')[0]
|
|
107
|
+
const range = one('range'); row.firstSeenAt = integer(range[0]); row.lastSeenAt = integer(range[1])
|
|
108
|
+
if (row.recordType === 'routerEnvelopeRow_v1') {
|
|
109
|
+
row.receiverPubkey = one('p')[0]
|
|
110
|
+
const router = one('router')
|
|
111
|
+
const list = tags.filter(t => t[0] === 'router-tag').sort((a, b) => Number(a[1]) - Number(b[1]))
|
|
112
|
+
list.forEach((t, i) => { if (t[1] !== String(i)) fail() })
|
|
113
|
+
row.router = { kind: 26300, pubkey: router[0], created_at: integer(router[1]), tags: list.map(t => t.slice(2)) }
|
|
114
|
+
row.payloadRow = JSON.stringify(one('payload'))
|
|
115
|
+
const envelope = [row.receiverPubkey, ...one('key'), ...(one('iykc', false) || [])]
|
|
116
|
+
row.row = JSON.stringify(envelope); row.iykcPubkey = envelope[2] || ''
|
|
117
|
+
row.innerEventId = one('event', false)?.[0] || ''
|
|
118
|
+
const file = one('file', false)
|
|
119
|
+
if (file) Object.assign(row, { controlChannelPubkey: file[0], peerPubkey: file[1], root: file[2], fileChannelPubkey: row.channelPubkey, chunkIndex: integer(row.router.tags.find(t => t[0] === 'i')?.[1]), receivedAt: integer(one('received')[0]) })
|
|
120
|
+
} else if (row.recordType === 'nymCarrier_v1') {
|
|
121
|
+
row.carriers = tags.filter(t => t[0] === 'carrier').sort((a, b) => Number(a[1]) - Number(b[1])).map((t, i) => {
|
|
122
|
+
if (t[1] !== String(i)) fail()
|
|
123
|
+
const original = tags.filter(v => v[0] === 'carrier-tag' && v[1].startsWith(`${i}:`)).sort((a, b) => Number(a[1].split(':')[1]) - Number(b[1].split(':')[1]))
|
|
124
|
+
original.forEach((v, j) => { if (v[1] !== `${i}:${j}`) fail() })
|
|
125
|
+
return { kind: integer(t[2]), pubkey: t[3], created_at: integer(t[4]), id: t[5], sig: t[6], content: t[7], tags: original.map(v => v.slice(2)) }
|
|
126
|
+
})
|
|
127
|
+
} else fail()
|
|
128
|
+
}
|
|
129
|
+
const encoded = encodeRecord(type, row)
|
|
130
|
+
// Compare the complete semantic tag multiset, including derived indices.
|
|
131
|
+
const canonical = list => list.map(t => JSON.stringify(t)).sort()
|
|
132
|
+
const cleanEncoded = encoded.tags.map(t => t.map((v, i) => i ? unescape(v) : v))
|
|
133
|
+
if (JSON.stringify(canonical(tags)) !== JSON.stringify(canonical(cleanEncoded))) fail()
|
|
134
|
+
return { type, row }
|
|
135
|
+
} catch (error) { if (error instanceof ValidationError) throw error; fail() }
|
|
136
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import NMMR from 'nmmr'
|
|
2
|
+
import { chunkStream } from '../file/helpers/stream.js'
|
|
3
|
+
import { ValidationError } from '../../error/index.js'
|
|
4
|
+
import { decodeIrfsChunk } from '../../irfs/index.js'
|
|
5
|
+
import { encodeRecord, decodeRecord, selector, days, PREFIX } from './helpers/codec.js'
|
|
6
|
+
export { encodeRecord as encodeRecoveryRecord, decodeRecord as decodeRecoveryRecord } from './helpers/codec.js'
|
|
7
|
+
|
|
8
|
+
const nowSeconds = () => Math.floor(Date.now() / 1000)
|
|
9
|
+
function assertOptions (eventStore, signer) {
|
|
10
|
+
if (!eventStore?.query || !eventStore?.addPersonalCopy || !signer?.getPublicKey || !signer?.obfuscate || !signer?.nip44v3?.decrypt) throw new ValidationError('INVALID_EVENT_STORE_OPTIONS')
|
|
11
|
+
}
|
|
12
|
+
function committed (value) {
|
|
13
|
+
if (!value?.result?.ok) throw Object.assign(new Error(value?.result?.message || 'EVENT_STORE_WRITE_FAILED'), { code: value?.result?.code || 'EVENT_STORE_WRITE_FAILED' })
|
|
14
|
+
return value
|
|
15
|
+
}
|
|
16
|
+
// Bound each page and retain only the IDs at the timestamp boundary.
|
|
17
|
+
async function * pages (eventStore, filter, signal) {
|
|
18
|
+
let until = filter.until, excluded = []
|
|
19
|
+
while (true) {
|
|
20
|
+
signal?.throwIfAborted()
|
|
21
|
+
const { results } = await eventStore.query({ ...filter, ...(until === undefined ? {} : { until }), ...(excluded.length ? { '!ids': excluded } : {}), limit: 128, search: 'sort:desc' })
|
|
22
|
+
if (!results.length) return
|
|
23
|
+
for (const event of results) { signal?.throwIfAborted(); yield event }
|
|
24
|
+
const boundary = results.at(-1).created_at
|
|
25
|
+
const ids = results.filter(e => e.created_at === boundary).map(e => e.id)
|
|
26
|
+
excluded = boundary === until ? [...excluded, ...ids] : ids
|
|
27
|
+
until = boundary
|
|
28
|
+
if (results.length < 128) return
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export function createPersonalCopyRecoveryStorage ({ eventStore, signer, now = nowSeconds }) {
|
|
32
|
+
assertOptions(eventStore, signer)
|
|
33
|
+
let closed = false
|
|
34
|
+
let opening
|
|
35
|
+
const ready = () => (opening ??= Promise.all([signer.getPublicKey(), signer.obfuscate('', '1006', '')]).catch(error => { opening = undefined; throw error }))
|
|
36
|
+
const check = () => { if (closed) throw new Error('RECOVERY_STORAGE_CLOSED') }
|
|
37
|
+
const mirrors = new Map(), grants = new Map()
|
|
38
|
+
const mirror = (name, value) => {
|
|
39
|
+
const key = `${name}:${value}`
|
|
40
|
+
if (!mirrors.has(key)) {
|
|
41
|
+
if (mirrors.size >= 256) mirrors.delete(mirrors.keys().next().value)
|
|
42
|
+
const promise = Promise.resolve().then(() => signer.obfuscate(value, '1006', `#${name}`)).catch(error => { if (mirrors.get(key) === promise) mirrors.delete(key); throw error })
|
|
43
|
+
mirrors.set(key, promise)
|
|
44
|
+
}
|
|
45
|
+
return mirrors.get(key)
|
|
46
|
+
}
|
|
47
|
+
async function filterFor (type) {
|
|
48
|
+
check()
|
|
49
|
+
const [owner, context] = await ready()
|
|
50
|
+
return { kinds: [1006], authors: [owner], '#k': ['30078'], '#c': [context], '#v': ['0', '1'], '&o': [await mirror('t', `${PREFIX}${type}`)] }
|
|
51
|
+
}
|
|
52
|
+
async function read (wrapper) {
|
|
53
|
+
check()
|
|
54
|
+
// Query the store before every use; only decryption of an immutable wrapper
|
|
55
|
+
// is cached. A removed wrapper therefore cannot authorize another reply.
|
|
56
|
+
const cached = grants.get(wrapper.id)
|
|
57
|
+
if (cached) return { ...cached, row: { ...cached.row }, wrapper }
|
|
58
|
+
const [owner] = await ready()
|
|
59
|
+
const bytes = await signer.nip44v3.decrypt(owner, 30078, '', wrapper.content)
|
|
60
|
+
const decoded = decodeRecord(JSON.parse(new TextDecoder().decode(bytes)))
|
|
61
|
+
if (decoded.type === 'grant') {
|
|
62
|
+
if (grants.size >= 128) grants.delete(grants.keys().next().value)
|
|
63
|
+
grants.set(wrapper.id, { ...decoded, row: { ...decoded.row } })
|
|
64
|
+
}
|
|
65
|
+
return { ...decoded, wrapper }
|
|
66
|
+
}
|
|
67
|
+
async function * records (type, query = {}) {
|
|
68
|
+
const filter = await filterFor(type)
|
|
69
|
+
if (query.recordId) filter['&o'].push(await mirror('d', query.recordId))
|
|
70
|
+
else if (query.fileChannelPubkey && query.receiverPubkey) {
|
|
71
|
+
const indices = type === 'seed' ? query.indices : [undefined]
|
|
72
|
+
if (indices?.length) filter['#o'] = await Promise.all(indices.map(chunkIndex => mirror('s', selector(type, { ...query, chunkIndex }))))
|
|
73
|
+
} else if (query.channelPubkey) {
|
|
74
|
+
filter['&o'].push(await mirror('c', query.channelPubkey))
|
|
75
|
+
if (Number.isFinite(query.since) && Number.isFinite(query.until)) {
|
|
76
|
+
const buckets = days(query.since, query.until)
|
|
77
|
+
// A wide query must include daily records too, so skip the day prefilter.
|
|
78
|
+
if (!buckets.includes('*')) filter['#o'] = await Promise.all([...buckets, '*'].map(d => mirror('D', d)))
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
for await (const wrapper of pages(eventStore, filter, query.signal)) {
|
|
82
|
+
const { row } = await read(wrapper)
|
|
83
|
+
if (query.channelPubkey && row.channelPubkey !== query.channelPubkey) continue
|
|
84
|
+
if (query.controlChannelPubkey && row.controlChannelPubkey !== query.controlChannelPubkey) continue
|
|
85
|
+
if (query.fileChannelPubkey && row.fileChannelPubkey !== query.fileChannelPubkey) continue
|
|
86
|
+
if (query.receiverPubkey && row.receiverPubkey && row.receiverPubkey !== query.receiverPubkey) continue
|
|
87
|
+
if (query.indices && !query.indices.includes(row.chunkIndex)) continue
|
|
88
|
+
if (query.since !== undefined && row.lastSeenAt < query.since) continue
|
|
89
|
+
if (query.until !== undefined && row.firstSeenAt > query.until) continue
|
|
90
|
+
if (!query.includeExpired && row.expiresAt <= now()) continue
|
|
91
|
+
yield { row, wrapper }
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function store (type) {
|
|
95
|
+
return {
|
|
96
|
+
async put (row) {
|
|
97
|
+
check()
|
|
98
|
+
const event = encodeRecord(type, row)
|
|
99
|
+
if (row.expiresAt <= now()) return
|
|
100
|
+
const recordId = event.tags[0][1]
|
|
101
|
+
const existing = records(type, { recordId, includeExpired: true })
|
|
102
|
+
try { const first = await existing.next(); if (!first.done) return first.value.row } finally { await existing.return() }
|
|
103
|
+
const saved = committed(await eventStore.addPersonalCopy(event, { context: '' }))
|
|
104
|
+
// A pending kind-5 tombstone can reject the insertion; do not cache it.
|
|
105
|
+
if (saved.result.stored === false && saved.result.code === 'blocked') return
|
|
106
|
+
return { ...row, recordId }
|
|
107
|
+
},
|
|
108
|
+
async * iterate (query = {}) { for await (const { row } of records(type, query)) yield row },
|
|
109
|
+
async has (row) {
|
|
110
|
+
const iterator = records(type, { recordId: row.recordId || encodeRecord(type, row).tags[0][1] })
|
|
111
|
+
try { return !(await iterator.next()).done } finally { await iterator.return() }
|
|
112
|
+
},
|
|
113
|
+
async removeLocal (query = {}) {
|
|
114
|
+
for await (const { wrapper } of records(type, { ...query, includeExpired: true })) await eventStore.remove([['e', wrapper.id]])
|
|
115
|
+
},
|
|
116
|
+
async prune ({ now: at = now() } = {}) {
|
|
117
|
+
// Personal copies preserve the inner expiration on their signed wrapper.
|
|
118
|
+
// Cleanup must not decrypt the entire seed archive on startup/request.
|
|
119
|
+
for await (const wrapper of pages(eventStore, await filterFor(type))) {
|
|
120
|
+
const expiry = Number(wrapper.tags.find(tag => tag[0] === 'expiration')?.[1])
|
|
121
|
+
if (Number.isSafeInteger(expiry) && expiry <= at) await eventStore.remove([['e', wrapper.id]])
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
async revoke (rows) {
|
|
125
|
+
check()
|
|
126
|
+
const [owner] = await ready()
|
|
127
|
+
for (let i = 0; i < rows.length; i += 100) {
|
|
128
|
+
const group = rows.slice(i, i + 100).filter(row => row.expiresAt > now())
|
|
129
|
+
if (!group.length) continue
|
|
130
|
+
const tags = []; let createdAt = now(), expiresAt = 0
|
|
131
|
+
for (const row of group) {
|
|
132
|
+
const recordId = row.recordId || encodeRecord(type, row).tags[0][1]
|
|
133
|
+
for await (const { wrapper } of records(type, { recordId })) createdAt = Math.max(createdAt, wrapper.created_at)
|
|
134
|
+
tags.push(['a', `30078:${owner}:${recordId}`]); expiresAt = Math.max(expiresAt, row.expiresAt)
|
|
135
|
+
}
|
|
136
|
+
if (expiresAt <= createdAt) continue
|
|
137
|
+
committed(await eventStore.addPersonalCopy({ kind: 5, created_at: createdAt, tags: [...tags, ['k', '30078'], ['expiration', String(expiresAt)]], content: '' }, { context: '' }))
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const seeds = store('seed'), authorizations = store('grant')
|
|
143
|
+
authorizations.find = async query => {
|
|
144
|
+
let best
|
|
145
|
+
for await (const row of authorizations.iterate(query)) if (!best || row.sharedAt > best.sharedAt || (row.sharedAt === best.sharedAt && (row.expiresAt < best.expiresAt || (row.expiresAt === best.expiresAt && row.recordId < best.recordId)))) best = row
|
|
146
|
+
return best
|
|
147
|
+
}
|
|
148
|
+
return { seeds, authorizations, close () { closed = true; mirrors.clear(); grants.clear() } }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function createEventStoreChunkStorage ({ eventStore }) {
|
|
152
|
+
if (!eventStore?.query || !eventStore?.addPersonalCopy) throw new ValidationError('INVALID_EVENT_STORE_OPTIONS')
|
|
153
|
+
const storage = {
|
|
154
|
+
async read (root, index) {
|
|
155
|
+
const { results } = await eventStore.query({ kinds: [34601], '#d': [NMMR.deriveChunkId(root, index)], limit: 1 })
|
|
156
|
+
return results[0]
|
|
157
|
+
},
|
|
158
|
+
async save (event, descriptor) {
|
|
159
|
+
const decoded = decodeIrfsChunk(event)
|
|
160
|
+
if (decoded.root !== descriptor.root) throw new ValidationError('FILE_CHUNK_DESCRIPTOR_MISMATCH')
|
|
161
|
+
committed(await eventStore.addPersonalCopy({ kind: 34601, created_at: event.created_at, tags: structuredClone(event.tags), content: event.content }, { context: `dm:${descriptor.peerPubkey}` }))
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
storage.stream = (descriptor, { signal } = {}) => chunkStream({ read: storage.read, ...descriptor, signal })
|
|
165
|
+
return storage
|
|
166
|
+
}
|
|
167
|
+
export function createEventStoreMessageStorage ({ eventStore }) {
|
|
168
|
+
if (!eventStore?.addPersonalCopy) throw new ValidationError('INVALID_EVENT_STORE_OPTIONS')
|
|
169
|
+
return {
|
|
170
|
+
async save (event, { peerPubkey, hearsay = false } = {}) {
|
|
171
|
+
const result = await eventStore.addPersonalCopy(event, { context: `dm:${peerPubkey}`, hearsay })
|
|
172
|
+
if (!result?.result?.ok && !['blocked', 'expired'].includes(result?.result?.code)) committed(result)
|
|
173
|
+
return result
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
`createPrivateFileTransfer({ messenger, resolveChannel, storage, onError })`
|
|
4
4
|
creates one account coordinator attached to an initialized PrivateMessenger.
|
|
5
5
|
It does not create permanent watched channels for files. The application resolves
|
|
6
|
-
channel signers
|
|
7
|
-
`storage.save(event, descriptor)
|
|
6
|
+
channel signers. An optional adapter supplies `storage.read(root, index, descriptor)`
|
|
7
|
+
and `storage.save(event, descriptor)`; otherwise the library uses its bounded local
|
|
8
|
+
cache. `save` must resolve only after persistence.
|
|
8
9
|
The descriptor is `{ controlChannelPubkey, peerPubkey, root, size?, sharedAt? }`.
|
|
9
10
|
`sharedAt` is a stable sharing timestamp, never a read/retry timestamp.
|
|
10
11
|
|
|
@@ -125,3 +126,27 @@ the same pair/root.
|
|
|
125
126
|
Multi-device seed synchronization and group-channel derivation remain separate
|
|
126
127
|
follow-ups. Applications own durable manual-download intent, metadata retention
|
|
127
128
|
and UI policy. Removing one message must not delete a root retained by another.
|
|
129
|
+
|
|
130
|
+
## Optional persistence (0.11.1)
|
|
131
|
+
|
|
132
|
+
`storage`, `authorizationStorage`, and `seedStorage` are optional. The latter two
|
|
133
|
+
use the semantic contracts in `private-messenger/event-store`; omitted stores
|
|
134
|
+
retain their existing indexed local implementation. External stores are owned by
|
|
135
|
+
the caller. Synced grants are checked against the current channel mode/retention
|
|
136
|
+
before serving, and are never deleted globally because a channel is not loaded.
|
|
137
|
+
|
|
138
|
+
With no chunk adapter, `createFileCache` owns
|
|
139
|
+
`<messenger prefix>:file-chunks:idb` (files and chunks stores). Its default
|
|
140
|
+
`cacheMaxBytes` is 256 MiB of useful decoded bytes; physical IndexedDB overhead is
|
|
141
|
+
additional. Deduplication uses root/index. FIFO root eviction discards inactive
|
|
142
|
+
files to admit new data. Durable renewable reservations protect active transfers
|
|
143
|
+
and readers across tabs; stale reservations expire after two minutes. A blocked
|
|
144
|
+
transfer waits with cancellation. Unknown sizes reserve the budget exclusively;
|
|
145
|
+
an individual oversized file raises `FILE_EXCEEDS_CACHE_CAPACITY`. Physical quota
|
|
146
|
+
failures try discarding additional inactive roots before propagating.
|
|
147
|
+
|
|
148
|
+
Outgoing chunks are saved before publication; this does not authorize recipients.
|
|
149
|
+
`readChunk(root, index)` reads local data. `stream(descriptor, { signal })` uses
|
|
150
|
+
bounded sequential reads and backpressure, not a whole-file Blob. Cancel streams
|
|
151
|
+
when unused. Cache eviction can remove a previously completed file, so new
|
|
152
|
+
requests recheck persisted indices. The cache is included in identity cleanup.
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { chunkStream } from './stream.js'
|
|
2
|
+
import { ValidationError } from '../../../error/index.js'
|
|
3
|
+
import { decodeIrfsChunk, IRFS_CHUNK_BYTES } from '../../../irfs/index.js'
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_FILE_CACHE_BYTES = 256 * 1024 * 1024
|
|
6
|
+
const request = value => new Promise((resolve, reject) => { value.onsuccess = () => resolve(value.result); value.onerror = () => reject(value.error) })
|
|
7
|
+
const done = tx => new Promise((resolve, reject) => { tx.oncomplete = resolve; tx.onabort = () => reject(tx.error || new Error('FILE_CACHE_ABORTED')); tx.onerror = () => {} })
|
|
8
|
+
|
|
9
|
+
// Payload budget is useful decoded bytes, not the browser's physical disk usage.
|
|
10
|
+
// Files and chunk mutations commit together; durable leases fence other tabs.
|
|
11
|
+
export function createFileCache ({ prefix, indexedDB = globalThis.indexedDB, maxBytes = DEFAULT_FILE_CACHE_BYTES, now = Date.now }) {
|
|
12
|
+
if (!indexedDB?.open || !prefix || !Number.isSafeInteger(maxBytes) || maxBytes < 1) throw new ValidationError('INVALID_FILE_CACHE_OPTIONS')
|
|
13
|
+
let closed = false
|
|
14
|
+
const tokens = new Map()
|
|
15
|
+
const waiters = new Set()
|
|
16
|
+
const dbPromise = new Promise((resolve, reject) => {
|
|
17
|
+
const opening = indexedDB.open(`${prefix}:file-chunks:idb`, 1)
|
|
18
|
+
opening.onerror = () => reject(opening.error)
|
|
19
|
+
opening.onupgradeneeded = () => {
|
|
20
|
+
opening.result.createObjectStore('files', { keyPath: 'root' })
|
|
21
|
+
opening.result.createObjectStore('chunks', { keyPath: ['root', 'index'] }).createIndex('root', 'root')
|
|
22
|
+
}
|
|
23
|
+
opening.onsuccess = () => { opening.result.onversionchange = () => opening.result.close(); resolve(opening.result) }
|
|
24
|
+
})
|
|
25
|
+
const notify = () => { for (const wake of waiters) wake() }
|
|
26
|
+
const assertOpen = () => { if (closed) throw new Error('FILE_CACHE_CLOSED') }
|
|
27
|
+
async function transaction (work) {
|
|
28
|
+
assertOpen()
|
|
29
|
+
const db = await dbPromise
|
|
30
|
+
assertOpen()
|
|
31
|
+
const tx = db.transaction(['files', 'chunks'], 'readwrite')
|
|
32
|
+
const completion = done(tx)
|
|
33
|
+
try { const result = await work(tx.objectStore('files'), tx.objectStore('chunks')); await completion; return result } catch (error) { try { tx.abort() } catch {} await completion.catch(() => {}); throw error }
|
|
34
|
+
}
|
|
35
|
+
async function removeRoot (files, chunks, root) {
|
|
36
|
+
await new Promise((resolve, reject) => {
|
|
37
|
+
const cursor = chunks.index('root').openCursor(root)
|
|
38
|
+
cursor.onerror = () => reject(cursor.error)
|
|
39
|
+
cursor.onsuccess = () => { if (!cursor.result) return resolve(); cursor.result.delete(); cursor.result.continue() }
|
|
40
|
+
})
|
|
41
|
+
await request(files.delete(root))
|
|
42
|
+
}
|
|
43
|
+
const live = file => Object.fromEntries(Object.entries(file.leases || {}).filter(([, lease]) => lease.until > now()))
|
|
44
|
+
const cost = file => Math.max(file.used, ...Object.values(file.leases || {}).map(v => v.size))
|
|
45
|
+
async function attempt (root, size, token) {
|
|
46
|
+
return transaction(async (files, chunks) => {
|
|
47
|
+
const all = await request(files.getAll())
|
|
48
|
+
for (const file of all) file.leases = live(file)
|
|
49
|
+
let file = all.find(f => f.root === root)
|
|
50
|
+
if (!file) { file = { root, used: 0, createdAt: now(), leases: {} }; all.push(file) }
|
|
51
|
+
file.leases[token] = { size, until: now() + 120000 }
|
|
52
|
+
let total = all.reduce((sum, f) => sum + cost(f), 0)
|
|
53
|
+
const victims = all.filter(f => f.root !== root && !Object.keys(f.leases).length).sort((a, b) => a.createdAt - b.createdAt || a.root.localeCompare(b.root))
|
|
54
|
+
if (total - victims.reduce((sum, f) => sum + cost(f), 0) > maxBytes) return false
|
|
55
|
+
for (const victim of victims) {
|
|
56
|
+
if (total <= maxBytes) break
|
|
57
|
+
await removeRoot(files, chunks, victim.root); total -= cost(victim)
|
|
58
|
+
}
|
|
59
|
+
await request(files.put(file)); return true
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
async function wait (signal) {
|
|
63
|
+
signal?.throwIfAborted(); assertOpen()
|
|
64
|
+
await new Promise((resolve, reject) => {
|
|
65
|
+
const finish = error => { clearTimeout(timer); waiters.delete(wake); signal?.removeEventListener('abort', abort); error ? reject(error) : resolve() }
|
|
66
|
+
const wake = () => finish(), abort = () => finish(signal.reason)
|
|
67
|
+
const timer = setTimeout(wake, 250)
|
|
68
|
+
waiters.add(wake); signal?.addEventListener('abort', abort, { once: true })
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
async function reserve (root, size, { signal } = {}) {
|
|
72
|
+
if (!/^[0-9a-f]{64}$/.test(root)) throw new ValidationError('INVALID_FILE_ROOT')
|
|
73
|
+
size ??= maxBytes
|
|
74
|
+
if (!Number.isSafeInteger(size) || size < 1) throw new ValidationError('INVALID_FILE_SIZE')
|
|
75
|
+
if (size > maxBytes) throw Object.assign(new Error('FILE_EXCEEDS_CACHE_CAPACITY'), { code: 'FILE_EXCEEDS_CACHE_CAPACITY', maxBytes })
|
|
76
|
+
const token = crypto.randomUUID()
|
|
77
|
+
while (true) {
|
|
78
|
+
signal?.throwIfAborted(); assertOpen()
|
|
79
|
+
if (await attempt(root, size, token)) break
|
|
80
|
+
await wait(signal)
|
|
81
|
+
}
|
|
82
|
+
tokens.set(token, root)
|
|
83
|
+
let released = false
|
|
84
|
+
return async () => {
|
|
85
|
+
if (released) return
|
|
86
|
+
released = true; tokens.delete(token)
|
|
87
|
+
if (!closed) {
|
|
88
|
+
await transaction(async files => {
|
|
89
|
+
const file = await request(files.get(root)); if (!file) return
|
|
90
|
+
delete file.leases[token]; await request(files.put(file))
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
notify()
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const timer = setInterval(() => {
|
|
97
|
+
if (!tokens.size || closed) return
|
|
98
|
+
transaction(async files => {
|
|
99
|
+
for (const [token, root] of tokens) {
|
|
100
|
+
const file = await request(files.get(root))
|
|
101
|
+
if (file?.leases[token]) { file.leases[token].until = now() + 120000; await request(files.put(file)) }
|
|
102
|
+
}
|
|
103
|
+
}).catch(() => {})
|
|
104
|
+
}, 30000)
|
|
105
|
+
timer.unref?.()
|
|
106
|
+
const api = {
|
|
107
|
+
maxBytes, reserve,
|
|
108
|
+
async read (root, index) {
|
|
109
|
+
assertOpen(); const db = await dbPromise
|
|
110
|
+
const tx = db.transaction('chunks', 'readonly')
|
|
111
|
+
const row = await request(tx.objectStore('chunks').get([root, index]))
|
|
112
|
+
return row?.event
|
|
113
|
+
},
|
|
114
|
+
async save (event, descriptor) {
|
|
115
|
+
const decoded = decodeIrfsChunk(event)
|
|
116
|
+
if (decoded.root !== descriptor.root) throw new ValidationError('FILE_CHUNK_DESCRIPTOR_MISMATCH')
|
|
117
|
+
if (descriptor.size !== undefined && (decoded.total !== Math.ceil(descriptor.size / IRFS_CHUNK_BYTES) || (decoded.index === decoded.total - 1 && decoded.contentBytes.length !== descriptor.size - decoded.index * IRFS_CHUNK_BYTES))) throw new ValidationError('FILE_CHUNK_DESCRIPTOR_MISMATCH')
|
|
118
|
+
const actualSize = decoded.index === decoded.total - 1 ? decoded.index * IRFS_CHUNK_BYTES + decoded.contentBytes.length : undefined
|
|
119
|
+
if (actualSize > maxBytes || decoded.index * IRFS_CHUNK_BYTES + decoded.contentBytes.length > maxBytes) throw Object.assign(new Error('FILE_EXCEEDS_CACHE_CAPACITY'), { code: 'FILE_EXCEEDS_CACHE_CAPACITY' })
|
|
120
|
+
const release = await reserve(decoded.root, descriptor.size ?? actualSize)
|
|
121
|
+
try {
|
|
122
|
+
while (true) {
|
|
123
|
+
try {
|
|
124
|
+
await transaction(async (files, chunks) => {
|
|
125
|
+
const file = await request(files.get(decoded.root))
|
|
126
|
+
const prior = await request(chunks.get([decoded.root, decoded.index]))
|
|
127
|
+
file.used += decoded.contentBytes.length - (prior?.bytes || 0)
|
|
128
|
+
await request(chunks.put({ root: decoded.root, index: decoded.index, bytes: decoded.contentBytes.length, event: structuredClone(event) }))
|
|
129
|
+
await request(files.put(file))
|
|
130
|
+
})
|
|
131
|
+
break
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (error?.name !== 'QuotaExceededError') throw error
|
|
134
|
+
const removed = await transaction(async (files, chunks) => {
|
|
135
|
+
const candidates = (await request(files.getAll())).filter(f => f.root !== decoded.root && !Object.keys(live(f)).length).sort((a, b) => a.createdAt - b.createdAt)
|
|
136
|
+
if (!candidates.length) return false
|
|
137
|
+
await removeRoot(files, chunks, candidates[0].root)
|
|
138
|
+
return true
|
|
139
|
+
})
|
|
140
|
+
if (!removed) throw error
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
} finally { await release() }
|
|
144
|
+
},
|
|
145
|
+
async stream (descriptor, { signal } = {}) {
|
|
146
|
+
const release = await reserve(descriptor.root, descriptor.size, { signal })
|
|
147
|
+
return chunkStream({ read: api.read, root: descriptor.root, size: descriptor.size, signal, release })
|
|
148
|
+
},
|
|
149
|
+
async close () {
|
|
150
|
+
if (closed) return
|
|
151
|
+
clearInterval(timer)
|
|
152
|
+
await transaction(async files => {
|
|
153
|
+
for (const [token, root] of tokens) { const file = await request(files.get(root)); if (file) { delete file.leases[token]; await request(files.put(file)) } }
|
|
154
|
+
})
|
|
155
|
+
closed = true; tokens.clear(); notify(); (await dbPromise).close()
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return api
|
|
159
|
+
}
|