@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 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.
@@ -0,0 +1,2 @@
1
+ /** CRC-32C (Castagnoli), returned as an unsigned 32-bit integer. */
2
+ export declare function crc32c(data: Uint8Array): number;
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
+ }
@@ -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,2 @@
1
+ import { type OrderedRecordStore } from './types.js';
2
+ export declare function createMemoryOrderedRecordStore(): OrderedRecordStore;
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ import { type OrderedRecordStore, type SegmentedOrderedRecordStoreOptions } from './types.js';
2
+ export declare function openSegmentedOrderedRecordStore(options: SegmentedOrderedRecordStoreOptions): Promise<OrderedRecordStore>;