@lossless.org/client 1.19.0 → 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.
Files changed (31) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/nosqldb/classes.atomicdelete.js +12 -4
  3. package/dist_ts/nosqldb/classes.atomicfindoneandupdate.js +11 -2
  4. package/dist_ts/nosqldb/classes.atomicupdate.d.ts +2 -0
  5. package/dist_ts/nosqldb/classes.atomicupdate.js +32 -5
  6. package/dist_ts/nosqldb/classes.bootstraprecord.js +7 -2
  7. package/dist_ts/nosqldb/classes.collection.js +52 -7
  8. package/dist_ts/nosqldb/classes.db.d.ts +19 -0
  9. package/dist_ts/nosqldb/classes.db.js +21 -1
  10. package/dist_ts/nosqldb/classes.doc.js +2 -2
  11. package/dist_ts/nosqldb/classes.exactpersistence.js +17 -2
  12. package/dist_ts/nosqldb/classes.persistence.d.ts +6 -0
  13. package/dist_ts/nosqldb/classes.persistence.js +16 -1
  14. package/dist_ts/nosqldb/classes.session.d.ts +11 -0
  15. package/dist_ts/nosqldb/classes.session.js +51 -1
  16. package/dist_ts/nosqldb/classes.writeobserver.d.ts +57 -0
  17. package/dist_ts/nosqldb/classes.writeobserver.js +139 -0
  18. package/package.json +3 -3
  19. package/readme.md +2 -1
  20. package/ts/00_commitinfo_data.ts +1 -1
  21. package/ts/nosqldb/classes.atomicdelete.ts +11 -2
  22. package/ts/nosqldb/classes.atomicfindoneandupdate.ts +22 -0
  23. package/ts/nosqldb/classes.atomicupdate.ts +53 -3
  24. package/ts/nosqldb/classes.bootstraprecord.ts +7 -1
  25. package/ts/nosqldb/classes.collection.ts +63 -4
  26. package/ts/nosqldb/classes.db.ts +31 -0
  27. package/ts/nosqldb/classes.doc.ts +1 -1
  28. package/ts/nosqldb/classes.exactpersistence.ts +24 -0
  29. package/ts/nosqldb/classes.persistence.ts +16 -0
  30. package/ts/nosqldb/classes.session.ts +73 -0
  31. package/ts/nosqldb/classes.writeobserver.ts +224 -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
+ };