@lsdsoftware/ordered-record-store 0.2.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 +21 -0
- package/README.md +106 -0
- package/dist/crc32c.d.ts +2 -0
- package/dist/crc32c.js +22 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/memory-store.d.ts +2 -0
- package/dist/memory-store.js +65 -0
- package/dist/record-id.d.ts +17 -0
- package/dist/record-id.js +82 -0
- package/dist/s3-object-store.d.ts +8 -0
- package/dist/s3-object-store.js +51 -0
- package/dist/segment-codec.d.ts +18 -0
- package/dist/segment-codec.js +172 -0
- package/dist/segmented-store.d.ts +2 -0
- package/dist/segmented-store.js +797 -0
- package/dist/types.d.ts +73 -0
- package/dist/types.js +11 -0
- package/dist/validation.d.ts +6 -0
- package/dist/validation.js +28 -0
- package/docs/DESIGN.md +359 -0
- package/package.json +47 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 LSD Software
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# ordered-record-store
|
|
2
|
+
|
|
3
|
+
A small TypeScript ESM module for appending and retrieving opaque records in order.
|
|
4
|
+
|
|
5
|
+
The package is deliberately domain-neutral. It knows about streams, records, and storage lifecycle; it does not know about users, buddies, messages, conversations, or any host application.
|
|
6
|
+
|
|
7
|
+
## Design status
|
|
8
|
+
|
|
9
|
+
The durable design is a **working design, not a frozen contract**. The package now
|
|
10
|
+
includes both the memory adapter used by fast tests and the segmented local/S3
|
|
11
|
+
adapter described below. Memory-store records disappear when the process exits;
|
|
12
|
+
do not deploy that adapter as production persistence.
|
|
13
|
+
|
|
14
|
+
The durable adapter uses local working segments as a write-back cache over one S3 segment namespace:
|
|
15
|
+
|
|
16
|
+
```text
|
|
17
|
+
append opaque record
|
|
18
|
+
-> append complete framed record to local working segment
|
|
19
|
+
-> durably flush local file
|
|
20
|
+
-> acknowledge caller
|
|
21
|
+
-> overwrite that segment's S3 object after at most about five minutes
|
|
22
|
+
-> rotate near 256 KiB; after 14 idle days evict a clean local copy
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
S3 is never on the synchronous append path. The design accepts up to roughly five minutes of loss only when the local durable volume itself is catastrophically lost.
|
|
26
|
+
|
|
27
|
+
## Public API
|
|
28
|
+
|
|
29
|
+
The current interface provides:
|
|
30
|
+
|
|
31
|
+
- `append({ streamId, data })`
|
|
32
|
+
- `read({ streamId, beforeId?, afterId?, limit })`
|
|
33
|
+
- `getLatest(streamIds)`
|
|
34
|
+
- `compareRecordIds(left, right)` and explicit async `close()`
|
|
35
|
+
- opaque string stream IDs, record IDs, and record data
|
|
36
|
+
|
|
37
|
+
The old caller-supplied idempotency key, duplicate result, and stream-deletion API
|
|
38
|
+
have been removed. The store does not permanently deduplicate caller requests. A
|
|
39
|
+
consuming application may perform cheap, domain-aware best-effort duplicate
|
|
40
|
+
suppression at the stream head.
|
|
41
|
+
|
|
42
|
+
The durable design uses per-record UUIDv7 values internally. Its public 43-character base64url cursor contains both the record UUID and its segment UUID, allowing exact segment lookup without a manifest. Both adapters use this format. Consumers treat it as opaque and call `compareRecordIds()` only when record ordering is actually required. The async durable factory reconstructs local files only and performs no S3 availability check at startup.
|
|
43
|
+
|
|
44
|
+
## Segment lifecycle summary
|
|
45
|
+
|
|
46
|
+
- The newest segment is mutable; older segments become immutable when rotation creates a newer segment.
|
|
47
|
+
- Before appending, rotate when the current segment is approximately 256 KiB or larger; overshoot by one complete record is fine.
|
|
48
|
+
- The first append that makes a clean local segment dirty sets a five-minute checkpoint deadline. Later appends do not move it.
|
|
49
|
+
- A durable per-file clean/dirty bit lets startup recover unsynced-to-S3 local bytes without a persisted catalog or remote active namespace.
|
|
50
|
+
- If no local file exists, locate the newest S3 segment with one newest-first listing. Download it when it is below the rotation target; otherwise create a new segment.
|
|
51
|
+
- After 14 days without a successful append, evict the normally clean local copy. A dirty-and-idle file is an exceptional long-outage recovery case and must upload successfully before eviction. This is cache eviction, not sealing.
|
|
52
|
+
- Startup validates local files, reconstructs only the local working set, and queues dirty uploads. It performs no S3 probe, listing, or download.
|
|
53
|
+
- Nonlocal segments are fetched as complete objects and retained in a disposable 64-segment in-memory LRU, avoiding repeat `GetObject` calls during continued pagination.
|
|
54
|
+
- The MVP assumes exactly one store/service instance.
|
|
55
|
+
- The bucket has versioning disabled and never transitions segment objects to Glacier or another restore-required storage class.
|
|
56
|
+
- Version 1 uses a small custom big-endian binary frame with leading/trailing lengths and CRC32C, plus SHA-256 for each S3 upload; it does not use Protobuf.
|
|
57
|
+
|
|
58
|
+
See [docs/DESIGN.md](docs/DESIGN.md) for the complete recovery cases, decisions, open questions, and implementation sequence.
|
|
59
|
+
|
|
60
|
+
## Usage
|
|
61
|
+
|
|
62
|
+
The memory adapter is created with:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
import { createMemoryOrderedRecordStore } from '@lsdsoftware/ordered-record-store'
|
|
66
|
+
|
|
67
|
+
const store = createMemoryOrderedRecordStore()
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The durable adapter is opened asynchronously with an explicit persistent local
|
|
71
|
+
directory and an injected object-store boundary:
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
import {
|
|
75
|
+
createS3SegmentObjectStore,
|
|
76
|
+
openSegmentedOrderedRecordStore,
|
|
77
|
+
} from '@lsdsoftware/ordered-record-store'
|
|
78
|
+
import { S3Client } from '@aws-sdk/client-s3'
|
|
79
|
+
|
|
80
|
+
const objectStore = createS3SegmentObjectStore({
|
|
81
|
+
client: new S3Client({ region: 'ap-southeast-1' }),
|
|
82
|
+
bucket: 'example-ordered-records',
|
|
83
|
+
keyPrefix: 'messenger',
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
const store = await openSegmentedOrderedRecordStore({
|
|
87
|
+
dataDirectory: '/var/lib/diepkhuc/ordered-record-store',
|
|
88
|
+
objectStore,
|
|
89
|
+
})
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Create one instance per consuming process and inject it into all handlers. The
|
|
93
|
+
application owns AWS client configuration and credentials. Call `close()` during
|
|
94
|
+
graceful shutdown; dirty local files are recovered and queued on the next start.
|
|
95
|
+
|
|
96
|
+
## Commands
|
|
97
|
+
|
|
98
|
+
Node.js 20 or newer is required.
|
|
99
|
+
|
|
100
|
+
```sh
|
|
101
|
+
npm install
|
|
102
|
+
npm test
|
|
103
|
+
npm run build
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The package is already consumed by `apsvc-diepkhuc-messenger` through a local `file:` dependency. Publishing or pinning a reproducible package version remains deployment work.
|
package/dist/crc32c.d.ts
ADDED
package/dist/crc32c.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const TABLE = createTable();
|
|
2
|
+
/** CRC-32C (Castagnoli), returned as an unsigned 32-bit integer. */
|
|
3
|
+
export function crc32c(data) {
|
|
4
|
+
let crc = 0xffffffff;
|
|
5
|
+
for (const byte of data) {
|
|
6
|
+
crc = TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
|
7
|
+
}
|
|
8
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
9
|
+
}
|
|
10
|
+
function createTable() {
|
|
11
|
+
const table = new Uint32Array(256);
|
|
12
|
+
for (let index = 0; index < table.length; index++) {
|
|
13
|
+
let value = index;
|
|
14
|
+
for (let bit = 0; bit < 8; bit++) {
|
|
15
|
+
value = (value & 1) === 1
|
|
16
|
+
? 0x82f63b78 ^ (value >>> 1)
|
|
17
|
+
: value >>> 1;
|
|
18
|
+
}
|
|
19
|
+
table[index] = value >>> 0;
|
|
20
|
+
}
|
|
21
|
+
return table;
|
|
22
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { MAX_DATA_BYTES, MAX_IDENTIFIER_BYTES, MAX_READ_LIMIT, OrderedRecordStoreError, type AppendRequest, type OrderedRecordStore, type OrderedRecordStoreErrorCode, type ReadRequest, type RecordId, type SegmentObject, type SegmentObjectStore, type SegmentedOrderedRecordStoreOptions, type StoredRecord, type StreamId, } from './types.js';
|
|
2
|
+
export { compareRecordIds } from './record-id.js';
|
|
3
|
+
export { createMemoryOrderedRecordStore } from './memory-store.js';
|
|
4
|
+
export { openSegmentedOrderedRecordStore } from './segmented-store.js';
|
|
5
|
+
export { createS3SegmentObjectStore, type S3SegmentObjectStoreOptions, } from './s3-object-store.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { MAX_DATA_BYTES, MAX_IDENTIFIER_BYTES, MAX_READ_LIMIT, OrderedRecordStoreError, } from './types.js';
|
|
2
|
+
export { compareRecordIds } from './record-id.js';
|
|
3
|
+
export { createMemoryOrderedRecordStore } from './memory-store.js';
|
|
4
|
+
export { openSegmentedOrderedRecordStore } from './segmented-store.js';
|
|
5
|
+
export { createS3SegmentObjectStore, } from './s3-object-store.js';
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { generateRecordIdentity, parseRecordId } from './record-id.js';
|
|
2
|
+
import { OrderedRecordStoreError, } from './types.js';
|
|
3
|
+
import { assertData, assertIdentifier, assertLimit, invalidArgument } from './validation.js';
|
|
4
|
+
export function createMemoryOrderedRecordStore() {
|
|
5
|
+
const streams = new Map();
|
|
6
|
+
const segmentIds = new Map();
|
|
7
|
+
let closed = false;
|
|
8
|
+
return {
|
|
9
|
+
async append({ streamId, data }) {
|
|
10
|
+
assertOpen(closed);
|
|
11
|
+
assertIdentifier(streamId, 'streamId');
|
|
12
|
+
assertData(data);
|
|
13
|
+
const identity = generateRecordIdentity(segmentIds.get(streamId));
|
|
14
|
+
segmentIds.set(streamId, identity.segmentBytes);
|
|
15
|
+
const record = {
|
|
16
|
+
id: identity.id,
|
|
17
|
+
streamId,
|
|
18
|
+
data,
|
|
19
|
+
createdAt: new Date(identity.createdAtMilliseconds).toISOString(),
|
|
20
|
+
};
|
|
21
|
+
const records = streams.get(streamId) ?? [];
|
|
22
|
+
records.push(record);
|
|
23
|
+
streams.set(streamId, records);
|
|
24
|
+
return record;
|
|
25
|
+
},
|
|
26
|
+
async read({ streamId, beforeId, afterId, limit }) {
|
|
27
|
+
assertOpen(closed);
|
|
28
|
+
assertIdentifier(streamId, 'streamId');
|
|
29
|
+
assertLimit(limit);
|
|
30
|
+
if (beforeId !== undefined && afterId !== undefined) {
|
|
31
|
+
throw invalidArgument('beforeId and afterId are mutually exclusive');
|
|
32
|
+
}
|
|
33
|
+
const records = streams.get(streamId) ?? [];
|
|
34
|
+
if (beforeId === undefined && afterId === undefined) {
|
|
35
|
+
return records.slice(Math.max(0, records.length - limit));
|
|
36
|
+
}
|
|
37
|
+
const boundary = beforeId ?? afterId;
|
|
38
|
+
parseRecordId(boundary);
|
|
39
|
+
const index = records.findIndex(record => record.id === boundary);
|
|
40
|
+
if (index < 0) {
|
|
41
|
+
throw invalidArgument('pagination boundary must be a record from the requested stream');
|
|
42
|
+
}
|
|
43
|
+
return beforeId !== undefined
|
|
44
|
+
? records.slice(Math.max(0, index - limit), index)
|
|
45
|
+
: records.slice(index + 1, index + 1 + limit);
|
|
46
|
+
},
|
|
47
|
+
async getLatest(streamIds) {
|
|
48
|
+
assertOpen(closed);
|
|
49
|
+
const result = new Map();
|
|
50
|
+
for (const streamId of streamIds) {
|
|
51
|
+
assertIdentifier(streamId, 'streamId');
|
|
52
|
+
result.set(streamId, streams.get(streamId)?.at(-1) ?? null);
|
|
53
|
+
}
|
|
54
|
+
return result;
|
|
55
|
+
},
|
|
56
|
+
async close() {
|
|
57
|
+
closed = true;
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function assertOpen(closed) {
|
|
62
|
+
if (closed) {
|
|
63
|
+
throw new OrderedRecordStoreError('CLOSED', 'ordered record store is closed');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface ParsedRecordId {
|
|
2
|
+
readonly recordBytes: Buffer;
|
|
3
|
+
readonly segmentBytes: Buffer;
|
|
4
|
+
readonly recordHex: string;
|
|
5
|
+
readonly segmentHex: string;
|
|
6
|
+
}
|
|
7
|
+
export interface GeneratedRecordIdentity extends ParsedRecordId {
|
|
8
|
+
readonly id: string;
|
|
9
|
+
readonly createdAtMilliseconds: number;
|
|
10
|
+
}
|
|
11
|
+
export declare function generateRecordIdentity(segmentBytes?: Uint8Array): GeneratedRecordIdentity;
|
|
12
|
+
export declare function parseRecordId(value: string, name?: string): ParsedRecordId;
|
|
13
|
+
export declare function compareRecordIds(left: string, right: string): -1 | 0 | 1;
|
|
14
|
+
export declare function makeRecordId(recordBytes: Uint8Array, segmentBytes: Uint8Array): string;
|
|
15
|
+
export declare function uuidV7Timestamp(bytes: Uint8Array): number;
|
|
16
|
+
export declare function invertUuidHex(bytes: Uint8Array): string;
|
|
17
|
+
export declare function segmentBytesFromHex(value: string): Buffer;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { parse as parseUuid, v7 as uuidv7 } from 'uuid';
|
|
2
|
+
import { invalidArgument } from './validation.js';
|
|
3
|
+
const UUID_BYTES = 16;
|
|
4
|
+
const CURSOR_BYTES = UUID_BYTES * 2;
|
|
5
|
+
const CURSOR_LENGTH = 43;
|
|
6
|
+
export function generateRecordIdentity(segmentBytes) {
|
|
7
|
+
const recordBytes = Buffer.from(parseUuid(uuidv7()));
|
|
8
|
+
const actualSegmentBytes = segmentBytes === undefined
|
|
9
|
+
? recordBytes
|
|
10
|
+
: Buffer.from(segmentBytes);
|
|
11
|
+
assertUuidV7(actualSegmentBytes, 'segment UUID');
|
|
12
|
+
const id = Buffer.concat([recordBytes, actualSegmentBytes]).toString('base64url');
|
|
13
|
+
const parsed = parseRecordId(id);
|
|
14
|
+
return {
|
|
15
|
+
...parsed,
|
|
16
|
+
id,
|
|
17
|
+
createdAtMilliseconds: uuidV7Timestamp(recordBytes),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export function parseRecordId(value, name = 'recordId') {
|
|
21
|
+
if (typeof value !== 'string'
|
|
22
|
+
|| value.length !== CURSOR_LENGTH
|
|
23
|
+
|| !/^[A-Za-z0-9_-]+$/.test(value)) {
|
|
24
|
+
throw invalidArgument(`${name} must be a canonical ordered-record cursor`);
|
|
25
|
+
}
|
|
26
|
+
const bytes = Buffer.from(value, 'base64url');
|
|
27
|
+
if (bytes.length !== CURSOR_BYTES || bytes.toString('base64url') !== value) {
|
|
28
|
+
throw invalidArgument(`${name} must be a canonical ordered-record cursor`);
|
|
29
|
+
}
|
|
30
|
+
const recordBytes = bytes.subarray(0, UUID_BYTES);
|
|
31
|
+
const segmentBytes = bytes.subarray(UUID_BYTES);
|
|
32
|
+
assertUuidV7(recordBytes, `${name} record UUID`);
|
|
33
|
+
assertUuidV7(segmentBytes, `${name} segment UUID`);
|
|
34
|
+
return {
|
|
35
|
+
recordBytes,
|
|
36
|
+
segmentBytes,
|
|
37
|
+
recordHex: recordBytes.toString('hex'),
|
|
38
|
+
segmentHex: segmentBytes.toString('hex'),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
export function compareRecordIds(left, right) {
|
|
42
|
+
const comparison = Buffer.compare(parseRecordId(left, 'left').recordBytes, parseRecordId(right, 'right').recordBytes);
|
|
43
|
+
return comparison < 0 ? -1 : comparison > 0 ? 1 : 0;
|
|
44
|
+
}
|
|
45
|
+
export function makeRecordId(recordBytes, segmentBytes) {
|
|
46
|
+
const record = Buffer.from(recordBytes);
|
|
47
|
+
const segment = Buffer.from(segmentBytes);
|
|
48
|
+
assertUuidV7(record, 'record UUID');
|
|
49
|
+
assertUuidV7(segment, 'segment UUID');
|
|
50
|
+
return Buffer.concat([record, segment]).toString('base64url');
|
|
51
|
+
}
|
|
52
|
+
export function uuidV7Timestamp(bytes) {
|
|
53
|
+
assertUuidV7(bytes, 'UUID');
|
|
54
|
+
let value = 0;
|
|
55
|
+
for (let index = 0; index < 6; index++) {
|
|
56
|
+
value = value * 256 + bytes[index];
|
|
57
|
+
}
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
export function invertUuidHex(bytes) {
|
|
61
|
+
assertUuidV7(bytes, 'segment UUID');
|
|
62
|
+
const inverted = Buffer.alloc(bytes.length);
|
|
63
|
+
for (let index = 0; index < bytes.length; index++) {
|
|
64
|
+
inverted[index] = 0xff ^ bytes[index];
|
|
65
|
+
}
|
|
66
|
+
return inverted.toString('hex');
|
|
67
|
+
}
|
|
68
|
+
export function segmentBytesFromHex(value) {
|
|
69
|
+
if (!/^[0-9a-f]{32}$/.test(value)) {
|
|
70
|
+
throw invalidArgument('segment ID must be lowercase UUID hex');
|
|
71
|
+
}
|
|
72
|
+
const bytes = Buffer.from(value, 'hex');
|
|
73
|
+
assertUuidV7(bytes, 'segment UUID');
|
|
74
|
+
return bytes;
|
|
75
|
+
}
|
|
76
|
+
function assertUuidV7(bytes, name) {
|
|
77
|
+
if (bytes.length !== UUID_BYTES
|
|
78
|
+
|| (bytes[6] >>> 4) !== 7
|
|
79
|
+
|| (bytes[8] & 0xc0) !== 0x80) {
|
|
80
|
+
throw invalidArgument(`${name} must be a UUIDv7`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { S3Client } from '@aws-sdk/client-s3';
|
|
2
|
+
import type { SegmentObjectStore } from './types.js';
|
|
3
|
+
export interface S3SegmentObjectStoreOptions {
|
|
4
|
+
readonly client: S3Client;
|
|
5
|
+
readonly bucket: string;
|
|
6
|
+
readonly keyPrefix?: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function createS3SegmentObjectStore(options: S3SegmentObjectStoreOptions): SegmentObjectStore;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { GetObjectCommand, ListObjectsV2Command, NoSuchKey, PutObjectCommand, } from '@aws-sdk/client-s3';
|
|
2
|
+
export function createS3SegmentObjectStore(options) {
|
|
3
|
+
const keyPrefix = normalizePrefix(options.keyPrefix ?? '');
|
|
4
|
+
return {
|
|
5
|
+
async list({ prefix, startAfter, limit }) {
|
|
6
|
+
const response = await options.client.send(new ListObjectsV2Command({
|
|
7
|
+
Bucket: options.bucket,
|
|
8
|
+
Prefix: `${keyPrefix}${prefix}`,
|
|
9
|
+
StartAfter: startAfter === undefined ? undefined : `${keyPrefix}${startAfter}`,
|
|
10
|
+
MaxKeys: limit,
|
|
11
|
+
}));
|
|
12
|
+
return (response.Contents ?? []).flatMap(object => {
|
|
13
|
+
if (object.Key === undefined || object.Size === undefined)
|
|
14
|
+
return [];
|
|
15
|
+
return [{ key: object.Key.slice(keyPrefix.length), size: object.Size }];
|
|
16
|
+
});
|
|
17
|
+
},
|
|
18
|
+
async get(key) {
|
|
19
|
+
try {
|
|
20
|
+
const response = await options.client.send(new GetObjectCommand({
|
|
21
|
+
Bucket: options.bucket,
|
|
22
|
+
Key: `${keyPrefix}${key}`,
|
|
23
|
+
}));
|
|
24
|
+
if (response.Body === undefined)
|
|
25
|
+
return new Uint8Array();
|
|
26
|
+
return await response.Body.transformToByteArray();
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
if (error instanceof NoSuchKey
|
|
30
|
+
|| (typeof error === 'object' && error !== null && '$metadata' in error
|
|
31
|
+
&& error.$metadata?.httpStatusCode === 404)) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
async put({ key, data, checksumSha256 }) {
|
|
38
|
+
await options.client.send(new PutObjectCommand({
|
|
39
|
+
Bucket: options.bucket,
|
|
40
|
+
Key: `${keyPrefix}${key}`,
|
|
41
|
+
Body: data,
|
|
42
|
+
ChecksumSHA256: checksumSha256,
|
|
43
|
+
}));
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function normalizePrefix(value) {
|
|
48
|
+
if (value === '')
|
|
49
|
+
return '';
|
|
50
|
+
return value.endsWith('/') ? value : `${value}/`;
|
|
51
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type GeneratedRecordIdentity } from './record-id.js';
|
|
2
|
+
import { type StoredRecord } from './types.js';
|
|
3
|
+
export interface DecodedSegment {
|
|
4
|
+
readonly streamId: string;
|
|
5
|
+
readonly segmentHex: string;
|
|
6
|
+
readonly segmentBytes: Buffer;
|
|
7
|
+
readonly createdAtMilliseconds: number;
|
|
8
|
+
readonly headerLength: number;
|
|
9
|
+
readonly validLength: number;
|
|
10
|
+
readonly incompleteTail: boolean;
|
|
11
|
+
readonly records: readonly StoredRecord[];
|
|
12
|
+
}
|
|
13
|
+
export declare function encodeSegmentHeader(streamId: string, segmentBytes: Uint8Array, createdAtMilliseconds: number): Buffer;
|
|
14
|
+
export declare function encodeRecordFrame(identity: GeneratedRecordIdentity, data: string): Buffer;
|
|
15
|
+
export declare function decodeSegment(bytes: Uint8Array, expected?: {
|
|
16
|
+
readonly streamId?: string;
|
|
17
|
+
readonly segmentHex?: string;
|
|
18
|
+
}): DecodedSegment;
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { TextDecoder } from 'node:util';
|
|
2
|
+
import { crc32c } from './crc32c.js';
|
|
3
|
+
import { makeRecordId, segmentBytesFromHex, uuidV7Timestamp, } from './record-id.js';
|
|
4
|
+
import { MAX_DATA_BYTES, OrderedRecordStoreError, } from './types.js';
|
|
5
|
+
const MAGIC = Buffer.from('ORSSEG01', 'ascii');
|
|
6
|
+
const FIXED_HEADER_BYTES = 46;
|
|
7
|
+
const MIN_RECORD_BODY_BYTES = 32;
|
|
8
|
+
const utf8 = new TextDecoder('utf-8', { fatal: true });
|
|
9
|
+
export function encodeSegmentHeader(streamId, segmentBytes, createdAtMilliseconds) {
|
|
10
|
+
const stream = Buffer.from(streamId, 'utf8');
|
|
11
|
+
const totalLength = FIXED_HEADER_BYTES + stream.length;
|
|
12
|
+
const header = Buffer.alloc(totalLength);
|
|
13
|
+
MAGIC.copy(header, 0);
|
|
14
|
+
header.writeUInt32BE(totalLength, 8);
|
|
15
|
+
header.writeUInt16BE(1, 12);
|
|
16
|
+
header.writeUInt8(0, 14);
|
|
17
|
+
header.writeUInt8(0, 15);
|
|
18
|
+
Buffer.from(segmentBytes).copy(header, 16);
|
|
19
|
+
header.writeBigUInt64BE(BigInt(createdAtMilliseconds), 32);
|
|
20
|
+
header.writeUInt16BE(stream.length, 40);
|
|
21
|
+
stream.copy(header, 42);
|
|
22
|
+
header.writeUInt32BE(crc32c(header.subarray(8, totalLength - 4)), totalLength - 4);
|
|
23
|
+
return header;
|
|
24
|
+
}
|
|
25
|
+
export function encodeRecordFrame(identity, data) {
|
|
26
|
+
const payload = Buffer.from(data, 'utf8');
|
|
27
|
+
const bodyLength = MIN_RECORD_BODY_BYTES + payload.length;
|
|
28
|
+
const frame = Buffer.alloc(bodyLength + 8);
|
|
29
|
+
frame.writeUInt32BE(bodyLength, 0);
|
|
30
|
+
identity.recordBytes.copy(frame, 4);
|
|
31
|
+
frame.writeBigUInt64BE(BigInt(identity.createdAtMilliseconds), 20);
|
|
32
|
+
frame.writeUInt32BE(payload.length, 28);
|
|
33
|
+
payload.copy(frame, 32);
|
|
34
|
+
const crcOffset = 32 + payload.length;
|
|
35
|
+
frame.writeUInt32BE(crc32c(frame.subarray(4, crcOffset)), crcOffset);
|
|
36
|
+
frame.writeUInt32BE(bodyLength, crcOffset + 4);
|
|
37
|
+
return frame;
|
|
38
|
+
}
|
|
39
|
+
export function decodeSegment(bytes, expected) {
|
|
40
|
+
const buffer = Buffer.from(bytes);
|
|
41
|
+
if (buffer.length < FIXED_HEADER_BYTES || !buffer.subarray(0, 8).equals(MAGIC)) {
|
|
42
|
+
throw corrupt('invalid or incomplete segment header');
|
|
43
|
+
}
|
|
44
|
+
const headerLength = buffer.readUInt32BE(8);
|
|
45
|
+
const streamLength = buffer.readUInt16BE(40);
|
|
46
|
+
if (headerLength !== FIXED_HEADER_BYTES + streamLength
|
|
47
|
+
|| headerLength > buffer.length
|
|
48
|
+
|| buffer.readUInt16BE(12) !== 1
|
|
49
|
+
|| buffer.readUInt8(14) !== 0
|
|
50
|
+
|| buffer.readUInt8(15) !== 0) {
|
|
51
|
+
throw corrupt('unsupported or malformed segment header');
|
|
52
|
+
}
|
|
53
|
+
if (buffer.readUInt32BE(headerLength - 4)
|
|
54
|
+
!== crc32c(buffer.subarray(8, headerLength - 4))) {
|
|
55
|
+
throw corrupt('segment header checksum mismatch');
|
|
56
|
+
}
|
|
57
|
+
const segmentBytes = Buffer.from(buffer.subarray(16, 32));
|
|
58
|
+
const segmentHex = segmentBytes.toString('hex');
|
|
59
|
+
try {
|
|
60
|
+
segmentBytesFromHex(segmentHex);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
throw corrupt('segment header contains an invalid UUIDv7');
|
|
64
|
+
}
|
|
65
|
+
const createdAtMilliseconds = numberFromUInt64(buffer.readBigUInt64BE(32), 'segment timestamp');
|
|
66
|
+
if (createdAtMilliseconds !== uuidV7Timestamp(segmentBytes)) {
|
|
67
|
+
throw corrupt('segment UUID and timestamp disagree');
|
|
68
|
+
}
|
|
69
|
+
let streamId;
|
|
70
|
+
try {
|
|
71
|
+
streamId = utf8.decode(buffer.subarray(42, 42 + streamLength));
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
throw corrupt('segment stream ID is not valid UTF-8');
|
|
75
|
+
}
|
|
76
|
+
if (expected?.streamId !== undefined && expected.streamId !== streamId) {
|
|
77
|
+
throw corrupt('segment stream ID does not match its requested stream');
|
|
78
|
+
}
|
|
79
|
+
if (expected?.segmentHex !== undefined && expected.segmentHex !== segmentHex) {
|
|
80
|
+
throw corrupt('segment ID does not match its path or object key');
|
|
81
|
+
}
|
|
82
|
+
const records = [];
|
|
83
|
+
let offset = headerLength;
|
|
84
|
+
let incompleteTail = false;
|
|
85
|
+
while (offset < buffer.length) {
|
|
86
|
+
if (buffer.length - offset < 4) {
|
|
87
|
+
incompleteTail = true;
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
const bodyLength = buffer.readUInt32BE(offset);
|
|
91
|
+
if (bodyLength < MIN_RECORD_BODY_BYTES || bodyLength > MIN_RECORD_BODY_BYTES + MAX_DATA_BYTES) {
|
|
92
|
+
throw corrupt(`invalid record length at byte ${offset}`);
|
|
93
|
+
}
|
|
94
|
+
const frameLength = bodyLength + 8;
|
|
95
|
+
if (offset + frameLength > buffer.length) {
|
|
96
|
+
incompleteTail = true;
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
const record = decodeFrame(buffer.subarray(offset, offset + frameLength), streamId, segmentBytes);
|
|
100
|
+
if (records.length > 0 && Buffer.compare(Buffer.from(records.at(-1).id, 'base64url').subarray(0, 16), Buffer.from(record.id, 'base64url').subarray(0, 16)) >= 0) {
|
|
101
|
+
throw corrupt('record IDs are not strictly increasing');
|
|
102
|
+
}
|
|
103
|
+
records.push(record);
|
|
104
|
+
offset += frameLength;
|
|
105
|
+
}
|
|
106
|
+
if (records.length === 0) {
|
|
107
|
+
throw corrupt('segment contains no complete records');
|
|
108
|
+
}
|
|
109
|
+
const firstRecordBytes = Buffer.from(records[0].id, 'base64url').subarray(0, 16);
|
|
110
|
+
if (!firstRecordBytes.equals(segmentBytes)) {
|
|
111
|
+
throw corrupt('first record UUID is not the segment UUID');
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
streamId,
|
|
115
|
+
segmentHex,
|
|
116
|
+
segmentBytes,
|
|
117
|
+
createdAtMilliseconds,
|
|
118
|
+
headerLength,
|
|
119
|
+
validLength: offset,
|
|
120
|
+
incompleteTail,
|
|
121
|
+
records,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
function decodeFrame(frame, streamId, segmentBytes) {
|
|
125
|
+
const bodyLength = frame.readUInt32BE(0);
|
|
126
|
+
const payloadLength = frame.readUInt32BE(28);
|
|
127
|
+
if (bodyLength !== MIN_RECORD_BODY_BYTES + payloadLength
|
|
128
|
+
|| frame.length !== bodyLength + 8
|
|
129
|
+
|| frame.readUInt32BE(frame.length - 4) !== bodyLength) {
|
|
130
|
+
throw corrupt('record framing is inconsistent');
|
|
131
|
+
}
|
|
132
|
+
const crcOffset = 32 + payloadLength;
|
|
133
|
+
if (frame.readUInt32BE(crcOffset) !== crc32c(frame.subarray(4, crcOffset))) {
|
|
134
|
+
throw corrupt('record checksum mismatch');
|
|
135
|
+
}
|
|
136
|
+
const recordBytes = frame.subarray(4, 20);
|
|
137
|
+
let timestamp;
|
|
138
|
+
try {
|
|
139
|
+
timestamp = numberFromUInt64(frame.readBigUInt64BE(20), 'record timestamp');
|
|
140
|
+
if (timestamp !== uuidV7Timestamp(recordBytes)) {
|
|
141
|
+
throw corrupt('record UUID and timestamp disagree');
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
if (error instanceof OrderedRecordStoreError)
|
|
146
|
+
throw error;
|
|
147
|
+
throw corrupt('record contains an invalid UUIDv7');
|
|
148
|
+
}
|
|
149
|
+
let data;
|
|
150
|
+
try {
|
|
151
|
+
data = utf8.decode(frame.subarray(32, crcOffset));
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
throw corrupt('record payload is not valid UTF-8');
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
id: makeRecordId(recordBytes, segmentBytes),
|
|
158
|
+
streamId,
|
|
159
|
+
data,
|
|
160
|
+
createdAt: new Date(timestamp).toISOString(),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
function numberFromUInt64(value, name) {
|
|
164
|
+
const number = Number(value);
|
|
165
|
+
if (!Number.isSafeInteger(number)) {
|
|
166
|
+
throw corrupt(`${name} is outside JavaScript's safe integer range`);
|
|
167
|
+
}
|
|
168
|
+
return number;
|
|
169
|
+
}
|
|
170
|
+
function corrupt(message) {
|
|
171
|
+
return new OrderedRecordStoreError('CORRUPT_DATA', message);
|
|
172
|
+
}
|