@crawlee/fs-storage 4.0.0-beta.177 → 4.0.0-beta.179

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.
@@ -28,11 +28,11 @@ export interface FileSystemStorageOptions {
28
28
  /**
29
29
  * The key the run input is read from — Crawlee's `inputKey` (`CRAWLEE_INPUT_KEY`).
30
30
  *
31
- * Like the conventional `INPUT`, this key may live in the default key-value store as a bare value
31
+ * Like the conventional `INPUT`, this key may arrive in the default key-value store as a bare value
32
32
  * file with no metadata sidecar (e.g. the Apify CLI writes the effective input to `__CLI_INPUT.json`
33
- * and points the run at that key). It is therefore readable out-of-band (`<key>`, `<key>.json`) and
34
- * preserved when the default store is purged, exactly like `INPUT`,
35
- * which is always kept regardless of this setting.
33
+ * and points the run at that key). Such a file is adopted into a record under this key when the
34
+ * store is opened, and the key is preserved when the default store is purged — exactly like
35
+ * `INPUT`, which is always treated as an input key regardless of this setting.
36
36
  *
37
37
  * @default 'INPUT'
38
38
  */
@@ -24,6 +24,15 @@ async function importNativeModule() {
24
24
  const DEFAULT_STORAGE_ALIAS = '__default__';
25
25
  /** The directory the default storage lives in, one level below `datasets` / `key_value_stores` / etc. */
26
26
  const DEFAULT_STORAGE_DIRECTORY = 'default';
27
+ /** The conventional run-input key, always treated as one alongside the configured `inputKey`. */
28
+ const DEFAULT_INPUT_KEY = 'INPUT';
29
+ /**
30
+ * Content types declared for adopted files. The native client infers nothing from extensions, and
31
+ * neither do we beyond this: a `.json` file is JSON, anything else is bytes for the
32
+ * {@link KeyValueStore} frontend to make sense of.
33
+ */
34
+ const ADOPTED_JSON_CONTENT_TYPE = 'application/json; charset=utf-8';
35
+ const ADOPTED_BINARY_CONTENT_TYPE = 'application/octet-stream';
27
36
  /**
28
37
  * A file-system storage backend backed by the native `@crawlee/fs-storage-native` Rust extension.
29
38
  *
@@ -39,7 +48,8 @@ export class FileSystemStorageBackend {
39
48
  requestQueuesDirectory;
40
49
  logger;
41
50
  requestQueueAccess;
42
- #inputKey;
51
+ /** `INPUT` plus the configured `inputKey`, deduplicated. */
52
+ #inputKeys;
43
53
  #keyValueStoreBackendCache = [];
44
54
  #datasetBackendCache = [];
45
55
  #requestQueueBackendCache = [];
@@ -47,7 +57,7 @@ export class FileSystemStorageBackend {
47
57
  const { logger, requestQueueAccess, inputKey, localDataDirectory } = parseArgument(options, fileSystemStorageOptionsSchema);
48
58
  this.logger = logger;
49
59
  this.requestQueueAccess = requestQueueAccess;
50
- this.#inputKey = inputKey;
60
+ this.#inputKeys = [...new Set([DEFAULT_INPUT_KEY, inputKey])];
51
61
  this.localDataDirectory = localDataDirectory;
52
62
  this.datasetsDirectory = resolve(this.localDataDirectory, 'datasets');
53
63
  this.keyValueStoresDirectory = resolve(this.localDataDirectory, 'key_value_stores');
@@ -100,13 +110,15 @@ export class FileSystemStorageBackend {
100
110
  if (found) {
101
111
  return found;
102
112
  }
103
- const nativeBackend = await (await importNativeModule()).FileSystemKeyValueStoreClient.open(id, name, alias, this.localDataDirectory);
113
+ const nativeBackend = await (await importNativeModule()).FileSystemKeyValueStoreClient.open(id, name, alias, this.localDataDirectory,
114
+ // useTestClock — always real wall-clock outside of native tests.
115
+ undefined, this.#adoptionCandidates(cacheKey === DEFAULT_STORAGE_DIRECTORY));
104
116
  const newStore = await KeyValueStoreBackend.create({
105
117
  name: alias ? undefined : (name ?? cacheKey),
106
118
  cacheKey,
107
119
  nativeBackend,
108
120
  logger: this.logger,
109
- inputKey: this.#inputKey,
121
+ inputKeys: this.#inputKeys,
110
122
  });
111
123
  this.#keyValueStoreBackendCache.push(newStore);
112
124
  return newStore;
@@ -131,6 +143,38 @@ export class FileSystemStorageBackend {
131
143
  this.#requestQueueBackendCache.push(newStore);
132
144
  return newStore;
133
145
  }
146
+ /**
147
+ * What the native `open` may turn into records: value files sitting in the store directory with no
148
+ * metadata sidecar, written out-of-band by a CLI, a project template, a v3 Crawlee or a text editor.
149
+ *
150
+ * Each run-input key claims a bare `<key>` or `<key>.json` first — that is the layout the Apify CLI
151
+ * and the templates produce, and the key must end up being `INPUT` rather than `INPUT.json`. Only
152
+ * the default store holds the run input, so elsewhere an `INPUT.json` is just a file named
153
+ * `INPUT.json`, adopted by the trailing sweep like every other one.
154
+ *
155
+ * Once adopted, the file is an ordinary record: readable, listed, deletable, and — in a run-scoped
156
+ * store — purged on start unless its key is a run-input key.
157
+ */
158
+ #adoptionCandidates(isDefaultStore) {
159
+ const inputCandidates = isDefaultStore
160
+ ? this.#inputKeys.map((key) => ({
161
+ key,
162
+ files: [
163
+ { filename: key, contentType: ADOPTED_BINARY_CONTENT_TYPE },
164
+ { filename: `${key}.json`, contentType: ADOPTED_JSON_CONTENT_TYPE },
165
+ ],
166
+ }))
167
+ : [];
168
+ return [
169
+ ...inputCandidates,
170
+ {
171
+ files: [
172
+ { filename: '*.json', contentType: ADOPTED_JSON_CONTENT_TYPE },
173
+ { filename: '*', contentType: ADOPTED_BINARY_CONTENT_TYPE },
174
+ ],
175
+ },
176
+ ];
177
+ }
134
178
  async storageExists(id, type) {
135
179
  let backends;
136
180
  let baseDir;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crawlee/fs-storage",
3
- "version": "4.0.0-beta.177",
3
+ "version": "4.0.0-beta.179",
4
4
  "description": "A file-system storage implementation of the Apify API",
5
5
  "engines": {
6
6
  "node": ">=22.0.0"
@@ -42,9 +42,9 @@
42
42
  "access": "public"
43
43
  },
44
44
  "dependencies": {
45
- "@crawlee/fs-storage-native": ">=0.2.1-beta.0 <0.3",
46
- "@crawlee/types": "4.0.0-beta.177",
47
- "@crawlee/utils": "4.0.0-beta.177",
45
+ "@crawlee/fs-storage-native": ">=0.2.1 <0.3",
46
+ "@crawlee/types": "4.0.0-beta.179",
47
+ "@crawlee/utils": "4.0.0-beta.179",
48
48
  "zod": "^4.5.4"
49
49
  },
50
50
  "lerna": {
@@ -54,5 +54,5 @@
54
54
  }
55
55
  }
56
56
  },
