@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.
@@ -9,15 +9,14 @@ export const getPublicRecordById = (keyIndex, entryId) => {
9
9
  }
10
10
  return toPublicRecord(entryId, entry.key, entry.value);
11
11
  };
12
- const buildMergedPayload = (targetRecord, patch, entryKey, skipValidation) => {
13
- const merged = { ...targetRecord.payload, ...patch };
12
+ export const validateAndEstimateSize = (payload, entryKey, skipValidation, payloadLimits) => {
14
13
  if (skipValidation) {
15
14
  return {
16
- payload: merged,
17
- sizeBytes: estimateRecordSizeBytes(entryKey, merged),
15
+ payload,
16
+ sizeBytes: estimateRecordSizeBytes(entryKey, payload),
18
17
  };
19
18
  }
20
- const validationResult = validateAndNormalizePayload(merged);
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 mergedResult = buildMergedPayload(targetRecord, options.patch, entry.key, options.skipPayloadValidation);
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 MAX_PAYLOAD_DEPTH = 64;
4
- const MAX_PAYLOAD_KEY_BYTES = 1024;
5
- const MAX_PAYLOAD_STRING_BYTES = 65535;
6
- const MAX_PAYLOAD_KEYS_PER_OBJECT = 256;
7
- const MAX_PAYLOAD_KEYS_TOTAL = 4096;
8
- const MAX_PAYLOAD_TOTAL_BYTES = 1048576;
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 > MAX_PAYLOAD_TOTAL_BYTES) {
30
- throw new ValidationError(`Payload aggregate validation bytes must be <= ${MAX_PAYLOAD_TOTAL_BYTES}.`);
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 > MAX_PAYLOAD_KEY_BYTES) {
42
- throw new ValidationError(`Payload key UTF-8 byte length must be <= ${MAX_PAYLOAD_KEY_BYTES}.`);
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 > MAX_PAYLOAD_KEYS_TOTAL) {
46
- throw new ValidationError(`Payload total key count must be <= ${MAX_PAYLOAD_KEYS_TOTAL}.`);
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 > MAX_PAYLOAD_STRING_BYTES) {
68
- throw new ValidationError(`Payload string UTF-8 byte length must be <= ${MAX_PAYLOAD_STRING_BYTES}.`);
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 > MAX_PAYLOAD_DEPTH) {
101
- throw new ValidationError(`Payload nesting depth must be <= ${MAX_PAYLOAD_DEPTH}.`);
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 > MAX_PAYLOAD_KEYS_PER_OBJECT) {
108
- throw new ValidationError(`Payload object key count must be <= ${MAX_PAYLOAD_KEYS_PER_OBJECT}.`);
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.0",
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",