@travetto/model 5.0.0-rc.10 → 5.0.0-rc.11
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 +85 -146
- package/__index__.ts +2 -4
- package/package.json +7 -7
- package/src/internal/service/blob.ts +5 -0
- package/src/internal/service/common.ts +8 -8
- package/src/internal/service/indexed.ts +0 -1
- package/src/service/blob.ts +41 -0
- package/src/util/blob.ts +60 -0
- package/support/doc.support.tsx +2 -2
- package/support/fixtures/alpha.txt +1 -0
- package/support/fixtures/empty +0 -0
- package/support/fixtures/empty.m4a +0 -0
- package/support/fixtures/logo.gif +0 -0
- package/support/fixtures/logo.png +0 -0
- package/support/fixtures/small-audio +0 -0
- package/support/fixtures/small-audio.mp3 +0 -0
- package/support/test/blob.ts +117 -0
- package/support/test/suite.ts +5 -5
- package/src/internal/service/stream.ts +0 -22
- package/src/provider/file.ts +0 -231
- package/src/provider/memory.ts +0 -339
- package/src/service/stream.ts +0 -72
- package/support/test/stream.ts +0 -113
|
@@ -0,0 +1,117 @@
|
|
|
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 { Util } from '@travetto/runtime';
|
|
6
|
+
|
|
7
|
+
import { ModelBlobSupport } from '../../src/service/blob';
|
|
8
|
+
import { ModelBlobUtil } from '../../src/util/blob';
|
|
9
|
+
|
|
10
|
+
@Suite()
|
|
11
|
+
export abstract class ModelBlobSuite extends BaseModelSuite<ModelBlobSupport> {
|
|
12
|
+
|
|
13
|
+
fixture = new TestFixtures(['@travetto/model']);
|
|
14
|
+
|
|
15
|
+
@Test()
|
|
16
|
+
async writeBasic(): Promise<void> {
|
|
17
|
+
const service = await this.service;
|
|
18
|
+
const buffer = await this.fixture.read('/asset.yml', true);
|
|
19
|
+
|
|
20
|
+
const id = Util.uuid();
|
|
21
|
+
|
|
22
|
+
await service.upsertBlob(id, buffer);
|
|
23
|
+
const meta = await service.describeBlob(id);
|
|
24
|
+
const retrieved = await service.describeBlob(id);
|
|
25
|
+
assert.deepStrictEqual(meta, retrieved);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
@Test()
|
|
29
|
+
async writeStream(): Promise<void> {
|
|
30
|
+
const service = await this.service;
|
|
31
|
+
const buffer = await this.fixture.read('/asset.yml', true);
|
|
32
|
+
|
|
33
|
+
const id = Util.uuid();
|
|
34
|
+
await service.upsertBlob(id, buffer);
|
|
35
|
+
const meta = await service.describeBlob(id);
|
|
36
|
+
|
|
37
|
+
const retrieved = await service.getBlob(id);
|
|
38
|
+
const retrievedMeta = retrieved.meta!;
|
|
39
|
+
assert(meta.hash === retrievedMeta.hash);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@Test()
|
|
43
|
+
async writeAndDelete(): Promise<void> {
|
|
44
|
+
const service = await this.service;
|
|
45
|
+
const buffer = await this.fixture.read('/asset.yml', true);
|
|
46
|
+
|
|
47
|
+
const id = Util.uuid();
|
|
48
|
+
await service.upsertBlob(id, buffer);
|
|
49
|
+
|
|
50
|
+
await service.deleteBlob(id);
|
|
51
|
+
|
|
52
|
+
await assert.rejects(async () => {
|
|
53
|
+
await service.getBlob(id);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
@Test()
|
|
58
|
+
async partialStream(): Promise<void> {
|
|
59
|
+
const service = await this.service;
|
|
60
|
+
const buffer = await this.fixture.read('/text.txt', true);
|
|
61
|
+
|
|
62
|
+
const id = Util.uuid();
|
|
63
|
+
await service.upsertBlob(id, buffer);
|
|
64
|
+
|
|
65
|
+
const retrieved = await service.getBlob(id);
|
|
66
|
+
const content = await retrieved.text();
|
|
67
|
+
assert(content.startsWith('abc'));
|
|
68
|
+
assert(content.endsWith('xyz'));
|
|
69
|
+
|
|
70
|
+
const partial = await service.getBlob(id, { start: 10, end: 20 });
|
|
71
|
+
assert(partial.size === 11);
|
|
72
|
+
const partialMeta = partial.meta!;
|
|
73
|
+
const subContent = await partial.text();
|
|
74
|
+
const range = await ModelBlobUtil.enforceRange({ start: 10, end: 20 }, partialMeta.size!);
|
|
75
|
+
assert(subContent.length === (range.end - range.start) + 1);
|
|
76
|
+
|
|
77
|
+
const og = await this.fixture.read('/text.txt');
|
|
78
|
+
|
|
79
|
+
assert(subContent === og.substring(10, 21));
|
|
80
|
+
|
|
81
|
+
const partialUnbounded = await service.getBlob(id, { start: 10 });
|
|
82
|
+
const partialUnboundedMeta = partial.meta!;
|
|
83
|
+
const subContent2 = await partialUnbounded.text();
|
|
84
|
+
const range2 = await ModelBlobUtil.enforceRange({ start: 10 }, partialUnboundedMeta.size!);
|
|
85
|
+
assert(subContent2.length === (range2.end - range2.start) + 1);
|
|
86
|
+
assert(subContent2.startsWith('klm'));
|
|
87
|
+
assert(subContent2.endsWith('xyz'));
|
|
88
|
+
|
|
89
|
+
const partialSingle = await service.getBlob(id, { start: 10, end: 10 });
|
|
90
|
+
const subContent3 = await partialSingle.text();
|
|
91
|
+
assert(subContent3.length === 1);
|
|
92
|
+
assert(subContent3 === 'k');
|
|
93
|
+
|
|
94
|
+
const partialOverBounded = await service.getBlob(id, { start: 20, end: 40 });
|
|
95
|
+
const subContent4 = await partialOverBounded.text();
|
|
96
|
+
assert(subContent4.length === 6);
|
|
97
|
+
assert(subContent4.endsWith('xyz'));
|
|
98
|
+
|
|
99
|
+
await assert.rejects(() => service.getBlob(id, { start: -10, end: 10 }));
|
|
100
|
+
await assert.rejects(() => service.getBlob(id, { start: 30, end: 37 }));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@Test()
|
|
105
|
+
async writeAndGet() {
|
|
106
|
+
const service = await this.service;
|
|
107
|
+
const buffer = await this.fixture.read('/asset.yml', true);
|
|
108
|
+
await service.upsertBlob('orange', buffer, { contentType: 'text/yaml', filename: 'asset.yml' });
|
|
109
|
+
const saved = await service.getBlob('orange');
|
|
110
|
+
const savedMeta = saved.meta!;
|
|
111
|
+
|
|
112
|
+
assert('text/yaml' === savedMeta.contentType);
|
|
113
|
+
assert(buffer.length === savedMeta.size);
|
|
114
|
+
assert('asset.yml' === savedMeta.filename);
|
|
115
|
+
assert(undefined === savedMeta.hash);
|
|
116
|
+
}
|
|
117
|
+
}
|
package/support/test/suite.ts
CHANGED
|
@@ -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 {
|
|
7
|
-
import {
|
|
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 (
|
|
64
|
+
if (isBlobSupported(service)) {
|
|
65
65
|
if (service.truncateModel) {
|
|
66
|
-
await service.truncateModel(
|
|
66
|
+
await service.truncateModel(MODEL_BLOB);
|
|
67
67
|
} else if (service.deleteModel) {
|
|
68
|
-
await service.deleteModel(
|
|
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
|
-
}
|
package/src/provider/file.ts
DELETED
|
@@ -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
|
-
}
|