57
- "gitHead": "bfd191ba71902fc682dd21fb9ab17590037a60d5"
57
+ "gitHead": "8b7816439436b46bad1007a48df9e8bef0564bd0"
58
58
  }
@@ -12,8 +12,11 @@ export interface KeyValueStoreBackendOptions {
12
12
  cacheKey: string;
13
13
  nativeBackend: NativeFileSystemKeyValueStoreBackend;
14
14
  logger?: CrawleeLogger;
15
- /** The configured run-input key, see `FileSystemStorageOptions.inputKey`. Treated like `INPUT`. */
16
- inputKey?: string;
15
+ /**
16
+ * The run-input keys of this store, deduplicated — `INPUT` plus `FileSystemStorageOptions.inputKey`.
17
+ * All that is left of the run-input special case: the keys {@link purgeExceptInput} spares.
18
+ */
19
+ inputKeys: string[];
17
20
  }
18
21
  /**
19
22
  * A file-system key-value store backend backed by the native `@crawlee/fs-storage-native` Rust
@@ -36,18 +39,15 @@ export declare class KeyValueStoreBackend extends CachedIdClient implements stor
36
39
  /**
37
40
  * Remove every record from the store except the run input. Used by
38
41
  * {@link FileSystemStorageBackend.purge} to clean the default key-value store at the start of a run
39
- * while preserving the run's input. The native keep-list matches exact filenames, so every extension
40
- * variant of every input key is listed.
42
+ * while preserving the run's input.
41
43
  */
