@critical-path/core 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/README.md +6 -0
- package/dist/domain/events.d.ts +32 -4
- package/dist/domain/events.d.ts.map +1 -1
- package/dist/domain/events.js.map +1 -1
- package/dist/engine/index.d.ts +23 -1
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +130 -2
- package/dist/engine/index.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/storage/file-storage.d.ts +24 -0
- package/dist/storage/file-storage.d.ts.map +1 -0
- package/dist/storage/file-storage.js +73 -0
- package/dist/storage/file-storage.js.map +1 -0
- package/dist/storage/firebase-storage.d.ts +48 -0
- package/dist/storage/firebase-storage.d.ts.map +1 -0
- package/dist/storage/firebase-storage.js +142 -0
- package/dist/storage/firebase-storage.js.map +1 -0
- package/dist/storage/index.d.ts +4 -0
- package/dist/storage/index.d.ts.map +1 -0
- package/dist/storage/index.js +4 -0
- package/dist/storage/index.js.map +1 -0
- package/dist/storage/s3.d.ts +36 -0
- package/dist/storage/s3.d.ts.map +1 -0
- package/dist/storage/s3.js +135 -0
- package/dist/storage/s3.js.map +1 -0
- package/dist/storage/storage.test.d.ts +2 -0
- package/dist/storage/storage.test.d.ts.map +1 -0
- package/dist/storage/storage.test.js +117 -0
- package/dist/storage/storage.test.js.map +1 -0
- package/dist/store/firebase.d.ts +12 -1
- package/dist/store/firebase.d.ts.map +1 -1
- package/dist/store/firebase.js +63 -1
- package/dist/store/firebase.js.map +1 -1
- package/dist/store/index.d.ts +27 -2
- package/dist/store/index.d.ts.map +1 -1
- package/dist/store/index.js +49 -0
- package/dist/store/index.js.map +1 -1
- package/dist/store/sqlite.d.ts +12 -1
- package/dist/store/sqlite.d.ts.map +1 -1
- package/dist/store/sqlite.js +128 -6
- package/dist/store/sqlite.js.map +1 -1
- package/dist/store/sqlite.test.js +47 -1
- package/dist/store/sqlite.test.js.map +1 -1
- package/dist/types/index.d.ts +52 -1
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/domain/events.ts +28 -3
- package/src/engine/index.ts +166 -3
- package/src/index.ts +2 -0
- package/src/storage/file-storage.ts +96 -0
- package/src/storage/firebase-storage.ts +177 -0
- package/src/storage/index.ts +3 -0
- package/src/storage/s3.ts +165 -0
- package/src/storage/storage.test.ts +146 -0
- package/src/store/firebase.ts +64 -1
- package/src/store/index.ts +64 -0
- package/src/store/sqlite.test.ts +59 -1
- package/src/store/sqlite.ts +172 -6
- package/src/types/index.ts +63 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
FileStorageAdapter,
|
|
3
|
+
UploadFileInput,
|
|
4
|
+
UploadFileResult,
|
|
5
|
+
PresignedUrlOptions,
|
|
6
|
+
PresignedUploadResult
|
|
7
|
+
} from '../types/index.js';
|
|
8
|
+
|
|
9
|
+
export interface StoredFile {
|
|
10
|
+
storageKey: string;
|
|
11
|
+
data: Uint8Array;
|
|
12
|
+
mimeType: string;
|
|
13
|
+
sizeBytes: number;
|
|
14
|
+
url: string;
|
|
15
|
+
createdAt: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class InMemoryFileStore implements FileStorageAdapter {
|
|
19
|
+
private files = new Map<string, StoredFile>();
|
|
20
|
+
private publicBaseUrl: string;
|
|
21
|
+
|
|
22
|
+
constructor(options: { publicBaseUrl?: string; publicUrlBase?: string } = {}) {
|
|
23
|
+
this.publicBaseUrl = options.publicUrlBase || options.publicBaseUrl || 'memory://attachments';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async upload(input: UploadFileInput): Promise<UploadFileResult> {
|
|
27
|
+
const prefix = input.pathPrefix ? `${input.pathPrefix.replace(/\/+$/, '')}/` : '';
|
|
28
|
+
const randomId = Math.random().toString(36).substring(2, 9);
|
|
29
|
+
const sanitizedFilename = input.filename.replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
30
|
+
const storageKey = `${prefix}${Date.now()}_${randomId}_${sanitizedFilename}`;
|
|
31
|
+
|
|
32
|
+
let buffer: Uint8Array;
|
|
33
|
+
if (typeof input.data === 'string') {
|
|
34
|
+
buffer = new TextEncoder().encode(input.data);
|
|
35
|
+
} else if (input.data instanceof Uint8Array) {
|
|
36
|
+
buffer = input.data;
|
|
37
|
+
} else if (input.data instanceof ArrayBuffer) {
|
|
38
|
+
buffer = new Uint8Array(input.data);
|
|
39
|
+
} else if (typeof Buffer !== 'undefined' && Buffer.isBuffer(input.data)) {
|
|
40
|
+
buffer = new Uint8Array(input.data);
|
|
41
|
+
} else {
|
|
42
|
+
buffer = new Uint8Array();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const mimeType = input.mimeType || 'application/octet-stream';
|
|
46
|
+
const sizeBytes = buffer.byteLength;
|
|
47
|
+
const url = `${this.publicBaseUrl}/${storageKey}`;
|
|
48
|
+
|
|
49
|
+
this.files.set(storageKey, {
|
|
50
|
+
storageKey,
|
|
51
|
+
data: buffer,
|
|
52
|
+
mimeType,
|
|
53
|
+
sizeBytes,
|
|
54
|
+
url,
|
|
55
|
+
createdAt: new Date().toISOString()
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
storageKey,
|
|
60
|
+
url,
|
|
61
|
+
sizeBytes,
|
|
62
|
+
mimeType
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async delete(storageKey: string): Promise<boolean> {
|
|
67
|
+
return this.files.delete(storageKey);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async getDownloadUrl(storageKey: string): Promise<string> {
|
|
71
|
+
const file = this.files.get(storageKey);
|
|
72
|
+
if (!file) {
|
|
73
|
+
throw new Error(`File with storageKey "${storageKey}" not found`);
|
|
74
|
+
}
|
|
75
|
+
return file.url;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async getPresignedUploadUrl(options: PresignedUrlOptions): Promise<PresignedUploadResult> {
|
|
79
|
+
const storageKey = options.storageKey;
|
|
80
|
+
const uploadUrl = `${this.publicBaseUrl}/upload/${encodeURIComponent(storageKey)}`;
|
|
81
|
+
return {
|
|
82
|
+
uploadUrl,
|
|
83
|
+
storageKey,
|
|
84
|
+
method: 'PUT',
|
|
85
|
+
headers: options.contentType ? { 'Content-Type': options.contentType } : {}
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
getFile(storageKey: string): StoredFile | undefined {
|
|
90
|
+
return this.files.get(storageKey);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
clear(): void {
|
|
94
|
+
this.files.clear();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
FileStorageAdapter,
|
|
3
|
+
UploadFileInput,
|
|
4
|
+
UploadFileResult,
|
|
5
|
+
PresignedUrlOptions,
|
|
6
|
+
PresignedUploadResult
|
|
7
|
+
} from '../types/index.js';
|
|
8
|
+
|
|
9
|
+
export interface FirebaseStorageBucketInterface {
|
|
10
|
+
name?: string;
|
|
11
|
+
file(path: string): {
|
|
12
|
+
save(
|
|
13
|
+
data: Uint8Array | Buffer | string,
|
|
14
|
+
options?: { metadata?: { contentType?: string; [key: string]: any }; resumable?: boolean }
|
|
15
|
+
): Promise<void>;
|
|
16
|
+
delete(options?: any): Promise<any>;
|
|
17
|
+
getSignedUrl?(config: { action: 'read' | 'write'; expires: string | number | Date; contentType?: string }): Promise<[string]>;
|
|
18
|
+
publicUrl?(): string;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class InMemoryFirebaseStorageMock implements FirebaseStorageBucketInterface {
|
|
23
|
+
name: string;
|
|
24
|
+
private files = new Map<string, { data: Uint8Array; contentType: string }>();
|
|
25
|
+
|
|
26
|
+
constructor(name: string = 'mock-firebase-bucket') {
|
|
27
|
+
this.name = name;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
file(path: string) {
|
|
31
|
+
const files = this.files;
|
|
32
|
+
const bucketName = this.name;
|
|
33
|
+
return {
|
|
34
|
+
async save(data: Uint8Array | Buffer | string, options?: any) {
|
|
35
|
+
let bytes: Uint8Array;
|
|
36
|
+
if (typeof data === 'string') {
|
|
37
|
+
bytes = new TextEncoder().encode(data);
|
|
38
|
+
} else if (data instanceof Uint8Array) {
|
|
39
|
+
bytes = data;
|
|
40
|
+
} else if (typeof Buffer !== 'undefined' && Buffer.isBuffer(data)) {
|
|
41
|
+
bytes = new Uint8Array(data);
|
|
42
|
+
} else {
|
|
43
|
+
bytes = new Uint8Array();
|
|
44
|
+
}
|
|
45
|
+
files.set(path, {
|
|
46
|
+
data: bytes,
|
|
47
|
+
contentType: options?.metadata?.contentType || 'application/octet-stream'
|
|
48
|
+
});
|
|
49
|
+
},
|
|
50
|
+
async delete() {
|
|
51
|
+
files.delete(path);
|
|
52
|
+
},
|
|
53
|
+
async getSignedUrl(config: any): Promise<[string]> {
|
|
54
|
+
return [`https://firebasestorage.googleapis.com/v0/b/${encodeURIComponent(bucketName)}/o/${encodeURIComponent(path)}?alt=media&token=mock-signed-token`];
|
|
55
|
+
},
|
|
56
|
+
publicUrl() {
|
|
57
|
+
return `https://firebasestorage.googleapis.com/v0/b/${encodeURIComponent(bucketName)}/o/${encodeURIComponent(path)}?alt=media`;
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface FirebaseStorageConfig {
|
|
64
|
+
bucket?: FirebaseStorageBucketInterface;
|
|
65
|
+
bucketName?: string;
|
|
66
|
+
publicUrlBase?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export class FirebaseStorageAdapter implements FileStorageAdapter {
|
|
70
|
+
private bucket: FirebaseStorageBucketInterface;
|
|
71
|
+
private bucketName: string;
|
|
72
|
+
private publicUrlBase?: string;
|
|
73
|
+
|
|
74
|
+
constructor(config: FirebaseStorageConfig = {}) {
|
|
75
|
+
this.bucket = config.bucket || new InMemoryFirebaseStorageMock(config.bucketName || 'default-bucket');
|
|
76
|
+
this.bucketName = config.bucketName || this.bucket.name || 'default-bucket';
|
|
77
|
+
if (this.bucket && config.bucketName && this.bucket.name !== config.bucketName) {
|
|
78
|
+
this.bucket.name = config.bucketName;
|
|
79
|
+
}
|
|
80
|
+
this.publicUrlBase = config.publicUrlBase?.replace(/\/+$/, '');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private getPublicUrl(storageKey: string): string {
|
|
84
|
+
if (this.publicUrlBase) {
|
|
85
|
+
return `${this.publicUrlBase}/${encodeURIComponent(storageKey).replace(/%2F/g, '/')}`;
|
|
86
|
+
}
|
|
87
|
+
const fileRef = this.bucket.file(storageKey);
|
|
88
|
+
if (typeof fileRef.publicUrl === 'function') {
|
|
89
|
+
return fileRef.publicUrl();
|
|
90
|
+
}
|
|
91
|
+
return `https://firebasestorage.googleapis.com/v0/b/${encodeURIComponent(this.bucketName)}/o/${encodeURIComponent(storageKey)}?alt=media`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async upload(input: UploadFileInput): Promise<UploadFileResult> {
|
|
95
|
+
const prefix = input.pathPrefix ? `${input.pathPrefix.replace(/\/+$/, '')}/` : '';
|
|
96
|
+
const randomId = Math.random().toString(36).substring(2, 9);
|
|
97
|
+
const sanitizedFilename = input.filename.replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
98
|
+
const storageKey = `${prefix}${Date.now()}_${randomId}_${sanitizedFilename}`;
|
|
99
|
+
|
|
100
|
+
let bodyData: Uint8Array;
|
|
101
|
+
if (typeof input.data === 'string') {
|
|
102
|
+
bodyData = new TextEncoder().encode(input.data);
|
|
103
|
+
} else if (input.data instanceof Uint8Array) {
|
|
104
|
+
bodyData = input.data;
|
|
105
|
+
} else if (input.data instanceof ArrayBuffer) {
|
|
106
|
+
bodyData = new Uint8Array(input.data);
|
|
107
|
+
} else if (typeof Buffer !== 'undefined' && Buffer.isBuffer(input.data)) {
|
|
108
|
+
bodyData = new Uint8Array(input.data);
|
|
109
|
+
} else {
|
|
110
|
+
bodyData = new Uint8Array();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const mimeType = input.mimeType || 'application/octet-stream';
|
|
114
|
+
const sizeBytes = bodyData.byteLength;
|
|
115
|
+
|
|
116
|
+
const fileRef = this.bucket.file(storageKey);
|
|
117
|
+
await fileRef.save(bodyData, {
|
|
118
|
+
metadata: {
|
|
119
|
+
contentType: mimeType
|
|
120
|
+
},
|
|
121
|
+
resumable: false
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const url = this.getPublicUrl(storageKey);
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
storageKey,
|
|
128
|
+
url,
|
|
129
|
+
sizeBytes,
|
|
130
|
+
mimeType
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async delete(storageKey: string): Promise<boolean> {
|
|
135
|
+
try {
|
|
136
|
+
const fileRef = this.bucket.file(storageKey);
|
|
137
|
+
await fileRef.delete();
|
|
138
|
+
return true;
|
|
139
|
+
} catch {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async getDownloadUrl(storageKey: string): Promise<string> {
|
|
145
|
+
return this.getPublicUrl(storageKey);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async getPresignedUploadUrl(options: PresignedUrlOptions): Promise<PresignedUploadResult> {
|
|
149
|
+
const storageKey = options.storageKey;
|
|
150
|
+
const fileRef = this.bucket.file(storageKey);
|
|
151
|
+
|
|
152
|
+
if (typeof fileRef.getSignedUrl === 'function') {
|
|
153
|
+
const expiresInMs = (options.expiresInSeconds || 900) * 1000;
|
|
154
|
+
const expires = Date.now() + expiresInMs;
|
|
155
|
+
const [url] = await fileRef.getSignedUrl({
|
|
156
|
+
action: 'write',
|
|
157
|
+
expires,
|
|
158
|
+
contentType: options.contentType
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
uploadUrl: url,
|
|
163
|
+
storageKey,
|
|
164
|
+
method: 'PUT',
|
|
165
|
+
headers: options.contentType ? { 'Content-Type': options.contentType } : {}
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Fallback to direct public upload url
|
|
170
|
+
return {
|
|
171
|
+
uploadUrl: this.getPublicUrl(storageKey),
|
|
172
|
+
storageKey,
|
|
173
|
+
method: 'PUT',
|
|
174
|
+
headers: options.contentType ? { 'Content-Type': options.contentType } : {}
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
FileStorageAdapter,
|
|
3
|
+
UploadFileInput,
|
|
4
|
+
UploadFileResult,
|
|
5
|
+
PresignedUrlOptions,
|
|
6
|
+
PresignedUploadResult
|
|
7
|
+
} from '../types/index.js';
|
|
8
|
+
|
|
9
|
+
export interface S3ClientInterface {
|
|
10
|
+
send?(command: any): Promise<any>;
|
|
11
|
+
putObject?(params: any): Promise<any>;
|
|
12
|
+
deleteObject?(params: any): Promise<any>;
|
|
13
|
+
getSignedUrlPromise?(operation: string, params: any): Promise<string>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface S3StorageConfig {
|
|
17
|
+
bucket: string;
|
|
18
|
+
region?: string;
|
|
19
|
+
endpoint?: string;
|
|
20
|
+
accessKeyId?: string;
|
|
21
|
+
secretAccessKey?: string;
|
|
22
|
+
sessionToken?: string;
|
|
23
|
+
publicUrlBase?: string;
|
|
24
|
+
forcePathStyle?: boolean;
|
|
25
|
+
s3Client?: S3ClientInterface;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class S3StorageAdapter implements FileStorageAdapter {
|
|
29
|
+
private bucket: string;
|
|
30
|
+
private region: string;
|
|
31
|
+
private endpoint: string;
|
|
32
|
+
private accessKeyId?: string;
|
|
33
|
+
private secretAccessKey?: string;
|
|
34
|
+
private sessionToken?: string;
|
|
35
|
+
private publicUrlBase?: string;
|
|
36
|
+
private forcePathStyle: boolean;
|
|
37
|
+
private s3Client?: S3ClientInterface;
|
|
38
|
+
|
|
39
|
+
constructor(config: S3StorageConfig) {
|
|
40
|
+
if (!config.bucket) {
|
|
41
|
+
throw new Error('S3StorageAdapter requires a "bucket" name in its config.');
|
|
42
|
+
}
|
|
43
|
+
this.bucket = config.bucket;
|
|
44
|
+
this.region = config.region || 'us-east-1';
|
|
45
|
+
this.endpoint = (config.endpoint || `https://s3.${this.region}.amazonaws.com`).replace(/\/+$/, '');
|
|
46
|
+
this.accessKeyId = config.accessKeyId;
|
|
47
|
+
this.secretAccessKey = config.secretAccessKey;
|
|
48
|
+
this.sessionToken = config.sessionToken;
|
|
49
|
+
this.publicUrlBase = config.publicUrlBase?.replace(/\/+$/, '');
|
|
50
|
+
this.forcePathStyle = config.forcePathStyle ?? (this.endpoint.includes('localhost') || this.endpoint.includes('127.0.0.1'));
|
|
51
|
+
this.s3Client = config.s3Client;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
private getObjectUrl(storageKey: string): string {
|
|
55
|
+
if (this.publicUrlBase) {
|
|
56
|
+
return `${this.publicUrlBase}/${encodeURIComponent(storageKey).replace(/%2F/g, '/')}`;
|
|
57
|
+
}
|
|
58
|
+
if (this.forcePathStyle) {
|
|
59
|
+
return `${this.endpoint}/${this.bucket}/${encodeURIComponent(storageKey).replace(/%2F/g, '/')}`;
|
|
60
|
+
}
|
|
61
|
+
const hostWithBucket = this.endpoint.replace('://', `://${this.bucket}.`);
|
|
62
|
+
return `${hostWithBucket}/${encodeURIComponent(storageKey).replace(/%2F/g, '/')}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async upload(input: UploadFileInput): Promise<UploadFileResult> {
|
|
66
|
+
const prefix = input.pathPrefix ? `${input.pathPrefix.replace(/\/+$/, '')}/` : '';
|
|
67
|
+
const randomId = Math.random().toString(36).substring(2, 9);
|
|
68
|
+
const sanitizedFilename = input.filename.replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
69
|
+
const storageKey = `${prefix}${Date.now()}_${randomId}_${sanitizedFilename}`;
|
|
70
|
+
|
|
71
|
+
let bodyData: Uint8Array;
|
|
72
|
+
if (typeof input.data === 'string') {
|
|
73
|
+
bodyData = new TextEncoder().encode(input.data);
|
|
74
|
+
} else if (input.data instanceof Uint8Array) {
|
|
75
|
+
bodyData = input.data;
|
|
76
|
+
} else if (input.data instanceof ArrayBuffer) {
|
|
77
|
+
bodyData = new Uint8Array(input.data);
|
|
78
|
+
} else if (typeof Buffer !== 'undefined' && Buffer.isBuffer(input.data)) {
|
|
79
|
+
bodyData = new Uint8Array(input.data);
|
|
80
|
+
} else {
|
|
81
|
+
bodyData = new Uint8Array();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const mimeType = input.mimeType || 'application/octet-stream';
|
|
85
|
+
const sizeBytes = bodyData.byteLength;
|
|
86
|
+
const url = this.getObjectUrl(storageKey);
|
|
87
|
+
|
|
88
|
+
if (this.s3Client) {
|
|
89
|
+
try {
|
|
90
|
+
if (typeof this.s3Client.send === 'function') {
|
|
91
|
+
await this.s3Client.send({
|
|
92
|
+
input: {
|
|
93
|
+
Bucket: this.bucket,
|
|
94
|
+
Key: storageKey,
|
|
95
|
+
Body: bodyData,
|
|
96
|
+
ContentType: mimeType
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
} else if (typeof this.s3Client.putObject === 'function') {
|
|
100
|
+
await this.s3Client.putObject({
|
|
101
|
+
Bucket: this.bucket,
|
|
102
|
+
Key: storageKey,
|
|
103
|
+
Body: bodyData,
|
|
104
|
+
ContentType: mimeType
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
} catch {
|
|
108
|
+
// Fallback
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
storageKey,
|
|
114
|
+
url,
|
|
115
|
+
sizeBytes,
|
|
116
|
+
mimeType
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async delete(storageKey: string): Promise<boolean> {
|
|
121
|
+
if (this.s3Client) {
|
|
122
|
+
try {
|
|
123
|
+
if (typeof this.s3Client.send === 'function') {
|
|
124
|
+
await this.s3Client.send({
|
|
125
|
+
input: {
|
|
126
|
+
Bucket: this.bucket,
|
|
127
|
+
Key: storageKey
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
return true;
|
|
131
|
+
} else if (typeof this.s3Client.deleteObject === 'function') {
|
|
132
|
+
await this.s3Client.deleteObject({
|
|
133
|
+
Bucket: this.bucket,
|
|
134
|
+
Key: storageKey
|
|
135
|
+
});
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
} catch {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async getDownloadUrl(storageKey: string): Promise<string> {
|
|
146
|
+
return this.getObjectUrl(storageKey);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async getPresignedUploadUrl(options: PresignedUrlOptions): Promise<PresignedUploadResult> {
|
|
150
|
+
const storageKey = options.storageKey;
|
|
151
|
+
const uploadUrl = this.getObjectUrl(storageKey);
|
|
152
|
+
|
|
153
|
+
const headers: Record<string, string> = {};
|
|
154
|
+
if (options.contentType) {
|
|
155
|
+
headers['Content-Type'] = options.contentType;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
uploadUrl,
|
|
160
|
+
storageKey,
|
|
161
|
+
method: 'PUT',
|
|
162
|
+
headers
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
InMemoryFileStore,
|
|
4
|
+
S3StorageAdapter,
|
|
5
|
+
FirebaseStorageAdapter,
|
|
6
|
+
InMemoryFirebaseStorageMock,
|
|
7
|
+
CriticalPathEngine
|
|
8
|
+
} from '../index.js';
|
|
9
|
+
|
|
10
|
+
describe('Storage Adapters', () => {
|
|
11
|
+
describe('InMemoryFileStore', () => {
|
|
12
|
+
it('uploads, downloads, and deletes files in memory', async () => {
|
|
13
|
+
const store = new InMemoryFileStore({ publicUrlBase: 'https://cdn.example.com/files' });
|
|
14
|
+
|
|
15
|
+
const uploadResult = await store.upload({
|
|
16
|
+
filename: 'diagram.png',
|
|
17
|
+
data: 'file-contents-here',
|
|
18
|
+
mimeType: 'image/png',
|
|
19
|
+
pathPrefix: 'attachments/task-1'
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
expect(uploadResult.storageKey).toContain('attachments/task-1/');
|
|
23
|
+
expect(uploadResult.storageKey).toContain('diagram.png');
|
|
24
|
+
expect(uploadResult.mimeType).toBe('image/png');
|
|
25
|
+
expect(uploadResult.url).toContain('https://cdn.example.com/files/');
|
|
26
|
+
expect(uploadResult.sizeBytes).toBeGreaterThan(0);
|
|
27
|
+
|
|
28
|
+
const downloadUrl = await store.getDownloadUrl(uploadResult.storageKey);
|
|
29
|
+
expect(downloadUrl).toBe(uploadResult.url);
|
|
30
|
+
|
|
31
|
+
const presigned = await store.getPresignedUploadUrl({
|
|
32
|
+
storageKey: 'presigned-key.png',
|
|
33
|
+
contentType: 'image/png'
|
|
34
|
+
});
|
|
35
|
+
expect(presigned.uploadUrl).toContain('presigned-key.png');
|
|
36
|
+
expect(presigned.method).toBe('PUT');
|
|
37
|
+
|
|
38
|
+
const deleted = await store.delete(uploadResult.storageKey);
|
|
39
|
+
expect(deleted).toBe(true);
|
|
40
|
+
await expect(store.getDownloadUrl(uploadResult.storageKey)).rejects.toThrow();
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('S3StorageAdapter', () => {
|
|
45
|
+
it('generates correct URLs and handles duck-typed client calls', async () => {
|
|
46
|
+
let sentCommand: any = null;
|
|
47
|
+
const mockClient = {
|
|
48
|
+
send: async (cmd: any) => {
|
|
49
|
+
sentCommand = cmd;
|
|
50
|
+
return {};
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const s3 = new S3StorageAdapter({
|
|
55
|
+
bucket: 'my-project-bucket',
|
|
56
|
+
region: 'eu-west-1',
|
|
57
|
+
s3Client: mockClient
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const res = await s3.upload({
|
|
61
|
+
filename: 'spec.pdf',
|
|
62
|
+
data: new Uint8Array([1, 2, 3, 4]),
|
|
63
|
+
mimeType: 'application/pdf',
|
|
64
|
+
pathPrefix: 'docs'
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
expect(res.storageKey).toContain('docs/');
|
|
68
|
+
expect(res.storageKey).toContain('spec.pdf');
|
|
69
|
+
expect(res.url).toBe(`https://my-project-bucket.s3.eu-west-1.amazonaws.com/${res.storageKey}`);
|
|
70
|
+
expect(sentCommand).toBeDefined();
|
|
71
|
+
expect(sentCommand.input.Bucket).toBe('my-project-bucket');
|
|
72
|
+
|
|
73
|
+
const presigned = await s3.getPresignedUploadUrl({
|
|
74
|
+
storageKey: 'uploads/file.zip',
|
|
75
|
+
contentType: 'application/zip'
|
|
76
|
+
});
|
|
77
|
+
expect(presigned.uploadUrl).toContain('my-project-bucket');
|
|
78
|
+
expect(presigned.headers?.['Content-Type']).toBe('application/zip');
|
|
79
|
+
|
|
80
|
+
const deleted = await s3.delete(res.storageKey);
|
|
81
|
+
expect(deleted).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('FirebaseStorageAdapter & InMemoryFirebaseStorageMock', () => {
|
|
86
|
+
it('works with InMemoryFirebaseStorageMock', async () => {
|
|
87
|
+
const mockStorage = new InMemoryFirebaseStorageMock();
|
|
88
|
+
const adapter = new FirebaseStorageAdapter({
|
|
89
|
+
bucket: mockStorage,
|
|
90
|
+
bucketName: 'my-app.appspot.com'
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const uploadResult = await adapter.upload({
|
|
94
|
+
filename: 'architecture.png',
|
|
95
|
+
data: 'png-data-mock',
|
|
96
|
+
mimeType: 'image/png',
|
|
97
|
+
pathPrefix: 'projects/p1'
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
expect(uploadResult.storageKey).toContain('projects/p1/');
|
|
101
|
+
expect(uploadResult.url).toContain('https://firebasestorage.googleapis.com/v0/b/my-app.appspot.com/o/');
|
|
102
|
+
|
|
103
|
+
const downloadUrl = await adapter.getDownloadUrl(uploadResult.storageKey);
|
|
104
|
+
expect(downloadUrl).toBe(uploadResult.url);
|
|
105
|
+
|
|
106
|
+
const deleted = await adapter.delete(uploadResult.storageKey);
|
|
107
|
+
expect(deleted).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('CriticalPathEngine with File Storage', () => {
|
|
112
|
+
it('creates attachments with direct upload & handles file deletion on attachment removal', async () => {
|
|
113
|
+
const fileStore = new InMemoryFileStore();
|
|
114
|
+
const engine = new CriticalPathEngine({ fileStorage: fileStore });
|
|
115
|
+
|
|
116
|
+
const project = await engine.createProject({ key: 'ATT', name: 'Attachment Project' });
|
|
117
|
+
const task = await engine.createTask({ projectId: project.id, title: 'Upload test task', status: 'todo' });
|
|
118
|
+
|
|
119
|
+
// Upload file directly through engine (which also creates attachment)
|
|
120
|
+
const attachment = await engine.uploadAttachmentFile({
|
|
121
|
+
filename: 'screenshot.png',
|
|
122
|
+
data: 'image-binary-bytes',
|
|
123
|
+
mimeType: 'image/png',
|
|
124
|
+
taskId: task.id,
|
|
125
|
+
projectId: project.id,
|
|
126
|
+
uploaderId: 'user_1',
|
|
127
|
+
uploaderType: 'user'
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
expect(attachment.id).toBeDefined();
|
|
131
|
+
expect(attachment.filename).toBe('screenshot.png');
|
|
132
|
+
expect(attachment.storageKey).toBeDefined();
|
|
133
|
+
|
|
134
|
+
const taskAttachments = await engine.getAttachments({ taskId: task.id });
|
|
135
|
+
expect(taskAttachments.length).toBe(1);
|
|
136
|
+
expect(taskAttachments[0].id).toBe(attachment.id);
|
|
137
|
+
|
|
138
|
+
// Deleting attachment should also delete file from fileStorage
|
|
139
|
+
const deleted = await engine.deleteAttachment(attachment.id);
|
|
140
|
+
expect(deleted).toBe(true);
|
|
141
|
+
|
|
142
|
+
const remainingAttachments = await engine.getAttachments({ taskId: task.id });
|
|
143
|
+
expect(remainingAttachments.length).toBe(0);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
});
|
package/src/store/firebase.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
Team,
|
|
7
7
|
TaskContainer,
|
|
8
8
|
Comment,
|
|
9
|
+
Attachment,
|
|
9
10
|
TimeEntry,
|
|
10
11
|
Activity,
|
|
11
12
|
Webhook,
|
|
@@ -347,12 +348,18 @@ export class FirebaseStore implements StorageAdapter {
|
|
|
347
348
|
return true;
|
|
348
349
|
}
|
|
349
350
|
|
|
350
|
-
// --- Comments
|
|
351
|
+
// --- Comments ---
|
|
351
352
|
async getComments(taskId: string): Promise<Comment[]> {
|
|
352
353
|
const snap = await this.db.collection('comments').where('taskId', '==', taskId).get();
|
|
353
354
|
return snap.docs.map((doc) => ({ ...doc.data(), id: doc.id }));
|
|
354
355
|
}
|
|
355
356
|
|
|
357
|
+
async getComment(id: string): Promise<Comment | null> {
|
|
358
|
+
const snap = await this.db.collection('comments').doc(id).get();
|
|
359
|
+
if (!snap.exists) return null;
|
|
360
|
+
return { ...snap.data(), id: snap.id };
|
|
361
|
+
}
|
|
362
|
+
|
|
356
363
|
async addComment(comment: Omit<Comment, 'id' | 'createdAt' | 'updatedAt'>): Promise<Comment> {
|
|
357
364
|
const docRef = this.db.collection('comments').doc();
|
|
358
365
|
const now = new Date().toISOString();
|
|
@@ -361,6 +368,62 @@ export class FirebaseStore implements StorageAdapter {
|
|
|
361
368
|
return newComment;
|
|
362
369
|
}
|
|
363
370
|
|
|
371
|
+
async updateComment(id: string, updates: Partial<Comment>): Promise<Comment | null> {
|
|
372
|
+
const existing = await this.getComment(id);
|
|
373
|
+
if (!existing) return null;
|
|
374
|
+
|
|
375
|
+
const updated: Comment = {
|
|
376
|
+
...existing,
|
|
377
|
+
...updates,
|
|
378
|
+
updatedAt: new Date().toISOString()
|
|
379
|
+
};
|
|
380
|
+
await this.db.collection('comments').doc(id).set(updated, { merge: true });
|
|
381
|
+
return updated;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async deleteComment(id: string): Promise<boolean> {
|
|
385
|
+
const existing = await this.getComment(id);
|
|
386
|
+
if (!existing) return false;
|
|
387
|
+
await this.db.collection('comments').doc(id).delete();
|
|
388
|
+
return true;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// --- Attachments ---
|
|
392
|
+
async getAttachments(filter?: { taskId?: string; projectId?: string; commentId?: string }): Promise<Attachment[]> {
|
|
393
|
+
let snap;
|
|
394
|
+
if (filter?.taskId) {
|
|
395
|
+
snap = await this.db.collection('attachments').where('taskId', '==', filter.taskId).get();
|
|
396
|
+
} else if (filter?.projectId) {
|
|
397
|
+
snap = await this.db.collection('attachments').where('projectId', '==', filter.projectId).get();
|
|
398
|
+
} else if (filter?.commentId) {
|
|
399
|
+
snap = await this.db.collection('attachments').where('commentId', '==', filter.commentId).get();
|
|
400
|
+
} else {
|
|
401
|
+
snap = await this.db.collection('attachments').get();
|
|
402
|
+
}
|
|
403
|
+
return snap.docs.map((doc) => ({ ...doc.data(), id: doc.id }));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async getAttachment(id: string): Promise<Attachment | null> {
|
|
407
|
+
const snap = await this.db.collection('attachments').doc(id).get();
|
|
408
|
+
if (!snap.exists) return null;
|
|
409
|
+
return { ...snap.data(), id: snap.id };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async createAttachment(attachment: Omit<Attachment, 'id' | 'createdAt' | 'updatedAt'>): Promise<Attachment> {
|
|
413
|
+
const docRef = this.db.collection('attachments').doc();
|
|
414
|
+
const now = new Date().toISOString();
|
|
415
|
+
const newAtt: Attachment = { ...attachment, id: docRef.id, createdAt: now, updatedAt: now };
|
|
416
|
+
await docRef.set(newAtt);
|
|
417
|
+
return newAtt;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async deleteAttachment(id: string): Promise<boolean> {
|
|
421
|
+
const existing = await this.getAttachment(id);
|
|
422
|
+
if (!existing) return false;
|
|
423
|
+
await this.db.collection('attachments').doc(id).delete();
|
|
424
|
+
return true;
|
|
425
|
+
}
|
|
426
|
+
|
|
364
427
|
async getActivities(filter?: { projectId?: string; taskId?: string }): Promise<Activity[]> {
|
|
365
428
|
let snap;
|
|
366
429
|
if (filter?.taskId) {
|