@travetto/model 5.0.0-rc.9 → 5.0.1

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.
@@ -0,0 +1,136 @@
1
+ import assert from 'node:assert';
2
+
3
+ import { Suite, Test, TestFixtures } from '@travetto/test';
4
+ import { BaseModelSuite } from '@travetto/model/support/test/base';
5
+ import { BinaryUtil, Util } from '@travetto/runtime';
6
+
7
+ import { ModelBlobSupport } from '../../src/service/blob';
8
+ import { ModelBlobUtil } from '../../src/internal/service/blob';
9
+
10
+ const meta = BinaryUtil.getBlobMeta;
11
+
12
+ @Suite()
13
+ export abstract class ModelBlobSuite extends BaseModelSuite<ModelBlobSupport> {
14
+
15
+ fixture = new TestFixtures(['@travetto/model']);
16
+
17
+ @Test()
18
+ async writeBasic(): Promise<void> {
19
+ const service = await this.service;
20
+ const buffer = await this.fixture.read('/asset.yml', true);
21
+
22
+ const id = Util.uuid();
23
+
24
+ await service.upsertBlob(id, buffer);
25
+ const m = await service.describeBlob(id);
26
+ const retrieved = await service.describeBlob(id);
27
+ assert.deepStrictEqual(m, retrieved);
28
+ }
29
+
30
+ @Test()
31
+ async upsert(): Promise<void> {
32
+ const service = await this.service;
33
+ const buffer = await this.fixture.read('/asset.yml', true);
34
+
35
+ const id = Util.uuid();
36
+
37
+ await service.upsertBlob(id, buffer, { hash: '10' });
38
+ assert((await service.describeBlob(id)).hash === '10');
39
+
40
+ await service.upsertBlob(id, buffer, { hash: '20' });
41
+ assert((await service.describeBlob(id)).hash === '20');
42
+
43
+ await service.upsertBlob(id, buffer, { hash: '30' }, false);
44
+ assert((await service.describeBlob(id)).hash === '20');
45
+ }
46
+
47
+ @Test()
48
+ async writeStream(): Promise<void> {
49
+ const service = await this.service;
50
+ const buffer = await this.fixture.read('/asset.yml', true);
51
+
52
+ const id = Util.uuid();
53
+ await service.upsertBlob(id, buffer);
54
+ const { hash } = await service.describeBlob(id);
55
+
56
+ const retrieved = await service.getBlob(id);
57
+ const { hash: received } = meta(retrieved)!;
58
+ assert(hash === received);
59
+ }
60
+
61
+ @Test()
62
+ async writeAndDelete(): Promise<void> {
63
+ const service = await this.service;
64
+ const buffer = await this.fixture.read('/asset.yml', true);
65
+
66
+ const id = Util.uuid();
67
+ await service.upsertBlob(id, buffer);
68
+
69
+ await service.deleteBlob(id);
70
+
71
+ await assert.rejects(async () => {
72
+ await service.getBlob(id);
73
+ });
74
+ }
75
+
76
+ @Test()
77
+ async partialStream(): Promise<void> {
78
+ const service = await this.service;
79
+ const buffer = await this.fixture.read('/text.txt', true);
80
+
81
+ const id = Util.uuid();
82
+ await service.upsertBlob(id, buffer);
83
+
84
+ const retrieved = await service.getBlob(id);
85
+ const content = await retrieved.text();
86
+ assert(content.startsWith('abc'));
87
+ assert(content.endsWith('xyz'));
88
+
89
+ const partial = await service.getBlob(id, { start: 10, end: 20 });
90
+ assert(partial.size === 11);
91
+ const partialMeta = meta(partial)!;
92
+ const subContent = await partial.text();
93
+ const range = await ModelBlobUtil.enforceRange({ start: 10, end: 20 }, partialMeta.size!);
94
+ assert(subContent.length === (range.end - range.start) + 1);
95
+
96
+ const og = await this.fixture.read('/text.txt');
97
+
98
+ assert(subContent === og.substring(10, 21));
99
+
100
+ const partialUnbounded = await service.getBlob(id, { start: 10 });
101
+ const partialUnboundedMeta = meta(partial)!;
102
+ const subContent2 = await partialUnbounded.text();
103
+ const range2 = await ModelBlobUtil.enforceRange({ start: 10 }, partialUnboundedMeta.size!);
104
+ assert(subContent2.length === (range2.end - range2.start) + 1);
105
+ assert(subContent2.startsWith('klm'));
106
+ assert(subContent2.endsWith('xyz'));
107
+
108
+ const partialSingle = await service.getBlob(id, { start: 10, end: 10 });
109
+ const subContent3 = await partialSingle.text();
110
+ assert(subContent3.length === 1);
111
+ assert(subContent3 === 'k');
112
+
113
+ const partialOverBounded = await service.getBlob(id, { start: 20, end: 40 });
114
+ const subContent4 = await partialOverBounded.text();
115
+ assert(subContent4.length === 6);
116
+ assert(subContent4.endsWith('xyz'));
117
+
118
+ await assert.rejects(() => service.getBlob(id, { start: -10, end: 10 }));
119
+ await assert.rejects(() => service.getBlob(id, { start: 30, end: 37 }));
120
+ }
121
+
122
+
123
+ @Test()
124
+ async writeAndGet() {
125
+ const service = await this.service;
126
+ const buffer = await this.fixture.read('/asset.yml', true);
127
+ await service.upsertBlob('orange', buffer, { contentType: 'text/yaml', filename: 'asset.yml' });
128
+ const saved = await service.getBlob('orange');
129
+ const savedMeta = meta(saved)!;
130
+
131
+ assert('text/yaml' === savedMeta.contentType);
132
+ assert(buffer.length === savedMeta.size);
133
+ assert('asset.yml' === savedMeta.filename);
134
+ assert(undefined === savedMeta.hash);
135
+ }
136
+ }
@@ -125,22 +125,22 @@ export abstract class ModelCrudSuite extends BaseModelSuite<ModelCrudSupport> {
125
125
  assert(o.id);
126
126
  assert(o.name === 'bob');
127
127
 
128
- const o2 = await service.updatePartial(Person, Person.from({
128
+ const o2 = await service.updatePartial(Person, {
129
129
  id: o.id,
130
130
  name: 'oscar'
131
- }));
131
+ });
132
132
 
133
133
  assert(o2.name === 'oscar');
134
134
  assert(o2.age === 20);
135
135
  assert(o2.address.street2 === 'roader');
136
136
 
137
- await service.updatePartial(Person, Person.from({
137
+ await service.updatePartial(Person, {
138
138
  id: o2.id,
139
139
  gender: 'f',
140
140
  address: {
141
141
  street1: 'changed\n',
142
142
  }
143
- }));
143
+ });
144
144
 
145
145
  const o3 = await service.get(Person, o.id);
146
146
 
@@ -175,11 +175,11 @@ export abstract class ModelCrudSuite extends BaseModelSuite<ModelCrudSupport> {
175
175
  ]
176
176
  }));