42
44
  purgeExceptInput(): Promise<void>;
43
45
  listKeys(options?: storage.KeyValueStoreListKeysOptions): Promise<storage.KeyValueStoreListKeysResult>;
44
46
  /**
45
47
  * Generates a public `file://` URL for accessing a specific record in the key-value store.
46
48
  *
47
- * The native `getPublicUrl` derives the URL from the key without probing bare-file extensions, so
48
- * an `INPUT` that lives on disk as a hand-placed `INPUT.json` is resolved first. Nothing on disk
49
- * means nothing to resolve — the requested key is used as-is, and the URL is the one the record will
50
- * have once written.
49
+ * Existence-agnostic per the {@link KeyValueStoreBackend} contract: the URL points at the file
50
+ * the key is bound to if there is a record, and at the file it would be written to otherwise.
51
51
  * @param key The key of the record to generate the public URL for.
52
52
  */
53
53
  getPublicUrl(key: string): Promise<string | undefined>;
@@ -61,10 +61,4 @@ export declare class KeyValueStoreBackend extends CachedIdClient implements stor
61
61
  getValue(key: string): Promise<storage.KeyValueStoreRecord | undefined>;
62
62
  setValue(record: storage.KeyValueStoreInputRecord): Promise<void>;
63
63
  deleteValue(key: string): Promise<void>;
64
- /**
65
- * Resolve `key` to the on-disk key that actually exists, or `undefined` if nothing does. Run-input
66
- * keys fall back to bare files, in which case the matched on-disk key is returned so callers like
67
- * `getPublicUrl` point at the file that exists.
68
- */
69
- private resolveExistingKey;
70
64
  }
