@travetto/model-firestore 8.0.0-alpha.2 → 8.0.0-alpha.20

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 (3) hide show
  1. package/README.md +1 -1
  2. package/package.json +13 -7
  3. package/src/service.ts +173 -49
package/README.md CHANGED
@@ -17,7 +17,7 @@ This module provides an [Firestore](https://firebase.google.com/docs/firestore)-
17
17
 
18
18
  Supported features:
19
19
  * [CRUD](https://github.com/travetto/travetto/tree/main/module/model/src/types/crud.ts#L11)
20
- * [Indexed](https://github.com/travetto/travetto/tree/main/module/model/src/types/indexed.ts#L11)
20
+ * [Indexed](https://github.com/travetto/travetto/tree/main/module/model-indexed/src/types/service.ts#L16)
21
21
 
22
22
  Out of the box, by installing the module, everything should be wired up by default.If you need to customize any aspect of the source or config, you can override and register it with the [Dependency Injection](https://github.com/travetto/travetto/tree/main/module/di#readme "Dependency registration/management and injection support.") module.
23
23
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@travetto/model-firestore",
3
- "version": "8.0.0-alpha.2",
3
+ "version": "8.0.0-alpha.20",
4
4
  "description": "Firestore backing for the travetto model module.",
5
5
  "keywords": [
6
6
  "typescript",
@@ -25,13 +25,14 @@
25
25
  "directory": "module/model-firestore"
26
26
  },
27
27
  "dependencies": {
28
- "@google-cloud/firestore": "^8.3.0",
29
- "@travetto/config": "^8.0.0-alpha.2",
30
- "@travetto/model": "^8.0.0-alpha.2",
31
- "@travetto/runtime": "^8.0.0-alpha.2"
28
+ "@google-cloud/firestore": "^8.6.0",
29
+ "@travetto/config": "^8.0.0-alpha.18",
30
+ "@travetto/model": "^8.0.0-alpha.18",
31
+ "@travetto/model-indexed": "^8.0.0-alpha.20",
32
+ "@travetto/runtime": "^8.0.0-alpha.17"
32
33
  },
33
34
  "peerDependencies": {
34
- "@travetto/cli": "^8.0.0-alpha.3"
35
+ "@travetto/cli": "^8.0.0-alpha.23"
35
36
  },
36
37
  "peerDependenciesMeta": {
37
38
  "@travetto/cli": {
@@ -39,7 +40,12 @@
39
40
  }
40
41
  },
41
42
  "travetto": {
42
- "displayName": "Firestore Model Support"
43
+ "displayName": "Firestore Model Support",
44
+ "build": {
45
+ "externalDependencies": [
46
+ "google-gax"
47
+ ]
48
+ }
43
49
  },
44
50
  "private": false,
45
51
  "publishConfig": {
package/src/service.ts CHANGED
@@ -1,12 +1,17 @@
1
1
  import { type DocumentData, FieldValue, Firestore, type Query } from '@google-cloud/firestore';
2
2
 
3
- import { JSONUtil, ShutdownManager, type Class, type DeepPartial } from '@travetto/runtime';
3
+ import { castTo, JSONUtil, ShutdownManager, type Class, RuntimeError } from '@travetto/runtime';
4
4
  import { Injectable, PostConstruct } from '@travetto/di';
5
5
  import {
6
- type ModelCrudSupport, ModelRegistryIndex, type ModelStorageSupport,
7
- type ModelIndexedSupport, type ModelType, NotFoundError, type OptionalId,
8
- ModelCrudUtil, ModelIndexedUtil,
6
+ type ModelCrudSupport, ModelRegistryIndex, type ModelStorageSupport, type ModelType, NotFoundError, type OptionalId, ModelCrudUtil,
7
+ type ModelListOptions,
9
8
  } from '@travetto/model';
9
+ import {
10
+ type ModelIndexedSupport, type KeyedIndexSelection, type KeyedIndexBody, type ModelPageOptions, ModelIndexedUtil,
11
+ type SingleItemIndex, type SortedIndexSelection, type ModelPageResult, type SortedIndex, type FullKeyedIndexBody,
12
+ type FullKeyedIndexWithPartialBody, ModelIndexedComputedIndex, warnIfIndexedUniqueIndex, warnIfNonIndexedIndex,
13
+ type ModelIndexedSearchOptions, type SortedIndexSelectionType
14
+ } from '@travetto/model-indexed';
10
15
 
11
16
  import type { FirestoreModelConfig } from './config.ts';
12
17
 
@@ -38,6 +43,72 @@ export class FirestoreModelService implements ModelCrudSupport, ModelStorageSupp
38
43
  return this.client.collection(this.#resolveTable(cls));
39
44
  }
40
45
 
46
+ async #getIdByIndex<
47
+ T extends ModelType,
48
+ K extends KeyedIndexSelection<T>,
49
+ S extends SortedIndexSelection<T>
50
+ >(cls: Class<T>, idx: SingleItemIndex<T, K, S>, body: FullKeyedIndexBody<T, K, S>): Promise<string> {
51
+ ModelCrudUtil.ensureNotSubType(cls);
52
+ const computed = ModelIndexedComputedIndex.get(idx, body).validate({ sort: true });
53
+ const query = [...computed.allParts, ...(computed.idPart ? [computed.idPart] : [])].reduce<Query>(
54
+ (result, { path, value }) => result.where(path.join('.'), '==', value),
55
+ this.#getCollection(cls)
56
+ );
57
+
58
+ const item = await query.get();
59
+ if (!item || item.empty) {
60
+ throw new NotFoundError(`${cls.name} Index=${idx}`, computed.getKey());
61
+ }
62
+ if (item.size > 1) {
63
+ throw new RuntimeError(`Multiple items found for ${cls.name} Index=${idx}`);
64
+ }
65
+ return item.docs[0].data().id;
66
+ }
67
+
68
+ #buildIndexQuery<T extends ModelType>(cls: Class<T>, idx: SortedIndex<T>, body: KeyedIndexBody<T>): Query {
69
+ ModelCrudUtil.ensureNotSubType(cls);
70
+ const computed = ModelIndexedComputedIndex.get(idx, body).validate();
71
+
72
+ let query = computed.keyedParts.reduce<Query>((result, { path, value, state }) =>
73
+ result.where(path.join('.'), '==', (state === 'empty' ? null : value)), this.#getCollection(cls));
74
+
75
+ for (const { path, value } of idx.sortTemplate) {
76
+ query = query.orderBy(path.join('.'), value === 1 ? 'asc' : 'desc');
77
+ }
78
+ return query;
79
+ }
80
+
81
+ async * #scanCollection<T extends ModelType>(
82
+ cls: Class<T>,
83
+ queryBuilder: () => Query,
84
+ options?: ModelListOptions & ModelPageOptions<number>
85
+ ): AsyncIterable<{ items: T[], nextOffset?: number }> {
86
+ const limit = options?.limit ?? Number.MAX_SAFE_INTEGER;
87
+ const batchSize = Math.min(options?.batchSizeHint ?? 100, limit);
88
+
89
+ let offset = options?.offset ?? 0;
90
+ let produced = 0;
91
+
92
+ while (!(options?.abort?.aborted) && produced < limit) {
93
+ const query = queryBuilder().limit(batchSize).offset(offset);
94
+
95
+ const { docs, size } = await query.get();
96
+ if (size === 0) {
97
+ break;
98
+ }
99
+
100
+ const remaining = (produced + size > limit) ? docs.slice(0, limit - produced) : docs;
101
+
102
+ offset += size;
103
+
104
+ const items = await ModelCrudUtil.filterOutNotFound(
105
+ remaining.map(item => ModelCrudUtil.load(cls, item.data()!)));
106
+ produced += items.length;
107
+
108
+ yield { items, nextOffset: offset };
109
+ }
110
+ }
111
+
41
112
  @PostConstruct()
42
113
  async initializeClient(): Promise<void> {
43
114
  this.client = new Firestore({ ...this.config, useBigInt: true });
@@ -45,13 +116,16 @@ export class FirestoreModelService implements ModelCrudSupport, ModelStorageSupp
45
116
  }
46
117
 
47
118
  // Storage
48
- async createStorage(): Promise<void> { }
119
+ async createStorage(): Promise<void> {
120
+ for (const cls of ModelRegistryIndex.getClasses()) {
121
+ warnIfIndexedUniqueIndex(this, cls, ModelRegistryIndex.getIndices(cls));
122
+ warnIfNonIndexedIndex(this, cls, ModelRegistryIndex.getIndices(cls));
123
+ }
124
+ }
49
125
  async deleteStorage(): Promise<void> { }
50
126
 
51
- async deleteModel(cls: Class): Promise<void> {
52
- for await (const item of this.list(cls)) {
53
- await this.delete(cls, item.id);
54
- }
127
+ async deleteModel<T extends ModelType>(cls: Class<T>): Promise<void> {
128
+ await this.client.recursiveDelete(this.#getCollection(cls));
55
129
  }
56
130
 
57
131
  // Crud
@@ -105,63 +179,113 @@ export class FirestoreModelService implements ModelCrudSupport, ModelStorageSupp
105
179
  }
106
180
  }
107
181
 
108
- async * list<T extends ModelType>(cls: Class<T>): AsyncIterable<T> {
109
- const batch = await this.#getCollection(cls).select().get();
110
- for (const item of batch.docs) {
111
- try {
112
- yield await this.get(cls, item.id);
113
- } catch (error) {
114
- if (!(error instanceof NotFoundError)) {
115
- throw error;
116
- }
117
- }
182
+ async * list<T extends ModelType>(cls: Class<T>, options?: ModelListOptions): AsyncIterable<T[]> {
183
+ for await (const { items } of this.#scanCollection(cls, () => this.#getCollection(cls), options)) {
184
+ yield items;
118
185
  }
119
186
  }
120
187
 
121
- // Indexed
122
- async #getIdByIndex<T extends ModelType>(cls: Class<T>, idx: string, body: DeepPartial<T>): Promise<string> {
123
- ModelCrudUtil.ensureNotSubType(cls);
124
-
125
- const { fields } = ModelIndexedUtil.computeIndexParts(cls, idx, body);
126
- const query = fields.reduce<Query>(
127
- (result, { path, value }) => result.where(path.join('.'), '==', value),
128
- this.#getCollection(cls)
129
- );
130
-
131
- const item = await query.get();
132
-
133
- if (item && !item.empty) {
134
- return item.docs[0].id;
135
- }
136
- throw new NotFoundError(`${cls.name} Index=${idx}`, ModelIndexedUtil.computeIndexKey(cls, idx, body, { separator: '; ' })?.key);
137
- }
188
+ // Indexed contract
138
189
 
139
- async getByIndex<T extends ModelType>(cls: Class<T>, idx: string, body: DeepPartial<T>): Promise<T> {
190
+ async getByIndex<
191
+ T extends ModelType,
192
+ K extends KeyedIndexSelection<T>,
193
+ S extends SortedIndexSelection<T>
194
+ >(cls: Class<T>, idx: SingleItemIndex<T, K, S>, body: FullKeyedIndexBody<T, K, S>): Promise<T> {
140
195
  return this.get(cls, await this.#getIdByIndex(cls, idx, body));
141
196
  }
142
197
 
143
- async deleteByIndex<T extends ModelType>(cls: Class<T>, idx: string, body: DeepPartial<T>): Promise<void> {
198
+ async deleteByIndex<
199
+ T extends ModelType,
200
+ K extends KeyedIndexSelection<T>,
201
+ S extends SortedIndexSelection<T>
202
+ >(cls: Class<T>, idx: SingleItemIndex<T, K, S>, body: FullKeyedIndexBody<T, K, S>): Promise<void> {
144
203
  return this.delete(cls, await this.#getIdByIndex(cls, idx, body));
145
204
  }
146
205
 
147
- upsertByIndex<T extends ModelType>(cls: Class<T>, idx: string, body: OptionalId<T>): Promise<T> {
206
+ upsertByIndex<
207
+ T extends ModelType,
208
+ K extends KeyedIndexSelection<T>,
209
+ S extends SortedIndexSelection<T>
210
+ >(cls: Class<T>, idx: SingleItemIndex<T, K, S>, body: OptionalId<T>): Promise<T> {
148
211
  return ModelIndexedUtil.naiveUpsert(this, cls, idx, body);
149
212
  }
150
213
 
151
- async * listByIndex<T extends ModelType>(cls: Class<T>, idx: string, body: DeepPartial<T>): AsyncIterable<T> {
152
- ModelCrudUtil.ensureNotSubType(cls);
214
+ updateByIndex<
215
+ T extends ModelType,
216
+ K extends KeyedIndexSelection<T>,
217
+ S extends SortedIndexSelection<T>
218
+ >(cls: Class<T>, idx: SingleItemIndex<T, K, S>, body: T): Promise<T> {
219
+ return ModelIndexedUtil.naiveUpdate(this, cls, idx, body);
220
+ }
153
221
 
154
- const config = ModelRegistryIndex.getIndex(cls, idx, ['sorted', 'unsorted']);
155
- const { fields, sorted } = ModelIndexedUtil.computeIndexParts(cls, config, body, { emptySortValue: null });
156
- let query = fields.reduce<Query>((result, { path, value }) =>
157
- result.where(path.join('.'), '==', value), this.#getCollection(cls));
222
+ async updatePartialByIndex<
223
+ T extends ModelType,
224
+ K extends KeyedIndexSelection<T>,
225
+ S extends SortedIndexSelection<T>
226
+ >(cls: Class<T>, idx: SingleItemIndex<T, K, S>, body: FullKeyedIndexWithPartialBody<T, K, S>): Promise<T> {
227
+ const item = await ModelCrudUtil.naivePartialUpdate(cls, () => this.getByIndex(cls, idx, castTo(body)), castTo(body));
228
+ return this.update(cls, item);
229
+ }
158
230
 
159
- if (sorted) {
160
- query = query.orderBy(sorted.path.join('.'), sorted.dir === 1 ? 'asc' : 'desc');
231
+ async pageByIndex<
232
+ T extends ModelType,
233
+ K extends KeyedIndexSelection<T>,
234
+ S extends SortedIndexSelection<T>
235
+ >(
236
+ cls: Class<T>,
237
+ idx: SortedIndex<T, K, S>,
238
+ body: KeyedIndexBody<T, K>,
239
+ options?: ModelPageOptions,
240
+ ): Promise<ModelPageResult<T>> {
241
+ const items: T[] = [];
242
+ let nextOffset: number | undefined;
243
+ for await (const batch of this.#scanCollection(cls, () => this.#buildIndexQuery(cls, idx, body), {
244
+ limit: 100,
245
+ ...options,
246
+ offset: options?.offset ? JSONUtil.fromBase64<number>(options.offset) : 0
247
+ })) {
248
+ items.push(...batch.items);
249
+ nextOffset = batch.nextOffset;
161
250
  }
162
251
 
163
- for (const item of (await query.get()).docs) {
164
- yield await ModelCrudUtil.load(cls, item.data()!);
252
+ return { items, nextOffset: nextOffset ? JSONUtil.toBase64(nextOffset) : undefined };
253
+ }
254
+
255
+ async * listByIndex<
256
+ T extends ModelType,
257
+ K extends KeyedIndexSelection<T>,
258
+ S extends SortedIndexSelection<T>
259
+ >(
260
+ cls: Class<T>,
261
+ idx: SortedIndex<T, K, S>,
262
+ body: KeyedIndexBody<T, K>,
263
+ options?: ModelListOptions
264
+ ): AsyncIterable<T[]> {
265
+ for await (const { items } of this.#scanCollection(cls, () => this.#buildIndexQuery(cls, idx, body), options)) {
266
+ yield items;
165
267
  }
166
268
  }
269
+
270
+ async suggestByIndex<
271
+ T extends ModelType,
272
+ S extends SortedIndexSelection<T>,
273
+ K extends KeyedIndexSelection<T>,
274
+ B extends SortedIndexSelectionType<T, S> & string
275
+ >(cls: Class<T>, idx: SortedIndex<T, K, S>, body: KeyedIndexBody<T, K>, prefix: B, options?: ModelIndexedSearchOptions): Promise<T[]> {
276
+ const results: T[] = [];
277
+
278
+ const field = idx.sortTemplate[0].path.join('.');
279
+
280
+ for await (const { items } of this.#scanCollection(cls,
281
+ () => this.#buildIndexQuery(cls, idx, body)
282
+ .where(field, '>=', prefix)
283
+ .where(field, '<', `${prefix}\uf8ff`),
284
+ { limit: 10, ...options })
285
+ ) {
286
+ results.push(...items);
287
+ }
288
+
289
+ return results;
290
+ }
167
291
  }