177
177
 
178
- const o2 = await service.updatePartial(SimpleList, SimpleList.from({
178
+ const o2 = await service.updatePartial(SimpleList, {
179
179
  id: o.id,
180
180
  names: ['a', 'd'],
181
181
  simples: [{ name: 'd' }]
182
- }));
182
+ });
183
183
 
184
184
  assert.deepStrictEqual(o2.names, ['a', 'd']);
185
185
  assert.deepStrictEqual(o2.simples, [SimpleItem.from({ name: 'd' })]);
@@ -195,12 +195,12 @@ export abstract class ModelCrudSuite extends BaseModelSuite<ModelCrudSupport> {
195
195
  assert(o.address === undefined);
196
196
  assert(o.name === 'bob-suffix');
197
197
 
198
- await service.updatePartial(User2, User2.from({
198
+ await service.updatePartial(User2, {
199
199
  id: o.id,
200
200
  address: {
201
201
  street1: 'blue'
202
202
  }
203
- }));
203
+ });
204
204
 
205
205
  const o3 = await service.get(User2, o.id);
206
206
 
@@ -133,6 +133,9 @@ export abstract class ModelPolymorphismSuite extends BaseModelSuite<ModelCrudSup
133
133
  name: 'bob2'
134
134
  }));
135
135
 