@@ -3,21 +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 a run-input lookup misses the tracked record, so a
8
- * lookup for `INPUT` also matches a hand-placed `INPUT.json`. Passed to the native
9
- * `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.
14
- */
15
- const BARE_FILE_FALLBACKS = [
16
- { extension: '', contentType: '' },
17
- { extension: '.json', contentType: 'application/json; charset=utf-8' },
18
- ];
19
- /** The conventional run-input key, always treated as one alongside the configured `inputKey`. */
20
- const DEFAULT_INPUT_KEY = 'INPUT';
21
6
  const keySchema = z.string();
22
7
  const inputRecordShape = z.object({
23
8
  key: z.string().min(1),
@@ -44,20 +29,14 @@ export class KeyValueStoreBackend extends CachedIdClient {
44
29
  name;
45
30
  cacheKey;
46
31
  #nativeBackend;
47
- /** `INPUT` plus the configured `inputKey`, deduplicated. */
32
+ /** See {@link KeyValueStoreBackendOptions.inputKeys}. */
48
33
  #inputKeys;
49
- /** Bare files the native `listKeys` should surface, under their on-disk name (e.g. `INPUT.json`). */
50
- #listBareFallbacks;
51
- /** Bare-file on-disk name (`INPUT.json`) to logical key (`INPUT`). */
52
- #bareFileLogicalKeys;
53
34
  constructor(options) {
54
35
  super();
55
36
  this.name = options.name;
56
37
  this.cacheKey = options.cacheKey;
57
38
  this.#nativeBackend = options.nativeBackend;
58
- this.#inputKeys = [...new Set([DEFAULT_INPUT_KEY, options.inputKey ?? DEFAULT_INPUT_KEY])];
59
- this.#listBareFallbacks = this.#inputKeys.flatMap((key) => BARE_FILE_FALLBACKS.map(({ extension, contentType }) => ({ name: `${key}${extension}`, contentType })));
60
- this.#bareFileLogicalKeys = new Map(this.#inputKeys.flatMap((key) => BARE_FILE_FALLBACKS.map(({ extension }) => [`${key}${extension}`, key])));
39
+ this.#inputKeys = options.inputKeys;
61
40
  }
62
41
  get keyValueStoreDirectory() {
63
42
  return this.#nativeBackend.pathToKvs;
@@ -79,54 +58,27 @@ export class KeyValueStoreBackend extends CachedIdClient {
79
58
  /**
80
59
  * Remove every record from the store except the run input. Used by
81
60
  * {@link FileSystemStorageBackend.purge} to clean the default key-value store at the start of a run
82
- * while preserving the run's input. The native keep-list matches exact filenames, so every extension
83
- * variant of every input key is listed.
61
+ * while preserving the run's input.
84
62
  */
85
63
  async purgeExceptInput() {
86
- const keep = this.#inputKeys.flatMap((key) => BARE_FILE_FALLBACKS.map(({ extension }) => `${key}${extension}`));
87
- await this.#nativeBackend.purge(keep);
64
+ await this.#nativeBackend.purge(this.#inputKeys);
88
65
  }
89
66
  async listKeys(options = {}) {
90
67
  const { prefix, exclusiveStartKey, limit } = parseArgument(options, schemas.keyValueStoreListKeysOptions);
91
- // Pass the bare-file fallbacks so out-of-band value files (e.g. a hand-placed `INPUT.json`)
92
- // are enumerated alongside tracked records, under their actual on-disk name. The native reads
93
- // everything it needs off the filesystem index — no per-file reads — so this stays cheap.
94
68
  // The native `listKeys` already returns a self-describing page (items + pagination cursors)
95
- // matching the `KeyValueStoreListKeysResult` contract, so we only post-process the items.
96
- const page = await this.#nativeBackend.listKeys(exclusiveStartKey, limit, prefix, this.#listBareFallbacks);
97
- const presentKeys = new Set(page.items.map((record) => record.key));
98
- // A bare value file is listed under its actual name (`INPUT.json`), which already round-trips
99
- // through `getValue`/`recordExists`. The only collision is a tracked record occupying the
100
- // logical key itself (`INPUT`): it shadows the extension-bearing bare variants (`INPUT.json`
101
- // etc.) for the same logical key, so drop those. The extensionless bare file *is* the logical
102
- // key, so it is never a separate duplicate.
103
- const items = page.items.filter((record) => {
104
- const logicalKey = this.#bareFileLogicalKeys.get(record.key);
105
- const isExtensionBearingBareFile = logicalKey !== undefined && logicalKey !== record.key;
106
- return !(isExtensionBearingBareFile && presentKeys.has(logicalKey));
107
- });
108
- return {
109
- items,
110
- count: items.length,
111
- limit: page.limit,
112
- exclusiveStartKey: page.exclusiveStartKey,
113
- isTruncated: page.isTruncated,
114
- nextExclusiveStartKey: page.nextExclusiveStartKey,
115
- };
69
+ // matching the `KeyValueStoreListKeysResult` contract.
70
+ return this.#nativeBackend.listKeys(exclusiveStartKey, limit, prefix);
116
71
  }
117
72
  /**
118
73
  * Generates a public `file://` URL for accessing a specific record in the key-value store.
