@lossless.org/client 1.18.3 → 1.20.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.atomicdelete.js +12 -4
- package/dist_ts/nosqldb/classes.atomicfindoneandupdate.js +11 -2
- package/dist_ts/nosqldb/classes.atomicupdate.d.ts +2 -0
- package/dist_ts/nosqldb/classes.atomicupdate.js +32 -5
- package/dist_ts/nosqldb/classes.bootstraprecord.js +7 -2
- package/dist_ts/nosqldb/classes.collection.js +52 -7
- package/dist_ts/nosqldb/classes.db.d.ts +19 -0
- package/dist_ts/nosqldb/classes.db.js +21 -1
- package/dist_ts/nosqldb/classes.doc.js +2 -2
- package/dist_ts/nosqldb/classes.exactpersistence.js +17 -2
- package/dist_ts/nosqldb/classes.persistence.d.ts +6 -0
- package/dist_ts/nosqldb/classes.persistence.js +16 -1
- package/dist_ts/nosqldb/classes.session.d.ts +11 -0
- package/dist_ts/nosqldb/classes.session.js +51 -1
- package/dist_ts/nosqldb/classes.writeobserver.d.ts +57 -0
- package/dist_ts/nosqldb/classes.writeobserver.js +139 -0
- package/dist_ts/objectstorage/classes.bucket.d.ts +9 -0
- package/dist_ts/objectstorage/classes.bucket.js +35 -11
- package/dist_ts/objectstorage/classes.metadata.js +4 -5
- package/dist_ts/objectstorage/interfaces.d.ts +4 -0
- package/dist_ts/objectstorage/internal.bucketrequests.d.ts +7 -0
- package/dist_ts/objectstorage/internal.bucketrequests.js +14 -1
- package/package.json +3 -3
- package/readme.md +5 -1
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/nosqldb/classes.atomicdelete.ts +11 -2
- package/ts/nosqldb/classes.atomicfindoneandupdate.ts +22 -0
- package/ts/nosqldb/classes.atomicupdate.ts +53 -3
- package/ts/nosqldb/classes.bootstraprecord.ts +7 -1
- package/ts/nosqldb/classes.collection.ts +63 -4
- package/ts/nosqldb/classes.db.ts +31 -0
- package/ts/nosqldb/classes.doc.ts +1 -1
- package/ts/nosqldb/classes.exactpersistence.ts +24 -0
- package/ts/nosqldb/classes.persistence.ts +16 -0
- package/ts/nosqldb/classes.session.ts +73 -0
- package/ts/nosqldb/classes.writeobserver.ts +224 -0
- package/ts/objectstorage/classes.bucket.ts +42 -10
- package/ts/objectstorage/classes.metadata.ts +4 -5
- package/ts/objectstorage/interfaces.ts +5 -0
- package/ts/objectstorage/internal.bucketrequests.ts +14 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import type { SmartdataDb } from './classes.db.js';
|
|
2
|
+
import { assertDataOptions } from './classes.operationbudget.js';
|
|
3
|
+
import { SmartdataPersistenceError } from './classes.persistence.js';
|
|
4
|
+
import { logger } from './logging.js';
|
|
5
|
+
|
|
6
|
+
/** What a write did to its documents. An upsert that inserted is an `insert`. */
|
|
7
|
+
export type TSmartdataWriteKind = 'insert' | 'update' | 'replace' | 'delete';
|
|
8
|
+
|
|
9
|
+
/** The values a report carries; any other stored value is left out. */
|
|
10
|
+
export type TSmartdataObservedValue = string | number | boolean | null;
|
|
11
|
+
|
|
12
|
+
/** The observed fields of one document or selector, by field name. */
|
|
13
|
+
export type TSmartdataObservedFields = Readonly<Record<string, TSmartdataObservedValue>>;
|
|
14
|
+
|
|
15
|
+
/** One acknowledged write, reported to every write observer of its database. */
|
|
16
|
+
export interface ISmartdataWriteReport {
|
|
17
|
+
/** The collection the write went to. */
|
|
18
|
+
readonly collection: string;
|
|
19
|
+
readonly kind: TSmartdataWriteKind;
|
|
20
|
+
/**
|
|
21
|
+
* The observed fields of each document the write named or returned: the
|
|
22
|
+
* inserted documents, a model instance's save or delete, an exact write, the
|
|
23
|
+
* document a find-one-and-modify returned. `null` when the write addressed
|
|
24
|
+
* its documents by selector only; `anchors` then says what it pinned.
|
|
25
|
+
*/
|
|
26
|
+
readonly documents: readonly TSmartdataObservedFields[] | null;
|
|
27
|
+
/**
|
|
28
|
+
* The observed fields the write's selector pinned by top-level equality
|
|
29
|
+
* (`{ field: value }`, `{ field: { $eq: value } }`, or inside `$and`).
|
|
30
|
+
* Empty for an insert, which has no selector.
|
|
31
|
+
*/
|
|
32
|
+
readonly anchors: TSmartdataObservedFields;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ISmartdataWriteObserverOptions {
|
|
36
|
+
/** Top-level field names the reports carry: 1 to 64 distinct names, no `.` and no leading `$`. */
|
|
37
|
+
fields: readonly string[];
|
|
38
|
+
/**
|
|
39
|
+
* Called synchronously once per write. A listener that throws is logged and
|
|
40
|
+
* never fails the write; one that needs to do async work schedules it.
|
|
41
|
+
*/
|
|
42
|
+
listener: (reportArg: ISmartdataWriteReport) => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** @internal What a write path states about one acknowledged write. */
|
|
46
|
+
export interface ISmartdataWrite {
|
|
47
|
+
collection: string;
|
|
48
|
+
kind: TSmartdataWriteKind;
|
|
49
|
+
/** The documents as written or returned; `null` for a write by selector only. */
|
|
50
|
+
documents: readonly object[] | null;
|
|
51
|
+
selector?: unknown;
|
|
52
|
+
/** The model's `identityAsDocumentId` field, whose value a selector carries as `_id`. */
|
|
53
|
+
identityDocumentIdField?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface IWriteObserver {
|
|
57
|
+
fields: readonly string[];
|
|
58
|
+
listener: (reportArg: ISmartdataWriteReport) => void;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const maximumObservedFields = 64;
|
|
62
|
+
const observersByDb = new WeakMap<SmartdataDb, Set<IWriteObserver>>();
|
|
63
|
+
|
|
64
|
+
const normalizeObserverOptions = (optionsArg: unknown): IWriteObserver => {
|
|
65
|
+
const { fields, listener } = assertDataOptions(optionsArg, ['fields', 'listener']);
|
|
66
|
+
if (typeof listener !== 'function') {
|
|
67
|
+
throw new SmartdataPersistenceError('invalid_argument',
|
|
68
|
+
'A SmartData write observer requires a listener function.');
|
|
69
|
+
}
|
|
70
|
+
if (
|
|
71
|
+
!Array.isArray(fields)
|
|
72
|
+
|| fields.length === 0
|
|
73
|
+
|| fields.length > maximumObservedFields
|
|
74
|
+
|| fields.some((fieldArg) =>
|
|
75
|
+
typeof fieldArg !== 'string'
|
|
76
|
+
|| fieldArg.length === 0
|
|
77
|
+
|| fieldArg.includes('.')
|
|
78
|
+
|| fieldArg.startsWith('$'))
|
|
79
|
+
|| new Set(fields).size !== fields.length
|
|
80
|
+
) {
|
|
81
|
+
throw new SmartdataPersistenceError('invalid_argument',
|
|
82
|
+
`A SmartData write observer requires 1 to ${maximumObservedFields} distinct top-level field names.`);
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
fields: Object.freeze([...fields] as string[]),
|
|
86
|
+
listener: listener as IWriteObserver['listener'],
|
|
87
|
+
};
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** @internal Registers a write observer; the returned function removes it. */
|
|
91
|
+
export const addSmartdataWriteObserver = (
|
|
92
|
+
dbArg: SmartdataDb,
|
|
93
|
+
optionsArg: ISmartdataWriteObserverOptions,
|
|
94
|
+
): (() => void) => {
|
|
95
|
+
const observer = normalizeObserverOptions(optionsArg);
|
|
96
|
+
let observers = observersByDb.get(dbArg);
|
|
97
|
+
if (!observers) {
|
|
98
|
+
observers = new Set();
|
|
99
|
+
observersByDb.set(dbArg, observers);
|
|
100
|
+
}
|
|
101
|
+
observers.add(observer);
|
|
102
|
+
return () => {
|
|
103
|
+
observersByDb.get(dbArg)?.delete(observer);
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/** @internal */
|
|
108
|
+
export const hasSmartdataWriteObservers = (dbArg: SmartdataDb): boolean =>
|
|
109
|
+
(observersByDb.get(dbArg)?.size ?? 0) > 0;
|
|
110
|
+
|
|
111
|
+
const observedValueOf = (valueArg: unknown): TSmartdataObservedValue | undefined =>
|
|
112
|
+
valueArg === null
|
|
113
|
+
|| typeof valueArg === 'string'
|
|
114
|
+
|| typeof valueArg === 'boolean'
|
|
115
|
+
|| (typeof valueArg === 'number' && Number.isFinite(valueArg))
|
|
116
|
+
? valueArg as TSmartdataObservedValue
|
|
117
|
+
: undefined;
|
|
118
|
+
|
|
119
|
+
const isPlainRecord = (valueArg: unknown): valueArg is Record<string, unknown> => {
|
|
120
|
+
if (typeof valueArg !== 'object' || valueArg === null || Array.isArray(valueArg)) {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
const prototype = Object.getPrototypeOf(valueArg);
|
|
124
|
+
return prototype === Object.prototype || prototype === null;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const pickDocumentFields = (
|
|
128
|
+
documentArg: object,
|
|
129
|
+
fieldsArg: readonly string[],
|
|
130
|
+
): Record<string, TSmartdataObservedValue> => {
|
|
131
|
+
const picked: Record<string, TSmartdataObservedValue> = {};
|
|
132
|
+
for (const field of fieldsArg) {
|
|
133
|
+
if (!Object.hasOwn(documentArg, field)) continue;
|
|
134
|
+
const value = observedValueOf((documentArg as Record<string, unknown>)[field]);
|
|
135
|
+
if (value !== undefined) picked[field] = value;
|
|
136
|
+
}
|
|
137
|
+
return picked;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* A `null` condition also matches a missing field, so only concrete values
|
|
142
|
+
* anchor a selector.
|
|
143
|
+
*/
|
|
144
|
+
const collectSelectorAnchors = (
|
|
145
|
+
selectorArg: unknown,
|
|
146
|
+
fieldsArg: ReadonlySet<string>,
|
|
147
|
+
identityDocumentIdFieldArg: string | undefined,
|
|
148
|
+
anchorsArg: Record<string, TSmartdataObservedValue>,
|
|
149
|
+
): void => {
|
|
150
|
+
if (!isPlainRecord(selectorArg)) return;
|
|
151
|
+
for (const key of Object.keys(selectorArg)) {
|
|
152
|
+
const condition = selectorArg[key];
|
|
153
|
+
if (key === '$and') {
|
|
154
|
+
if (Array.isArray(condition)) {
|
|
155
|
+
for (const conjunct of condition) {
|
|
156
|
+
collectSelectorAnchors(conjunct, fieldsArg, identityDocumentIdFieldArg, anchorsArg);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
let value = observedValueOf(condition);
|
|
162
|
+
if (
|
|
163
|
+
value === undefined
|
|
164
|
+
&& isPlainRecord(condition)
|
|
165
|
+
&& Object.keys(condition).length === 1
|
|
166
|
+
&& Object.hasOwn(condition, '$eq')
|
|
167
|
+
) {
|
|
168
|
+
value = observedValueOf(condition.$eq);
|
|
169
|
+
}
|
|
170
|
+
if (value === undefined || value === null) continue;
|
|
171
|
+
const fields = key === '_id' && identityDocumentIdFieldArg
|
|
172
|
+
? [key, identityDocumentIdFieldArg]
|
|
173
|
+
: [key];
|
|
174
|
+
for (const field of fields) {
|
|
175
|
+
if (fieldsArg.has(field) && !Object.hasOwn(anchorsArg, field)) {
|
|
176
|
+
anchorsArg[field] = value;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const describeError = (errorArg: unknown): string =>
|
|
183
|
+
errorArg instanceof Error ? errorArg.message : String(errorArg);
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* @internal Captures one acknowledged write for the observers registered now,
|
|
187
|
+
* and returns the delivery, or `undefined` when nobody observes the database.
|
|
188
|
+
* The observed values are copied at capture, so a delivery deferred to a
|
|
189
|
+
* transaction's commit reports the write as it was made. An observer removed
|
|
190
|
+
* before the delivery receives nothing.
|
|
191
|
+
*/
|
|
192
|
+
export const captureSmartdataWrite = (
|
|
193
|
+
dbArg: SmartdataDb,
|
|
194
|
+
writeArg: ISmartdataWrite,
|
|
195
|
+
): (() => void) | undefined => {
|
|
196
|
+
const registered = observersByDb.get(dbArg);
|
|
197
|
+
if (!registered || registered.size === 0) return undefined;
|
|
198
|
+
const observers = [...registered];
|
|
199
|
+
const fields = [...new Set(observers.flatMap((observerArg) => observerArg.fields))];
|
|
200
|
+
const documents = writeArg.documents?.map((documentArg) =>
|
|
201
|
+
pickDocumentFields(documentArg, fields)) ?? null;
|
|
202
|
+
const anchors: Record<string, TSmartdataObservedValue> = {};
|
|
203
|
+
collectSelectorAnchors(writeArg.selector, new Set(fields), writeArg.identityDocumentIdField, anchors);
|
|
204
|
+
return () => {
|
|
205
|
+
for (const observer of observers) {
|
|
206
|
+
if (!observersByDb.get(dbArg)?.has(observer)) continue;
|
|
207
|
+
const report: ISmartdataWriteReport = Object.freeze({
|
|
208
|
+
collection: writeArg.collection,
|
|
209
|
+
kind: writeArg.kind,
|
|
210
|
+
documents: documents === null
|
|
211
|
+
? null
|
|
212
|
+
: Object.freeze(documents.map((documentArg) =>
|
|
213
|
+
Object.freeze(pickDocumentFields(documentArg, observer.fields)))),
|
|
214
|
+
anchors: Object.freeze(pickDocumentFields(anchors, observer.fields)),
|
|
215
|
+
});
|
|
216
|
+
try {
|
|
217
|
+
observer.listener(report);
|
|
218
|
+
} catch (errorArg) {
|
|
219
|
+
logger.log('error',
|
|
220
|
+
`A SmartData write observer threw on a write to "${writeArg.collection}": ${describeError(errorArg)}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
};
|
|
@@ -18,7 +18,12 @@ import {
|
|
|
18
18
|
purgeExactPath as purgeExactPathOperation,
|
|
19
19
|
} from './internal.exactpathpurge.operations.js';
|
|
20
20
|
import { beginBasicBucketOperation } from './internal.exactupload.capability.js';
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
getProviderStatus,
|
|
23
|
+
headBucketExists,
|
|
24
|
+
isMissingObjectError,
|
|
25
|
+
runAccountRequest,
|
|
26
|
+
} from './internal.bucketrequests.js';
|
|
22
27
|
import { ObjectRangeError } from './classes.objectrangeerror.js';
|
|
23
28
|
|
|
24
29
|
export interface IListObjectKeysPageOptions {
|
|
@@ -288,6 +293,15 @@ const destroyReadableAndWaitForSettlement = async (
|
|
|
288
293
|
}
|
|
289
294
|
};
|
|
290
295
|
|
|
296
|
+
/** Reads a downloaded object body to its end and destroys the stream. */
|
|
297
|
+
const readObjectBody = async (sourceArg: plugins.stream.Readable): Promise<Buffer> => {
|
|
298
|
+
const chunks: Buffer[] = [];
|
|
299
|
+
try {
|
|
300
|
+
for await (const chunk of sourceArg) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
301
|
+
return Buffer.concat(chunks);
|
|
302
|
+
} finally { sourceArg.destroy(); }
|
|
303
|
+
};
|
|
304
|
+
|
|
291
305
|
/**
|
|
292
306
|
* The bucket class exposes the basic functionality of a bucket.
|
|
293
307
|
* The functions of the bucket alone are enough to
|
|
@@ -456,12 +470,30 @@ export class Bucket {
|
|
|
456
470
|
* get file
|
|
457
471
|
*/
|
|
458
472
|
public async fastGet(optionsArg: { path: string }): Promise<Buffer> {
|
|
459
|
-
|
|
460
|
-
|
|
473
|
+
return await readObjectBody(await this.fastGetStream(optionsArg));
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Reads an object that may be missing with one GetObject request: resolves
|
|
478
|
+
* its bytes, or `null` when the provider answers that the key does not
|
|
479
|
+
* exist (`NoSuchKey`, `NotFound` or HTTP 404). Every other failure rejects
|
|
480
|
+
* as `fastGet()` does: a missing bucket, a denied request, a failure while
|
|
481
|
+
* the body streams, and an abort through `signal` or `SmartBucket.close()`,
|
|
482
|
+
* which rejects with the abort reason.
|
|
483
|
+
*/
|
|
484
|
+
public async fastGetIfPresent(
|
|
485
|
+
optionsArg: interfaces.IFastGetIfPresentOptions,
|
|
486
|
+
): Promise<Buffer | null> {
|
|
487
|
+
let source: plugins.stream.Readable;
|
|
461
488
|
try {
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
489
|
+
source = await this.fastGetStream(optionsArg);
|
|
490
|
+
} catch (error) {
|
|
491
|
+
if (isMissingObjectError(error)) {
|
|
492
|
+
return null;
|
|
493
|
+
}
|
|
494
|
+
throw error;
|
|
495
|
+
}
|
|
496
|
+
return await readObjectBody(source);
|
|
465
497
|
}
|
|
466
498
|
|
|
467
499
|
/**
|
|
@@ -822,13 +854,13 @@ export class Bucket {
|
|
|
822
854
|
{ abortSignal: operation.signal },
|
|
823
855
|
));
|
|
824
856
|
exists = true;
|
|
825
|
-
} catch (error
|
|
857
|
+
} catch (error) {
|
|
826
858
|
try {
|
|
827
859
|
operation.assertCurrent();
|
|
828
860
|
} catch (abortError) {
|
|
829
861
|
throw abortError;
|
|
830
862
|
}
|
|
831
|
-
if (error
|
|
863
|
+
if (isMissingObjectError(error)) {
|
|
832
864
|
exists = false;
|
|
833
865
|
} else {
|
|
834
866
|
throw error;
|
|
@@ -989,13 +1021,13 @@ export class Bucket {
|
|
|
989
1021
|
() => operation.client.send(command, { abortSignal: operation.signal }),
|
|
990
1022
|
);
|
|
991
1023
|
return true;
|
|
992
|
-
} catch (error
|
|
1024
|
+
} catch (error) {
|
|
993
1025
|
try {
|
|
994
1026
|
operation.assertCurrent();
|
|
995
1027
|
} catch (abortError) {
|
|
996
1028
|
throw abortError;
|
|
997
1029
|
}
|
|
998
|
-
if (error
|
|
1030
|
+
if (isMissingObjectError(error)) {
|
|
999
1031
|
return false;
|
|
1000
1032
|
} else {
|
|
1001
1033
|
throw error; // Rethrow if it's not a NotFound error to handle unexpected issues
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as plugins from './plugins.js';
|
|
2
2
|
|
|
3
3
|
import { File } from './classes.file.js';
|
|
4
|
+
import { isMissingObjectError } from './internal.bucketrequests.js';
|
|
4
5
|
|
|
5
6
|
export class MetaData {
|
|
6
7
|
public static async hasMetaData(optionsArg: { file: File }) {
|
|
@@ -10,12 +11,10 @@ export class MetaData {
|
|
|
10
11
|
path: optionsArg.file.name + '.metadata',
|
|
11
12
|
});
|
|
12
13
|
return !!existingFile;
|
|
13
|
-
} catch (error
|
|
14
|
-
const message = error?.message || '';
|
|
14
|
+
} catch (error) {
|
|
15
15
|
const isNotFound =
|
|
16
|
-
message.includes('File not found') ||
|
|
17
|
-
error
|
|
18
|
-
error?.$metadata?.httpStatusCode === 404;
|
|
16
|
+
(error instanceof Error && error.message.includes('File not found')) ||
|
|
17
|
+
isMissingObjectError(error);
|
|
19
18
|
if (isNotFound) {
|
|
20
19
|
return false;
|
|
21
20
|
}
|
|
@@ -78,6 +78,11 @@ export interface IFastExistsOptions {
|
|
|
78
78
|
signal?: AbortSignal;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
export interface IFastGetIfPresentOptions {
|
|
82
|
+
path: string;
|
|
83
|
+
signal?: AbortSignal;
|
|
84
|
+
}
|
|
85
|
+
|
|
81
86
|
export interface IFastStatOptions extends IPathDecriptor {
|
|
82
87
|
signal?: AbortSignal;
|
|
83
88
|
}
|
|
@@ -35,6 +35,20 @@ export const getProviderStatus = (errorArg: unknown): { name?: string; httpStatu
|
|
|
35
35
|
};
|
|
36
36
|
};
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Answers whether a provider error says the addressed object does not exist:
|
|
40
|
+
* GetObject's `NoSuchKey`, HeadObject's body-less `NotFound`, or any other
|
|
41
|
+
* HTTP 404. A `NoSuchBucket` answer names a missing bucket, not a missing
|
|
42
|
+
* object, and is not one.
|
|
43
|
+
*/
|
|
44
|
+
export const isMissingObjectError = (errorArg: unknown): boolean => {
|
|
45
|
+
const { name, httpStatusCode } = getProviderStatus(errorArg);
|
|
46
|
+
if (name === 'NoSuchBucket') {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
return name === 'NoSuchKey' || name === 'NotFound' || httpStatusCode === 404;
|
|
50
|
+
};
|
|
51
|
+
|
|
38
52
|
/**
|
|
39
53
|
* Answers whether the bucket exists with one HeadBucket request, which a
|
|
40
54
|
* bucket-scoped credential may send for its own bucket. A missing bucket
|