136
+ const all2 = await collect(service.list(Worker));
137
+ assert(all2.length === 4);
138
+
136
139
  const engineers2 = await collect(service.list(Engineer));
137
140
  assert(engineers2.length === 2);
138
141
  }
@@ -3,8 +3,8 @@ import { DependencyRegistry } from '@travetto/di';
3
3
  import { RootRegistry } from '@travetto/registry';
4
4
  import { SuiteRegistry, TestFixtures } from '@travetto/test';
5
5
 
6
- import { isStorageSupported, isStreamSupported } from '../../src/internal/service/common';
7
- import { StreamModel } from '../../src/internal/service/stream';
6
+ import { isBlobSupported, isStorageSupported } from '../../src/internal/service/common';
7
+ import { MODEL_BLOB } from '../../src/internal/service/blob';
8
8
  import { ModelRegistry } from '../../src/registry/model';
9
9
 
10
10
  const Loaded = Symbol();
@@ -61,11 +61,11 @@ export function ModelSuite<T extends { configClass: Class<{ autoCreate?: boolean
61
61
  }
62
62
  }
63
63
  }
64
- if (isStreamSupported(service)) {
64
+ if (isBlobSupported(service)) {
65
65
  if (service.truncateModel) {
66
- await service.truncateModel(StreamModel);
66
+ await service.truncateModel(MODEL_BLOB);
67
67
  } else if (service.deleteModel) {
68
- await service.deleteModel(StreamModel);
68
+ await service.deleteModel(MODEL_BLOB);
69
69
  }
70
70
  }
71
71
  } else {
@@ -1,22 +0,0 @@
1
- import { AppError, Class } from '@travetto/runtime';
2
-
3
- import { ModelType } from '../../types/model';
4
- import { StreamRange } from '../../service/stream';
5
-
6
- class Cls { id: string; }
7
- export const StreamModel: Class<ModelType> = Cls;
8
- export const STREAMS = '_streams';
9
-
10
-
11
- /**
12
- * Enforce byte range for stream stream/file of a certain size
13
- */
14
- export function enforceRange({ start, end }: StreamRange, size: number): Required<StreamRange> {
15
- end = Math.min(end ?? size - 1, size - 1);
16
-
17
- if (Number.isNaN(start) || Number.isNaN(end) || !Number.isFinite(start) || start >= size || start < 0 || start > end) {
18
- throw new AppError('Invalid position, out of range', 'data');
19
- }
20
-
21
- return { start, end };
22
- }
@@ -1,231 +0,0 @@
1
- import fs from 'node:fs/promises';
2
- import { createReadStream, createWriteStream } from 'node:fs';
3
- import os from 'node:os';
4
- import { Readable } from 'node:stream';
5
- import { pipeline } from 'node:stream/promises';
6
- import path from 'node:path';
7
-
8
- import { Class, TimeSpan, Runtime, asFull } from '@travetto/runtime';
9
- import { Injectable } from '@travetto/di';
10
- import { Config } from '@travetto/config';
11
- import { Required } from '@travetto/schema';
12
-
13
- import { ModelCrudSupport } from '../service/crud';
14
- import { ModelStreamSupport, StreamMeta, StreamRange } from '../service/stream';
15
- import { ModelType, OptionalId } from '../types/model';
16
- import { ModelExpirySupport } from '../service/expiry';
17
- import { ModelRegistry } from '../registry/model';
18
- import { ModelStorageSupport } from '../service/storage';
19
- import { ModelCrudUtil } from '../internal/service/crud';
20
- import { ModelExpiryUtil } from '../internal/service/expiry';
21
- import { NotFoundError } from '../error/not-found';
22
- import { ExistsError } from '../error/exists';
23
- import { enforceRange, StreamModel, STREAMS } from '../internal/service/stream';
24
-
25
- type Suffix = '.bin' | '.meta' | '.json' | '.expires';
26
-
27
- const BIN = '.bin';
28
- const META = '.meta';
29
-
30
- @Config('model.file')
31
- export class FileModelConfig {
32
- @Required(false)
33
- folder: string;
34
- namespace: string = '.';
35
- autoCreate?: boolean;
36
- cullRate?: number | TimeSpan;
37
-
38
- async postConstruct(): Promise<void> {
39
- this.folder ??= path.resolve(os.tmpdir(), `trv_file_${Runtime.main.name.replace(/[^a-z]/ig, '_')}`);
40
- }
41
- }
42
-
43
- const exists = (f: string): Promise<boolean> => fs.stat(f).then(() => true, () => false);
44
-
45
- /**
46
- * Standard file support
47
- */
48
- @Injectable()
49
- export class FileModelService implements ModelCrudSupport, ModelStreamSupport, ModelExpirySupport, ModelStorageSupport {
50
-
51
- private static async * scanFolder(folder: string, suffix: string): AsyncGenerator<[id: string, field: string]> {
52
- for (const sub of await fs.readdir(folder)) {
53
- for (const file of await fs.readdir(path.resolve(folder, sub))) {
54
- if (file.endsWith(suffix)) {
55
- yield [file.replace(suffix, ''), path.resolve(folder, sub, file)];
56
- }
57
- }
58
- }
59
- }
60
-
61
- idSource = ModelCrudUtil.uuidSource();
62
-
63
- get client(): string {
64
- return this.config.folder;
65
- }
66
-
67
- /**
68
- * The root location for all activity
69
- */
70
- constructor(public readonly config: FileModelConfig) { }
71
-
72
- async #resolveName<T extends ModelType>(cls: Class<T> | string, suffix?: Suffix, id?: string): Promise<string> {
73
- const name = typeof cls === 'string' ? cls : ModelRegistry.getStore(cls);
74
- let resolved = path.resolve(this.config.folder, this.config.namespace, name);
75
- if (id) {
76
- resolved = path.resolve(resolved, id.replace(/^[/]/, '').substring(0, 3));
77
- }
78
- let dir = resolved;
79
- if (id) {
80
- resolved = path.resolve(resolved, `${id}${suffix}`);
81
- dir = path.dirname(resolved);
82
- }
83
-
84
- await fs.mkdir(dir, { recursive: true });
85
- return resolved;
86
- }
87
-
88
- async #find<T extends ModelType>(cls: Class<T> | string, suffix: Suffix, id?: string): Promise<string> {
89
- const file = await this.#resolveName(cls, suffix, id);
90
- if (id && !(await exists(file))) {
91
- throw new NotFoundError(cls, id);
92
- }
93
- return file;
94
- }
95
-
96
- postConstruct(): void {
97
- ModelExpiryUtil.registerCull(this);
98
- }
99
-
100
- checkExpiry<T extends ModelType>(cls: Class<T>, item: T): T {
101
- const { expiresAt } = ModelRegistry.get(cls);
102
- if (expiresAt && ModelExpiryUtil.getExpiryState(cls, item).expired) {
103
- throw new NotFoundError(cls, item.id);
104
- }
105
- return item;
106
- }
107
-
108
- async get<T extends ModelType>(cls: Class<T>, id: string): Promise<T> {
109
- await this.#find(cls, '.json', id);
110
-
111
- const file = await this.#resolveName(cls, '.json', id);
112
-
113
- if (await exists(file)) {
114
- const content = await fs.readFile(file);
115
- return this.checkExpiry(cls, await ModelCrudUtil.load(cls, content));
116
- }
117
-
118
- throw new NotFoundError(cls, id);
119
- }
120
-
121
- async create<T extends ModelType>(cls: Class<T>, item: OptionalId<T>): Promise<T> {
122
- if (!item.id) {
123
- item.id = this.idSource.create();
124
- }
125
-
126
- const file = await this.#resolveName(cls, '.json', item.id);
127
-
128
- if (await exists(file)) {
129
- throw new ExistsError(cls, item.id!);
130
- }
131
-
132
- return await this.upsert(cls, item);
133
- }
134
-
135
- async update<T extends ModelType>(cls: Class<T>, item: T): Promise<T> {
136
- await this.get(cls, item.id);
137
- return await this.upsert(cls, item);
138
- }
139
-
140
- async upsert<T extends ModelType>(cls: Class<T>, item: OptionalId<T>): Promise<T> {
141
- ModelCrudUtil.ensureNotSubType(cls);
142
- const prepped = await ModelCrudUtil.preStore(cls, item, this);
143
-
144
- const file = await this.#resolveName(cls, '.json', item.id);
145
- await fs.writeFile(file, JSON.stringify(item), { encoding: 'utf8' });
146
-
147
- return prepped;
148
- }
149
-
150
- async updatePartial<T extends ModelType>(cls: Class<T>, item: Partial<T> & { id: string }, view?: string): Promise<T> {
151
- ModelCrudUtil.ensureNotSubType(cls);
152
- const id = item.id;
153
- item = await ModelCrudUtil.naivePartialUpdate(cls, item, view, () => this.get(cls, id));
154
- const file = await this.#resolveName(cls, '.json', item.id);
155
- await fs.writeFile(file, JSON.stringify(item), { encoding: 'utf8' });
156
- return asFull<T>(item);
157
- }
158
-
159
- async delete<T extends ModelType>(cls: Class<T>, id: string): Promise<void> {
160
- const file = await this.#find(cls, '.json', id);
161
- await fs.unlink(file);
162
- }
163
-
164
- async * list<T extends ModelType>(cls: Class<T>): AsyncIterable<T> {
165
- for await (const [id] of FileModelService.scanFolder(await this.#resolveName(cls, '.json'), '.json')) {
166
- try {
167
- yield await this.get(cls, id);
168
- } catch (err) {
169
- if (!(err instanceof NotFoundError)) {
170
- throw err;
171
- }
172
- }
173
- }
174
- }
175
-
176
- // Stream
177
- async upsertStream(location: string, input: Readable, meta: StreamMeta): Promise<void> {
178
- const file = await this.#resolveName(STREAMS, BIN, location);
179
- await Promise.all([
180
- await pipeline(input, createWriteStream(file)),
181
- fs.writeFile(file.replace(BIN, META), JSON.stringify(meta), 'utf8')
182
- ]);
183
- }
184
-
185
- async getStream(location: string, range?: StreamRange): Promise<Readable> {
186
- const file = await this.#find(STREAMS, BIN, location);
187
- if (range) {
188
- const meta = await this.describeStream(location);
189
- range = enforceRange(range, meta.size);
190
- }
191
- return createReadStream(file, range);
192
- }
193
-
194
- async describeStream(location: string): Promise<StreamMeta> {
195
- const file = await this.#find(STREAMS, META, location);
196
- const content = await fs.readFile(file);
197
- const text: StreamMeta = JSON.parse(content.toString('utf8'));
198
- return text;
199
- }
200
-
201
- async deleteStream(location: string): Promise<void> {
202
- const file = await this.#resolveName(STREAMS, BIN, location);
203
- if (await exists(file)) {
204
- await Promise.all([
205
- fs.unlink(file),
206
- fs.unlink(file.replace('.bin', META))
207
- ]);
208
- } else {
209
- throw new NotFoundError('Stream', location);
210
- }
211
- }
212
-
213
- // Expiry
214
- async deleteExpired<T extends ModelType>(cls: Class<T>): Promise<number> {
215
- return ModelExpiryUtil.naiveDeleteExpired(this, cls);
216
- }
217
-
218
- // Storage management
219
- async createStorage(): Promise<void> {
220
- const dir = path.resolve(this.config.folder, this.config.namespace);
221
- await fs.mkdir(dir, { recursive: true });
222
- }
223
-
224
- async deleteStorage(): Promise<void> {
225
- await fs.rm(path.resolve(this.config.folder, this.config.namespace), { recursive: true, force: true });
226
- }
227
-
228
- async truncateModel(cls: Class<ModelType>): Promise<void> {
229
- await fs.rm(await this.#resolveName(cls === StreamModel ? STREAMS : cls), { recursive: true, force: true });
230
- }
231
- }