@lossless.org/client 1.5.1 → 1.7.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/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/nosqldb/classes.collection.d.ts +2 -0
- package/dist_ts/nosqldb/classes.collection.js +5 -2
- package/dist_ts/nosqldb/classes.collectiontopology.d.ts +9 -0
- package/dist_ts/nosqldb/classes.collectiontopology.js +46 -2
- package/dist_ts/nosqldb/classes.db.d.ts +30 -2
- package/dist_ts/nosqldb/classes.db.js +1 -1
- package/dist_ts/nosqldb/classes.doc.d.ts +8 -4
- package/dist_ts/nosqldb/classes.doc.js +7 -3
- package/dist_ts/objectstorage/classes.bucket.d.ts +53 -0
- package/dist_ts/objectstorage/classes.bucket.js +161 -54
- package/package.json +7 -7
- package/readme.md +16 -7
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/nosqldb/classes.collection.ts +6 -0
- package/ts/nosqldb/classes.collectiontopology.ts +61 -1
- package/ts/nosqldb/classes.db.ts +32 -5
- package/ts/nosqldb/classes.doc.ts +8 -4
- package/ts/objectstorage/classes.bucket.ts +239 -60
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
getExpectedCollectionTopologyForSchema,
|
|
40
40
|
getIdentityIndexName,
|
|
41
41
|
isIdentityIndexFor,
|
|
42
|
+
isPlainIndexDocument,
|
|
42
43
|
compareSmartdataTopologyStrings,
|
|
43
44
|
} from './classes.collectiontopology.js';
|
|
44
45
|
|
|
@@ -210,6 +211,8 @@ export interface ISmartdataIndexInfo {
|
|
|
210
211
|
unique: boolean;
|
|
211
212
|
sparse: boolean;
|
|
212
213
|
expireAfterSeconds?: number;
|
|
214
|
+
/** The filter of a partial index exactly as the backend lists it; absent otherwise. */
|
|
215
|
+
partialFilterExpression?: Record<string, unknown>;
|
|
213
216
|
}
|
|
214
217
|
interface ISmartdataDecoratorMetadata {
|
|
215
218
|
globalSaveableProperties?: string[];
|
|
@@ -2814,6 +2817,9 @@ export class SmartdataCollection<T> {
|
|
|
2814
2817
|
typeof indexArg.expireAfterSeconds === 'number'
|
|
2815
2818
|
? indexArg.expireAfterSeconds
|
|
2816
2819
|
: undefined,
|
|
2820
|
+
...(isPlainIndexDocument(indexArg.partialFilterExpression)
|
|
2821
|
+
? { partialFilterExpression: indexArg.partialFilterExpression }
|
|
2822
|
+
: {}),
|
|
2817
2823
|
};
|
|
2818
2824
|
});
|
|
2819
2825
|
}
|
|
@@ -33,6 +33,13 @@ export interface ISmartdataCollectionTopologyIndex {
|
|
|
33
33
|
readonly unique: boolean;
|
|
34
34
|
readonly sparse: boolean;
|
|
35
35
|
readonly expireAfterSeconds: number | null;
|
|
36
|
+
/**
|
|
37
|
+
* The filter of a partial index exactly as the backend lists it, present
|
|
38
|
+
* only on a partial index. Plain documents and arrays inside it are frozen
|
|
39
|
+
* copies; BSON values keep the type the driver decoded. A model never
|
|
40
|
+
* declares one, so an expected index never carries it.
|
|
41
|
+
*/
|
|
42
|
+
readonly partialFilterExpression?: Readonly<Record<string, unknown>>;
|
|
36
43
|
}
|
|
37
44
|
|
|
38
45
|
export interface ISmartdataExpectedCollectionTopology {
|
|
@@ -110,6 +117,7 @@ const ordinaryActualIndexKeys = new Set([
|
|
|
110
117
|
'unique',
|
|
111
118
|
'sparse',
|
|
112
119
|
'expireAfterSeconds',
|
|
120
|
+
'partialFilterExpression',
|
|
113
121
|
]);
|
|
114
122
|
const textActualIndexKeys = new Set([
|
|
115
123
|
...ordinaryActualIndexKeys,
|
|
@@ -141,6 +149,37 @@ export const compareSmartdataTopologyStrings = (
|
|
|
141
149
|
return leftCodePoints.length < rightCodePoints.length ? -1 : 1;
|
|
142
150
|
};
|
|
143
151
|
|
|
152
|
+
/** @internal Whether a listed index property is a plain BSON document. */
|
|
153
|
+
export const isPlainIndexDocument = (
|
|
154
|
+
valueArg: unknown,
|
|
155
|
+
): valueArg is Record<string, unknown> => {
|
|
156
|
+
if (typeof valueArg !== 'object' || valueArg === null || Array.isArray(valueArg)) {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
const prototype = Object.getPrototypeOf(valueArg);
|
|
160
|
+
return prototype === Object.prototype || prototype === null;
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Copies and freezes the documents and arrays of a listed index filter, so the
|
|
165
|
+
* reported filter is exactly what the backend listed and nobody can edit it
|
|
166
|
+
* afterwards. BSON values — ObjectIds, dates, decimals — are returned as the
|
|
167
|
+
* driver decoded them.
|
|
168
|
+
*/
|
|
169
|
+
const snapshotIndexFilterValue = (valueArg: unknown): unknown => {
|
|
170
|
+
if (Array.isArray(valueArg)) {
|
|
171
|
+
return Object.freeze(valueArg.map((entryArg) => snapshotIndexFilterValue(entryArg)));
|
|
172
|
+
}
|
|
173
|
+
if (isPlainIndexDocument(valueArg)) {
|
|
174
|
+
const copy: Record<string, unknown> = {};
|
|
175
|
+
for (const [key, entry] of Object.entries(valueArg)) {
|
|
176
|
+
copy[key] = snapshotIndexFilterValue(entry);
|
|
177
|
+
}
|
|
178
|
+
return Object.freeze(copy);
|
|
179
|
+
}
|
|
180
|
+
return valueArg;
|
|
181
|
+
};
|
|
182
|
+
|
|
144
183
|
function freezeIndex(
|
|
145
184
|
indexArg: {
|
|
146
185
|
name: string;
|
|
@@ -152,6 +191,7 @@ function freezeIndex(
|
|
|
152
191
|
unique: boolean;
|
|
153
192
|
sparse: boolean;
|
|
154
193
|
expireAfterSeconds: number | null;
|
|
194
|
+
partialFilterExpression?: Readonly<Record<string, unknown>>;
|
|
155
195
|
},
|
|
156
196
|
): ISmartdataCollectionTopologyIndex {
|
|
157
197
|
return Object.freeze({
|
|
@@ -162,6 +202,15 @@ function freezeIndex(
|
|
|
162
202
|
unique: indexArg.unique,
|
|
163
203
|
sparse: indexArg.sparse,
|
|
164
204
|
expireAfterSeconds: indexArg.expireAfterSeconds,
|
|
205
|
+
// Absent rather than null on an ordinary index, so every index that is
|
|
206
|
+
// not partial reads exactly as it did before filters were reported.
|
|
207
|
+
...(indexArg.partialFilterExpression === undefined
|
|
208
|
+
? {}
|
|
209
|
+
: {
|
|
210
|
+
partialFilterExpression: snapshotIndexFilterValue(
|
|
211
|
+
indexArg.partialFilterExpression,
|
|
212
|
+
) as Readonly<Record<string, unknown>>,
|
|
213
|
+
}),
|
|
165
214
|
});
|
|
166
215
|
}
|
|
167
216
|
|
|
@@ -390,6 +439,13 @@ const normalizeActualIndex = (
|
|
|
390
439
|
if (rawIndex.sparse !== undefined && typeof rawIndex.sparse !== 'boolean') {
|
|
391
440
|
supported = false;
|
|
392
441
|
}
|
|
442
|
+
const partialFilterExpression = rawIndex.partialFilterExpression;
|
|
443
|
+
if (
|
|
444
|
+
partialFilterExpression !== undefined &&
|
|
445
|
+
!isPlainIndexDocument(partialFilterExpression)
|
|
446
|
+
) {
|
|
447
|
+
supported = false;
|
|
448
|
+
}
|
|
393
449
|
const expireAfterSeconds = rawIndex.expireAfterSeconds;
|
|
394
450
|
if (
|
|
395
451
|
expireAfterSeconds !== undefined &&
|
|
@@ -495,6 +551,9 @@ const normalizeActualIndex = (
|
|
|
495
551
|
expireAfterSeconds >= 0
|
|
496
552
|
? expireAfterSeconds
|
|
497
553
|
: null,
|
|
554
|
+
...(isPlainIndexDocument(partialFilterExpression)
|
|
555
|
+
? { partialFilterExpression }
|
|
556
|
+
: {}),
|
|
498
557
|
});
|
|
499
558
|
if (
|
|
500
559
|
name === '_id_' &&
|
|
@@ -504,7 +563,8 @@ const normalizeActualIndex = (
|
|
|
504
563
|
normalized.keys[0].weight === null &&
|
|
505
564
|
(rawIndex.unique === undefined || rawIndex.unique === true) &&
|
|
506
565
|
normalized.sparse === false &&
|
|
507
|
-
normalized.expireAfterSeconds === null
|
|
566
|
+
normalized.expireAfterSeconds === null &&
|
|
567
|
+
normalized.partialFilterExpression === undefined
|
|
508
568
|
) {
|
|
509
569
|
normalized = fixedIdIndex;
|
|
510
570
|
}
|
package/ts/nosqldb/classes.db.ts
CHANGED
|
@@ -37,6 +37,35 @@ import type { ICapabilities } from '../core/interfaces.js';
|
|
|
37
37
|
*/
|
|
38
38
|
export type TConnectionStatus = 'initial' | 'disconnected' | 'connected' | 'failed';
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* How a `SmartdataDb` connects. The first four fields locate and authenticate
|
|
42
|
+
* the database — the same shape as `IMongoDescriptor`, so every descriptor is
|
|
43
|
+
* valid options — and the pool fields tune the driver's connection pool.
|
|
44
|
+
*/
|
|
45
|
+
export interface ISmartdataDbOptions {
|
|
46
|
+
/**
|
|
47
|
+
* The connection URL. `<USERNAME>`/`<USER>`, `<PASSWORD>` and `<DBNAME>`
|
|
48
|
+
* (upper or lower case) are replaced with the URL-encoded `mongoDbUser`,
|
|
49
|
+
* `mongoDbPass` and `mongoDbName`.
|
|
50
|
+
*/
|
|
51
|
+
mongoDbUrl: string;
|
|
52
|
+
/** The database to use. */
|
|
53
|
+
mongoDbName?: string;
|
|
54
|
+
mongoDbUser?: string;
|
|
55
|
+
mongoDbPass?: string;
|
|
56
|
+
/** Upper bound of pooled connections per server. Default 100. */
|
|
57
|
+
maxPoolSize?: number;
|
|
58
|
+
/** Milliseconds an idle pooled connection is kept before it is closed. Default 300000. */
|
|
59
|
+
maxIdleTimeMS?: number;
|
|
60
|
+
/**
|
|
61
|
+
* Milliseconds to wait for a suitable server. Default 30000. A bounded
|
|
62
|
+
* `init({ timeoutMs })` replaces it with the remaining init budget.
|
|
63
|
+
*/
|
|
64
|
+
serverSelectionTimeoutMS?: number;
|
|
65
|
+
/** Milliseconds a socket may stay inactive before it is closed. Default 30000. */
|
|
66
|
+
socketTimeoutMS?: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
40
69
|
export interface ISmartdataReadinessOptions {
|
|
41
70
|
timeoutMs?: number;
|
|
42
71
|
signal?: AbortSignal;
|
|
@@ -138,7 +167,7 @@ const assertExactDataObject = (
|
|
|
138
167
|
};
|
|
139
168
|
|
|
140
169
|
export class SmartdataDb {
|
|
141
|
-
smartdataOptions:
|
|
170
|
+
smartdataOptions: ISmartdataDbOptions;
|
|
142
171
|
mongoDbClient!: plugins.mongodb.MongoClient;
|
|
143
172
|
mongoDb!: plugins.mongodb.Db;
|
|
144
173
|
status: TConnectionStatus;
|
|
@@ -151,7 +180,7 @@ export class SmartdataDb {
|
|
|
151
180
|
private engineIdentityState: ISmartdataEngineIdentity = unidentifiedSmartdataEngine;
|
|
152
181
|
private capabilitiesState: ICapabilities | undefined;
|
|
153
182
|
|
|
154
|
-
constructor(smartdataOptions:
|
|
183
|
+
constructor(smartdataOptions: ISmartdataDbOptions) {
|
|
155
184
|
this.smartdataOptions = smartdataOptions;
|
|
156
185
|
this.status = 'initial';
|
|
157
186
|
void this.statusConnectedDeferred.promise.catch(() => {});
|
|
@@ -219,9 +248,7 @@ export class SmartdataDb {
|
|
|
219
248
|
.replace('<DBNAME>', this.smartdataOptions.mongoDbName || '')
|
|
220
249
|
.replace('<dbname>', this.smartdataOptions.mongoDbName || '');
|
|
221
250
|
|
|
222
|
-
const descriptor = this.smartdataOptions
|
|
223
|
-
Pick<plugins.mongodb.MongoClientOptions, 'maxPoolSize' | 'maxIdleTimeMS' |
|
|
224
|
-
'serverSelectionTimeoutMS' | 'socketTimeoutMS'>;
|
|
251
|
+
const descriptor = this.smartdataOptions;
|
|
225
252
|
const clientOptions: plugins.mongodb.MongoClientOptions = {
|
|
226
253
|
maxPoolSize: descriptor.maxPoolSize ?? 100,
|
|
227
254
|
maxIdleTimeMS: descriptor.maxIdleTimeMS ?? 300000,
|
|
@@ -5774,9 +5774,11 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|
|
5774
5774
|
}
|
|
5775
5775
|
|
|
5776
5776
|
/**
|
|
5777
|
-
*
|
|
5777
|
+
* Saves this instance, optionally within a transaction. `session` takes the
|
|
5778
|
+
* handle from `db.createSession()` — leased for the one write, like every
|
|
5779
|
+
* other model API — or a raw driver session.
|
|
5778
5780
|
*/
|
|
5779
|
-
public async save(opts?: { session?:
|
|
5781
|
+
public async save(opts?: { session?: TSmartdataOrdinarySession }) {
|
|
5780
5782
|
if (getOrdinaryPersistencePolicy(this.constructor)) {
|
|
5781
5783
|
throw new SmartdataPersistenceError('unsupported_operation',
|
|
5782
5784
|
'Validated ordinary models use insert() or transactional postimage updates.');
|
|
@@ -5980,9 +5982,11 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|
|
5980
5982
|
}
|
|
5981
5983
|
|
|
5982
5984
|
/**
|
|
5983
|
-
*
|
|
5985
|
+
* Deletes this instance's document, optionally within a transaction.
|
|
5986
|
+
* `session` takes the handle from `db.createSession()` — leased for the one
|
|
5987
|
+
* write, like every other model API — or a raw driver session.
|
|
5984
5988
|
*/
|
|
5985
|
-
public async delete(opts?: { session?:
|
|
5989
|
+
public async delete(opts?: { session?: TSmartdataOrdinarySession }) {
|
|
5986
5990
|
const exactPersistencePolicy = (this.constructor as any)[
|
|
5987
5991
|
exactPersistencePolicySymbol
|
|
5988
5992
|
] as { forbiddenDelete?: () => Promise<never> } | undefined;
|
|
@@ -30,9 +30,171 @@ export interface IListObjectKeysPageResult {
|
|
|
30
30
|
nextStartAfter?: string;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
export interface IListObjectEntriesPageOptions extends IListObjectKeysPageOptions {
|
|
34
|
+
/** Optional caller-owned cancellation signal */
|
|
35
|
+
signal?: AbortSignal;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* One object as ListObjectsV2 reported it when the page was listed.
|
|
40
|
+
* - `size`: the `Size` field, the object's byte length; HEAD reports the same
|
|
41
|
+
* value as `ContentLength`.
|
|
42
|
+
* - `etag`: the `ETag` field without its surrounding double quotes; HEAD
|
|
43
|
+
* reports the same entity tag, quoted. It identifies an object version, it
|
|
44
|
+
* is not a content digest: AWS S3 derives it from the content MD5 only for
|
|
45
|
+
* single-part uploads without SSE-KMS or SSE-C, and other providers define
|
|
46
|
+
* it themselves.
|
|
47
|
+
* - `lastModified`: the `LastModified` field when the provider sends one.
|
|
48
|
+
*/
|
|
49
|
+
export interface IObjectListingEntry {
|
|
50
|
+
key: string;
|
|
51
|
+
size: number;
|
|
52
|
+
etag: string;
|
|
53
|
+
lastModified?: Date;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface IListObjectEntriesPageResult {
|
|
57
|
+
entries: IObjectListingEntry[];
|
|
58
|
+
nextStartAfter?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface IListAllObjectEntriesOptions {
|
|
62
|
+
/** Entries fetched per ListObjectsV2 request, 1 to 1000 (default 1000) */
|
|
63
|
+
pageSize?: number;
|
|
64
|
+
/** Optional caller-owned cancellation signal */
|
|
65
|
+
signal?: AbortSignal;
|
|
66
|
+
}
|
|
67
|
+
|
|
33
68
|
const compareUtf8ObjectKeys = (leftArg: string, rightArg: string): number =>
|
|
34
69
|
Buffer.compare(Buffer.from(leftArg, 'utf8'), Buffer.from(rightArg, 'utf8'));
|
|
35
70
|
|
|
71
|
+
const assertListingPageOptions = (
|
|
72
|
+
optionsArg: IListObjectKeysPageOptions,
|
|
73
|
+
labelArg: string,
|
|
74
|
+
): string => {
|
|
75
|
+
if (!optionsArg || typeof optionsArg !== 'object' || Array.isArray(optionsArg)) {
|
|
76
|
+
throw new TypeError(`${labelArg} options must be a plain object`);
|
|
77
|
+
}
|
|
78
|
+
const prefix = optionsArg.prefix ?? '';
|
|
79
|
+
if (typeof prefix !== 'string') {
|
|
80
|
+
throw new TypeError(`${labelArg} prefix must be a string`);
|
|
81
|
+
}
|
|
82
|
+
if (!Number.isInteger(optionsArg.limit) || optionsArg.limit < 1 || optionsArg.limit > 1_000) {
|
|
83
|
+
throw new Error(`${labelArg} limit must be an integer from 1 to 1000`);
|
|
84
|
+
}
|
|
85
|
+
if (
|
|
86
|
+
optionsArg.startAfter !== undefined
|
|
87
|
+
&& (
|
|
88
|
+
typeof optionsArg.startAfter !== 'string'
|
|
89
|
+
|| optionsArg.startAfter.length === 0
|
|
90
|
+
|| !optionsArg.startAfter.startsWith(prefix)
|
|
91
|
+
)
|
|
92
|
+
) {
|
|
93
|
+
throw new Error(`${labelArg} startAfter must be a non-empty key under the prefix`);
|
|
94
|
+
}
|
|
95
|
+
return prefix;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const createListingPageCommand = (
|
|
99
|
+
bucketNameArg: string,
|
|
100
|
+
prefixArg: string,
|
|
101
|
+
optionsArg: IListObjectKeysPageOptions,
|
|
102
|
+
): plugins.s3.ListObjectsV2Command => new plugins.s3.ListObjectsV2Command({
|
|
103
|
+
Bucket: bucketNameArg,
|
|
104
|
+
Prefix: prefixArg,
|
|
105
|
+
MaxKeys: optionsArg.limit,
|
|
106
|
+
...(optionsArg.startAfter ? { StartAfter: optionsArg.startAfter } : {}),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Validate one ListObjectsV2 keyset page: a truncation flag, at most `limit`
|
|
111
|
+
* string keys under the prefix in strict UTF-8 byte order after `startAfter`,
|
|
112
|
+
* and no empty truncated page.
|
|
113
|
+
*/
|
|
114
|
+
const readListingPageContents = (
|
|
115
|
+
responseArg: plugins.s3.ListObjectsV2Output,
|
|
116
|
+
prefixArg: string,
|
|
117
|
+
optionsArg: IListObjectKeysPageOptions,
|
|
118
|
+
labelArg: string,
|
|
119
|
+
): { contents: plugins.s3._Object[]; keys: string[]; nextStartAfter?: string } => {
|
|
120
|
+
if (typeof responseArg.IsTruncated !== 'boolean') {
|
|
121
|
+
throw new Error(`Object storage returned an invalid ${labelArg} truncation flag`);
|
|
122
|
+
}
|
|
123
|
+
if (responseArg.Contents !== undefined && !Array.isArray(responseArg.Contents)) {
|
|
124
|
+
throw new Error(`Object storage returned invalid ${labelArg} contents`);
|
|
125
|
+
}
|
|
126
|
+
const contents = responseArg.Contents ?? [];
|
|
127
|
+
const keys = contents.map((entry) => {
|
|
128
|
+
if (!entry || typeof entry.Key !== 'string') {
|
|
129
|
+
throw new Error(`Object storage returned a ${labelArg} entry without a string key`);
|
|
130
|
+
}
|
|
131
|
+
return entry.Key;
|
|
132
|
+
});
|
|
133
|
+
if (keys.length > optionsArg.limit) {
|
|
134
|
+
throw new Error(`Object storage returned more ${labelArg === 'key page' ? 'keys' : 'entries'} than the requested page limit`);
|
|
135
|
+
}
|
|
136
|
+
if (new Set(keys).size !== keys.length) {
|
|
137
|
+
throw new Error('Object storage returned duplicate keys in one page');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let previousKey = optionsArg.startAfter;
|
|
141
|
+
for (const key of keys) {
|
|
142
|
+
if (!key.startsWith(prefixArg)) {
|
|
143
|
+
throw new Error('Object storage returned a key outside the requested prefix');
|
|
144
|
+
}
|
|
145
|
+
if (previousKey !== undefined && compareUtf8ObjectKeys(key, previousKey) <= 0) {
|
|
146
|
+
throw new Error('Object storage returned keys outside strict UTF-8 byte order');
|
|
147
|
+
}
|
|
148
|
+
previousKey = key;
|
|
149
|
+
}
|
|
150
|
+
if (responseArg.IsTruncated && keys.length === 0) {
|
|
151
|
+
throw new Error(`Object storage returned an empty truncated ${labelArg}`);
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
contents,
|
|
155
|
+
keys,
|
|
156
|
+
...(responseArg.IsTruncated ? { nextStartAfter: keys[keys.length - 1] } : {}),
|
|
157
|
+
};
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Strip the double quotes S3 puts around an entity tag. A provider that
|
|
162
|
+
* returns the tag unquoted is accepted as is; anything else is refused.
|
|
163
|
+
*/
|
|
164
|
+
const normalizeListingEtag = (etagArg: unknown, keyArg: string): string => {
|
|
165
|
+
if (typeof etagArg !== 'string') {
|
|
166
|
+
throw new Error(`Object storage returned a listing entry without an ETag for key '${keyArg}'`);
|
|
167
|
+
}
|
|
168
|
+
const quoted = /^"([^"]+)"$/.exec(etagArg);
|
|
169
|
+
const etag = quoted ? quoted[1] : etagArg;
|
|
170
|
+
if (etag.length === 0 || etag.includes('"')) {
|
|
171
|
+
throw new Error(`Object storage returned an invalid ETag for key '${keyArg}'`);
|
|
172
|
+
}
|
|
173
|
+
return etag;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const toObjectListingEntry = (
|
|
177
|
+
entryArg: plugins.s3._Object,
|
|
178
|
+
keyArg: string,
|
|
179
|
+
): IObjectListingEntry => {
|
|
180
|
+
if (!Number.isSafeInteger(entryArg.Size) || (entryArg.Size as number) < 0) {
|
|
181
|
+
throw new Error(`Object storage returned a listing entry without a valid size for key '${keyArg}'`);
|
|
182
|
+
}
|
|
183
|
+
const lastModified = entryArg.LastModified;
|
|
184
|
+
if (
|
|
185
|
+
lastModified !== undefined
|
|
186
|
+
&& (!(lastModified instanceof Date) || Number.isNaN(lastModified.getTime()))
|
|
187
|
+
) {
|
|
188
|
+
throw new Error(`Object storage returned an invalid LastModified for key '${keyArg}'`);
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
key: keyArg,
|
|
192
|
+
size: entryArg.Size as number,
|
|
193
|
+
etag: normalizeListingEtag(entryArg.ETag, keyArg),
|
|
194
|
+
...(lastModified ? { lastModified } : {}),
|
|
195
|
+
};
|
|
196
|
+
};
|
|
197
|
+
|
|
36
198
|
const destroyReadableAndWaitForSettlement = async (
|
|
37
199
|
readableArg: plugins.stream.Readable,
|
|
38
200
|
): Promise<void> => {
|
|
@@ -933,73 +1095,90 @@ export class Bucket {
|
|
|
933
1095
|
public async listObjectKeysPage(
|
|
934
1096
|
optionsArg: IListObjectKeysPageOptions,
|
|
935
1097
|
): Promise<IListObjectKeysPageResult> {
|
|
936
|
-
|
|
937
|
-
throw new TypeError('Object key page options must be a plain object');
|
|
938
|
-
}
|
|
939
|
-
const prefix = optionsArg.prefix ?? '';
|
|
940
|
-
if (typeof prefix !== 'string') {
|
|
941
|
-
throw new TypeError('Object key page prefix must be a string');
|
|
942
|
-
}
|
|
943
|
-
if (!Number.isInteger(optionsArg.limit) || optionsArg.limit < 1 || optionsArg.limit > 1_000) {
|
|
944
|
-
throw new Error('Object key page limit must be an integer from 1 to 1000');
|
|
945
|
-
}
|
|
946
|
-
if (
|
|
947
|
-
optionsArg.startAfter !== undefined
|
|
948
|
-
&& (
|
|
949
|
-
typeof optionsArg.startAfter !== 'string'
|
|
950
|
-
|| optionsArg.startAfter.length === 0
|
|
951
|
-
|| !optionsArg.startAfter.startsWith(prefix)
|
|
952
|
-
)
|
|
953
|
-
) {
|
|
954
|
-
throw new Error('Object key page startAfter must be a non-empty key under the prefix');
|
|
955
|
-
}
|
|
956
|
-
|
|
1098
|
+
const prefix = assertListingPageOptions(optionsArg, 'Object key page');
|
|
957
1099
|
const response = await this.smartbucketRef.storageClient.send(
|
|
958
|
-
|
|
959
|
-
Bucket: this.name,
|
|
960
|
-
Prefix: prefix,
|
|
961
|
-
MaxKeys: optionsArg.limit,
|
|
962
|
-
...(optionsArg.startAfter ? { StartAfter: optionsArg.startAfter } : {}),
|
|
963
|
-
}),
|
|
1100
|
+
createListingPageCommand(this.name, prefix, optionsArg),
|
|
964
1101
|
);
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
1102
|
+
const { keys, nextStartAfter } = readListingPageContents(
|
|
1103
|
+
response,
|
|
1104
|
+
prefix,
|
|
1105
|
+
optionsArg,
|
|
1106
|
+
'key page',
|
|
1107
|
+
);
|
|
1108
|
+
return {
|
|
1109
|
+
keys,
|
|
1110
|
+
...(nextStartAfter !== undefined ? { nextStartAfter } : {}),
|
|
1111
|
+
};
|
|
1112
|
+
}
|
|
971
1113
|
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
1114
|
+
/**
|
|
1115
|
+
* List one keyset page of objects with the size, ETag and last-modified time
|
|
1116
|
+
* ListObjectsV2 already returns, so a caller needs no HEAD per object.
|
|
1117
|
+
* Pagination, prefix, limit (1 to 1000) and `startAfter` behave exactly like
|
|
1118
|
+
* `listObjectKeysPage()`: `nextStartAfter` is present only while the provider
|
|
1119
|
+
* reports more entries. `signal` aborts the in-flight request and rejects with
|
|
1120
|
+
* its reason. This requires a general-purpose S3 provider with UTF-8
|
|
1121
|
+
* byte-ordered keys.
|
|
1122
|
+
*/
|
|
1123
|
+
public async listObjectEntriesPage(
|
|
1124
|
+
optionsArg: IListObjectEntriesPageOptions,
|
|
1125
|
+
): Promise<IListObjectEntriesPageResult> {
|
|
1126
|
+
const prefix = assertListingPageOptions(optionsArg, 'Object entry page');
|
|
1127
|
+
if (optionsArg.signal !== undefined && !(optionsArg.signal instanceof AbortSignal)) {
|
|
1128
|
+
throw new TypeError('Object entry page signal must be an AbortSignal');
|
|
980
1129
|
}
|
|
981
|
-
|
|
982
|
-
|
|
1130
|
+
const operation = beginBasicBucketOperation(this, optionsArg.signal);
|
|
1131
|
+
try {
|
|
1132
|
+
operation.assertCurrent();
|
|
1133
|
+
const command = createListingPageCommand(operation.bucketName, prefix, optionsArg);
|
|
1134
|
+
const response = await operation.runProvider(
|
|
1135
|
+
() => operation.client.send(command, { abortSignal: operation.signal }),
|
|
1136
|
+
);
|
|
1137
|
+
operation.assertCurrent();
|
|
1138
|
+
const { contents, keys, nextStartAfter } = readListingPageContents(
|
|
1139
|
+
response,
|
|
1140
|
+
prefix,
|
|
1141
|
+
optionsArg,
|
|
1142
|
+
'entry page',
|
|
1143
|
+
);
|
|
1144
|
+
return {
|
|
1145
|
+
entries: contents.map((entry, index) => toObjectListingEntry(entry, keys[index])),
|
|
1146
|
+
...(nextStartAfter !== undefined ? { nextStartAfter } : {}),
|
|
1147
|
+
};
|
|
1148
|
+
} finally {
|
|
1149
|
+
operation.finish();
|
|
983
1150
|
}
|
|
1151
|
+
}
|
|
984
1152
|
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
1153
|
+
/**
|
|
1154
|
+
* Iterate every object under a prefix with its size, ETag and last-modified
|
|
1155
|
+
* time, one `listObjectEntriesPage()` request per page. No request is in
|
|
1156
|
+
* flight while the caller holds an entry; breaking out stops the listing.
|
|
1157
|
+
* @example
|
|
1158
|
+
* ```ts
|
|
1159
|
+
* for await (const entry of bucket.listAllObjectEntries('npm/', { signal })) {
|
|
1160
|
+
* console.log(entry.key, entry.size, entry.etag);
|
|
1161
|
+
* }
|
|
1162
|
+
* ```
|
|
1163
|
+
*/
|
|
1164
|
+
public async *listAllObjectEntries(
|
|
1165
|
+
prefix: string = '',
|
|
1166
|
+
optionsArg: IListAllObjectEntriesOptions = {},
|
|
1167
|
+
): AsyncIterableIterator<IObjectListingEntry> {
|
|
1168
|
+
const limit = optionsArg.pageSize ?? 1_000;
|
|
1169
|
+
let startAfter: string | undefined;
|
|
1170
|
+
do {
|
|
1171
|
+
const page = await this.listObjectEntriesPage({
|
|
1172
|
+
prefix,
|
|
1173
|
+
limit,
|
|
1174
|
+
...(startAfter !== undefined ? { startAfter } : {}),
|
|
1175
|
+
...(optionsArg.signal ? { signal: optionsArg.signal } : {}),
|
|
1176
|
+
});
|
|
1177
|
+
for (const entry of page.entries) {
|
|
1178
|
+
yield entry;
|
|
992
1179
|
}
|
|
993
|
-
|
|
994
|
-
}
|
|
995
|
-
if (response.IsTruncated && keys.length === 0) {
|
|
996
|
-
throw new Error('Object storage returned an empty truncated key page');
|
|
997
|
-
}
|
|
998
|
-
|
|
999
|
-
return {
|
|
1000
|
-
keys,
|
|
1001
|
-
...(response.IsTruncated ? { nextStartAfter: keys[keys.length - 1] } : {}),
|
|
1002
|
-
};
|
|
1180
|
+
startAfter = page.nextStartAfter;
|
|
1181
|
+
} while (startAfter !== undefined);
|
|
1003
1182
|
}
|
|
1004
1183
|
|
|
1005
1184
|
/**
|