@@ -6,53 +6,21 @@ import JSON5 from 'json5';
66import ow , { ArgumentError } from 'ow' ;
77
88import { KEY_VALUE_STORE_KEY_REGEX } from '@apify/consts' ;
9- import { jsonStringifyExtended } from '@apify/utilities' ;
109
1110import { Configuration } from '../configuration.js' ;
1211import { serviceLocator } from '../service_locator.js' ;
1312import type { Awaitable } from '../typedefs.js' ;
1413import { checkStorageAccess } from './access_checking.js' ;
14+ import { parseValue , serializeValue } from './key_value_store_codec.js' ;
1515import type { StorageIdentifier } from './storage_instance_manager.js' ;
1616import type { StorageOpenOptions } from './utils.js' ;
1717import { resolveStorageIdentifier } from './storage_instance_manager.js' ;
1818import { createDualIterable , purgeDefaultStorages } from './utils.js' ;
19+ import { isBuffer , isStream } from '@crawlee/utils' ;
1920
2021/** @internal */
2122const KVS_KEYS_DEFAULT_LIMIT = 1000 ;
2223
23- /**
24- * Helper function to possibly stringify value if options.contentType is not set.
25- *
26- * @ignore
27- */
28- export const maybeStringify = < T > ( value : T , options : { contentType ?: string } ) => {
29- // If contentType is missing, value will be stringified to JSON
30- if ( options . contentType === null || options . contentType === undefined ) {
31- options . contentType = 'application/json; charset=utf-8' ;
32-
33- try {
34- // Format JSON to simplify debugging, the overheads with compression is negligible
35- value = jsonStringifyExtended ( value as Dictionary , null , 2 ) as unknown as T ;
36- } catch ( e ) {
37- const error = e as Error ;
38- // Give more meaningful error message
39- if ( error . message ?. includes ( 'Invalid string length' ) ) {
40- error . message = 'Object is too large' ;
41- }
42- throw new Error ( `The "value" parameter cannot be stringified to JSON: ${ error . message } ` ) ;
43- }
44-
45- if ( value === undefined ) {
46- throw new Error (
47- 'The "value" parameter was stringified to JSON and returned undefined. ' +
48- "Make sure you're not trying to stringify an undefined value." ,
49- ) ;
50- }
51- }
52-
53- return value ;
54- } ;
55-
5624/**
5725 * The `KeyValueStore` class represents a key-value store, a simple data storage that is used
5826 * for saving and reading data records or files. Each data record is
@@ -232,7 +200,53 @@ export class KeyValueStore {
232200 ow ( key , ow . string . nonEmpty ) ;
233201 const record = await this . client . getValue ( key ) ;
234202
235- return ( record ?. value as T ) ?? defaultValue ?? null ;
203+ // A missing record falls back to the default; a record that parses to a falsy value (including
204+ // a stored literal `null`) is returned verbatim, so callers can tell "stored null" from "absent".
205+ if ( ! record ) {
206+ return defaultValue ?? null ;
207+ }
208+
209+ // Storage clients are byte transports — the value is raw bytes; the frontend parses it here.
210+ return parseValue ( record . value , record . contentType ?? null ) as T ;
211+ }
212+
213+ /**
214+ * Reads a record from the key-value store without parsing the value.
215+ *
216+ * Use this when you need the raw bytes and the content type — for example, to run your own
217+ * parser (`simdjson`, a custom XML library, etc.) or to forward the bytes verbatim.
218+ *
219+ * There is no symmetric `setRecord` method, because {@apilink KeyValueStore.setValue} already
220+ * passes a `Buffer` (or `string` / `Stream`) through unchanged when an explicit `contentType`
221+ * is provided. To write pre-serialized bytes, call
222+ * `setValue(key, buffer, { contentType: 'application/json; charset=utf-8' })`.
223+ *
224+ * Returns `null` if the record does not exist.
225+ *
226+ * **Example usage:**
227+ * ```javascript
228+ * const store = await KeyValueStore.open();
229+ * const record = await store.getRecord('huge.json');
230+ * if (record) {
231+ * const data = simdjson.parse(record.value);
232+ * }
233+ * ```
234+ *
235+ * @param key
236+ * Unique key of the record. It can be at most 256 characters long and only consist
237+ * of the following characters: `a`-`z`, `A`-`Z`, `0`-`9` and `!-_.'()`
238+ */
239+ async getRecord ( key : string ) : Promise < KeyValueStoreRawRecord | null > {
240+ checkStorageAccess ( ) ;
241+
242+ ow ( key , ow . string . nonEmpty ) ;
243+ const record = await this . client . getValue ( key ) ;
244+ if ( ! record ) return null ;
245+
246+ return {
247+ value : record . value ,
248+ contentType : record . contentType ?? null ,
249+ } ;
236250 }
237251
238252 /**
@@ -301,7 +315,10 @@ export class KeyValueStore {
301315 const results : T [ ] = [ ] ;
302316 for ( const item of page ) {
303317 const record = await this . client . getValue ( item . key ) ;
304- if ( record ) results . push ( mapRecord ( item . key , record . value ) ) ;
318+ if ( record ) {
319+ const parsed = parseValue ( record . value , record . contentType ?? null ) ;
320+ results . push ( mapRecord ( item . key , parsed ) ) ;
321+ }
305322 }
306323 yield results ;
307324 }
@@ -375,15 +392,9 @@ export class KeyValueStore {
375392 message : `The "key" argument "${ key } " must be at most 256 characters long and only contain the following characters: a-zA-Z0-9!-_.'()` ,
376393 } ) ) ,
377394 ) ;
378- if (
379- options . contentType &&
380- ! (
381- ow . isValid ( value , ow . any ( ow . string , ow . uint8Array ) ) ||
382- ( ow . isValid ( value , ow . object ) && typeof ( value as Dictionary ) . pipe === 'function' )
383- )
384- ) {
395+ if ( options . contentType && ! ( typeof value === 'string' || isBuffer ( value ) || isStream ( value ) ) ) {
385396 throw new ArgumentError (
386- 'The "value" parameter must be a String, Buffer or Stream when "options.contentType" is specified.' ,
397+ 'The "value" parameter must be a String, Buffer, ArrayBuffer, TypedArray, or Stream when "options.contentType" is specified.' ,
387398 this . setValue ,
388399 ) ;
389400 }
@@ -417,12 +428,12 @@ export class KeyValueStore {
417428 // In this case delete the record.
418429 if ( value === null ) return this . client . deleteValue ( key ) ;
419430
420- value = maybeStringify ( value , optionsCopy ) ;
431+ const serialized = serializeValue ( value , optionsCopy . contentType ) ;
421432
422433 return this . client . setValue ( {
423434 key,
424- value,
425- contentType : optionsCopy . contentType ,
435+ value : serialized . value ,
436+ contentType : serialized . contentType ,
426437 } ) ;
427438 }
428439
@@ -745,6 +756,23 @@ export class KeyValueStore {
745756 return store . getValue < T > ( key , defaultValue as T ) ;
746757 }
747758
759+ /**
760+ * Reads a record from the default {@apilink KeyValueStore} associated with the current crawler run
761+ * without parsing the value.
762+ *
763+ * This is just a convenient shortcut for {@apilink KeyValueStore.getRecord}. Returns `null` if the
764+ * record does not exist.
765+ *
766+ * @param key
767+ * Unique key of the record. It can be at most 256 characters long and only consist
768+ * of the following characters: `a`-`z`, `A`-`Z`, `0`-`9` and `!-_.'()`
769+ * @ignore
770+ */
771+ static async getRecord ( key : string ) : Promise < KeyValueStoreRawRecord | null > {
772+ const store = await this . open ( ) ;
773+ return store . getRecord ( key ) ;
774+ }
775+
748776 /**
749777 * Tests whether a record with the given key exists in the default {@apilink KeyValueStore} associated with the current crawler run.
750778 * @param key The queried record key.
@@ -865,6 +893,15 @@ export interface KeyValueStoreOptions {
865893 client : KeyValueStoreClient ;
866894}
867895
896+ /**
897+ * A raw, unparsed key-value store record as returned by {@apilink KeyValueStore.getRecord}: the
898+ * verbatim bytes plus the content type, with parsing left to the caller.
899+ */
900+ export interface KeyValueStoreRawRecord {
901+ value : Buffer | ArrayBuffer ;
902+ contentType : string | null ;
903+ }
904+
868905export interface RecordOptions {
869906 /**
870907 * Specifies a custom MIME content type of the record.
0 commit comments