@travetto/model-indexed 8.0.0-alpha.10

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/README.md ADDED
@@ -0,0 +1,353 @@
1
+ <!-- This file was generated by @travetto/doc and should not be modified directly -->
2
+ <!-- Please modify https://github.com/travetto/travetto/tree/main/module/model-indexed/DOC.tsx and execute "npx trv doc" to rebuild -->
3
+ # Data Model Indexing Support
4
+
5
+ ## Basic indexing support for model sources that support it.
6
+
7
+ **Install: @travetto/model-indexed**
8
+ ```bash
9
+ npm install @travetto/model-indexed
10
+
11
+ # or
12
+
13
+ yarn add @travetto/model-indexed
14
+ ```
15
+
16
+ This module provides computed index support for data model sources that support it. It enables efficient lookups and list operations using composite keys extracted from model fields, without requiring a full query engine.
17
+
18
+ ## Overview
19
+ The module allows you to define indexes on your models and use them for fast single-item lookups, uniqueness enforcement, and efficient paginated list operations. Indexes are computed from model field values and act as alternative keys for data access.
20
+
21
+ ### Index Types
22
+ Three types of indexes are supported:
23
+ * **Keyed Indexes** — Fast single-item lookups using composite keys
24
+ * **Unique Indexes** — Enforce uniqueness constraints on key fields
25
+ * **Sorted Indexes** — Enable range queries and paginated listing with sorting
26
+
27
+ ## Defining Indexes
28
+ Indexes are defined using factory functions provided by the module. Each index is registered with the model at decoration time.
29
+
30
+ ### Keyed Indexes
31
+ A [keyedIndex](https://github.com/travetto/travetto/tree/main/module/model-indexed/src/indexes.ts#L29) provides fast lookups by computed key values. It's useful when you want to query records by specific field combinations.
32
+
33
+ **Code: Creating a Keyed Index**
34
+ ```typescript
35
+ import { keyedIndex } from '@travetto/model-indexed';
36
+ import { Model } from '@travetto/model';
37
+
38
+ @Model()
39
+ export class User {
40
+ id: string;
41
+ name: string;
42
+ email: string;
43
+ }
44
+
45
+ export const userByName = keyedIndex(User, {
46
+ name: 'userByName',
47
+ key: { name: true }
48
+ });
49
+ ```
50
+
51
+ The index definition specifies:
52
+ * `name` — The identifier for this index
53
+ * `key` — An object where each key path should be included in the index (set to `true`)
54
+
55
+ ### Unique Indexes
56
+ A [uniqueIndex](https://github.com/travetto/travetto/tree/main/module/model-indexed/src/indexes.ts#L47) enforces uniqueness constraints on key fields. This is useful for emails, usernames, or any field that should be globally unique.
57
+
58
+ **Code: Creating a Unique Index**
59
+ ```typescript
60
+ import { uniqueIndex } from '@travetto/model-indexed';
61
+ import { Model } from '@travetto/model';
62
+
63
+ @Model()
64
+ export class User {
65
+ id: string;
66
+ name: string;
67
+ email: string;
68
+ }
69
+
70
+ export const emailUnique = uniqueIndex(User, {
71
+ name: 'uniqueEmail',
72
+ key: { email: true }
73
+ });
74
+ ```
75
+
76
+ Unique indexes work exactly like keyed indexes, but enforce a uniqueness constraint. A model service will reject writes that violate the uniqueness guarantee.
77
+
78
+ ### Sorted Indexes
79
+ A [sortedIndex](https://github.com/travetto/travetto/tree/main/module/model-indexed/src/indexes.ts#L65) enables range queries and paginated listing. It requires both a `key` for filtering and a `sort` field for ordering.
80
+
81
+ **Code: Creating a Sorted Index**
82
+ ```typescript
83
+ import { sortedIndex } from '@travetto/model-indexed';
84
+ import { Model } from '@travetto/model';
85
+
86
+ @Model()
87
+ export class User {
88
+ id: string;
89
+ name: string;
90
+ age: number;
91
+ createdAt: Date;
92
+ }
93
+
94
+ export const usersByNameAge = sortedIndex(User, {
95
+ name: 'usersByNameAge',
96
+ key: { name: true },
97
+ sort: { age: 1 } // 1 for ascending, -1 for descending
98
+ });
99
+
100
+ export const recentUsers = sortedIndex(User, {
101
+ name: 'recentUsers',
102
+ key: {}, // No key filtering
103
+ sort: { createdAt: -1 } // Most recent first
104
+ });
105
+ ```
106
+
107
+ The `sort` field must be numeric or a `Date` type. The value `1` means ascending order, `-1` means descending.
108
+
109
+ ### Composite Keys
110
+ Indexes can use multiple fields or nested fields in their keys. This allows querying by combinations of values.
111
+
112
+ **Code: Composite Key Index**
113
+ ```typescript
114
+ import { keyedIndex } from '@travetto/model-indexed';
115
+ import { Model } from '@travetto/model';
116
+
117
+ @Model()
118
+ export class Order {
119
+ id: string;
120
+ customerId: string;
121
+ status: string;
122
+ productId: string;
123
+ }
124
+
125
+ // Find orders by customer and status
126
+ export const orders = keyedIndex(Order, {
127
+ name: 'ordersByCustomerStatus',
128
+ key: { customerId: true, status: true }
129
+ });
130
+
131
+ // Find orders by customer, status, and product
132
+ export const specificOrders = keyedIndex(Order, {
133
+ name: 'ordersByCustomerStatusProduct',
134
+ key: { customerId: true, status: true, productId: true }
135
+ });
136
+ ```
137
+
138
+ ## Using Indexes
139
+ Model services that implement [ModelIndexedSupport](https://github.com/travetto/travetto/tree/main/module/model-indexed/src/types/service.ts#L23) allow you to query using the indexes you've defined.
140
+
141
+ ### Service Interface
142
+
143
+ **Code: ModelIndexedSupport Interface**
144
+ ```typescript
145
+ export interface ModelIndexedSupport extends ModelBasicSupport {
146
+ /**
147
+ * Get entity by index as defined by fields of idx and the body fields
148
+ * @param cls The type to search by
149
+ * @param idx The index to search against
150
+ * @param body The payload of fields needed to search
151
+ */
152
+ getByIndex<
153
+ T extends ModelType,
154
+ K extends KeyedIndexSelection<T>,
155
+ S extends SortedIndexSelection<T>
156
+ >(cls: Class<T>, idx: SingleItemIndex<T, K, S>, body: FullKeyedIndexBody<T, K, S>): Promise<T>;
157
+
158
+ /**
159
+ * Delete entity by index as defined by fields of idx and the body fields
160
+ * @param cls The type to search by
161
+ * @param idx The index to search against
162
+ * @param body The payload of fields needed to search
163
+ */
164
+ deleteByIndex<
165
+ T extends ModelType,
166
+ K extends KeyedIndexSelection<T>,
167
+ S extends SortedIndexSelection<T>
168
+ >(cls: Class<T>, idx: SingleItemIndex<T, K, S>, body: FullKeyedIndexBody<T, K, S>): Promise<void>;
169
+
170
+ /**
171
+ * Upsert by index, allowing the index to act as a primary key
172
+ * @param cls The type to create for
173
+ * @param idx The index to use
174
+ * @param body The document to potentially store
175
+ */
176
+ upsertByIndex<
177
+ T extends ModelType,
178
+ K extends KeyedIndexSelection<T>,
179
+ S extends SortedIndexSelection<T>
180
+ >(cls: Class<T>, idx: SingleItemIndex<T, K, S>, body: OptionalId<T>): Promise<T>;
181
+
182
+ /**
183
+ * Update by index
184
+ * @param cls The type to update for
185
+ * @param idx The index to update by
186
+ * @param body The document to update
187
+ */
188
+ updateByIndex<
189
+ T extends ModelType,
190
+ K extends KeyedIndexSelection<T>,
191
+ S extends SortedIndexSelection<T>
192
+ >(cls: Class<T>, idx: SingleItemIndex<T, K, S>, body: T): Promise<T>;
193
+
194
+ /**
195
+ * Update partial by index
196
+ * @param cls The type to update for
197
+ * @param idx The index to update by
198
+ * @param body The partial document to update
199
+ */
200
+ updatePartialByIndex<
201
+ T extends ModelType,
202
+ K extends KeyedIndexSelection<T>,
203
+ S extends SortedIndexSelection<T>
204
+ >(cls: Class<T>, idx: SingleItemIndex<T, K, S>, body: FullKeyedIndexWithPartialBody<T, K, S>): Promise<T>;
205
+
206
+ /**
207
+ * List entity by ranged index as defined by fields of idx
208
+ * @param cls The type to search by
209
+ * @param idx The index to search against
210
+ * @param body The payload of fields needed to search
211
+ * @param options The configuration for listing
212
+ */
213
+ listByIndex<
214
+ T extends ModelType,
215
+ S extends SortedIndexSelection<T>,
216
+ K extends KeyedIndexSelection<T>
217
+ >(cls: Class<T>, idx: SortedIndex<T, K, S>, body: KeyedIndexBody<T, K>, options?: ListPageOptions): Promise<ListPageResult<T>>;
218
+ }
219
+ ```
220
+
221
+ The service provides these operations:
222
+ * `getByIndex` — Fetch a single item by index
223
+ * `deleteByIndex` — Delete a single item by index
224
+ * `upsertByIndex` — Insert or update by index
225
+ * `updateByIndex` — Update an existing item by index
226
+ * `updatePartialByIndex` — Partially update an item by index
227
+ * `listByIndex` — List items with pagination and sorting
228
+
229
+ ### Getting Items
230
+ Use `getByIndex` to fetch a single item by providing all required key fields.
231
+
232
+ **Code: Getting by Keyed Index**
233
+ ```typescript
234
+ export async function getExample(modelService: any) {
235
+ const user = await modelService.getByIndex(User, userByName, {
236
+ name: 'John Doe'
237
+ });
238
+ return user;
239
+ }
240
+ ```
241
+
242
+ For sorted indexes with key fields, you must provide all key values plus the sort value if using it to identify a specific item.
243
+
244
+ ### Deleting Items
245
+ Use `deleteByIndex` to remove an item by index.
246
+
247
+ **Code: Deleting by Index**
248
+ ```typescript
249
+ export async function deleteExample(modelService: any) {
250
+ await modelService.deleteByIndex(User, userByName, {
251
+ name: 'John Doe'
252
+ });
253
+ }
254
+ ```
255
+
256
+ ### Upserting Items
257
+ Use `upsertByIndex` to insert a new item or update an existing one. The index acts as a primary key.
258
+
259
+ **Code: Upserting by Index**
260
+ ```typescript
261
+ export async function upsertExample(modelService: any) {
262
+ const user = await modelService.upsertByIndex(User, userByName, {
263
+ id: 'user-1',
264
+ name: 'John Doe',
265
+ email: 'john@example.com'
266
+ });
267
+ return user;
268
+ }
269
+ ```
270
+
271
+ ### Updating Items
272
+ Use `updateByIndex` to update an existing item, or `updatePartialByIndex` for partial updates.
273
+
274
+ **Code: Updating by Index**
275
+ ```typescript
276
+ export async function updateExample(modelService: any) {
277
+ // Full update — all fields required
278
+ const user = await modelService.updateByIndex(User, userByName, {
279
+ id: 'user-1',
280
+ name: 'John Doe',
281
+ email: 'john.new@example.com',
282
+ age: 31
283
+ });
284
+ return user;
285
+ }
286
+
287
+ export async function updatePartialExample(modelService: any) {
288
+ // Partial update — only updated fields required
289
+ const user = await modelService.updatePartialByIndex(User, userByName, {
290
+ name: 'John Doe',
291
+ email: 'john.newer@example.com'
292
+ });
293
+ return user;
294
+ }
295
+ ```
296
+
297
+ ### Listing Items
298
+ Use `listByIndex` to fetch multiple items from a sorted index with pagination.
299
+
300
+ **Code: Listing by Sorted Index**
301
+ ```typescript
302
+ export async function listExample(modelService: any) {
303
+ const result = await modelService.listByIndex(User, recentUsers, {}, {
304
+ limit: 20,
305
+ offset: '0'
306
+ });
307
+
308
+ console.log(result.items); // Array of users
309
+ console.log(result.nextOffset); // Token for next page, if more results exist
310
+ return result;
311
+ }
312
+ ```
313
+
314
+ You can also provide key values to filter within a sorted index:
315
+
316
+ **Code: Listing with Key Filter**
317
+ ```typescript
318
+ export async function listWithFilterExample(modelService: any) {
319
+ // Get all users named 'John' sorted by age
320
+ const result = await modelService.listByIndex(User, usersByNameAge, {
321
+ name: 'John'
322
+ }, {
323
+ limit: 10
324
+ });
325
+ return result;
326
+ }
327
+ ```
328
+
329
+ ## Integration
330
+ Index registration happens automatically when models are decorated with [@Model](https://github.com/travetto/travetto/tree/main/module/model/src/registry/decorator.ts#L14). Model services like [Memory Model Support](https://github.com/travetto/travetto/tree/main/module/model-memory#readme "Memory backing for the travetto model module."), [MongoDB Model Support](https://github.com/travetto/travetto/tree/main/module/model-mongo#readme "Mongo backing for the travetto model module."), and [SQL Model Service](https://github.com/travetto/travetto/tree/main/module/model-sql#readme "SQL backing for the travetto model module, with real-time modeling support for SQL schemas.") implement the [ModelIndexedSupport](https://github.com/travetto/travetto/tree/main/module/model-indexed/src/types/service.ts#L23) interface to provide indexed access.
331
+
332
+ ### Reading Registry Information
333
+ You can access registered indexes via [ModelRegistryIndex](https://github.com/travetto/travetto/tree/main/module/model/src/registry/registry-index.ts#L12) at runtime:
334
+
335
+ **Code: Accessing Model Indexes**
336
+ ```typescript
337
+ export function registryAccessExample() {
338
+ const registry = ModelRegistryIndex.get(User);
339
+ const indexes = registry.indices; // Map of all indexes for the model
340
+
341
+ // Access a specific index
342
+ const userByName = indexes['userByName'];
343
+ return userByName;
344
+ }
345
+ ```
346
+
347
+ ## Best Practices
348
+
349
+ * **Plan indexes strategically** — Define indexes for your common query patterns
350
+ * **Use composite keys** — When filtering by multiple fields, include all of them in a single index
351
+ * **Leverage sorting** — Use sorted indexes for paginated lists and range queries
352
+ * **Enforce uniqueness** — Use [uniqueIndex](https://github.com/travetto/travetto/tree/main/module/model-indexed/src/indexes.ts#L47) for fields that must be globally unique
353
+ * **Handle errors gracefully** — Catch [IndexedFieldError](https://github.com/travetto/travetto/tree/main/module/model-indexed/src/types/indexes.ts#L88) when working with user input
package/__index__.ts ADDED
@@ -0,0 +1,5 @@
1
+ export * from './src/types/indexes.ts';
2
+ export * from './src/types/service.ts';
3
+ export * from './src/computed.ts';
4
+ export * from './src/indexes.ts';
5
+ export * from './src/util.ts';
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@travetto/model-indexed",
3
+ "version": "8.0.0-alpha.10",
4
+ "type": "module",
5
+ "description": "Basic indexing support for model sources that support it.",
6
+ "keywords": [
7
+ "datastore",
8
+ "indexing",
9
+ "typescript",
10
+ "travetto"
11
+ ],
12
+ "homepage": "https://travetto.io",
13
+ "license": "MIT",
14
+ "author": {
15
+ "email": "travetto.framework@gmail.com",
16
+ "name": "Travetto Framework"
17
+ },
18
+ "files": [
19
+ "__index__.ts",
20
+ "src",
21
+ "support"
22
+ ],
23
+ "main": "__index__.ts",
24
+ "repository": {
25
+ "url": "git+https://github.com/travetto/travetto.git",
26
+ "directory": "module/model-indexed"
27
+ },
28
+ "dependencies": {
29
+ "@travetto/model": "^8.0.0-alpha.10",
30
+ "@travetto/registry": "^8.0.0-alpha.10",
31
+ "@travetto/schema": "^8.0.0-alpha.10"
32
+ },
33
+ "peerDependencies": {
34
+ "@travetto/cli": "^8.0.0-alpha.15",
35
+ "@travetto/test": "^8.0.0-alpha.10"
36
+ },
37
+ "peerDependenciesMeta": {
38
+ "@travetto/cli": {
39
+ "optional": true
40
+ },
41
+ "@travetto/test": {
42
+ "optional": true
43
+ }
44
+ },
45
+ "travetto": {
46
+ "displayName": "Data Model Indexing Support"
47
+ },
48
+ "publishConfig": {
49
+ "access": "public"
50
+ }
51
+ }
@@ -0,0 +1,149 @@
1
+ import type { ModelType } from '@travetto/model';
2
+ import { castTo, type Any } from '@travetto/runtime';
3
+
4
+ import {
5
+ type KeyedIndexSelection, type SortedIndexSelection, type AllIndexes, type KeyedIndexBody, IndexedFieldError,
6
+ type FullKeyedIndexBody, type TemplateValue, type TemplatePart
7
+ } from './types/indexes.ts';
8
+
9
+ const DEFAULT_SEP = '\u8203';
10
+
11
+ type IndexPart<T extends TemplateValue = TemplateValue> = {
12
+ state: 'missing' | 'empty' | 'mismatch' | 'found';
13
+ value: unknown;
14
+ path: string[];
15
+ templateValue: T;
16
+ };
17
+
18
+ function buildIndexParts<T extends TemplateValue = TemplateValue>(
19
+ template: TemplatePart<T>[],
20
+ body: Record<string, unknown> | undefined,
21
+ checkValueType?: (value: unknown) => boolean,
22
+ ): IndexPart<T>[] {
23
+ const out: IndexPart<T>[] = [];
24
+ for (const { path, value: templateValue } of template) {
25
+ let value: unknown = body;
26
+ let bodyPart: Pick<IndexPart, 'value' | 'state'> | undefined;
27
+ for (const pathItem of path) {
28
+ if (typeof value === 'object' && value !== null) {
29
+ if (value && pathItem in value) {
30
+ value = castTo<Record<string, unknown>>(value)[pathItem];
31
+ } else {
32
+ bodyPart = { value: undefined!, state: 'missing' };
33
+ break;
34
+ }
35
+ } else {
36
+ bodyPart = { value: undefined!, state: 'mismatch' };
37
+ break;
38
+ }
39
+ }
40
+ if (bodyPart === undefined) {
41
+ if (value === null || value === undefined) {
42
+ bodyPart = { value: null, state: 'empty' };
43
+ } else if (checkValueType && !checkValueType(value)) {
44
+ bodyPart = { value, state: 'mismatch' };
45
+ } else {
46
+ bodyPart = { value, state: 'found' };
47
+ }
48
+ }
49
+ out.push({ ...bodyPart, path, templateValue });
50
+ }
51
+ return out;
52
+ }
53
+
54
+ function validate<T extends ModelType>(idx: AllIndexes<T>, parts: IndexPart[]): void {
55
+ for (const { state, path } of parts) {
56
+ if (state === 'missing') {
57
+ throw new IndexedFieldError(idx.class, idx, path.join('.'), 'Missing field');
58
+ } else if (state === 'mismatch') {
59
+ throw new IndexedFieldError(idx.class, idx, path.join('.'), 'Field type mismatch');
60
+ }
61
+ }
62
+ }
63
+
64
+ type Body<T extends ModelType> = KeyedIndexBody<T, Any> | FullKeyedIndexBody<T, Any, Any> | Partial<T>;
65
+
66
+ type IndexProcessConfig<T = {}> = T & { keyed?: boolean, sort?: boolean };
67
+
68
+ export class ModelIndexedComputedIndex<T extends ModelType> {
69
+ static get<T extends ModelType, K extends KeyedIndexSelection<T>, S extends SortedIndexSelection<T>>(
70
+ idx: AllIndexes<T, K, S>,
71
+ body: Body<T>,
72
+ ): ModelIndexedComputedIndex<T> {
73
+ return new ModelIndexedComputedIndex(idx, body);
74
+ }
75
+
76
+ keyedParts: IndexPart<true>[];
77
+ sortParts: IndexPart<-1 | 1>[];
78
+ idx: AllIndexes<T>;
79
+
80
+ constructor(
81
+ idx: AllIndexes<T>,
82
+ body: Body<T>,
83
+ ) {
84
+ this.idx = idx;
85
+ this.keyedParts = buildIndexParts(idx.keyTemplate, castTo(body));
86
+ this.sortParts = buildIndexParts(idx.sortTemplate, castTo(body), value => typeof value === 'number' || value instanceof Date);
87
+ }
88
+
89
+ get allParts(): IndexPart[] {
90
+ return [...this.keyedParts, ...this.sortParts];
91
+ }
92
+
93
+ validate(config: IndexProcessConfig = {}): this {
94
+ const { keyed = true, sort = false } = config;
95
+ if (keyed) {
96
+ validate(this.idx, this.keyedParts);
97
+ }
98
+ if (sort) {
99
+ validate(this.idx, this.sortParts);
100
+ }
101
+ return this;
102
+ }
103
+
104
+ getKey(config: IndexProcessConfig<{ sep?: string }> = {}): string {
105
+ const { keyed = true, sort = false, sep = DEFAULT_SEP } = config;
106
+ const parts = [keyed ? this.keyedParts : [], sort ? this.sortParts : []].flat();
107
+ return parts.map(({ value }) => value).map(value => `${value}`).join(sep);
108
+ }
109
+
110
+ getSort(): number {
111
+ const { value } = this.sortParts[0] ?? {};
112
+ const direction = (this.sortParts[0]?.templateValue ?? 1);
113
+ if (value instanceof Date) {
114
+ return value.getTime() * direction;
115
+ } else if (typeof value === 'number') {
116
+ return value * direction;
117
+ } else {
118
+ return 0;
119
+ }
120
+ }
121
+
122
+ project(config: IndexProcessConfig<{ emptyValue?: unknown }> = {}): Record<string, unknown> {
123
+ const { keyed = true, sort = false, emptyValue = null } = config;
124
+ const response: Record<string, unknown> = {};
125
+ if (keyed) {
126
+ for (const { path, value, state } of this.keyedParts) {
127
+ let sub: Record<string, unknown> = response;
128
+ const all = path.slice(0);
129
+ const last = all.pop()!;
130
+ for (const part of all) {
131
+ sub = castTo(sub[part] ??= {});
132
+ }
133
+ sub[last] = state === 'empty' ? emptyValue : value;
134
+ }
135
+ }
136
+ if (sort) {
137
+ for (const { path, value, state } of this.sortParts) {
138
+ let sub: Record<string, unknown> = response;
139
+ const all = path.slice(0);
140
+ const last = all.pop()!;
141
+ for (const part of all) {
142
+ sub = castTo(sub[part] ??= {});
143
+ }
144
+ sub[last] = state === 'empty' ? emptyValue : value;
145
+ }
146
+ }
147
+ return response;
148
+ }
149
+ }
package/src/indexes.ts ADDED
@@ -0,0 +1,83 @@
1
+ import { type ModelType, ModelRegistryIndex } from '@travetto/model';
2
+ import { type Class, type Any, castTo } from '@travetto/runtime';
3
+
4
+ import {
5
+ type AllIndexes, type KeyedIndexSelection, type KeyedIndex,
6
+ type SortedIndexSelection, type SortedIndex, type TemplatePart, type TemplateValue
7
+ } from './types/indexes.ts';
8
+
9
+ function buildTemplateParts<T extends TemplateValue = TemplateValue>(
10
+ part: 'key' | 'sort',
11
+ template: Record<string, unknown>,
12
+ prefix: string[] = [],
13
+ ): TemplatePart<T>[] {
14
+ const out: TemplatePart<T>[] = [];
15
+ for (const [key, value] of Object.entries(template)) {
16
+ const path = prefix.length ? [...prefix, key] : [key];
17
+ if (typeof value === 'object' && value !== null) {
18
+ out.push(...buildTemplateParts<T>(part, castTo(value), path));
19
+ } else {
20
+ out.push({ path, value: castTo<T>(value), part });
21
+ }
22
+ }
23
+ return out;
24
+ }
25
+
26
+ /**
27
+ * Defines a keyed index for a model
28
+ */
29
+ export function keyedIndex<
30
+ T extends ModelType,
31
+ K extends KeyedIndexSelection<T>
32
+ >(cls: Class<T>, config: { name: string, key: K }): KeyedIndex<T, K, {}> {
33
+ const { name, key } = config;
34
+ const keyTemplate = buildTemplateParts<true>('key', key);
35
+ const idx: KeyedIndex<T, K, {}> = {
36
+ type: 'indexed:keyed',
37
+ class: cls, name, unique: false,
38
+ key, keyTemplate, sort: {}, sortTemplate: []
39
+ };
40
+ ModelRegistryIndex.getForRegister(cls).register({ indices: { [idx.name]: idx } });
41
+ return idx;
42
+ }
43
+
44
+ /**
45
+ * Defines a unique index for a model
46
+ */
47
+ export function uniqueIndex<
48
+ T extends ModelType,
49
+ K extends KeyedIndexSelection<T>
50
+ >(cls: Class<T>, config: { name: string, key: K }): KeyedIndex<T, K, {}> {
51
+ const { name, key } = config;
52
+ const keyTemplate = buildTemplateParts<true>('key', key);
53
+ const idx: KeyedIndex<T, K, {}> = {
54
+ type: 'indexed:keyed',
55
+ class: cls, name, unique: true,
56
+ key, keyTemplate, sort: {}, sortTemplate: []
57
+ };
58
+ ModelRegistryIndex.getForRegister(cls).register({ indices: { [idx.name]: idx } });
59
+ return idx;
60
+ }
61
+
62
+ /**
63
+ * Defines a sorted index for a model
64
+ */
65
+ export function sortedIndex<
66
+ T extends ModelType,
67
+ K extends KeyedIndexSelection<T>,
68
+ S extends SortedIndexSelection<T>
69
+ >(cls: Class<T>, config: { name: string, key: K, sort: S }): SortedIndex<T, K, S> {
70
+ const { name, key, sort } = config;
71
+ const keyTemplate = buildTemplateParts<true>('key', key);
72
+ const sortTemplate = buildTemplateParts<1 | -1>('sort', sort);
73
+ const idx: SortedIndex<T, K, S> = {
74
+ type: 'indexed:sorted',
75
+ class: cls, name, key, sort,
76
+ keyTemplate, sortTemplate,
77
+ };
78
+ ModelRegistryIndex.getForRegister(cls).register({ indices: { [idx.name]: idx } });
79
+ return idx;
80
+ }
81
+
82
+ export const isModelIndexedIndex = <T extends ModelType>(idx: Any): idx is AllIndexes<T> =>
83
+ typeof idx === 'object' && idx !== null && 'type' in idx && typeof idx.type === 'string' && idx.type.startsWith('indexed:');
@@ -0,0 +1,94 @@
1
+ import type { ModelType, IndexConfig } from '@travetto/model';
2
+ import { type IntrinsicType, type Any, type DeepPartial, RuntimeError, type Class } from '@travetto/runtime';
3
+
4
+ type TypeProjection<T, V> = {
5
+ [P in keyof T]?:
6
+ (T[P] extends (IntrinsicType | undefined) ? (V | undefined) :
7
+ (T[P] extends Any[] ?
8
+ (TypeProjection<T[P][number], V> | null | undefined)[] :
9
+ TypeProjection<T[P], V>)
10
+ );
11
+ };
12
+
13
+ export type KeyedIndexSelection<T> = TypeProjection<T, true>;
14
+ export type SortedIndexSelection<T> = TypeProjection<T, 1 | -1>;
15
+
16
+ export type KeyedIndexBody<T, K> = {
17
+ [P in keyof K]: (P extends keyof T ?
18
+ (K[P] extends true | 1 | -1 ? T[P] :
19
+ (T[P] extends Any[] | null | undefined ? T[P] :
20
+ KeyedIndexBody<T[P], NonNullable<K[P]>>))
21
+ : never);
22
+ };
23
+
24
+ type Merge<A, B> = {
25
+ [K in keyof A | keyof B]: (K extends keyof A & keyof B ?
26
+ A[K] | B[K] : (K extends keyof B ?
27
+ B[K] : (K extends keyof A ?
28
+ A[K] : never
29
+ )
30
+ )
31
+ );
32
+ };
33
+
34
+ export type KeyedIndexWithPartialBody<T, K> = {
35
+ [P in keyof K]: (P extends keyof T ?
36
+ (K[P] extends true | 1 | -1 ? T[P] :
37
+ (T[P] extends Any[] | null | undefined ? T[P] :
38
+ // Recurse for nested objects
39
+ KeyedIndexWithPartialBody<T[P], NonNullable<K[P]>>)
40
+ )
41
+ : never);
42
+ } &
43
+ // 2. All other fields in T (not in K) become OPTIONAL
44
+ DeepPartial<Omit<T, keyof K>>;
45
+
46
+
47
+ export type FullKeyedIndexBody<T, K, S> = KeyedIndexBody<T, Merge<K, S>>;
48
+ export type FullKeyedIndexWithPartialBody<T, K, S> = KeyedIndexWithPartialBody<T, Merge<K, S>>;
49
+
50
+ export type TemplateValue = 1 | -1 | true;
51
+ export type TemplatePart<T extends TemplateValue = TemplateValue> = { path: string[], value: T, part: 'key' | 'sort' };
52
+
53
+ export interface KeyedIndex<
54
+ T extends ModelType,
55
+ K extends KeyedIndexSelection<T>,
56
+ S extends SortedIndexSelection<T>
57
+ > extends IndexConfig<'indexed:keyed'> {
58
+ key: K;
59
+ sort: S;
60
+ unique: boolean;
61
+ keyTemplate: TemplatePart<true>[];
62
+ sortTemplate: TemplatePart<1 | -1>[];
63
+ }
64
+
65
+ export interface SortedIndex<
66
+ T extends ModelType,
67
+ K extends KeyedIndexSelection<T>,
68
+ S extends SortedIndexSelection<T>
69
+ > extends IndexConfig<'indexed:sorted'> {
70
+ key: K;
71
+ sort: S;
72
+ keyTemplate: TemplatePart<true>[];
73
+ sortTemplate: TemplatePart<1 | -1>[];
74
+ }
75
+
76
+ export type SingleItemIndex<
77
+ T extends ModelType,
78
+ K extends KeyedIndexSelection<T> = Any,
79
+ S extends SortedIndexSelection<T> = Any
80
+ > = KeyedIndex<T, K, S> | SortedIndex<T, K, S>;
81
+
82
+ export type AllIndexes<
83
+ T extends ModelType,
84
+ K extends KeyedIndexSelection<T> = Any,
85
+ S extends SortedIndexSelection<T> = Any
86
+ > = KeyedIndex<T, K, S> | SortedIndex<T, K, S>;
87
+
88
+ export class IndexedFieldError<T extends ModelType> extends RuntimeError {
89
+ constructor(cls: Class<T>, idx: AllIndexes<T>, fieldPath: string, message: string) {
90
+ super(`${message}: ${idx.name} on ${cls.name} at path ${fieldPath}`, {
91
+ details: { cls: cls.name, index: idx.name, fieldPath }
92
+ });
93
+ }
94
+ }
@@ -0,0 +1,96 @@
1
+ import type { ModelType, ModelBasicSupport, OptionalId } from '@travetto/model';
2
+ import type { Class } from '@travetto/runtime';
3
+ import type {
4
+ KeyedIndexSelection, KeyedIndexBody, SortedIndexSelection, SortedIndex,
5
+ SingleItemIndex, FullKeyedIndexBody, FullKeyedIndexWithPartialBody
6
+ } from './indexes.ts';
7
+
8
+ export type ListPageOptions<O = string> = {
9
+ limit?: number;
10
+ offset?: O;
11
+ };
12
+
13
+ export type ListPageResult<T extends ModelType> = {
14
+ items: T[];
15
+ nextOffset?: string;
16
+ };
17
+
18
+ /**
19
+ * Support for simple indexed activity
20
+ *
21
+ * @concrete
22
+ */
23
+ export interface ModelIndexedSupport extends ModelBasicSupport {
24
+ /**
25
+ * Get entity by index as defined by fields of idx and the body fields
26
+ * @param cls The type to search by
27
+ * @param idx The index to search against
28
+ * @param body The payload of fields needed to search
29
+ */
30
+ getByIndex<
31
+ T extends ModelType,
32
+ K extends KeyedIndexSelection<T>,
33
+ S extends SortedIndexSelection<T>
34
+ >(cls: Class<T>, idx: SingleItemIndex<T, K, S>, body: FullKeyedIndexBody<T, K, S>): Promise<T>;
35
+
36
+ /**
37
+ * Delete entity by index as defined by fields of idx and the body fields
38
+ * @param cls The type to search by
39
+ * @param idx The index to search against
40
+ * @param body The payload of fields needed to search
41
+ */
42
+ deleteByIndex<
43
+ T extends ModelType,
44
+ K extends KeyedIndexSelection<T>,
45
+ S extends SortedIndexSelection<T>
46
+ >(cls: Class<T>, idx: SingleItemIndex<T, K, S>, body: FullKeyedIndexBody<T, K, S>): Promise<void>;
47
+
48
+ /**
49
+ * Upsert by index, allowing the index to act as a primary key
50
+ * @param cls The type to create for
51
+ * @param idx The index to use
52
+ * @param body The document to potentially store
53
+ */
54
+ upsertByIndex<
55
+ T extends ModelType,
56
+ K extends KeyedIndexSelection<T>,
57
+ S extends SortedIndexSelection<T>
58
+ >(cls: Class<T>, idx: SingleItemIndex<T, K, S>, body: OptionalId<T>): Promise<T>;
59
+
60
+ /**
61
+ * Update by index
62
+ * @param cls The type to update for
63
+ * @param idx The index to update by
64
+ * @param body The document to update
65
+ */
66
+ updateByIndex<
67
+ T extends ModelType,
68
+ K extends KeyedIndexSelection<T>,
69
+ S extends SortedIndexSelection<T>
70
+ >(cls: Class<T>, idx: SingleItemIndex<T, K, S>, body: T): Promise<T>;
71
+
72
+ /**
73
+ * Update partial by index
74
+ * @param cls The type to update for
75
+ * @param idx The index to update by
76
+ * @param body The partial document to update
77
+ */
78
+ updatePartialByIndex<
79
+ T extends ModelType,
80
+ K extends KeyedIndexSelection<T>,
81
+ S extends SortedIndexSelection<T>
82
+ >(cls: Class<T>, idx: SingleItemIndex<T, K, S>, body: FullKeyedIndexWithPartialBody<T, K, S>): Promise<T>;
83
+
84
+ /**
85
+ * List entity by ranged index as defined by fields of idx
86
+ * @param cls The type to search by
87
+ * @param idx The index to search against
88
+ * @param body The payload of fields needed to search
89
+ * @param options The configuration for listing
90
+ */
91
+ listByIndex<
92
+ T extends ModelType,
93
+ S extends SortedIndexSelection<T>,
94
+ K extends KeyedIndexSelection<T>
95
+ >(cls: Class<T>, idx: SortedIndex<T, K, S>, body: KeyedIndexBody<T, K>, options?: ListPageOptions): Promise<ListPageResult<T>>;
96
+ }
package/src/util.ts ADDED
@@ -0,0 +1,62 @@
1
+ import { castTo, type Class, hasFunction } from '@travetto/runtime';
2
+ import { type ModelType, type ModelCrudSupport, type OptionalId, NotFoundError } from '@travetto/model';
3
+
4
+ import type { ModelIndexedSupport } from './types/service.ts';
5
+ import type { KeyedIndexSelection, SingleItemIndex, SortedIndexSelection } from './types/indexes.ts';
6
+
7
+ /**
8
+ * Utils for working with indexed model services
9
+ */
10
+ export class ModelIndexedUtil {
11
+
12
+ /**
13
+ * Type guard for determining if service supports indexed operation
14
+ */
15
+ static isSupported = hasFunction<ModelIndexedSupport>('getByIndex');
16
+
17
+ /**
18
+ * Naive upsert by index
19
+ * @param service
20
+ * @param cls
21
+ * @param idx
22
+ * @param body
23
+ */
24
+ static async naiveUpsert<
25
+ T extends ModelType,
26
+ K extends KeyedIndexSelection<T>,
27
+ S extends SortedIndexSelection<T>
28
+ >(
29
+ service: ModelIndexedSupport & ModelCrudSupport,
30
+ cls: Class<T>, idx: SingleItemIndex<T, K, S>, body: OptionalId<T>
31
+ ): Promise<T> {
32
+ try {
33
+ return await this.naiveUpdate(service, cls, idx, body);
34
+ } catch (error) {
35
+ if (error instanceof NotFoundError) {
36
+ return await service.create(cls, body);
37
+ } else {
38
+ throw error;
39
+ }
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Naive update by index
45
+ * @param service
46
+ * @param cls
47
+ * @param idx
48
+ * @param body
49
+ */
50
+ static async naiveUpdate<
51
+ T extends ModelType,
52
+ K extends KeyedIndexSelection<T>,
53
+ S extends SortedIndexSelection<T>
54
+ >(
55
+ service: ModelIndexedSupport & ModelCrudSupport,
56
+ cls: Class<T>, idx: SingleItemIndex<T, K, S>, body: OptionalId<T>
57
+ ): Promise<T> {
58
+ const { id } = await service.getByIndex(cls, idx, castTo(body));
59
+ body.id = id;
60
+ return await service.update(cls, castTo(body));
61
+ }
62
+ }
@@ -0,0 +1,27 @@
1
+ /** @jsxImportSource @travetto/doc/support */
2
+ import { d, type DocJSXElementByFn, type DocJSXElement, DocFileUtil } from '@travetto/doc';
3
+ import { Runtime, toConcrete } from '@travetto/runtime';
4
+
5
+ import type { ModelIndexedSupport } from '../src/types/service.ts';
6
+
7
+ const toLink = (title: string, target: Function): DocJSXElementByFn<'CodeLink'> =>
8
+ d.codeLink(title, Runtime.getSourceFile(target), new RegExp(`\\binterface\\s+${target.name}`));
9
+
10
+ export const Links = {
11
+ Indexed: toLink('Indexed', toConcrete<ModelIndexedSupport>()),
12
+ };
13
+
14
+ export const ModelIndexedTypes = (fn: Function): DocJSXElement[] => {
15
+ const { content } = DocFileUtil.readSource(fn);
16
+ const found: DocJSXElementByFn<'CodeLink'>[] = [];
17
+ const seen = new Set<string>();
18
+ for (const [, key] of content.matchAll(/Model(Indexed)Support/g)) {
19
+ if (!seen.has(key) && key in Links) {
20
+ seen.add(key);
21
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
22
+ const link = Links[key as keyof typeof Links];
23
+ found.push(link);
24
+ }
25
+ }
26
+ return found.map(type => <li>{type}</li>);
27
+ };
@@ -0,0 +1,323 @@
1
+ import assert from 'node:assert';
2
+
3
+ import { Suite, Test } from '@travetto/test';
4
+ import { Schema } from '@travetto/schema';
5
+ import { castTo, TimeUtil } from '@travetto/runtime';
6
+ import { Model, NotFoundError } from '@travetto/model';
7
+ import { BaseModelSuite } from '@travetto/model/support/test/base.ts';
8
+
9
+ import type { ModelIndexedSupport } from '../../src/types/service.ts';
10
+ import { keyedIndex, sortedIndex } from '../../src/indexes.ts';
11
+ import { IndexedFieldError } from '../../__index__.ts';
12
+
13
+ @Model('index_user')
14
+ class User {
15
+ id: string;
16
+ name: string;
17
+ }
18
+
19
+ const userNameIndex = keyedIndex(User, {
20
+ name: 'userName',
21
+ key: { name: true }
22
+ });
23
+
24
+ @Model('index_user_2')
25
+ class User2 {
26
+ id: string;
27
+ name: string;
28
+ }
29
+
30
+ @Model()
31
+ class User3 {
32
+ id: string;
33
+ name: string;
34
+ age: number;
35
+ color?: string;
36
+ }
37
+
38
+ const userAgeIndex = sortedIndex(User3, {
39
+ name: 'userAge',
40
+ key: { name: true },
41
+ sort: { age: 1 }
42
+ });
43
+ const userAgeReversedIndex = sortedIndex(User3, {
44
+ name: 'userAgeReverse',
45
+ key: { name: true },
46
+ sort: { age: -1 }
47
+ });
48
+ const userAgeNoKeyIndex = sortedIndex(User3, {
49
+ name: 'userAgeNoKey',
50
+ key: {},
51
+ sort: { age: 1 }
52
+ });
53
+
54
+ @Schema()
55
+ class Child {
56
+ name: string;
57
+ age: number;
58
+ }
59
+
60
+ @Model()
61
+ class User4 {
62
+ id: string;
63
+ createdDate?: Date = new Date();
64
+ color: string;
65
+ child: Child;
66
+ }
67
+
68
+ const childAgeIndex = sortedIndex(User4, {
69
+ name: 'childAge',
70
+ key: { child: { name: true } },
71
+ sort: { child: { age: 1 } }
72
+ });
73
+ const nameCreatedIndex = sortedIndex(User4, {
74
+ name: 'nameCreated',
75
+ key: { child: { name: true } },
76
+ sort: { createdDate: 1 }
77
+ });
78
+
79
+ @Suite()
80
+ export abstract class ModelIndexedSuite extends BaseModelSuite<ModelIndexedSupport> {
81
+
82
+ indexLimitSkew = 0;
83
+ supportsDeepIndexes = true;
84
+
85
+ @Test()
86
+ async writeAndRead() {
87
+ const service = await this.service;
88
+
89
+ await service.create(User, User.from({ name: 'bob1' }));
90
+ await service.create(User, User.from({ name: 'bob2' }));
91
+
92
+ const found1 = await service.getByIndex(User, userNameIndex, {
93
+ name: 'bob1'
94
+ });
95
+
96
+ assert(found1.name === 'bob1');
97
+
98
+ const found2 = await service.getByIndex(User, userNameIndex, {
99
+ name: 'bob2'
100
+ });
101
+
102
+ assert(found2.name === 'bob2');
103
+ }
104
+
105
+ @Test()
106
+ async readMissingValue() {
107
+ const service = await this.service;
108
+ await assert.rejects(() => service.getByIndex(User, userNameIndex, { name: 'jim' }), NotFoundError);
109
+ }
110
+
111
+ @Test()
112
+ async readDifferentType() {
113
+ const service = await this.service;
114
+ await assert.rejects(() => service.getByIndex(User2, userNameIndex, { name: 'jim' }), NotFoundError);
115
+ }
116
+
117
+ @Test()
118
+ async queryMultiple() {
119
+ const service = await this.service;
120
+
121
+ await service.create(User3, User3.from({ name: 'bob', age: 20 }));
122
+ await service.create(User3, User3.from({ name: 'bob', age: 30, color: 'green' }));
123
+
124
+ const found = await service.getByIndex(User3, userAgeIndex, { name: 'bob', age: 30 });
125
+
126
+ assert(found.color === 'green');
127
+
128
+ const found2 = await service.getByIndex(User3, userAgeIndex, { name: 'bob', age: 20 });
129
+
130
+ assert(!found2.color);
131
+
132
+ // @ts-expect-error
133
+ await assert.rejects(() => service.getByIndex(User3, userAgeIndex, { name: 'bob' }), IndexedFieldError);
134
+ }
135
+
136
+ @Test()
137
+ async queryList() {
138
+ const service = await this.service;
139
+
140
+ await service.create(User3, User3.from({ name: 'bob', age: 40, color: 'blue' }));
141
+ await service.create(User3, User3.from({ name: 'bob', age: 30, color: 'red' }));
142
+ await service.create(User3, User3.from({ name: 'bob', age: 50, color: 'green' }));
143
+
144
+ const { items: arr } = await service.listByIndex(User3, userAgeIndex, { name: 'bob' });
145
+
146
+ console.error(arr);
147
+
148
+ assert(arr[0].color === 'red');
149
+ assert(arr[0].name === 'bob');
150
+ assert(arr[1].color === 'blue');
151
+ assert(arr[1].name === 'bob');
152
+ assert(arr[2].color === 'green');
153
+ assert(arr[2].name === 'bob');
154
+
155
+ // @ts-expect-error
156
+ await assert.rejects(() => service.listByIndex(User3, userAgeIndex, {}), IndexedFieldError);
157
+ }
158
+
159
+ @Test()
160
+ async queryListNoSelectedKeys() {
161
+ const service = await this.service;
162
+
163
+ await service.create(User3, User3.from({ name: 'charlie', age: 40, color: 'blue' }));
164
+ await service.create(User3, User3.from({ name: 'alice', age: 30, color: 'red' }));
165
+ await service.create(User3, User3.from({ name: 'bob', age: 50, color: 'green' }));
166
+
167
+ const { items: arr } = await service.listByIndex(User3, userAgeNoKeyIndex, {});
168
+
169
+ assert(arr[0].name === 'alice' && arr[0].age === 30);
170
+ assert(arr[1].name === 'charlie' && arr[1].age === 40);
171
+ assert(arr[2].name === 'bob' && arr[2].age === 50);
172
+
173
+ const found = await service.getByIndex(User3, userAgeNoKeyIndex, { age: 40 });
174
+ assert(found.name === 'charlie');
175
+
176
+ // @ts-expect-error
177
+ await assert.rejects(() => service.getByIndex(User3, userAgeNoKeyIndex, {}), IndexedFieldError);
178
+ }
179
+
180
+ @Test({ skip: (self) => !castTo<ModelIndexedSuite>(self).supportsDeepIndexes })
181
+ async queryDeepList() {
182
+ const service = await this.service;
183
+
184
+ await service.create(User4, User4.from({ child: { name: 'bob', age: 40 }, color: 'blue' }));
185
+ await service.create(User4, User4.from({ child: { name: 'bob', age: 30 }, color: 'red' }));
186
+ await service.create(User4, User4.from({ child: { name: 'bob', age: 50 }, color: 'green' }));
187
+
188
+ const { items: arr } = await service.listByIndex(User4, childAgeIndex, { child: { name: 'bob' } });
189
+ assert(arr[0].color === 'red' && arr[0].child.name === 'bob' && arr[0].child.age === 30);
190
+ assert(arr[1].color === 'blue' && arr[1].child.name === 'bob' && arr[1].child.age === 40);
191
+ assert(arr[2].color === 'green' && arr[2].child.name === 'bob' && arr[2].child.age === 50);
192
+
193
+ // @ts-expect-error
194
+ await assert.rejects(() => service.listByIndex(User4, childAgeIndex, {}), IndexedFieldError);
195
+ }
196
+
197
+ @Test({ skip: (self) => !castTo<ModelIndexedSuite>(self).supportsDeepIndexes })
198
+ async queryComplexDateList() {
199
+ const service = await this.service;
200
+
201
+ await service.create(User4, User4.from({ child: { name: 'bob', age: 40 }, createdDate: TimeUtil.fromNow('3d'), color: 'blue' }));
202
+ await service.create(User4, User4.from({ child: { name: 'bob', age: 30 }, createdDate: TimeUtil.fromNow('2d'), color: 'red' }));
203
+ await service.create(User4, User4.from({ child: { name: 'bob', age: 50 }, createdDate: TimeUtil.fromNow('-1d'), color: 'green' }));
204
+
205
+ const { items: arr } = await service.listByIndex(User4, nameCreatedIndex, { child: { name: 'bob' } });
206
+
207
+ assert(arr[0].color === 'green' && arr[0].child.name === 'bob' && arr[0].child.age === 50);
208
+ assert(arr[1].color === 'red' && arr[1].child.name === 'bob' && arr[1].child.age === 30);
209
+ assert(arr[2].color === 'blue' && arr[2].child.name === 'bob' && arr[2].child.age === 40);
210
+
211
+ // @ts-expect-error
212
+ await assert.rejects(() => service.listByIndex(User4, nameCreatedIndex, {}), IndexedFieldError);
213
+ }
214
+
215
+ @Test()
216
+ async upsertByIndex() {
217
+ const service = await this.service;
218
+
219
+ const user1 = await service.upsertByIndex(User3, userAgeIndex, { name: 'bob', age: 40, color: 'blue' });
220
+ const user2 = await service.upsertByIndex(User3, userAgeIndex, { name: 'bob', age: 40, color: 'green' });
221
+ const user3 = await service.upsertByIndex(User3, userAgeIndex, { name: 'bob', age: 40, color: 'red' });
222
+
223
+ const { items: arr } = await service.listByIndex(User3, userAgeIndex, { name: 'bob' });
224
+ assert(arr.length === 1);
225
+
226
+ assert(user1.id === user2.id);
227
+ assert(user2.id === user3.id);
228
+ assert(user1.color === 'blue');
229
+ assert(user3.color === 'red');
230
+
231
+ const user4 = await service.upsertByIndex(User3, userAgeIndex, { name: 'bob', age: 30, color: 'red' });
232
+ const { items: arr2 } = await service.listByIndex(User3, userAgeIndex, { name: 'bob' });
233
+ assert(arr2.length === 2);
234
+
235
+ await service.deleteByIndex(User3, userAgeIndex, user1);
236
+
237
+ const { items: arr3 } = await service.listByIndex(User3, userAgeIndex, { name: 'bob' });
238
+ assert(arr3.length === 1);
239
+ assert(arr3[0].id === user4.id);
240
+ }
241
+
242
+ @Test()
243
+ async updateByIndex() {
244
+ const service = await this.service;
245
+
246
+ const created = await service.create(User3, User3.from({ name: 'alice', age: 25, color: 'blue' }));
247
+
248
+ const updated = await service.updateByIndex(User3, userAgeIndex, { ...created, color: 'red' });
249
+
250
+ assert(updated.id === created.id);
251
+ assert(updated.name === 'alice');
252
+ assert(updated.age === 25);
253
+ assert(updated.color === 'red');
254
+
255
+ const found = await service.getByIndex(User3, userAgeIndex, { name: 'alice', age: 25 });
256
+ assert(found.color === 'red');
257
+ }
258
+
259
+ @Test()
260
+ async updatePartialByIndex() {
261
+ const service = await this.service;
262
+
263
+ const created = await service.create(User3, User3.from({ name: 'carol', age: 35, color: 'green' }));
264
+
265
+ const updated = await service.updatePartialByIndex(User3, userAgeIndex, { name: 'carol', age: 35, color: 'yellow' });
266
+
267
+ assert(updated.id === created.id);
268
+ assert(updated.name === 'carol');
269
+ assert(updated.age === 35);
270
+ assert(updated.color === 'yellow');
271
+
272
+ const found = await service.getByIndex(User3, userAgeIndex, { name: 'carol', age: 35 });
273
+ assert(found.color === 'yellow');
274
+ }
275
+
276
+ @Test()
277
+ async paginateByIndex() {
278
+ const service = await this.service;
279
+
280
+ const allColors = 'abcdefghijklmnopqrstuvwxyzABCDEFGHJIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_=+,<.>/?;:\'"[{]}|`~'.repeat(2).split('');
281
+
282
+ for (const [i, color] of allColors.entries()) {
283
+ await service.create(User3, User3.from({ name: 'page', age: (i + 1) * 10, color }));
284
+ }
285
+
286
+ const limit = 7;
287
+ const items: string[] = [];
288
+ let offset: string | undefined;
289
+
290
+ do {
291
+ const page = await service.listByIndex(User3, userAgeIndex, { name: 'page' }, { limit, offset });
292
+ items.push(...page.items.map(u => u.color!));
293
+ offset = page.nextOffset;
294
+ } while (offset);
295
+
296
+ assert(items.length === allColors.length);
297
+ assert.deepEqual(items, allColors);
298
+ }
299
+
300
+ @Test()
301
+ async paginateByIndexReverse() {
302
+ const service = await this.service;
303
+
304
+ const allColors = 'abcdefghijklmnopqrstuvwxyzABCDEFGHJIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_=+,<.>/?;:\'"[{]}|`~'.repeat(2).split('');
305
+
306
+ for (const [i, color] of allColors.entries()) {
307
+ await service.create(User3, User3.from({ name: 'page', age: (i + 1) * 10, color }));
308
+ }
309
+
310
+ const limit = 7;
311
+ const items: string[] = [];
312
+ let offset: string | undefined;
313
+
314
+ do {
315
+ const page = await service.listByIndex(User3, userAgeReversedIndex, { name: 'page' }, { limit, offset });
316
+ items.push(...page.items.map(u => u.color!));
317
+ offset = page.nextOffset;
318
+ } while (offset);
319
+
320
+ assert(items.length === allColors.length);
321
+ assert.deepEqual(items, allColors.toReversed());
322
+ }
323
+ }
@@ -0,0 +1,116 @@
1
+ import assert from 'node:assert';
2
+
3
+ import { Suite, Test } from '@travetto/test';
4
+ import { castTo } from '@travetto/runtime';
5
+ import { Discriminated } from '@travetto/schema';
6
+ import { Model, NotFoundError, SubTypeNotSupportedError } from '@travetto/model';
7
+
8
+ import { BaseModelSuite } from '@travetto/model/support/test/base.ts';
9
+
10
+ import type { ModelIndexedSupport } from '../../src/types/service.ts';
11
+ import { ModelIndexedUtil } from '../../src/util.ts';
12
+ import { keyedIndex } from '../../src/indexes.ts';
13
+
14
+ @Model()
15
+ @Discriminated('type')
16
+ export class IndexedWorker {
17
+ id: string;
18
+ type: string;
19
+ name: string;
20
+ age?: number;
21
+ }
22
+
23
+ const workerNameIndex = keyedIndex(IndexedWorker, {
24
+ name: 'worker-name',
25
+ key: { name: true, age: true }
26
+ });
27
+
28
+ @Model()
29
+ export class IndexedDoctor extends IndexedWorker {
30
+ specialty: string;
31
+ }
32
+
33
+ @Model()
34
+ export class IndexedFirefighter extends IndexedWorker {
35
+ firehouse: number;
36
+ }
37
+
38
+ @Model()
39
+ export class IndexedEngineer extends IndexedWorker {
40
+ major: string;
41
+ }
42
+
43
+ @Suite()
44
+ export abstract class ModelIndexedPolymorphismSuite extends BaseModelSuite<ModelIndexedSupport> {
45
+
46
+ @Test('Polymorphic index', { skip: BaseModelSuite.ifNot(ModelIndexedUtil.isSupported) })
47
+ async polymorphicIndexGet() {
48
+ const service: ModelIndexedSupport = castTo(await this.service);
49
+ const now = 30;
50
+ const [doc, fire, eng] = [
51
+ IndexedDoctor.from({ name: 'bob', specialty: 'feet', age: now }),
52
+ IndexedFirefighter.from({ name: 'rob', firehouse: 20, age: now }),
53
+ IndexedEngineer.from({ name: 'cob', major: 'oranges', age: now })
54
+ ];
55
+
56
+ await this.saveAll(IndexedWorker, [doc, fire, eng]);
57
+
58
+ const result = await service.getByIndex(IndexedWorker, workerNameIndex, {
59
+ age: now,
60
+ name: 'rob'
61
+ });
62
+
63
+ assert(result instanceof IndexedFirefighter);
64
+
65
+ try {
66
+ const res2 = await service.getByIndex(IndexedFirefighter, workerNameIndex, {
67
+ age: now,
68
+ name: 'rob'
69
+ });
70
+ assert(res2 instanceof IndexedFirefighter); // If service allows for get by subtype
71
+ } catch (err) {
72
+ assert(err instanceof SubTypeNotSupportedError || err instanceof NotFoundError); // If it does not
73
+ }
74
+ }
75
+
76
+ @Test('Polymorphic index', { skip: BaseModelSuite.ifNot(ModelIndexedUtil.isSupported) })
77
+ async polymorphicIndexDelete() {
78
+ const service: ModelIndexedSupport = castTo(await this.service);
79
+ const now = 30;
80
+ const [doc, fire, eng] = [
81
+ IndexedDoctor.from({ name: 'bob', specialty: 'feet', age: now }),
82
+ IndexedFirefighter.from({ name: 'rob', firehouse: 20, age: now }),
83
+ IndexedEngineer.from({ name: 'cob', major: 'oranges', age: now })
84
+ ];
85
+
86
+ await this.saveAll(IndexedWorker, [doc, fire, eng]);
87
+
88
+ assert(await this.getSize(IndexedWorker) === 3);
89
+
90
+ await service.deleteByIndex(IndexedWorker, workerNameIndex, {
91
+ age: now,
92
+ name: 'bob'
93
+ });
94
+
95
+ assert(await this.getSize(IndexedWorker) === 2);
96
+ assert(await this.getSize(IndexedDoctor) === 0);
97
+
98
+ try {
99
+ await service.deleteByIndex(IndexedFirefighter, workerNameIndex, {
100
+ age: now,
101
+ name: 'rob'
102
+ });
103
+ } catch (err) {
104
+ assert(err instanceof SubTypeNotSupportedError || err instanceof NotFoundError);
105
+ }
106
+
107
+ try {
108
+ await service.deleteByIndex(IndexedEngineer, workerNameIndex, {
109
+ age: now,
110
+ name: 'bob'
111
+ });
112
+ } catch (err) {
113
+ assert(err instanceof SubTypeNotSupportedError || err instanceof NotFoundError);
114
+ }
115
+ }
116
+ }