@frostpillar/frostpillar-storage-engine 0.1.0 → 0.1.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-JA.md +42 -15
- package/README.md +42 -15
- package/dist/drivers/file.cjs +28 -28
- package/dist/drivers/indexedDB.cjs +28 -28
- package/dist/drivers/localStorage.cjs +28 -28
- package/dist/drivers/opfs.cjs +28 -28
- package/dist/drivers/syncStorage.cjs +28 -28
- package/dist/index.cjs +155 -36
- package/dist/index.d.ts +1 -1
- package/dist/storage/config/config.shared.d.ts +3 -1
- package/dist/storage/config/config.shared.js +26 -0
- package/dist/storage/datastore/Datastore.d.ts +4 -1
- package/dist/storage/datastore/Datastore.js +47 -12
- package/dist/storage/datastore/mutationById.d.ts +34 -1
- package/dist/storage/datastore/mutationById.js +59 -6
- package/dist/types.d.ts +9 -0
- package/dist/validation/payload.d.ts +16 -1
- package/dist/validation/payload.js +28 -19
- package/package.json +1 -1
|
@@ -9,15 +9,14 @@ export const getPublicRecordById = (keyIndex, entryId) => {
|
|
|
9
9
|
}
|
|
10
10
|
return toPublicRecord(entryId, entry.key, entry.value);
|
|
11
11
|
};
|
|
12
|
-
const
|
|
13
|
-
const merged = { ...targetRecord.payload, ...patch };
|
|
12
|
+
export const validateAndEstimateSize = (payload, entryKey, skipValidation, payloadLimits) => {
|
|
14
13
|
if (skipValidation) {
|
|
15
14
|
return {
|
|
16
|
-
payload
|
|
17
|
-
sizeBytes: estimateRecordSizeBytes(entryKey,
|
|
15
|
+
payload,
|
|
16
|
+
sizeBytes: estimateRecordSizeBytes(entryKey, payload),
|
|
18
17
|
};
|
|
19
18
|
}
|
|
20
|
-
const validationResult = validateAndNormalizePayload(
|
|
19
|
+
const validationResult = validateAndNormalizePayload(payload, payloadLimits);
|
|
21
20
|
const keyBytes = estimateKeySizeBytes(entryKey);
|
|
22
21
|
return {
|
|
23
22
|
payload: validationResult.payload,
|
|
@@ -31,7 +30,8 @@ export const updateRecordById = (options) => {
|
|
|
31
30
|
}
|
|
32
31
|
const targetRecord = entry.value;
|
|
33
32
|
const oldSize = targetRecord.sizeBytes;
|
|
34
|
-
const
|
|
33
|
+
const merged = { ...targetRecord.payload, ...options.patch };
|
|
34
|
+
const mergedResult = validateAndEstimateSize(merged, entry.key, options.skipPayloadValidation, options.payloadLimits);
|
|
35
35
|
const mergedPayload = mergedResult.payload;
|
|
36
36
|
const newSize = mergedResult.sizeBytes;
|
|
37
37
|
const encodedDelta = newSize - oldSize;
|
|
@@ -56,6 +56,36 @@ export const updateRecordById = (options) => {
|
|
|
56
56
|
durabilitySignalBytes: Math.abs(encodedDelta),
|
|
57
57
|
};
|
|
58
58
|
};
|
|
59
|
+
export const replaceRecordById = (options) => {
|
|
60
|
+
const entry = options.keyIndex.peekById(options.id);
|
|
61
|
+
if (entry === null) {
|
|
62
|
+
return { replaced: false, currentSizeBytes: options.currentSizeBytes, durabilitySignalBytes: 0 };
|
|
63
|
+
}
|
|
64
|
+
const oldSize = entry.value.sizeBytes;
|
|
65
|
+
const replacementResult = validateAndEstimateSize(options.payload, entry.key, options.skipPayloadValidation, options.payloadLimits);
|
|
66
|
+
const newSize = replacementResult.sizeBytes;
|
|
67
|
+
const encodedDelta = newSize - oldSize;
|
|
68
|
+
if (options.capacityState !== null &&
|
|
69
|
+
encodedDelta > 0 &&
|
|
70
|
+
options.currentSizeBytes + encodedDelta > options.capacityState.maxSizeBytes) {
|
|
71
|
+
throw new QuotaExceededError('replaceById exceeds configured capacity.maxSize boundary.');
|
|
72
|
+
}
|
|
73
|
+
const replacedRecord = {
|
|
74
|
+
payload: replacementResult.payload,
|
|
75
|
+
sizeBytes: newSize,
|
|
76
|
+
};
|
|
77
|
+
if (options.keyIndex.updateById(options.id, replacedRecord) === null) {
|
|
78
|
+
throw new IndexCorruptionError('Record index state is inconsistent during replaceById.');
|
|
79
|
+
}
|
|
80
|
+
// Underflow is not possible: encodedDelta = newSize - oldSize, and oldSize was
|
|
81
|
+
// accumulated into currentSizeBytes on insertion. Math.max is purely defensive
|
|
82
|
+
// against any future estimation inconsistency.
|
|
83
|
+
return {
|
|
84
|
+
replaced: true,
|
|
85
|
+
currentSizeBytes: Math.max(0, options.currentSizeBytes + encodedDelta),
|
|
86
|
+
durabilitySignalBytes: Math.abs(encodedDelta),
|
|
87
|
+
};
|
|
88
|
+
};
|
|
59
89
|
export const deleteRecordById = (options) => {
|
|
60
90
|
const removedFromIndex = options.keyIndex.removeById(options.id);
|
|
61
91
|
if (removedFromIndex === null) {
|
|
@@ -75,3 +105,26 @@ export const deleteRecordById = (options) => {
|
|
|
75
105
|
durabilitySignalBytes: freedBytes,
|
|
76
106
|
};
|
|
77
107
|
};
|
|
108
|
+
export const deleteRecordByIds = (options) => {
|
|
109
|
+
let deletedCount = 0;
|
|
110
|
+
let totalFreedBytes = 0;
|
|
111
|
+
let currentSizeBytes = options.currentSizeBytes;
|
|
112
|
+
for (const id of options.ids) {
|
|
113
|
+
const removedFromIndex = options.keyIndex.removeById(id);
|
|
114
|
+
if (removedFromIndex === null) {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const freedBytes = removedFromIndex.value.sizeBytes;
|
|
118
|
+
totalFreedBytes += freedBytes;
|
|
119
|
+
// Underflow is not possible: freedBytes was accumulated into currentSizeBytes
|
|
120
|
+
// on insertion and has not been modified since. Math.max is purely defensive
|
|
121
|
+
// against any future estimation inconsistency.
|
|
122
|
+
currentSizeBytes = Math.max(0, currentSizeBytes - freedBytes);
|
|
123
|
+
deletedCount += 1;
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
deletedCount,
|
|
127
|
+
currentSizeBytes,
|
|
128
|
+
durabilitySignalBytes: totalFreedBytes,
|
|
129
|
+
};
|
|
130
|
+
};
|
package/dist/types.d.ts
CHANGED
|
@@ -42,12 +42,21 @@ export interface AutoCommitConfig {
|
|
|
42
42
|
frequency?: AutoCommitFrequencyInput;
|
|
43
43
|
maxPendingBytes?: number;
|
|
44
44
|
}
|
|
45
|
+
export interface PayloadLimitsConfig {
|
|
46
|
+
maxDepth?: number;
|
|
47
|
+
maxKeyBytes?: number;
|
|
48
|
+
maxStringBytes?: number;
|
|
49
|
+
maxKeysPerObject?: number;
|
|
50
|
+
maxTotalKeys?: number;
|
|
51
|
+
maxTotalBytes?: number;
|
|
52
|
+
}
|
|
45
53
|
export interface DatastoreCommonConfig {
|
|
46
54
|
key?: DatastoreKeyDefinition<unknown, unknown>;
|
|
47
55
|
capacity?: CapacityConfig;
|
|
48
56
|
autoCommit?: AutoCommitConfig;
|
|
49
57
|
duplicateKeys?: DuplicateKeyPolicy;
|
|
50
58
|
skipPayloadValidation?: boolean;
|
|
59
|
+
payloadLimits?: PayloadLimitsConfig;
|
|
51
60
|
}
|
|
52
61
|
export interface FileTargetByPathConfig {
|
|
53
62
|
kind: 'path';
|
|
@@ -1,7 +1,22 @@
|
|
|
1
1
|
import type { RecordPayload } from '../types.js';
|
|
2
|
+
export declare const DEFAULT_MAX_PAYLOAD_DEPTH = 64;
|
|
3
|
+
export declare const DEFAULT_MAX_PAYLOAD_KEY_BYTES = 1024;
|
|
4
|
+
export declare const DEFAULT_MAX_PAYLOAD_STRING_BYTES = 65535;
|
|
5
|
+
export declare const DEFAULT_MAX_PAYLOAD_KEYS_PER_OBJECT = 256;
|
|
6
|
+
export declare const DEFAULT_MAX_PAYLOAD_KEYS_TOTAL = 4096;
|
|
7
|
+
export declare const DEFAULT_MAX_PAYLOAD_TOTAL_BYTES = 1048576;
|
|
8
|
+
export interface ResolvedPayloadLimits {
|
|
9
|
+
maxDepth: number;
|
|
10
|
+
maxKeyBytes: number;
|
|
11
|
+
maxStringBytes: number;
|
|
12
|
+
maxKeysPerObject: number;
|
|
13
|
+
maxTotalKeys: number;
|
|
14
|
+
maxTotalBytes: number;
|
|
15
|
+
}
|
|
16
|
+
export declare const DEFAULT_PAYLOAD_LIMITS: Readonly<ResolvedPayloadLimits>;
|
|
2
17
|
export interface PayloadValidationResult {
|
|
3
18
|
payload: RecordPayload;
|
|
4
19
|
sizeBytes: number;
|
|
5
20
|
}
|
|
6
21
|
export declare const deepFreezePayload: (payload: RecordPayload) => RecordPayload;
|
|
7
|
-
export declare const validateAndNormalizePayload: (payload: unknown) => PayloadValidationResult;
|
|
22
|
+
export declare const validateAndNormalizePayload: (payload: unknown, limits?: ResolvedPayloadLimits) => PayloadValidationResult;
|
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
import { ValidationError } from '../errors/index.js';
|
|
2
2
|
import { computeUtf8ByteLength, estimateJsonStringBytes } from '../storage/backend/encoding.js';
|
|
3
|
-
const
|
|
4
|
-
const
|
|
5
|
-
const
|
|
6
|
-
const
|
|
7
|
-
const
|
|
8
|
-
const
|
|
3
|
+
export const DEFAULT_MAX_PAYLOAD_DEPTH = 64;
|
|
4
|
+
export const DEFAULT_MAX_PAYLOAD_KEY_BYTES = 1024;
|
|
5
|
+
export const DEFAULT_MAX_PAYLOAD_STRING_BYTES = 65535;
|
|
6
|
+
export const DEFAULT_MAX_PAYLOAD_KEYS_PER_OBJECT = 256;
|
|
7
|
+
export const DEFAULT_MAX_PAYLOAD_KEYS_TOTAL = 4096;
|
|
8
|
+
export const DEFAULT_MAX_PAYLOAD_TOTAL_BYTES = 1048576;
|
|
9
|
+
export const DEFAULT_PAYLOAD_LIMITS = {
|
|
10
|
+
maxDepth: DEFAULT_MAX_PAYLOAD_DEPTH,
|
|
11
|
+
maxKeyBytes: DEFAULT_MAX_PAYLOAD_KEY_BYTES,
|
|
12
|
+
maxStringBytes: DEFAULT_MAX_PAYLOAD_STRING_BYTES,
|
|
13
|
+
maxKeysPerObject: DEFAULT_MAX_PAYLOAD_KEYS_PER_OBJECT,
|
|
14
|
+
maxTotalKeys: DEFAULT_MAX_PAYLOAD_KEYS_TOTAL,
|
|
15
|
+
maxTotalBytes: DEFAULT_MAX_PAYLOAD_TOTAL_BYTES,
|
|
16
|
+
};
|
|
9
17
|
// JSON-aware byte estimates for non-string primitives.
|
|
10
18
|
const NULL_ESTIMATION_BYTES = 4; // "null" = 4 bytes
|
|
11
19
|
// JSON structural overhead constants for size estimation.
|
|
@@ -26,8 +34,8 @@ const isPlainObject = (value) => {
|
|
|
26
34
|
};
|
|
27
35
|
const addValidationBytes = (state, bytes) => {
|
|
28
36
|
state.totalValidationBytes += bytes;
|
|
29
|
-
if (state.totalValidationBytes >
|
|
30
|
-
throw new ValidationError(`Payload aggregate validation bytes must be <= ${
|
|
37
|
+
if (state.totalValidationBytes > state.limits.maxTotalBytes) {
|
|
38
|
+
throw new ValidationError(`Payload aggregate validation bytes must be <= ${state.limits.maxTotalBytes}.`);
|
|
31
39
|
}
|
|
32
40
|
};
|
|
33
41
|
const validatePayloadKey = (key, state) => {
|
|
@@ -38,12 +46,12 @@ const validatePayloadKey = (key, state) => {
|
|
|
38
46
|
throw new ValidationError(`Payload key "${key}" is reserved and not allowed.`);
|
|
39
47
|
}
|
|
40
48
|
const keyBytes = computeUtf8ByteLength(key);
|
|
41
|
-
if (keyBytes >
|
|
42
|
-
throw new ValidationError(`Payload key UTF-8 byte length must be <= ${
|
|
49
|
+
if (keyBytes > state.limits.maxKeyBytes) {
|
|
50
|
+
throw new ValidationError(`Payload key UTF-8 byte length must be <= ${state.limits.maxKeyBytes}.`);
|
|
43
51
|
}
|
|
44
52
|
state.totalKeyCount += 1;
|
|
45
|
-
if (state.totalKeyCount >
|
|
46
|
-
throw new ValidationError(`Payload total key count must be <= ${
|
|
53
|
+
if (state.totalKeyCount > state.limits.maxTotalKeys) {
|
|
54
|
+
throw new ValidationError(`Payload total key count must be <= ${state.limits.maxTotalKeys}.`);
|
|
47
55
|
}
|
|
48
56
|
// Add key as JSON string (with escaping + quotes) plus colon.
|
|
49
57
|
addValidationBytes(state, estimateJsonStringBytes(key) + JSON_KEY_COLON_OVERHEAD);
|
|
@@ -64,8 +72,8 @@ const validateAndCloneValue = (value, depth, state) => {
|
|
|
64
72
|
}
|
|
65
73
|
if (typeof value === 'string') {
|
|
66
74
|
const stringBytes = computeUtf8ByteLength(value);
|
|
67
|
-
if (stringBytes >
|
|
68
|
-
throw new ValidationError(`Payload string UTF-8 byte length must be <= ${
|
|
75
|
+
if (stringBytes > state.limits.maxStringBytes) {
|
|
76
|
+
throw new ValidationError(`Payload string UTF-8 byte length must be <= ${state.limits.maxStringBytes}.`);
|
|
69
77
|
}
|
|
70
78
|
addValidationBytes(state, estimateJsonStringBytes(value));
|
|
71
79
|
return value;
|
|
@@ -97,15 +105,15 @@ const validateAndCloneValue = (value, depth, state) => {
|
|
|
97
105
|
};
|
|
98
106
|
const validateAndClonePayloadObject = (payloadObject, depth, state) => {
|
|
99
107
|
const objectLevel = depth + 1;
|
|
100
|
-
if (objectLevel >
|
|
101
|
-
throw new ValidationError(`Payload nesting depth must be <= ${
|
|
108
|
+
if (objectLevel > state.limits.maxDepth) {
|
|
109
|
+
throw new ValidationError(`Payload nesting depth must be <= ${state.limits.maxDepth}.`);
|
|
102
110
|
}
|
|
103
111
|
if (state.activePath.has(payloadObject)) {
|
|
104
112
|
throw new ValidationError('Circular payload references are not supported.');
|
|
105
113
|
}
|
|
106
114
|
const entries = Object.entries(payloadObject);
|
|
107
|
-
if (entries.length >
|
|
108
|
-
throw new ValidationError(`Payload object key count must be <= ${
|
|
115
|
+
if (entries.length > state.limits.maxKeysPerObject) {
|
|
116
|
+
throw new ValidationError(`Payload object key count must be <= ${state.limits.maxKeysPerObject}.`);
|
|
109
117
|
}
|
|
110
118
|
state.activePath.add(payloadObject);
|
|
111
119
|
// JSON structural overhead: 2 bytes for braces + (N-1) bytes for comma separators.
|
|
@@ -120,7 +128,7 @@ const validateAndClonePayloadObject = (payloadObject, depth, state) => {
|
|
|
120
128
|
state.activePath.delete(payloadObject);
|
|
121
129
|
return copied;
|
|
122
130
|
};
|
|
123
|
-
export const validateAndNormalizePayload = (payload) => {
|
|
131
|
+
export const validateAndNormalizePayload = (payload, limits = DEFAULT_PAYLOAD_LIMITS) => {
|
|
124
132
|
if (!isPlainObject(payload)) {
|
|
125
133
|
throw new ValidationError('payload must be a non-null plain object.');
|
|
126
134
|
}
|
|
@@ -128,6 +136,7 @@ export const validateAndNormalizePayload = (payload) => {
|
|
|
128
136
|
activePath: new WeakSet(),
|
|
129
137
|
totalKeyCount: 0,
|
|
130
138
|
totalValidationBytes: 0,
|
|
139
|
+
limits,
|
|
131
140
|
};
|
|
132
141
|
const cloned = validateAndClonePayloadObject(payload, 0, state);
|
|
133
142
|
const sizeBytes = state.totalValidationBytes + JSON_ROOT_WRAPPER_OVERHEAD;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frostpillar/frostpillar-storage-engine",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Chunk-based storage engine for browsers and Node.js that packs many small key-value entries into a single backing store.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|