@crawlee/fs-storage 4.0.0-rc.0 → 4.0.0-rc.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.md CHANGED
@@ -34,7 +34,7 @@ Crawlee is available as the [`crawlee`](https://www.npmjs.com/package/crawlee) N
34
34
 
35
35
  We recommend visiting the [Introduction tutorial](https://crawlee.dev/js/docs/introduction) in Crawlee documentation for more information.
36
36
 
37
- > Crawlee requires **Node.js 16 or higher**.
37
+ > Crawlee requires **Node.js 22.13 or higher**.
38
38
 
39
39
  ### With Crawlee CLI
40
40
 
@@ -1,8 +1,16 @@
1
1
  import type * as storage from '@crawlee/types';
2
2
  import type { CrawleeLogger } from '@crawlee/types';
3
- import { DatasetBackend } from './resource-clients/dataset.js';
4
- import { KeyValueStoreBackend } from './resource-clients/key-value-store.js';
5
- import { RequestQueueBackend } from './resource-clients/request-queue.js';
3
+ import type { AdoptionCandidate } from '@crawlee/fs-storage-native';
4
+ /** What the key-value store hooks of {@link FileSystemStorageBackend} learn about the store at hand. */
5
+ export interface KeyValueStoreHookOptions {
6
+ /** Whether the store is the run's default key-value store. */
7
+ isDefaultStore: boolean;
8
+ }
9
+ /** A file-system key-value store backend as seen by {@link FileSystemStorageBackend.purgeKeyValueStore}. */
10
+ export interface PurgeableKeyValueStoreBackend extends storage.KeyValueStoreBackend {
11
+ /** Remove every record from the store except the given keys. */
12
+ purgeExcept(keys: string[]): Promise<void>;
13
+ }
6
14
  export interface FileSystemStorageOptions {
7
15
  /**
8
16
  * Path to directory where the data will be saved.
@@ -10,6 +18,8 @@ export interface FileSystemStorageOptions {
10
18
  localDataDirectory: string;
11
19
  /**
12
20
  * Optional logger for FileSystemStorageBackend warnings.
21
+ *
22
+ * @internal
13
23
  */
14
24
  logger?: CrawleeLogger;
15
25
  /**
@@ -39,15 +49,6 @@ export interface FileSystemStorageOptions {
39
49
  */
40
50
  export declare class FileSystemStorageBackend implements storage.StorageBackend {
41
51
  #private;
42
- readonly localDataDirectory: string;
43
- readonly datasetsDirectory: string;
44
- readonly keyValueStoresDirectory: string;
45
- readonly requestQueuesDirectory: string;
46
- readonly logger?: CrawleeLogger;
47
- readonly requestQueueAccess: 'single' | 'shared';
48
- readonly keyValueStoreBackendCache: KeyValueStoreBackend[];
49
- readonly datasetBackendCache: DatasetBackend[];
50
- readonly requestQueueBackendCache: RequestQueueBackend[];
51
52
  constructor(options: FileSystemStorageOptions);
52
53
  /**
53
54
  * Return a cache key that includes the resolved storage directory, so that two
@@ -58,6 +59,26 @@ export declare class FileSystemStorageBackend implements storage.StorageBackend
58
59
  createDatasetBackend(options?: storage.StorageIdentifier): Promise<storage.DatasetBackend>;
59
60
  createKeyValueStoreBackend(options?: storage.StorageIdentifier): Promise<storage.KeyValueStoreBackend>;
60
61
  createRequestQueueBackend(options?: storage.StorageIdentifier): Promise<storage.RequestQueueBackend>;
62
+ /**
63
+ * What the native `open` may turn into records: value files sitting in the store directory with no
64
+ * metadata sidecar, written out-of-band by a CLI, a project template, a v3 Crawlee or a text editor.
65
+ * Each becomes a record keyed by its filename, with the content type derived from the extension alone.
66
+ *
67
+ * Subclasses may claim specific files under a key of their own by prepending candidates — the Apify
68
+ * SDK adopts a bare `INPUT` or `INPUT.json` in the default store as the record `INPUT` this way.
69
+ *
70
+ * Once adopted, the file is an ordinary record: readable, listed, deletable, and — in a run-scoped
71
+ * store — purged on start unless {@link purgeKeyValueStore} spares it. Files adoption skips (dotfiles)
72
+ * are purged all the same: the purge sweeps the directory and keeps only the store metadata and the
73
+ * spared keys.
74
+ *
75
+ */
76
+ protected keyValueStoreAdoptionCandidates(_options: KeyValueStoreHookOptions): AdoptionCandidate[];
77
+ /**
78
+ * Empties one run-scoped key-value store during {@link purge}. Subclasses may spare some keys of the
79
+ * default store with {@link PurgeableKeyValueStoreBackend.purgeExcept} — the Apify SDK keeps its run input.
80
+ */
81
+ protected purgeKeyValueStore(store: PurgeableKeyValueStoreBackend, _options: KeyValueStoreHookOptions): Promise<void>;
61
82
  storageExists(id: string, type: 'Dataset' | 'KeyValueStore' | 'RequestQueue'): Promise<boolean>;
62
83
  /**
63
84
  * Cleans up the run-scoped storages before the run starts, sweeping the storage directories so that
@@ -23,6 +23,13 @@ async function importNativeModule() {
23
23
  const DEFAULT_STORAGE_ALIAS = '__default__';
24
24
  /** The directory the default storage lives in, one level below `datasets` / `key_value_stores` / etc. */
25
25
  const DEFAULT_STORAGE_DIRECTORY = 'default';
26
+ /**
27
+ * Content types declared for adopted files. The native client infers nothing from extensions, and
28
+ * neither do we beyond this: a `.json` file is JSON, anything else is bytes for the
29
+ * {@link KeyValueStore} frontend to make sense of.
30
+ */
31
+ const ADOPTED_JSON_CONTENT_TYPE = 'application/json; charset=utf-8';
32
+ const ADOPTED_BINARY_CONTENT_TYPE = 'application/octet-stream';
26
33
  /**
27
34
  * A file-system storage backend backed by the native `@crawlee/fs-storage-native` Rust extension.
28
35
  *
@@ -32,23 +39,23 @@ const DEFAULT_STORAGE_DIRECTORY = 'default';
32
39
  * `teardown` can operate over them), and exposing them through the `@crawlee/types` interfaces.
33
40
  */
34
41
  export class FileSystemStorageBackend {
35
- localDataDirectory;
36
- datasetsDirectory;
37
- keyValueStoresDirectory;
38
- requestQueuesDirectory;
39
- logger;
40
- requestQueueAccess;
41
- keyValueStoreBackendCache = [];
42
- datasetBackendCache = [];
43
- requestQueueBackendCache = [];
42
+ #localDataDirectory;
43
+ #datasetsDirectory;
44
+ #keyValueStoresDirectory;
45
+ #requestQueuesDirectory;
46
+ #logger;
47
+ #requestQueueAccess;
48
+ #keyValueStoreBackendCache = [];
49
+ #datasetBackendCache = [];
50
+ #requestQueueBackendCache = [];
44
51
  constructor(options) {
45
52
  const { logger, requestQueueAccess, localDataDirectory } = parseArgument(options, fileSystemStorageOptionsSchema);
46
- this.logger = logger;
47
- this.requestQueueAccess = requestQueueAccess;
48
- this.localDataDirectory = localDataDirectory;
49
- this.datasetsDirectory = resolve(this.localDataDirectory, 'datasets');
50
- this.keyValueStoresDirectory = resolve(this.localDataDirectory, 'key_value_stores');
51
- this.requestQueuesDirectory = resolve(this.localDataDirectory, 'request_queues');
53
+ this.#logger = logger;
54
+ this.#requestQueueAccess = requestQueueAccess;
55
+ this.#localDataDirectory = localDataDirectory;
56
+ this.#datasetsDirectory = resolve(this.#localDataDirectory, 'datasets');
57
+ this.#keyValueStoresDirectory = resolve(this.#localDataDirectory, 'key_value_stores');
58
+ this.#requestQueuesDirectory = resolve(this.#localDataDirectory, 'request_queues');
52
59
  }
53
60
  /**
54
61
  * Return a cache key that includes the resolved storage directory, so that two
@@ -56,7 +63,7 @@ export class FileSystemStorageBackend {
56
63
  * partitions, by including the storage directory in the cache key.
57
64
  */
58
65
  getStorageBackendCacheKey() {
59
- return `FileSystemStorageBackend:${resolve(this.localDataDirectory)}`;
66
+ return `FileSystemStorageBackend:${resolve(this.#localDataDirectory)}`;
60
67
  }
61
68
  static #resolveStorageKey(options) {
62
69
  // No identifier at all means the default storage, which is opened under the reserved alias —
@@ -73,75 +80,108 @@ export class FileSystemStorageBackend {
73
80
  }
74
81
  async createDatasetBackend(options = {}) {
75
82
  const { id, name, alias, cacheKey } = FileSystemStorageBackend.#resolveStorageKey(options);
76
- const found = this.datasetBackendCache.find((store) => store.id === cacheKey ||
83
+ const found = this.#datasetBackendCache.find((store) => store.id === cacheKey ||
77
84
  store.name?.toLowerCase() === cacheKey.toLowerCase() ||
78
85
  store.cacheKey.toLowerCase() === cacheKey.toLowerCase());
79
86
  if (found) {
80
87
  return found;
81
88
  }
82
- const nativeBackend = await (await importNativeModule()).FileSystemDatasetClient.open(id, name, alias, this.localDataDirectory);
89
+ const nativeBackend = await (await importNativeModule()).FileSystemDatasetClient.open(id, name, alias, this.#localDataDirectory);
83
90
  const newStore = await DatasetBackend.create({
84
91
  name: alias ? undefined : (name ?? cacheKey),
85
92
  cacheKey,
86
93
  nativeBackend,
87
- logger: this.logger,
94
+ logger: this.#logger,
88
95
  });
89
- this.datasetBackendCache.push(newStore);
96
+ this.#datasetBackendCache.push(newStore);
90
97
  return newStore;
91
98
  }
92
99
  async createKeyValueStoreBackend(options = {}) {
93
100
  const { id, name, alias, cacheKey } = FileSystemStorageBackend.#resolveStorageKey(options);
94
- const found = this.keyValueStoreBackendCache.find((store) => store.id === cacheKey ||
101
+ const found = this.#keyValueStoreBackendCache.find((store) => store.id === cacheKey ||
95
102
  store.name?.toLowerCase() === cacheKey.toLowerCase() ||
96
103
  store.cacheKey.toLowerCase() === cacheKey.toLowerCase());
97
104
  if (found) {
98
105
  return found;
99
106
  }
100
- const nativeBackend = await (await importNativeModule()).FileSystemKeyValueStoreClient.open(id, name, alias, this.localDataDirectory);
107
+ const nativeBackend = await (await importNativeModule()).FileSystemKeyValueStoreClient.open(id, name, alias, this.#localDataDirectory,
108
+ // useTestClock — always real wall-clock outside of native tests.
109
+ undefined, this.keyValueStoreAdoptionCandidates({ isDefaultStore: cacheKey === DEFAULT_STORAGE_DIRECTORY }));
101
110
  const newStore = await KeyValueStoreBackend.create({
102
111
  name: alias ? undefined : (name ?? cacheKey),
103
112
  cacheKey,
104
113
  nativeBackend,
105
- logger: this.logger,
114
+ logger: this.#logger,
106
115
  });
107
- this.keyValueStoreBackendCache.push(newStore);
116
+ this.#keyValueStoreBackendCache.push(newStore);
108
117
  return newStore;
109
118
  }
110
119
  async createRequestQueueBackend(options = {}) {
111
120
  const { id, name, alias, cacheKey } = FileSystemStorageBackend.#resolveStorageKey(options);
112
- const found = this.requestQueueBackendCache.find((queue) => queue.id === cacheKey ||
121
+ const found = this.#requestQueueBackendCache.find((queue) => queue.id === cacheKey ||
113
122
  queue.name?.toLowerCase() === cacheKey.toLowerCase() ||
114
123
  queue.cacheKey.toLowerCase() === cacheKey.toLowerCase());
115
124
  if (found) {
116
125
  return found;
117
126
  }
118
- const nativeBackend = await (await importNativeModule()).FileSystemRequestQueueClient.open(id, name, alias, this.localDataDirectory,
127
+ const nativeBackend = await (await importNativeModule()).FileSystemRequestQueueClient.open(id, name, alias, this.#localDataDirectory,
119
128
  // useTestClock — always real wall-clock outside of native tests.
120
- undefined, this.requestQueueAccess);
129
+ undefined, this.#requestQueueAccess);
121
130
  const newStore = await RequestQueueBackend.create({
122
131
  name: alias ? undefined : (name ?? cacheKey),
123
132
  cacheKey,
124
133
  nativeBackend,
125
- logger: this.logger,
134
+ logger: this.#logger,
126
135
  });
127
- this.requestQueueBackendCache.push(newStore);
136
+ this.#requestQueueBackendCache.push(newStore);
128
137
  return newStore;
129
138
  }
139
+ /**
140
+ * What the native `open` may turn into records: value files sitting in the store directory with no
141
+ * metadata sidecar, written out-of-band by a CLI, a project template, a v3 Crawlee or a text editor.
142
+ * Each becomes a record keyed by its filename, with the content type derived from the extension alone.
143
+ *
144
+ * Subclasses may claim specific files under a key of their own by prepending candidates — the Apify
145
+ * SDK adopts a bare `INPUT` or `INPUT.json` in the default store as the record `INPUT` this way.
146
+ *
147
+ * Once adopted, the file is an ordinary record: readable, listed, deletable, and — in a run-scoped
148
+ * store — purged on start unless {@link purgeKeyValueStore} spares it. Files adoption skips (dotfiles)
149
+ * are purged all the same: the purge sweeps the directory and keeps only the store metadata and the
150
+ * spared keys.
151
+ *
152
+ */
153
+ keyValueStoreAdoptionCandidates(_options) {
154
+ return [
155
+ {
156
+ files: [
157
+ { filename: '*.json', contentType: ADOPTED_JSON_CONTENT_TYPE },
158
+ { filename: '*', contentType: ADOPTED_BINARY_CONTENT_TYPE },
159
+ ],
160
+ },
161
+ ];
162
+ }
163
+ /**
164
+ * Empties one run-scoped key-value store during {@link purge}. Subclasses may spare some keys of the
165
+ * default store with {@link PurgeableKeyValueStoreBackend.purgeExcept} — the Apify SDK keeps its run input.
166
+ */
167
+ async purgeKeyValueStore(store, _options) {
168
+ await store.purge();
169
+ }
130
170
  async storageExists(id, type) {
131
171
  let backends;
132
172
  let baseDir;
133
173
  switch (type) {
134
174
  case 'Dataset':
135
- backends = this.datasetBackendCache;
136
- baseDir = this.datasetsDirectory;
175
+ backends = this.#datasetBackendCache;
176
+ baseDir = this.#datasetsDirectory;
137
177
  break;
138
178
  case 'KeyValueStore':
139
- backends = this.keyValueStoreBackendCache;
140
- baseDir = this.keyValueStoresDirectory;
179
+ backends = this.#keyValueStoreBackendCache;
180
+ baseDir = this.#keyValueStoresDirectory;
141
181
  break;
142
182
  case 'RequestQueue':
143
- backends = this.requestQueueBackendCache;
144
- baseDir = this.requestQueuesDirectory;
183
+ backends = this.#requestQueueBackendCache;
184
+ baseDir = this.#requestQueuesDirectory;
145
185
  break;
146
186
  default:
147
187
  return false;
@@ -214,11 +254,9 @@ export class FileSystemStorageBackend {
214
254
  */
215
255
  async purge() {
216
256
  await Promise.all([
217
- this.#purgeRunScopedStorages(this.keyValueStoresDirectory, async (alias) => this.createKeyValueStoreBackend({ alias }),
218
- // Only the default store holds the run input, so it is the only one that keeps `INPUT`.
219
- async (store, isDefault) => (isDefault ? store.purgeExceptInput() : store.purge())),
220
- this.#purgeRunScopedStorages(this.datasetsDirectory, async (alias) => this.createDatasetBackend({ alias }), async (store) => store.purge()),
221
- this.#purgeRunScopedStorages(this.requestQueuesDirectory, async (alias) => this.createRequestQueueBackend({ alias }), async (store) => store.purge()),
257
+ this.#purgeRunScopedStorages(this.#keyValueStoresDirectory, async (alias) => this.createKeyValueStoreBackend({ alias }), async (store, isDefault) => this.purgeKeyValueStore(store, { isDefaultStore: isDefault })),
258
+ this.#purgeRunScopedStorages(this.#datasetsDirectory, async (alias) => this.createDatasetBackend({ alias }), async (store) => store.purge()),
259
+ this.#purgeRunScopedStorages(this.#requestQueuesDirectory, async (alias) => this.createRequestQueueBackend({ alias }), async (store) => store.purge()),
222
260
  ]);
223
261
  }
224
262
  /**
@@ -281,6 +319,6 @@ export class FileSystemStorageBackend {
281
319
  * are not stuck (until their lock expires) for the next consumer of the same on-disk queue.
282
320
  */
283
321
  async teardown() {
284
- await Promise.all(this.requestQueueBackendCache.map(async (queue) => queue.persistState()));
322
+ await Promise.all(this.#requestQueueBackendCache.map(async (queue) => queue.persistState()));
285
323
  }
286
324
  }
package/index.d.ts CHANGED
@@ -1 +1,2 @@
1
1
  export * from './file-system-storage.js';
2
+ export type { AdoptionCandidate } from '@crawlee/fs-storage-native';
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@crawlee/fs-storage",
3
- "version": "4.0.0-rc.0",
3
+ "version": "4.0.0-rc.1",
4
4
  "description": "A file-system storage implementation of the Apify API",
5
5
  "engines": {
6
- "node": ">=22.0.0"
6
+ "node": ">=22.13.0"
7
7
  },
8
8
  "type": "module",
9
9
  "exports": {
@@ -42,10 +42,10 @@
42
42
  "access": "public"
43
43
  },
44
44
  "dependencies": {
45
- "@crawlee/fs-storage-native": "0.1.5-beta.18",
46
- "@crawlee/types": "4.0.0-rc.0",
47
- "@crawlee/utils": "4.0.0-rc.0",
48
- "zod": "^4.4.3"
45
+ "@crawlee/fs-storage-native": ">=0.2.2 <0.3",
46
+ "@crawlee/types": "4.0.0-rc.1",
47
+ "@crawlee/utils": "4.0.0-rc.1",
48
+ "zod": "^4.5.4"
49
49
  },
50
50
  "lerna": {
51
51
  "command": {
@@ -54,5 +54,5 @@
54
54
  }
55
55
  }
56
56
  },
57
- "gitHead": "79ab33dacdacb83e0197e6516d145f3aceef80c7"
57
+ "gitHead": "f354ca5e943bed1a5c657a1fce1974c053f9fff0"
58
58
  }
@@ -32,19 +32,16 @@ export declare class KeyValueStoreBackend extends CachedIdClient implements stor
32
32
  drop(): Promise<void>;
33
33
  purge(): Promise<void>;
34
34
  /**
35
- * Remove every record from the store except the run input. Used by
36
- * {@link FileSystemStorageBackend.purge} to clean the default key-value store at the start of a run
37
- * while preserving the run's input, matching the historical file-system storage behavior.
38
- *
39
- * The native `purge` keep-list matches by exact key with no extension globbing, so we pass every
40
- * filename the input might live under (`INPUT`, `INPUT.json`, `INPUT.txt`, `INPUT.bin`).
35
+ * Remove every record from the store except the given keys. For {@link FileSystemStorageBackend}
36
+ * subclasses that keep some of the default store's records across the purge on start.
41
37
  */
42
- purgeExceptInput(): Promise<void>;
38
+ purgeExcept(keys: string[]): Promise<void>;
43
39
  listKeys(options?: storage.KeyValueStoreListKeysOptions): Promise<storage.KeyValueStoreListKeysResult>;
44
40
  /**
45
41
  * Generates a public `file://` URL for accessing a specific record in the key-value store.
46
42
  *
47
- * Returns `undefined` if the record does not exist.
43
+ * Existence-agnostic per the {@link KeyValueStoreBackend} contract: the URL points at the file
44
+ * the key is bound to if there is a record, and at the file it would be written to otherwise.
48
45
  * @param key The key of the record to generate the public URL for.
49
46
  */
50
47
  getPublicUrl(key: string): Promise<string | undefined>;
@@ -58,24 +55,4 @@ export declare class KeyValueStoreBackend extends CachedIdClient implements stor
58
55
  getValue(key: string): Promise<storage.KeyValueStoreRecord | undefined>;
59
56
  setValue(record: storage.KeyValueStoreInputRecord): Promise<void>;
60
57
  deleteValue(key: string): Promise<void>;
61
- /**
62
- * Resolve `key` to the on-disk key that actually exists, or `undefined` if nothing does. Every
63
- * key is checked against its tracked record; the run-input keys additionally fall back to
64
- * out-of-band bare files, in which case the matched on-disk key is returned so callers like
65
- * `getPublicUrl` point at the file that exists. Two run-input shapes are handled (see
66
- * {@link bareFallbacksFor}): the logical `INPUT`, which probes the conventional extensions, and a
67
- * literal bare filename such as `INPUT.json` as listed by `listKeys`, which resolves itself.
68
- */
69
- private resolveExistingKey;
70
- /**
71
- * The native `resolveValue`/`resolveExistingKey` bare-file fallbacks to use for `key`, or
72
- * `undefined` if `key` is a plain tracked-record lookup with no bare-file probing.
73
- *
74
- * - The logical run-input key (`INPUT`) probes the full extension ladder (`INPUT`, `INPUT.json`,
75
- * `INPUT.txt`, `INPUT.bin`), matching how Crawlee reads run input.
76
- * - A literal bare filename as surfaced by `listKeys` (`INPUT.json`/`.txt`/`.bin`) resolves itself:
77
- * the tracked record first, then the bare file at that exact name (a single empty-extension
78
- * fallback), so a listed key round-trips through `getValue`/`recordExists`.
79
- */
80
- private bareFallbacksFor;
81
58
  }
@@ -3,22 +3,6 @@ import { parseArgument, schemas } from '@crawlee/utils/internal';
3
3
  import { z } from 'zod';
4
4
  import { isStream } from '../utils.js';
5
5
  import { CachedIdClient } from './cached-id-client.js';
6
- /**
7
- * Out-of-band ("bare") value-file fallbacks tried when the {@link ALLOWED_BARE_FILES} lookup misses the tracked
8
- * record, so a lookup for `INPUT` also matches a hand-placed `INPUT.json`/`.txt`/`.bin`. Passed to the
9
- * native `resolveValue`/`resolveExistingKey`, which do the probing and re-keying.
10
- *
11
- * Each entry declares the content type to report on a match — the native client does no MIME
12
- * inference. An empty `contentType` is its sentinel for "keep the synthesized
13
- * `application/octet-stream`", used for the extensionless key and `.bin`.
14
- */
15
- const BARE_FILE_FALLBACKS = [
16
- { extension: '', contentType: '' },
17
- { extension: '.json', contentType: 'application/json; charset=utf-8' },
18
- { extension: '.txt', contentType: 'text/plain; charset=utf-8' },
19
- { extension: '.bin', contentType: '' },
20
- ];
21
- const ALLOWED_BARE_FILES = ['INPUT'];
22
6
  const keySchema = z.string();
23
7
  const inputRecordShape = z.object({
24
8
  key: z.string().min(1),
@@ -33,23 +17,6 @@ const inputRecordShape = z.object({
33
17
  ]),
34
18
  contentType: z.string().min(1).optional(),
35
19
  });
36
- /**
37
- * The out-of-band ("bare") files to surface from the native `listKeys`, derived from
38
- * {@link ALLOWED_BARE_FILES} × {@link BARE_FILE_FALLBACKS}. Each native {@link ListBareFallback}
39
- * `name` is the literal on-disk filename to probe (e.g. `INPUT.json`), and the native lists a match
40
- * under that same `name` — which is exactly the key we return, so a listed bare file round-trips
41
- * through `getValue`/`recordExists` (see {@link BARE_FILE_CONTENT_TYPES}).
42
- */
43
- const LIST_BARE_FALLBACKS = ALLOWED_BARE_FILES.flatMap((key) => BARE_FILE_FALLBACKS.map(({ extension, contentType }) => ({ name: `${key}${extension}`, contentType })));
44
- /**
45
- * Lookup from a bare file's literal on-disk name (e.g. `INPUT.json`) to the content type to report
46
- * for it, used to read a listed bare key back directly (`getValue('INPUT.json')`). The empty-extension
47
- * entry (`INPUT`) is intentionally excluded: an extensionless `INPUT` lookup goes through the
48
- * `resolveValue` fallback probing instead, which already covers the extensionless file.
49
- */
50
- const BARE_FILE_CONTENT_TYPES = new Map(ALLOWED_BARE_FILES.flatMap((key) => BARE_FILE_FALLBACKS.filter(({ extension }) => extension !== '').map(({ extension, contentType }) => [`${key}${extension}`, contentType])));
51
- /** Maps a bare file's on-disk name (e.g. `INPUT.json`) to its logical key (e.g. `INPUT`), for dedup. */
52
- const BARE_FILE_LOGICAL_KEYS = new Map(ALLOWED_BARE_FILES.flatMap((key) => BARE_FILE_FALLBACKS.map(({ extension }) => [`${key}${extension}`, key])));
53
20
  /**
54
21
  * A file-system key-value store backend backed by the native `@crawlee/fs-storage-native` Rust
55
22
  * extension.
@@ -86,60 +53,28 @@ export class KeyValueStoreBackend extends CachedIdClient {
86
53
  await this.#nativeBackend.purge();
87
54
  }
88
55
  /**
89
- * Remove every record from the store except the run input. Used by
90
- * {@link FileSystemStorageBackend.purge} to clean the default key-value store at the start of a run
91
- * while preserving the run's input, matching the historical file-system storage behavior.
92
- *
93
- * The native `purge` keep-list matches by exact key with no extension globbing, so we pass every
94
- * filename the input might live under (`INPUT`, `INPUT.json`, `INPUT.txt`, `INPUT.bin`).
56
+ * Remove every record from the store except the given keys. For {@link FileSystemStorageBackend}
57
+ * subclasses that keep some of the default store's records across the purge on start.
95
58
  */
96
- async purgeExceptInput() {
97
- await this.#nativeBackend.purge(BARE_FILE_FALLBACKS.flatMap(({ extension }) => `INPUT${extension}`));
59
+ async purgeExcept(keys) {
60
+ await this.#nativeBackend.purge(keys);
98
61
  }
99
62
  async listKeys(options = {}) {
100
63
  const { prefix, exclusiveStartKey, limit } = parseArgument(options, schemas.keyValueStoreListKeysOptions);
101
- // Pass the bare-file fallbacks so out-of-band value files (e.g. a hand-placed `INPUT.json`)
102
- // are enumerated alongside tracked records, under their actual on-disk name. The native reads
103
- // everything it needs off the filesystem index — no per-file reads — so this stays cheap.
104
64
  // The native `listKeys` already returns a self-describing page (items + pagination cursors)
105
- // matching the `KeyValueStoreListKeysResult` contract, so we only post-process the items.
106
- const page = await this.#nativeBackend.listKeys(exclusiveStartKey, limit, prefix, LIST_BARE_FALLBACKS);
107
- const presentKeys = new Set(page.items.map((record) => record.key));
108
- // A bare value file is listed under its actual name (`INPUT.json`), which already round-trips
109
- // through `getValue`/`recordExists`. The only collision is a tracked record occupying the
110
- // logical key itself (`INPUT`): it shadows the extension-bearing bare variants (`INPUT.json`
111
- // etc.) for the same logical key, so drop those. The extensionless bare file *is* the logical
112
- // key, so it is never a separate duplicate.
113
- const items = page.items.filter((record) => {
114
- const logicalKey = BARE_FILE_LOGICAL_KEYS.get(record.key);
115
- const isExtensionBearingBareFile = logicalKey !== undefined && logicalKey !== record.key;
116
- return !(isExtensionBearingBareFile && presentKeys.has(logicalKey));
117
- });
118
- return {
119
- items,
120
- count: items.length,
121
- limit: page.limit,
122
- exclusiveStartKey: page.exclusiveStartKey,
123
- isTruncated: page.isTruncated,
124
- nextExclusiveStartKey: page.nextExclusiveStartKey,
125
- };
65
+ // matching the `KeyValueStoreListKeysResult` contract.
66
+ return this.#nativeBackend.listKeys(exclusiveStartKey, limit, prefix);
126
67
  }
127
68
  /**
128
69
  * Generates a public `file://` URL for accessing a specific record in the key-value store.
129
70
  *
130
- * Returns `undefined` if the record does not exist.
71
+ * Existence-agnostic per the {@link KeyValueStoreBackend} contract: the URL points at the file
72
+ * the key is bound to if there is a record, and at the file it would be written to otherwise.
131
73
  * @param key The key of the record to generate the public URL for.
132
74
  */
133
75
  async getPublicUrl(key) {
134
76
  parseArgument(key, keySchema);
135
- // The native `getPublicUrl` stats the encoded path but does not probe bare-file extensions,
136
- // so we resolve the on-disk key first (handling e.g. `INPUT` -> `INPUT.json`) and normalize
137
- // the native `null`-for-missing result to the historical `undefined` contract.
138
- const resolvedKey = await this.resolveExistingKey(key);
139
- if (resolvedKey === undefined) {
140
- return undefined;
141
- }
142
- return (await this.#nativeBackend.getPublicUrl(resolvedKey)) ?? undefined;
77
+ return this.#nativeBackend.getPublicUrl(key);
143
78
  }
144
79
  /**
145
80
  * Tests whether a record with the given key exists without retrieving its value.
@@ -149,14 +84,11 @@ export class KeyValueStoreBackend extends CachedIdClient {
149
84
  */
150
85
  async recordExists(key) {
151
86
  parseArgument(key, keySchema);
152
- return (await this.resolveExistingKey(key)) !== undefined;
87
+ return this.#nativeBackend.recordExists(key);
153
88
  }
154
89
  async getValue(key) {
155
90
  parseArgument(key, keySchema);
156
- const fallbacks = this.bareFallbacksFor(key);
157
- const record = fallbacks
158
- ? await this.#nativeBackend.resolveValue(key, fallbacks)
159
- : await this.#nativeBackend.getValue(key);
91
+ const record = await this.#nativeBackend.getValue(key);
160
92
  if (record) {
161
93
  return {
162
94
  key: record.key,
@@ -197,40 +129,4 @@ export class KeyValueStoreBackend extends CachedIdClient {
197
129
  parseArgument(key, keySchema);
198
130
  await this.#nativeBackend.deleteValue(key);
199
131
  }
200
- /**
201
- * Resolve `key` to the on-disk key that actually exists, or `undefined` if nothing does. Every
202
- * key is checked against its tracked record; the run-input keys additionally fall back to
203
- * out-of-band bare files, in which case the matched on-disk key is returned so callers like
204
- * `getPublicUrl` point at the file that exists. Two run-input shapes are handled (see
205
- * {@link bareFallbacksFor}): the logical `INPUT`, which probes the conventional extensions, and a
206
- * literal bare filename such as `INPUT.json` as listed by `listKeys`, which resolves itself.
207
- */
208
- async resolveExistingKey(key) {
209
- const fallbacks = this.bareFallbacksFor(key);
210
- if (fallbacks) {
211
- return ((await this.#nativeBackend.resolveExistingKey(key, fallbacks.map(({ extension }) => extension))) ?? undefined);
212
- }
213
- return (await this.#nativeBackend.recordExists(key)) ? key : undefined;
214
- }
215
- /**
216
- * The native `resolveValue`/`resolveExistingKey` bare-file fallbacks to use for `key`, or
217
- * `undefined` if `key` is a plain tracked-record lookup with no bare-file probing.
218
- *
219
- * - The logical run-input key (`INPUT`) probes the full extension ladder (`INPUT`, `INPUT.json`,
220
- * `INPUT.txt`, `INPUT.bin`), matching how Crawlee reads run input.
221
- * - A literal bare filename as surfaced by `listKeys` (`INPUT.json`/`.txt`/`.bin`) resolves itself:
222
- * the tracked record first, then the bare file at that exact name (a single empty-extension
223
- * fallback), so a listed key round-trips through `getValue`/`recordExists`.
224
- */
225
- // eslint-disable-next-line class-methods-use-this
226
- bareFallbacksFor(key) {
227
- if (ALLOWED_BARE_FILES.includes(key)) {
228
- return BARE_FILE_FALLBACKS;
229
- }
230
- const contentType = BARE_FILE_CONTENT_TYPES.get(key);
231
- if (contentType !== undefined) {
232
- return [{ extension: '', contentType }];
233
- }
234
- return undefined;
235
- }
236
132
  }
@@ -32,6 +32,11 @@ export declare class RequestQueueBackend extends CachedIdClient implements stora
32
32
  * available again.
33
33
  */
34
34
  setExpectedRequestProcessingTimeSecs(secs: number): Promise<void>;
35
+ /**
36
+ * @inheritdoc
37
+ * The lock is owned and extended by the native client; `false` means it no longer holds the request.
38
+ */
39
+ extendRequestProcessingTimeSecs(requestId: string, secs: number): Promise<boolean>;
35
40
  getMetadata(): Promise<storage.RequestQueueInfo>;
36
41
  drop(): Promise<void>;
37
42
  purge(): Promise<void>;
@@ -49,6 +49,13 @@ export class RequestQueueBackend extends CachedIdClient {
49
49
  async setExpectedRequestProcessingTimeSecs(secs) {
50
50
  await this.#nativeBackend.setExpectedRequestProcessingTime(secs);
51
51
  }
52
+ /**
53
+ * @inheritdoc
54
+ * The lock is owned and extended by the native client; `false` means it no longer holds the request.
55
+ */
56
+ async extendRequestProcessingTimeSecs(requestId, secs) {
57
+ return this.#nativeBackend.prolongRequestLock(requestId, secs);
58
+ }
52
59
  async getMetadata() {
53
60
  return this.#nativeBackend.getMetadata();
54
61
  }