@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.
@@ -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
  }
@@ -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: plugins.tsclass.database.IMongoDescriptor;
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: plugins.tsclass.database.IMongoDescriptor) {
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 as plugins.tsclass.database.IMongoDescriptor &
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
- * saves this instance (optionally within a transaction)
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?: plugins.mongodb.ClientSession }) {
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
- * deletes a document from the database (optionally within a transaction)
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?: plugins.mongodb.ClientSession }) {
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
- if (!optionsArg || typeof optionsArg !== 'object' || Array.isArray(optionsArg)) {
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
- new plugins.s3.ListObjectsV2Command({
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
- if (typeof response.IsTruncated !== 'boolean') {
966
- throw new Error('Object storage returned an invalid key page truncation flag');
967
- }
968
- if (response.Contents !== undefined && !Array.isArray(response.Contents)) {
969
- throw new Error('Object storage returned invalid key page contents');
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
- const keys = (response.Contents ?? []).map((entry) => {
973
- if (!entry || typeof entry.Key !== 'string') {
974
- throw new Error('Object storage returned a key page entry without a string key');
975
- }
976
- return entry.Key;
977
- });
978
- if (keys.length > optionsArg.limit) {
979
- throw new Error('Object storage returned more keys than the requested page limit');
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
- if (new Set(keys).size !== keys.length) {
982
- throw new Error('Object storage returned duplicate keys in one page');
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
- let previousKey = optionsArg.startAfter;
986
- for (const key of keys) {
987
- if (!key.startsWith(prefix)) {
988
- throw new Error('Object storage returned a key outside the requested prefix');
989
- }
990
- if (previousKey !== undefined && compareUtf8ObjectKeys(key, previousKey) <= 0) {
991
- throw new Error('Object storage returned keys outside strict UTF-8 byte order');
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
- previousKey = key;
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
  /**