119
74
  *
120
- * The native `getPublicUrl` derives the URL from the key without probing bare-file extensions, so
121
- * an `INPUT` that lives on disk as a hand-placed `INPUT.json` is resolved first. Nothing on disk
122
- * means nothing to resolve — the requested key is used as-is, and the URL is the one the record will
123
- * have once written.
75
+ * Existence-agnostic per the {@link KeyValueStoreBackend} contract: the URL points at the file
76
+ * the key is bound to if there is a record, and at the file it would be written to otherwise.
124
77
  * @param key The key of the record to generate the public URL for.
125
78
  */
126
79
  async getPublicUrl(key) {
127
80
  parseArgument(key, keySchema);
128
- const resolvedKey = (await this.resolveExistingKey(key)) ?? key;
129
- return this.#nativeBackend.getPublicUrl(resolvedKey);
81
+ return this.#nativeBackend.getPublicUrl(key);
130
82
  }
131
83
  /**
132
84
  * Tests whether a record with the given key exists without retrieving its value.
@@ -136,14 +88,11 @@ export class KeyValueStoreBackend extends CachedIdClient {
136
88
  */
137
89
  async recordExists(key) {
138
90
  parseArgument(key, keySchema);
139
- return (await this.resolveExistingKey(key)) !== undefined;
91
+ return this.#nativeBackend.recordExists(key);
140
92
  }
141
93
  async getValue(key) {
142
94
  parseArgument(key, keySchema);
143
- const fallbacks = this.#bareFallbacksFor(key);
144
- const record = fallbacks
145
- ? await this.#nativeBackend.resolveValue(key, fallbacks)
146
- : await this.#nativeBackend.getValue(key);
95
+ const record = await this.#nativeBackend.getValue(key);
147
96
  if (record) {
148
97
  return {
149
98
  key: record.key,
@@ -184,32 +133,4 @@ export class KeyValueStoreBackend extends CachedIdClient {
184
133
  parseArgument(key, keySchema);
185
134
  await this.#nativeBackend.deleteValue(key);
186
135
  }
187
- /**
188
- * Resolve `key` to the on-disk key that actually exists, or `undefined` if nothing does. Run-input
189
- * keys fall back to bare files, in which case the matched on-disk key is returned so callers like
190
- * `getPublicUrl` point at the file that exists.
191
- */
192
- async resolveExistingKey(key) {
193
- const fallbacks = this.#bareFallbacksFor(key);
194
- if (fallbacks) {
195
- return ((await this.#nativeBackend.resolveExistingKey(key, fallbacks.map(({ extension }) => extension))) ?? undefined);
196
- }
197
- return (await this.#nativeBackend.recordExists(key)) ? key : undefined;
198
- }
199
- /**
200
- * Bare-file fallbacks for `key`, or `undefined` for a plain tracked-record lookup. A logical input
201
- * key (`INPUT`) probes the whole extension ladder; a literal bare filename (`INPUT.json`, as listed
202
- * by `listKeys`) probes only itself, so a listed key reads back under its own name.
203
- */
204
- #bareFallbacksFor(key) {
205
- if (this.#inputKeys.includes(key)) {
206
- return BARE_FILE_FALLBACKS;
207
- }
208
- const logicalKey = this.#bareFileLogicalKeys.get(key);
209
- if (logicalKey === undefined) {
210
- return undefined;
211
- }
212
- const { contentType } = BARE_FILE_FALLBACKS.find(({ extension }) => `${logicalKey}${extension}` === key);
213
- return [{ extension: '', contentType }];
214
- }
215
136
  }