@crawlee/fs-storage 4.0.0-beta.98 → 4.0.0-rc.0
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/file-system-storage.d.ts +3 -16
- package/file-system-storage.js +129 -72
- package/package.json +5 -4
- package/resource-clients/dataset.d.ts +1 -2
- package/resource-clients/dataset.js +13 -19
- package/resource-clients/key-value-store.d.ts +1 -1
- package/resource-clients/key-value-store.js +38 -41
- package/resource-clients/request-queue.d.ts +1 -1
- package/resource-clients/request-queue.js +25 -37
package/file-system-storage.d.ts
CHANGED
|
@@ -38,6 +38,7 @@ export interface FileSystemStorageOptions {
|
|
|
38
38
|
* `teardown` can operate over them), and exposing them through the `@crawlee/types` interfaces.
|
|
39
39
|
*/
|
|
40
40
|
export declare class FileSystemStorageBackend implements storage.StorageBackend {
|
|
41
|
+
#private;
|
|
41
42
|
readonly localDataDirectory: string;
|
|
42
43
|
readonly datasetsDirectory: string;
|
|
43
44
|
readonly keyValueStoresDirectory: string;
|
|
@@ -54,27 +55,13 @@ export declare class FileSystemStorageBackend implements storage.StorageBackend
|
|
|
54
55
|
* partitions, by including the storage directory in the cache key.
|
|
55
56
|
*/
|
|
56
57
|
getStorageBackendCacheKey(): string;
|
|
57
|
-
private static resolveStorageKey;
|
|
58
58
|
createDatasetBackend(options?: storage.StorageIdentifier): Promise<storage.DatasetBackend>;
|
|
59
59
|
createKeyValueStoreBackend(options?: storage.StorageIdentifier): Promise<storage.KeyValueStoreBackend>;
|
|
60
60
|
createRequestQueueBackend(options?: storage.StorageIdentifier): Promise<storage.RequestQueueBackend>;
|
|
61
61
|
storageExists(id: string, type: 'Dataset' | 'KeyValueStore' | 'RequestQueue'): Promise<boolean>;
|
|
62
62
|
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
* `__metadata__.json`; the directory itself is named after the storage's `name ?? id`. So this
|
|
66
|
-
* first tries the directory named exactly `entryNameOrId` (reading its metadata id), then falls
|
|
67
|
-
* back to scanning sibling directories for one whose metadata id equals `entryNameOrId` (the case
|
|
68
|
-
* of a storage opened by name and later looked up by its auto-assigned id).
|
|
69
|
-
*/
|
|
70
|
-
private static resolveStorageIdOnDisk;
|
|
71
|
-
/** Read the `id` field from a storage directory's `__metadata__.json`, or `undefined` if absent. */
|
|
72
|
-
private static readMetadataId;
|
|
73
|
-
/**
|
|
74
|
-
* Cleans up the default storages before the run starts:
|
|
75
|
-
* - the default dataset;
|
|
76
|
-
* - all records from the default key-value store, except for the "INPUT" key;
|
|
77
|
-
* - the default request queue.
|
|
63
|
+
* Cleans up the run-scoped storages before the run starts, sweeping the storage directories so that
|
|
64
|
+
* leftovers from a previous process are caught too.
|
|
78
65
|
*/
|
|
79
66
|
purge(): Promise<void>;
|
|
80
67
|
/**
|
package/file-system-storage.js
CHANGED
|
@@ -1,10 +1,28 @@
|
|
|
1
1
|
import { opendir, readFile } from 'node:fs/promises';
|
|
2
2
|
import { resolve } from 'node:path';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { parseArgument, schemas } from '@crawlee/utils/internal';
|
|
4
|
+
import { z } from 'zod';
|
|
5
5
|
import { DatasetBackend } from './resource-clients/dataset.js';
|
|
6
6
|
import { KeyValueStoreBackend } from './resource-clients/key-value-store.js';
|
|
7
7
|
import { RequestQueueBackend } from './resource-clients/request-queue.js';
|
|
8
|
+
const fileSystemStorageOptionsSchema = z.object({
|
|
9
|
+
localDataDirectory: z.string(),
|
|
10
|
+
requestQueueAccess: z.enum(['single', 'shared']).default('single'),
|
|
11
|
+
logger: schemas.logger.optional(),
|
|
12
|
+
});
|
|
13
|
+
// The native package throws at load time on platforms without a published binary (e.g.
|
|
14
|
+
// linux musl), and `@crawlee/core` imports this module eagerly via its service locator.
|
|
15
|
+
// Load it lazily so merely importing `@crawlee/fs-storage` stays safe everywhere and the
|
|
16
|
+
// native binding is only loaded when a file-system storage is actually used.
|
|
17
|
+
let nativeModule;
|
|
18
|
+
async function importNativeModule() {
|
|
19
|
+
nativeModule ??= import('@crawlee/fs-storage-native');
|
|
20
|
+
return nativeModule;
|
|
21
|
+
}
|
|
22
|
+
/** The alias `@crawlee/core` opens the default (unnamed) storage under. */
|
|
23
|
+
const DEFAULT_STORAGE_ALIAS = '__default__';
|
|
24
|
+
/** The directory the default storage lives in, one level below `datasets` / `key_value_stores` / etc. */
|
|
25
|
+
const DEFAULT_STORAGE_DIRECTORY = 'default';
|
|
8
26
|
/**
|
|
9
27
|
* A file-system storage backend backed by the native `@crawlee/fs-storage-native` Rust extension.
|
|
10
28
|
*
|
|
@@ -24,13 +42,10 @@ export class FileSystemStorageBackend {
|
|
|
24
42
|
datasetBackendCache = [];
|
|
25
43
|
requestQueueBackendCache = [];
|
|
26
44
|
constructor(options) {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
this.logger = options.logger;
|
|
32
|
-
this.requestQueueAccess = options.requestQueueAccess ?? 'single';
|
|
33
|
-
this.localDataDirectory = options.localDataDirectory;
|
|
45
|
+
const { logger, requestQueueAccess, localDataDirectory } = parseArgument(options, fileSystemStorageOptionsSchema);
|
|
46
|
+
this.logger = logger;
|
|
47
|
+
this.requestQueueAccess = requestQueueAccess;
|
|
48
|
+
this.localDataDirectory = localDataDirectory;
|
|
34
49
|
this.datasetsDirectory = resolve(this.localDataDirectory, 'datasets');
|
|
35
50
|
this.keyValueStoresDirectory = resolve(this.localDataDirectory, 'key_value_stores');
|
|
36
51
|
this.requestQueuesDirectory = resolve(this.localDataDirectory, 'request_queues');
|
|
@@ -43,27 +58,31 @@ export class FileSystemStorageBackend {
|
|
|
43
58
|
getStorageBackendCacheKey() {
|
|
44
59
|
return `FileSystemStorageBackend:${resolve(this.localDataDirectory)}`;
|
|
45
60
|
}
|
|
46
|
-
static resolveStorageKey(options) {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
//
|
|
50
|
-
const
|
|
51
|
-
|
|
61
|
+
static #resolveStorageKey(options) {
|
|
62
|
+
// No identifier at all means the default storage, which is opened under the reserved alias —
|
|
63
|
+
// same rule as `resolveStorageIdentifier` in @crawlee/core, so that a backend used directly
|
|
64
|
+
// lands on the very storage the frontends would have opened.
|
|
65
|
+
const requestedAlias = options.alias || (!options.id && !options.name ? DEFAULT_STORAGE_ALIAS : undefined);
|
|
66
|
+
// `__default__` is an internal sentinel and must not escape onto disk: the default storage lives
|
|
67
|
+
// in `default`, which is what the docs, the project templates and every pre-existing local
|
|
68
|
+
// `storage/` directory expect. Normalizing here keeps the cache key and the directory in step.
|
|
69
|
+
const alias = requestedAlias === DEFAULT_STORAGE_ALIAS ? DEFAULT_STORAGE_DIRECTORY : requestedAlias;
|
|
70
|
+
// `alias` covers the identifier-less case, so one of the three is always set.
|
|
71
|
+
const cacheKey = alias ?? options.name ?? options.id;
|
|
72
|
+
return { id: options.id, name: options.name, alias, cacheKey };
|
|
52
73
|
}
|
|
53
74
|
async createDatasetBackend(options = {}) {
|
|
54
|
-
const { id, name, alias, cacheKey } = FileSystemStorageBackend
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
return found;
|
|
61
|
-
}
|
|
75
|
+
const { id, name, alias, cacheKey } = FileSystemStorageBackend.#resolveStorageKey(options);
|
|
76
|
+
const found = this.datasetBackendCache.find((store) => store.id === cacheKey ||
|
|
77
|
+
store.name?.toLowerCase() === cacheKey.toLowerCase() ||
|
|
78
|
+
store.cacheKey.toLowerCase() === cacheKey.toLowerCase());
|
|
79
|
+
if (found) {
|
|
80
|
+
return found;
|
|
62
81
|
}
|
|
63
|
-
const nativeBackend = await
|
|
82
|
+
const nativeBackend = await (await importNativeModule()).FileSystemDatasetClient.open(id, name, alias, this.localDataDirectory);
|
|
64
83
|
const newStore = await DatasetBackend.create({
|
|
65
84
|
name: alias ? undefined : (name ?? cacheKey),
|
|
66
|
-
cacheKey
|
|
85
|
+
cacheKey,
|
|
67
86
|
nativeBackend,
|
|
68
87
|
logger: this.logger,
|
|
69
88
|
});
|
|
@@ -71,19 +90,17 @@ export class FileSystemStorageBackend {
|
|
|
71
90
|
return newStore;
|
|
72
91
|
}
|
|
73
92
|
async createKeyValueStoreBackend(options = {}) {
|
|
74
|
-
const { id, name, alias, cacheKey } = FileSystemStorageBackend
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
return found;
|
|
81
|
-
}
|
|
93
|
+
const { id, name, alias, cacheKey } = FileSystemStorageBackend.#resolveStorageKey(options);
|
|
94
|
+
const found = this.keyValueStoreBackendCache.find((store) => store.id === cacheKey ||
|
|
95
|
+
store.name?.toLowerCase() === cacheKey.toLowerCase() ||
|
|
96
|
+
store.cacheKey.toLowerCase() === cacheKey.toLowerCase());
|
|
97
|
+
if (found) {
|
|
98
|
+
return found;
|
|
82
99
|
}
|
|
83
|
-
const nativeBackend = await
|
|
100
|
+
const nativeBackend = await (await importNativeModule()).FileSystemKeyValueStoreClient.open(id, name, alias, this.localDataDirectory);
|
|
84
101
|
const newStore = await KeyValueStoreBackend.create({
|
|
85
102
|
name: alias ? undefined : (name ?? cacheKey),
|
|
86
|
-
cacheKey
|
|
103
|
+
cacheKey,
|
|
87
104
|
nativeBackend,
|
|
88
105
|
logger: this.logger,
|
|
89
106
|
});
|
|
@@ -91,21 +108,19 @@ export class FileSystemStorageBackend {
|
|
|
91
108
|
return newStore;
|
|
92
109
|
}
|
|
93
110
|
async createRequestQueueBackend(options = {}) {
|
|
94
|
-
const { id, name, alias, cacheKey } = FileSystemStorageBackend
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
return found;
|
|
101
|
-
}
|
|
111
|
+
const { id, name, alias, cacheKey } = FileSystemStorageBackend.#resolveStorageKey(options);
|
|
112
|
+
const found = this.requestQueueBackendCache.find((queue) => queue.id === cacheKey ||
|
|
113
|
+
queue.name?.toLowerCase() === cacheKey.toLowerCase() ||
|
|
114
|
+
queue.cacheKey.toLowerCase() === cacheKey.toLowerCase());
|
|
115
|
+
if (found) {
|
|
116
|
+
return found;
|
|
102
117
|
}
|
|
103
|
-
const nativeBackend = await
|
|
118
|
+
const nativeBackend = await (await importNativeModule()).FileSystemRequestQueueClient.open(id, name, alias, this.localDataDirectory,
|
|
104
119
|
// useTestClock — always real wall-clock outside of native tests.
|
|
105
120
|
undefined, this.requestQueueAccess);
|
|
106
121
|
const newStore = await RequestQueueBackend.create({
|
|
107
122
|
name: alias ? undefined : (name ?? cacheKey),
|
|
108
|
-
cacheKey
|
|
123
|
+
cacheKey,
|
|
109
124
|
nativeBackend,
|
|
110
125
|
logger: this.logger,
|
|
111
126
|
});
|
|
@@ -144,7 +159,7 @@ export class FileSystemStorageBackend {
|
|
|
144
159
|
// has a matching directory. We therefore read the real id from the metadata and only report
|
|
145
160
|
// existence when it equals the queried string. This matches upstream PR #3800/#3808 and
|
|
146
161
|
// prevents a named storage from being re-resolved as `{ id: name }` on a subsequent run.
|
|
147
|
-
const resolvedId = await FileSystemStorageBackend
|
|
162
|
+
const resolvedId = await FileSystemStorageBackend.#resolveStorageIdOnDisk(baseDir, id);
|
|
148
163
|
return resolvedId === id;
|
|
149
164
|
}
|
|
150
165
|
/**
|
|
@@ -155,10 +170,10 @@ export class FileSystemStorageBackend {
|
|
|
155
170
|
* back to scanning sibling directories for one whose metadata id equals `entryNameOrId` (the case
|
|
156
171
|
* of a storage opened by name and later looked up by its auto-assigned id).
|
|
157
172
|
*/
|
|
158
|
-
static async resolveStorageIdOnDisk(baseDirectory, entryNameOrId) {
|
|
173
|
+
static async #resolveStorageIdOnDisk(baseDirectory, entryNameOrId) {
|
|
159
174
|
// Directory named exactly after the string: return its real (metadata) id, which may differ
|
|
160
175
|
// from the string when the string is a name rather than an id.
|
|
161
|
-
const directId = await FileSystemStorageBackend
|
|
176
|
+
const directId = (await FileSystemStorageBackend.#readMetadata(resolve(baseDirectory, entryNameOrId)))?.id;
|
|
162
177
|
if (directId !== undefined) {
|
|
163
178
|
return directId;
|
|
164
179
|
}
|
|
@@ -174,49 +189,91 @@ export class FileSystemStorageBackend {
|
|
|
174
189
|
if (!directory.isDirectory()) {
|
|
175
190
|
continue;
|
|
176
191
|
}
|
|
177
|
-
const metadataId = await FileSystemStorageBackend
|
|
192
|
+
const metadataId = (await FileSystemStorageBackend.#readMetadata(resolve(baseDirectory, directory.name)))
|
|
193
|
+
?.id;
|
|
178
194
|
if (metadataId === entryNameOrId) {
|
|
179
195
|
return metadataId;
|
|
180
196
|
}
|
|
181
197
|
}
|
|
182
198
|
return undefined;
|
|
183
199
|
}
|
|
184
|
-
/** Read
|
|
185
|
-
static async
|
|
200
|
+
/** Read a storage directory's `__metadata__.json`, or `undefined` if there is none to read. */
|
|
201
|
+
static async #readMetadata(storageDirectory) {
|
|
186
202
|
try {
|
|
187
203
|
const fileContent = await readFile(resolve(storageDirectory, '__metadata__.json'), 'utf8');
|
|
188
|
-
return JSON.parse(fileContent)
|
|
204
|
+
return JSON.parse(fileContent);
|
|
189
205
|
}
|
|
190
206
|
catch {
|
|
191
|
-
// Directory missing, or no/unreadable metadata file —
|
|
207
|
+
// Directory missing, or no/unreadable metadata file — nothing to report.
|
|
192
208
|
return undefined;
|
|
193
209
|
}
|
|
194
210
|
}
|
|
195
211
|
/**
|
|
196
|
-
* Cleans up the
|
|
197
|
-
*
|
|
198
|
-
* - all records from the default key-value store, except for the "INPUT" key;
|
|
199
|
-
* - the default request queue.
|
|
212
|
+
* Cleans up the run-scoped storages before the run starts, sweeping the storage directories so that
|
|
213
|
+
* leftovers from a previous process are caught too.
|
|
200
214
|
*/
|
|
201
215
|
async purge() {
|
|
202
|
-
// Resolve the default stores up front so leftover on-disk records are purged even when the
|
|
203
|
-
// store has not been opened in this process yet (e.g. a fresh run over a pre-existing
|
|
204
|
-
// directory). Opening caches the backend, so the subsequent purge operates on a real backend.
|
|
205
|
-
// The default store is opened via the internal `__default__` alias (see resolveStorageIdentifier
|
|
206
|
-
// in @crawlee/core), which resolves to the `default` cache key — match that here so we purge the
|
|
207
|
-
// very backend the default open would return rather than creating a divergent one.
|
|
208
|
-
const [defaultKeyValueStore, defaultDataset, defaultRequestQueue] = await Promise.all([
|
|
209
|
-
this.createKeyValueStoreBackend({ alias: '__default__' }),
|
|
210
|
-
this.createDatasetBackend({ alias: '__default__' }),
|
|
211
|
-
this.createRequestQueueBackend({ alias: '__default__' }),
|
|
212
|
-
]);
|
|
213
216
|
await Promise.all([
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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()),
|
|
218
222
|
]);
|
|
219
223
|
}
|
|
224
|
+
/**
|
|
225
|
+
* Purge every run-scoped storage under `storagesDirectory`, whether or not it has been opened in this
|
|
226
|
+
* process yet. Storages are opened rather than emptied on disk directly, so that one already open
|
|
227
|
+
* under the same name or alias is purged through the backend the run is using, not a second one.
|
|
228
|
+
*/
|
|
229
|
+
async #purgeRunScopedStorages(storagesDirectory, open, purgeStorage) {
|
|
230
|
+
// The default storage is listed unconditionally, so that a run over an empty directory still ends
|
|
231
|
+
// up with it opened (and cached) exactly as it was before. Deduplicating by cache key then keeps
|
|
232
|
+
// it to a single open: every run after the first also finds its `default` directory on disk, and
|
|
233
|
+
// opening the same storage twice concurrently would race two backends onto one directory.
|
|
234
|
+
const aliasesByCacheKey = new Map();
|
|
235
|
+
for (const alias of [
|
|
236
|
+
DEFAULT_STORAGE_ALIAS,
|
|
237
|
+
...(await FileSystemStorageBackend.#listUnnamedStorages(storagesDirectory)),
|
|
238
|
+
]) {
|
|
239
|
+
const { cacheKey } = FileSystemStorageBackend.#resolveStorageKey({ alias });
|
|
240
|
+
if (!aliasesByCacheKey.has(cacheKey)) {
|
|
241
|
+
aliasesByCacheKey.set(cacheKey, alias);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
await Promise.all(Array.from(aliasesByCacheKey, async ([cacheKey, alias]) => {
|
|
245
|
+
await purgeStorage(await open(alias), cacheKey === DEFAULT_STORAGE_DIRECTORY);
|
|
246
|
+
}));
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* The directory names of the on-disk storages under `storagesDirectory` that Crawlee created without
|
|
250
|
+
* a name — the default storage and every alias-keyed one. Since the directory is named after the
|
|
251
|
+
* storage's `name ?? alias`, the name is read from the metadata rather than guessed.
|
|
252
|
+
*
|
|
253
|
+
* Two kinds of directory are left out, as purging them would destroy data this process never wrote:
|
|
254
|
+
* one without a readable `__metadata__.json` (not written by Crawlee — a hand-placed input directory,
|
|
255
|
+
* say), and one named after its own id, which is reachable only by `{ id }` and so is not run-scoped.
|
|
256
|
+
*/
|
|
257
|
+
static async #listUnnamedStorages(storagesDirectory) {
|
|
258
|
+
let directories;
|
|
259
|
+
try {
|
|
260
|
+
directories = await opendir(storagesDirectory);
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
return [];
|
|
264
|
+
}
|
|
265
|
+
const unnamed = [];
|
|
266
|
+
for await (const directory of directories) {
|
|
267
|
+
if (!directory.isDirectory()) {
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
const metadata = await FileSystemStorageBackend.#readMetadata(resolve(storagesDirectory, directory.name));
|
|
271
|
+
if (metadata !== undefined && typeof metadata.name !== 'string' && metadata.id !== directory.name) {
|
|
272
|
+
unnamed.push(directory.name);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return unnamed;
|
|
276
|
+
}
|
|
220
277
|
/**
|
|
221
278
|
* This method should be called at the end of the process, to ensure all data is saved.
|
|
222
279
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crawlee/fs-storage",
|
|
3
|
-
"version": "4.0.0-
|
|
3
|
+
"version": "4.0.0-rc.0",
|
|
4
4
|
"description": "A file-system storage implementation of the Apify API",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=22.0.0"
|
|
@@ -43,8 +43,9 @@
|
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"@crawlee/fs-storage-native": "0.1.5-beta.18",
|
|
46
|
-
"@crawlee/types": "4.0.0-
|
|
47
|
-
"@
|
|
46
|
+
"@crawlee/types": "4.0.0-rc.0",
|
|
47
|
+
"@crawlee/utils": "4.0.0-rc.0",
|
|
48
|
+
"zod": "^4.4.3"
|
|
48
49
|
},
|
|
49
50
|
"lerna": {
|
|
50
51
|
"command": {
|
|
@@ -53,5 +54,5 @@
|
|
|
53
54
|
}
|
|
54
55
|
}
|
|
55
56
|
},
|
|
56
|
-
"gitHead": "
|
|
57
|
+
"gitHead": "79ab33dacdacb83e0197e6516d145f3aceef80c7"
|
|
57
58
|
}
|
|
@@ -21,10 +21,9 @@ export interface DatasetBackendOptions {
|
|
|
21
21
|
* the `@crawlee/types` interfaces.
|
|
22
22
|
*/
|
|
23
23
|
export declare class DatasetBackend<Data extends Dictionary = Dictionary> extends CachedIdClient implements storage.DatasetBackend<Data> {
|
|
24
|
+
#private;
|
|
24
25
|
readonly name?: string;
|
|
25
26
|
readonly cacheKey: string;
|
|
26
|
-
private readonly nativeBackend;
|
|
27
|
-
private readonly logger?;
|
|
28
27
|
constructor(options: DatasetBackendOptions);
|
|
29
28
|
get datasetDirectory(): string;
|
|
30
29
|
static create<Data extends Dictionary = Dictionary>(options: DatasetBackendOptions): Promise<DatasetBackend<Data>>;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { parseArgument, schemas } from '@crawlee/utils/internal';
|
|
2
2
|
import { CachedIdClient } from './cached-id-client.js';
|
|
3
3
|
/**
|
|
4
4
|
* `getData` options accepted by the high-level `Dataset` frontend but not supported by the native
|
|
@@ -19,17 +19,17 @@ const UNSUPPORTED_GET_DATA_OPTIONS = ['clean', 'fields', 'omit', 'skipHidden', '
|
|
|
19
19
|
export class DatasetBackend extends CachedIdClient {
|
|
20
20
|
name;
|
|
21
21
|
cacheKey;
|
|
22
|
-
nativeBackend;
|
|
23
|
-
logger;
|
|
22
|
+
#nativeBackend;
|
|
23
|
+
#logger;
|
|
24
24
|
constructor(options) {
|
|
25
25
|
super();
|
|
26
26
|
this.name = options.name;
|
|
27
27
|
this.cacheKey = options.cacheKey;
|
|
28
|
-
this
|
|
29
|
-
this
|
|
28
|
+
this.#nativeBackend = options.nativeBackend;
|
|
29
|
+
this.#logger = options.logger;
|
|
30
30
|
}
|
|
31
31
|
get datasetDirectory() {
|
|
32
|
-
return this
|
|
32
|
+
return this.#nativeBackend.pathToDataset;
|
|
33
33
|
}
|
|
34
34
|
static async create(options) {
|
|
35
35
|
const backend = new DatasetBackend(options);
|
|
@@ -37,32 +37,26 @@ export class DatasetBackend extends CachedIdClient {
|
|
|
37
37
|
return backend;
|
|
38
38
|
}
|
|
39
39
|
async getMetadata() {
|
|
40
|
-
return this
|
|
40
|
+
return this.#nativeBackend.getMetadata();
|
|
41
41
|
}
|
|
42
42
|
async drop() {
|
|
43
|
-
await this
|
|
43
|
+
await this.#nativeBackend.dropStorage();
|
|
44
44
|
}
|
|
45
45
|
async purge() {
|
|
46
|
-
await this
|
|
46
|
+
await this.#nativeBackend.purge();
|
|
47
47
|
}
|
|
48
48
|
async pushData(items) {
|
|
49
|
-
await this
|
|
49
|
+
await this.#nativeBackend.pushData(items);
|
|
50
50
|
}
|
|
51
51
|
async getData(options = {}) {
|
|
52
52
|
const passedOptions = options;
|
|
53
53
|
const ignored = UNSUPPORTED_GET_DATA_OPTIONS.filter((key) => passedOptions[key] !== undefined);
|
|
54
54
|
if (ignored.length > 0) {
|
|
55
|
-
this
|
|
55
|
+
this.#logger?.warning?.(`getData() options [${ignored.join(', ')}] are not supported by the file-system dataset ` +
|
|
56
56
|
`and were ignored. Only "offset", "limit" and "desc" are honored.`);
|
|
57
57
|
}
|
|
58
|
-
const { desc, limit, offset } =
|
|
59
|
-
|
|
60
|
-
desc: s.boolean().optional(),
|
|
61
|
-
limit: s.number().int().optional(),
|
|
62
|
-
offset: s.number().int().optional(),
|
|
63
|
-
})
|
|
64
|
-
.parse(options);
|
|
65
|
-
const page = await this.nativeBackend.getData(offset ?? 0, limit, desc ?? false, false);
|
|
58
|
+
const { desc, limit, offset } = parseArgument(options, schemas.datasetListItemsOptions);
|
|
59
|
+
const page = await this.#nativeBackend.getData(offset ?? 0, limit, desc ?? false, false);
|
|
66
60
|
return {
|
|
67
61
|
count: page.count,
|
|
68
62
|
desc: page.desc,
|
|
@@ -22,9 +22,9 @@ export interface KeyValueStoreBackendOptions {
|
|
|
22
22
|
* is the {@link KeyValueStore} frontend codec's job, not this backend's.
|
|
23
23
|
*/
|
|
24
24
|
export declare class KeyValueStoreBackend extends CachedIdClient implements storage.KeyValueStoreBackend {
|
|
25
|
+
#private;
|
|
25
26
|
readonly name?: string;
|
|
26
27
|
readonly cacheKey: string;
|
|
27
|
-
private readonly nativeBackend;
|
|
28
28
|
constructor(options: KeyValueStoreBackendOptions);
|
|
29
29
|
get keyValueStoreDirectory(): string;
|
|
30
30
|
static create(options: KeyValueStoreBackendOptions): Promise<KeyValueStoreBackend>;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Readable } from 'node:stream';
|
|
2
|
-
import {
|
|
2
|
+
import { parseArgument, schemas } from '@crawlee/utils/internal';
|
|
3
|
+
import { z } from 'zod';
|
|
3
4
|
import { isStream } from '../utils.js';
|
|
4
5
|
import { CachedIdClient } from './cached-id-client.js';
|
|
5
6
|
/**
|
|
@@ -18,6 +19,20 @@ const BARE_FILE_FALLBACKS = [
|
|
|
18
19
|
{ extension: '.bin', contentType: '' },
|
|
19
20
|
];
|
|
20
21
|
const ALLOWED_BARE_FILES = ['INPUT'];
|
|
22
|
+
const keySchema = z.string();
|
|
23
|
+
const inputRecordShape = z.object({
|
|
24
|
+
key: z.string().min(1),
|
|
25
|
+
value: z.union([
|
|
26
|
+
z.string(),
|
|
27
|
+
z.instanceof(Buffer),
|
|
28
|
+
z.instanceof(ArrayBuffer),
|
|
29
|
+
schemas.typedArray,
|
|
30
|
+
// A stream is an object; this only checks it is a non-null, non-array object
|
|
31
|
+
// (the stream guard in `setValue` does the real check).
|
|
32
|
+
schemas.plainObject,
|
|
33
|
+
]),
|
|
34
|
+
contentType: z.string().min(1).optional(),
|
|
35
|
+
});
|
|
21
36
|
/**
|
|
22
37
|
* The out-of-band ("bare") files to surface from the native `listKeys`, derived from
|
|
23
38
|
* {@link ALLOWED_BARE_FILES} × {@link BARE_FILE_FALLBACKS}. Each native {@link ListBareFallback}
|
|
@@ -46,15 +61,15 @@ const BARE_FILE_LOGICAL_KEYS = new Map(ALLOWED_BARE_FILES.flatMap((key) => BARE_
|
|
|
46
61
|
export class KeyValueStoreBackend extends CachedIdClient {
|
|
47
62
|
name;
|
|
48
63
|
cacheKey;
|
|
49
|
-
nativeBackend;
|
|
64
|
+
#nativeBackend;
|
|
50
65
|
constructor(options) {
|
|
51
66
|
super();
|
|
52
67
|
this.name = options.name;
|
|
53
68
|
this.cacheKey = options.cacheKey;
|
|
54
|
-
this
|
|
69
|
+
this.#nativeBackend = options.nativeBackend;
|
|
55
70
|
}
|
|
56
71
|
get keyValueStoreDirectory() {
|
|
57
|
-
return this
|
|
72
|
+
return this.#nativeBackend.pathToKvs;
|
|
58
73
|
}
|
|
59
74
|
static async create(options) {
|
|
60
75
|
const backend = new KeyValueStoreBackend(options);
|
|
@@ -62,13 +77,13 @@ export class KeyValueStoreBackend extends CachedIdClient {
|
|
|
62
77
|
return backend;
|
|
63
78
|
}
|
|
64
79
|
async getMetadata() {
|
|
65
|
-
return this
|
|
80
|
+
return this.#nativeBackend.getMetadata();
|
|
66
81
|
}
|
|
67
82
|
async drop() {
|
|
68
|
-
await this
|
|
83
|
+
await this.#nativeBackend.dropStorage();
|
|
69
84
|
}
|
|
70
85
|
async purge() {
|
|
71
|
-
await this
|
|
86
|
+
await this.#nativeBackend.purge();
|
|
72
87
|
}
|
|
73
88
|
/**
|
|
74
89
|
* Remove every record from the store except the run input. Used by
|
|
@@ -79,22 +94,16 @@ export class KeyValueStoreBackend extends CachedIdClient {
|
|
|
79
94
|
* filename the input might live under (`INPUT`, `INPUT.json`, `INPUT.txt`, `INPUT.bin`).
|
|
80
95
|
*/
|
|
81
96
|
async purgeExceptInput() {
|
|
82
|
-
await this
|
|
97
|
+
await this.#nativeBackend.purge(BARE_FILE_FALLBACKS.flatMap(({ extension }) => `INPUT${extension}`));
|
|
83
98
|
}
|
|
84
99
|
async listKeys(options = {}) {
|
|
85
|
-
const { prefix, exclusiveStartKey, limit } =
|
|
86
|
-
.object({
|
|
87
|
-
prefix: s.string().optional(),
|
|
88
|
-
exclusiveStartKey: s.string().optional(),
|
|
89
|
-
limit: s.number().int().greaterThan(0).optional(),
|
|
90
|
-
})
|
|
91
|
-
.parse(options);
|
|
100
|
+
const { prefix, exclusiveStartKey, limit } = parseArgument(options, schemas.keyValueStoreListKeysOptions);
|
|
92
101
|
// Pass the bare-file fallbacks so out-of-band value files (e.g. a hand-placed `INPUT.json`)
|
|
93
102
|
// are enumerated alongside tracked records, under their actual on-disk name. The native reads
|
|
94
103
|
// everything it needs off the filesystem index — no per-file reads — so this stays cheap.
|
|
95
104
|
// The native `listKeys` already returns a self-describing page (items + pagination cursors)
|
|
96
105
|
// matching the `KeyValueStoreListKeysResult` contract, so we only post-process the items.
|
|
97
|
-
const page = await this
|
|
106
|
+
const page = await this.#nativeBackend.listKeys(exclusiveStartKey, limit, prefix, LIST_BARE_FALLBACKS);
|
|
98
107
|
const presentKeys = new Set(page.items.map((record) => record.key));
|
|
99
108
|
// A bare value file is listed under its actual name (`INPUT.json`), which already round-trips
|
|
100
109
|
// through `getValue`/`recordExists`. The only collision is a tracked record occupying the
|
|
@@ -122,7 +131,7 @@ export class KeyValueStoreBackend extends CachedIdClient {
|
|
|
122
131
|
* @param key The key of the record to generate the public URL for.
|
|
123
132
|
*/
|
|
124
133
|
async getPublicUrl(key) {
|
|
125
|
-
|
|
134
|
+
parseArgument(key, keySchema);
|
|
126
135
|
// The native `getPublicUrl` stats the encoded path but does not probe bare-file extensions,
|
|
127
136
|
// so we resolve the on-disk key first (handling e.g. `INPUT` -> `INPUT.json`) and normalize
|
|
128
137
|
// the native `null`-for-missing result to the historical `undefined` contract.
|
|
@@ -130,7 +139,7 @@ export class KeyValueStoreBackend extends CachedIdClient {
|
|
|
130
139
|
if (resolvedKey === undefined) {
|
|
131
140
|
return undefined;
|
|
132
141
|
}
|
|
133
|
-
return (await this
|
|
142
|
+
return (await this.#nativeBackend.getPublicUrl(resolvedKey)) ?? undefined;
|
|
134
143
|
}
|
|
135
144
|
/**
|
|
136
145
|
* Tests whether a record with the given key exists without retrieving its value.
|
|
@@ -139,15 +148,15 @@ export class KeyValueStoreBackend extends CachedIdClient {
|
|
|
139
148
|
* @returns `true` if the record exists, `false` otherwise.
|
|
140
149
|
*/
|
|
141
150
|
async recordExists(key) {
|
|
142
|
-
|
|
151
|
+
parseArgument(key, keySchema);
|
|
143
152
|
return (await this.resolveExistingKey(key)) !== undefined;
|
|
144
153
|
}
|
|
145
154
|
async getValue(key) {
|
|
146
|
-
|
|
155
|
+
parseArgument(key, keySchema);
|
|
147
156
|
const fallbacks = this.bareFallbacksFor(key);
|
|
148
157
|
const record = fallbacks
|
|
149
|
-
? await this
|
|
150
|
-
: await this
|
|
158
|
+
? await this.#nativeBackend.resolveValue(key, fallbacks)
|
|
159
|
+
: await this.#nativeBackend.getValue(key);
|
|
151
160
|
if (record) {
|
|
152
161
|
return {
|
|
153
162
|
key: record.key,
|
|
@@ -162,19 +171,7 @@ export class KeyValueStoreBackend extends CachedIdClient {
|
|
|
162
171
|
// serialized it: non-bytes become a `string`, everything else is a `Buffer`/typed array or a
|
|
163
172
|
// stream. So we only accept those shapes here — there is no JSON inference or `String(value)`
|
|
164
173
|
// coercion left to do.
|
|
165
|
-
|
|
166
|
-
key: s.string().lengthGreaterThan(0),
|
|
167
|
-
value: s.union([
|
|
168
|
-
s.string(),
|
|
169
|
-
s.instance(Buffer),
|
|
170
|
-
s.instance(ArrayBuffer),
|
|
171
|
-
s.typedArray(),
|
|
172
|
-
// A stream is an object; disabling validation makes shapeshift only check it is a
|
|
173
|
-
// non-null, non-array object (the stream guard below does the real check).
|
|
174
|
-
s.object({}).setValidationEnabled(false),
|
|
175
|
-
]),
|
|
176
|
-
contentType: s.string().lengthGreaterThan(0).optional(),
|
|
177
|
-
}).parse(record);
|
|
174
|
+
parseArgument(record, inputRecordShape);
|
|
178
175
|
const { key, value } = record;
|
|
179
176
|
// The frontend resolves the content type before it reaches the backend; this backend is a plain
|
|
180
177
|
// byte transport and does not infer content types.
|
|
@@ -183,7 +180,7 @@ export class KeyValueStoreBackend extends CachedIdClient {
|
|
|
183
180
|
// consumes a Web `ReadableStream`, so convert the Node `Readable` we get from the frontend.
|
|
184
181
|
if (isStream(value)) {
|
|
185
182
|
const webStream = Readable.toWeb(value);
|
|
186
|
-
await this
|
|
183
|
+
await this.#nativeBackend.setValueStream(key, webStream, contentType);
|
|
187
184
|
return;
|
|
188
185
|
}
|
|
189
186
|
// Normalize the remaining (already-serialized) value into a Buffer for the native client.
|
|
@@ -194,11 +191,11 @@ export class KeyValueStoreBackend extends CachedIdClient {
|
|
|
194
191
|
: ArrayBuffer.isView(value)
|
|
195
192
|
? Buffer.from(value.buffer, value.byteOffset, value.byteLength)
|
|
196
193
|
: Buffer.from(value);
|
|
197
|
-
await this
|
|
194
|
+
await this.#nativeBackend.setValue(key, buffer, contentType);
|
|
198
195
|
}
|
|
199
196
|
async deleteValue(key) {
|
|
200
|
-
|
|
201
|
-
await this
|
|
197
|
+
parseArgument(key, keySchema);
|
|
198
|
+
await this.#nativeBackend.deleteValue(key);
|
|
202
199
|
}
|
|
203
200
|
/**
|
|
204
201
|
* Resolve `key` to the on-disk key that actually exists, or `undefined` if nothing does. Every
|
|
@@ -211,9 +208,9 @@ export class KeyValueStoreBackend extends CachedIdClient {
|
|
|
211
208
|
async resolveExistingKey(key) {
|
|
212
209
|
const fallbacks = this.bareFallbacksFor(key);
|
|
213
210
|
if (fallbacks) {
|
|
214
|
-
return ((await this
|
|
211
|
+
return ((await this.#nativeBackend.resolveExistingKey(key, fallbacks.map(({ extension }) => extension))) ?? undefined);
|
|
215
212
|
}
|
|
216
|
-
return (await this
|
|
213
|
+
return (await this.#nativeBackend.recordExists(key)) ? key : undefined;
|
|
217
214
|
}
|
|
218
215
|
/**
|
|
219
216
|
* The native `resolveValue`/`resolveExistingKey` bare-file fallbacks to use for `key`, or
|
|
@@ -21,9 +21,9 @@ export interface RequestQueueBackendOptions {
|
|
|
21
21
|
* This adapter forwards each operation and converts result shapes to the `@crawlee/types` interfaces.
|
|
22
22
|
*/
|
|
23
23
|
export declare class RequestQueueBackend extends CachedIdClient implements storage.RequestQueueBackend {
|
|
24
|
+
#private;
|
|
24
25
|
readonly name?: string;
|
|
25
26
|
readonly cacheKey: string;
|
|
26
|
-
private readonly nativeBackend;
|
|
27
27
|
constructor(options: RequestQueueBackendOptions);
|
|
28
28
|
get requestQueueDirectory(): string;
|
|
29
29
|
static create(options: RequestQueueBackendOptions): Promise<RequestQueueBackend>;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { parseArgument, schemas } from '@crawlee/utils/internal';
|
|
2
|
+
import { z } from 'zod';
|
|
2
3
|
import { CachedIdClient } from './cached-id-client.js';
|
|
4
|
+
const uniqueKeySchema = z.string();
|
|
3
5
|
/**
|
|
4
6
|
* Convert a request (either a Crawlee `Request` instance or a plain schema object) into a plain object
|
|
5
7
|
* whose properties are all enumerable.
|
|
@@ -15,21 +17,6 @@ import { CachedIdClient } from './cached-id-client.js';
|
|
|
15
17
|
function plainifyRequest(request) {
|
|
16
18
|
return JSON.parse(JSON.stringify(request));
|
|
17
19
|
}
|
|
18
|
-
const requestShape = s
|
|
19
|
-
.object({
|
|
20
|
-
id: s.string(),
|
|
21
|
-
url: s.string().url({ allowedProtocols: ['http:', 'https:'] }),
|
|
22
|
-
uniqueKey: s.string(),
|
|
23
|
-
method: s.string().optional(),
|
|
24
|
-
retryCount: s.number().int().optional(),
|
|
25
|
-
handledAt: s.union([s.string(), s.date().valid()]).optional(),
|
|
26
|
-
})
|
|
27
|
-
.passthrough();
|
|
28
|
-
const requestShapeWithoutId = requestShape.omit(['id']);
|
|
29
|
-
const batchRequestShapeWithoutId = requestShapeWithoutId.array();
|
|
30
|
-
const requestOptionsShape = s.object({
|
|
31
|
-
forefront: s.boolean().optional(),
|
|
32
|
-
});
|
|
33
20
|
/**
|
|
34
21
|
* A file-system request queue backend backed by the native `@crawlee/fs-storage-native` Rust
|
|
35
22
|
* extension.
|
|
@@ -40,15 +27,15 @@ const requestOptionsShape = s.object({
|
|
|
40
27
|
export class RequestQueueBackend extends CachedIdClient {
|
|
41
28
|
name;
|
|
42
29
|
cacheKey;
|
|
43
|
-
nativeBackend;
|
|
30
|
+
#nativeBackend;
|
|
44
31
|
constructor(options) {
|
|
45
32
|
super();
|
|
46
33
|
this.name = options.name;
|
|
47
34
|
this.cacheKey = options.cacheKey;
|
|
48
|
-
this
|
|
35
|
+
this.#nativeBackend = options.nativeBackend;
|
|
49
36
|
}
|
|
50
37
|
get requestQueueDirectory() {
|
|
51
|
-
return this
|
|
38
|
+
return this.#nativeBackend.pathToRq;
|
|
52
39
|
}
|
|
53
40
|
static async create(options) {
|
|
54
41
|
const backend = new RequestQueueBackend(options);
|
|
@@ -60,21 +47,21 @@ export class RequestQueueBackend extends CachedIdClient {
|
|
|
60
47
|
* available again.
|
|
61
48
|
*/
|
|
62
49
|
async setExpectedRequestProcessingTimeSecs(secs) {
|
|
63
|
-
await this
|
|
50
|
+
await this.#nativeBackend.setExpectedRequestProcessingTime(secs);
|
|
64
51
|
}
|
|
65
52
|
async getMetadata() {
|
|
66
|
-
return this
|
|
53
|
+
return this.#nativeBackend.getMetadata();
|
|
67
54
|
}
|
|
68
55
|
async drop() {
|
|
69
|
-
await this
|
|
56
|
+
await this.#nativeBackend.dropStorage();
|
|
70
57
|
}
|
|
71
58
|
async purge() {
|
|
72
|
-
await this
|
|
59
|
+
await this.#nativeBackend.purge();
|
|
73
60
|
}
|
|
74
61
|
async addBatchOfRequests(requests, options = {}) {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const response = await this
|
|
62
|
+
parseArgument(requests, schemas.storageRequestBatch);
|
|
63
|
+
parseArgument(options, schemas.requestQueueOperationOptions);
|
|
64
|
+
const response = await this.#nativeBackend.addBatchOfRequests(requests.map((request) => plainifyRequest(request)), options.forefront ?? false);
|
|
78
65
|
// `processedRequests` is structurally identical between the native and `storage` types, so it
|
|
79
66
|
// passes through unchanged. `unprocessedRequests` only differs in that the native `method` is
|
|
80
67
|
// a plain `string`, hence the cast to the narrower `AllowedHttpMethods` union.
|
|
@@ -84,29 +71,30 @@ export class RequestQueueBackend extends CachedIdClient {
|
|
|
84
71
|
};
|
|
85
72
|
}
|
|
86
73
|
async getRequest(uniqueKey) {
|
|
87
|
-
|
|
74
|
+
parseArgument(uniqueKey, uniqueKeySchema);
|
|
88
75
|
// The native client tags requests with an internal `orderNo`; it's harmless to leak, so we
|
|
89
76
|
// hand the request back as-is rather than copying it just to drop one undeclared property.
|
|
90
77
|
// The native client already returns `undefined` for a missing request, matching this contract.
|
|
91
|
-
return (await this
|
|
78
|
+
return (await this.#nativeBackend.getRequest(uniqueKey));
|
|
92
79
|
}
|
|
93
80
|
async fetchNextRequest() {
|
|
94
|
-
return (await this
|
|
81
|
+
return (await this.#nativeBackend.fetchNextRequest());
|
|
95
82
|
}
|
|
96
83
|
async markRequestAsHandled(request) {
|
|
97
|
-
|
|
98
|
-
return (await this
|
|
84
|
+
parseArgument(request, schemas.storageRequest);
|
|
85
|
+
return (await this.#nativeBackend.markRequestAsHandled(plainifyRequest(request))) ?? undefined;
|
|
99
86
|
}
|
|
100
87
|
async reclaimRequest(request, options = {}) {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
return ((await this
|
|
88
|
+
parseArgument(request, schemas.storageRequest);
|
|
89
|
+
parseArgument(options, schemas.requestQueueOperationOptions);
|
|
90
|
+
return ((await this.#nativeBackend.reclaimRequest(plainifyRequest(request), options.forefront ?? false)) ??
|
|
91
|
+
undefined);
|
|
104
92
|
}
|
|
105
93
|
async isEmpty() {
|
|
106
|
-
return this
|
|
94
|
+
return this.#nativeBackend.isEmpty();
|
|
107
95
|
}
|
|
108
96
|
async isFinished() {
|
|
109
|
-
return this
|
|
97
|
+
return this.#nativeBackend.isFinished();
|
|
110
98
|
}
|
|
111
99
|
/**
|
|
112
100
|
* Persist the native client's in-memory state to disk. Called by
|
|
@@ -114,6 +102,6 @@ export class RequestQueueBackend extends CachedIdClient {
|
|
|
114
102
|
* for the next consumer of the same on-disk queue.
|
|
115
103
|
*/
|
|
116
104
|
async persistState() {
|
|
117
|
-
await this
|
|
105
|
+
await this.#nativeBackend.persistState();
|
|
118
106
|
}
|
|
119
107
|
}
|