nuxt-filer 0.0.2
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 +231 -0
- package/dist/module.d.mts +14 -0
- package/dist/module.json +9 -0
- package/dist/module.mjs +64 -0
- package/dist/runtime/server/plugins/default-provider.d.ts +2 -0
- package/dist/runtime/server/plugins/default-provider.js +6 -0
- package/dist/runtime/server/provider.d.ts +3 -0
- package/dist/runtime/server/provider.js +12 -0
- package/dist/runtime/server/providers/unstorage.d.ts +7 -0
- package/dist/runtime/server/providers/unstorage.js +128 -0
- package/dist/runtime/server/tsconfig.json +3 -0
- package/dist/runtime/server/utils/storage.d.ts +24 -0
- package/dist/runtime/server/utils/storage.js +82 -0
- package/dist/runtime/types.d.ts +56 -0
- package/dist/runtime/types.js +0 -0
- package/dist/types.d.mts +3 -0
- package/package.json +75 -0
- package/src/runtime/server/plugins/default-provider.ts +8 -0
- package/src/runtime/server/provider.ts +16 -0
- package/src/runtime/server/providers/unstorage.ts +194 -0
- package/src/runtime/server/tsconfig.json +3 -0
- package/src/runtime/server/utils/storage.ts +140 -0
- package/src/runtime/types.ts +68 -0
package/README.md
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# nuxt-filer
|
|
2
|
+
|
|
3
|
+
[![npm version][npm-version-src]][npm-version-href]
|
|
4
|
+
[![npm downloads][npm-downloads-src]][npm-downloads-href]
|
|
5
|
+
[![License][license-src]][license-href]
|
|
6
|
+
[![Nuxt][nuxt-src]][nuxt-href]
|
|
7
|
+
|
|
8
|
+
File storage module for Nuxt. Provides a server-side `useFileStorage()` composable with pluggable storage backends — from zero-config local filesystem to custom providers with separate metadata databases and external file sync.
|
|
9
|
+
|
|
10
|
+
## Features
|
|
11
|
+
|
|
12
|
+
- **Pluggable provider architecture** — use the built-in unstorage provider or bring your own (Prisma, Drizzle, etc.)
|
|
13
|
+
- **File versioning** — built-in version tracking, latest-version filtering, and duplicate detection
|
|
14
|
+
- **External file sync** — two-way sync with external systems (Jira, SharePoint, etc.) via optional provider interface
|
|
15
|
+
- **Zero-config default** — works out of the box with local filesystem storage, no database required
|
|
16
|
+
- **Auto-imported** — `useFileStorage()`, types, and provider utilities are auto-imported in server context
|
|
17
|
+
- **Group-based organization** — files are organized by `groupId` (project, ticket, order, etc.)
|
|
18
|
+
|
|
19
|
+
## Quick Setup
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npx nuxi module add nuxt-filer
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Configuration
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
// nuxt.config.ts
|
|
29
|
+
export default defineNuxtConfig({
|
|
30
|
+
modules: ['nuxt-filer'],
|
|
31
|
+
filer: {
|
|
32
|
+
// Nitro storage mount name (default: 'documents')
|
|
33
|
+
storageName: 'documents',
|
|
34
|
+
// Base path for fs-lite driver (default: '.data/documents')
|
|
35
|
+
storagePath: '.data/documents',
|
|
36
|
+
// 'unstorage' (built-in) or 'custom' (bring your own provider)
|
|
37
|
+
provider: 'unstorage',
|
|
38
|
+
},
|
|
39
|
+
})
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Usage
|
|
43
|
+
|
|
44
|
+
### Basic — Upload and retrieve files
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
// server/api/files/[groupId].post.ts
|
|
48
|
+
export default defineEventHandler(async (event) => {
|
|
49
|
+
const groupId = getRouterParam(event, 'groupId')!
|
|
50
|
+
const body = await readMultipartFormData(event)
|
|
51
|
+
const file = body![0]!
|
|
52
|
+
|
|
53
|
+
const storage = useFileStorage()
|
|
54
|
+
|
|
55
|
+
const id = await storage.upload(groupId, file.data, {
|
|
56
|
+
meta: {
|
|
57
|
+
name: file.filename || 'unnamed',
|
|
58
|
+
mime: file.type || 'application/octet-stream',
|
|
59
|
+
type: 'document',
|
|
60
|
+
version: 1,
|
|
61
|
+
},
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
return { id, groupId }
|
|
65
|
+
})
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
// server/api/files/[groupId].get.ts
|
|
70
|
+
export default defineEventHandler(async (event) => {
|
|
71
|
+
const groupId = getRouterParam(event, 'groupId')!
|
|
72
|
+
const storage = useFileStorage()
|
|
73
|
+
|
|
74
|
+
return await storage.list(groupId)
|
|
75
|
+
})
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### `useFileStorage()` API
|
|
79
|
+
|
|
80
|
+
| Method | Description |
|
|
81
|
+
|---|---|
|
|
82
|
+
| `upload(groupId, data, options?)` | Store a file, returns its ID |
|
|
83
|
+
| `list(groupId)` | List all files in a group |
|
|
84
|
+
| `get(groupId, id)` | Get a file with data and metadata |
|
|
85
|
+
| `getData(groupId, id)` | Get raw binary data only |
|
|
86
|
+
| `getMeta(id)` | Get metadata only |
|
|
87
|
+
| `updateMeta(id, meta)` | Deep-merge metadata update |
|
|
88
|
+
| `remove(groupId, id)` | Delete a file |
|
|
89
|
+
| `clear(groupId)` | Delete all files in a group |
|
|
90
|
+
| `has(groupId, id)` | Check if a file exists |
|
|
91
|
+
| `findByMeta(key, value, groupId?)` | Find a file by metadata field |
|
|
92
|
+
| `checkDuplicate(groupId, key, value)` | Check if a duplicate exists |
|
|
93
|
+
| `getLatestVersions(groupId)` | Get latest version of each file by name |
|
|
94
|
+
| `getNextVersionNumber(files, name)` | Calculate next version number |
|
|
95
|
+
| `external?.sync(groupId, id)` | Sync with external system (if provider supports it) |
|
|
96
|
+
| `external?.push(groupId, id, data, meta)` | Push to external system |
|
|
97
|
+
| `external?.pull(groupId, ref)` | Pull from external system |
|
|
98
|
+
|
|
99
|
+
## Custom Provider
|
|
100
|
+
|
|
101
|
+
For advanced use cases (database-backed metadata, S3 storage, external file sync), implement the `FileStorageProvider` interface and register it in a Nitro plugin:
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
// nuxt.config.ts
|
|
105
|
+
export default defineNuxtConfig({
|
|
106
|
+
modules: ['nuxt-filer'],
|
|
107
|
+
filer: {
|
|
108
|
+
provider: 'custom',
|
|
109
|
+
},
|
|
110
|
+
})
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
// server/plugins/file-provider.ts
|
|
115
|
+
export default defineNitroPlugin(() => {
|
|
116
|
+
setFileStorageProvider({
|
|
117
|
+
async create(groupId, data, meta) {
|
|
118
|
+
// Store binary data (e.g. S3, unstorage)
|
|
119
|
+
// Store metadata (e.g. Prisma, Drizzle)
|
|
120
|
+
return { id: '...' }
|
|
121
|
+
},
|
|
122
|
+
async get(groupId, id) { /* ... */ },
|
|
123
|
+
async getData(groupId, id) { /* ... */ },
|
|
124
|
+
async getMeta(id) { /* ... */ },
|
|
125
|
+
async list(groupId) { /* ... */ },
|
|
126
|
+
async update(id, meta) { /* ... */ },
|
|
127
|
+
async remove(groupId, id) { /* ... */ },
|
|
128
|
+
async clear(groupId) { /* ... */ },
|
|
129
|
+
async has(groupId, id) { /* ... */ },
|
|
130
|
+
async findByMeta(filter) { /* ... */ },
|
|
131
|
+
|
|
132
|
+
// Optional: external file sync
|
|
133
|
+
external: {
|
|
134
|
+
async sync(groupId, id) { /* ... */ },
|
|
135
|
+
async push(groupId, id, data, meta) { /* ... */ },
|
|
136
|
+
async pull(groupId, externalRef) { /* ... */ },
|
|
137
|
+
},
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### Provider interface
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
interface FileStorageProvider {
|
|
146
|
+
create(groupId: string, data: Buffer | Uint8Array, meta?: FileMeta): Promise<{ id: string }>
|
|
147
|
+
get(groupId: string, id: string): Promise<StoredFile | null>
|
|
148
|
+
getData(groupId: string, id: string): Promise<Buffer | null>
|
|
149
|
+
getMeta(id: string): Promise<FileMeta | null>
|
|
150
|
+
list(groupId: string): Promise<StoredFile[]>
|
|
151
|
+
update(id: string, meta: Partial<FileMeta>): Promise<void>
|
|
152
|
+
remove(groupId: string, id: string): Promise<void>
|
|
153
|
+
clear(groupId: string): Promise<void>
|
|
154
|
+
has(groupId: string, id: string): Promise<boolean>
|
|
155
|
+
findByMeta(filter: { key: string; value: unknown; groupId?: string }): Promise<StoredFile | null>
|
|
156
|
+
external?: FileStorageExternalProvider
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Types
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
interface FileMeta {
|
|
164
|
+
name: string
|
|
165
|
+
mime: string
|
|
166
|
+
type: string
|
|
167
|
+
version: number
|
|
168
|
+
username?: string
|
|
169
|
+
comment?: string
|
|
170
|
+
[key: string]: unknown // extend with your own fields
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
interface StoredFile {
|
|
174
|
+
id: string
|
|
175
|
+
groupId: string
|
|
176
|
+
data?: Buffer
|
|
177
|
+
meta: FileMeta
|
|
178
|
+
external?: ExternalRef
|
|
179
|
+
createdAt?: Date
|
|
180
|
+
updatedAt?: Date
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
interface ExternalRef {
|
|
184
|
+
source: string // e.g. 'jira', 'sharepoint'
|
|
185
|
+
externalId: string
|
|
186
|
+
externalUrl?: string
|
|
187
|
+
cachedAt?: Date
|
|
188
|
+
}
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
## Contribution
|
|
192
|
+
|
|
193
|
+
<details>
|
|
194
|
+
<summary>Local development</summary>
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
# Install dependencies
|
|
198
|
+
pnpm install
|
|
199
|
+
|
|
200
|
+
# Generate type stubs
|
|
201
|
+
pnpm run dev:prepare
|
|
202
|
+
|
|
203
|
+
# Develop with the playground
|
|
204
|
+
pnpm run dev
|
|
205
|
+
|
|
206
|
+
# Run ESLint
|
|
207
|
+
pnpm run lint
|
|
208
|
+
|
|
209
|
+
# Run Vitest
|
|
210
|
+
pnpm run test
|
|
211
|
+
|
|
212
|
+
# Release new version
|
|
213
|
+
pnpm run release
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
</details>
|
|
217
|
+
|
|
218
|
+
## License
|
|
219
|
+
|
|
220
|
+
[MIT](./LICENSE)
|
|
221
|
+
|
|
222
|
+
<!-- Badges -->
|
|
223
|
+
|
|
224
|
+
[npm-version-src]: https://img.shields.io/npm/v/nuxt-filer/latest.svg?style=flat&colorA=020420&colorB=00DC82
|
|
225
|
+
[npm-version-href]: https://npmjs.com/package/nuxt-filer
|
|
226
|
+
[npm-downloads-src]: https://img.shields.io/npm/dm/nuxt-filer.svg?style=flat&colorA=020420&colorB=00DC82
|
|
227
|
+
[npm-downloads-href]: https://npm.chart.dev/nuxt-filer
|
|
228
|
+
[license-src]: https://img.shields.io/npm/l/nuxt-filer.svg?style=flat&colorA=020420&colorB=00DC82
|
|
229
|
+
[license-href]: https://npmjs.com/package/nuxt-filer
|
|
230
|
+
[nuxt-src]: https://img.shields.io/badge/Nuxt-020420?logo=nuxt.js
|
|
231
|
+
[nuxt-href]: https://nuxt.com
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import * as _nuxt_schema from '@nuxt/schema';
|
|
2
|
+
|
|
3
|
+
interface ModuleOptions {
|
|
4
|
+
/** Nitro storage mount name for binary file data. Default: 'documents' */
|
|
5
|
+
storageName?: string;
|
|
6
|
+
/** Base path for the default fs-lite storage driver. Default: '.data/documents' */
|
|
7
|
+
storagePath?: string;
|
|
8
|
+
/** Provider mode. 'unstorage' uses the built-in unstorage provider. 'custom' expects you to call setFileStorageProvider() in a Nitro plugin. Default: 'unstorage' */
|
|
9
|
+
provider?: 'unstorage' | 'custom';
|
|
10
|
+
}
|
|
11
|
+
declare const _default: _nuxt_schema.NuxtModule<ModuleOptions, ModuleOptions, false>;
|
|
12
|
+
|
|
13
|
+
export { _default as default };
|
|
14
|
+
export type { ModuleOptions };
|
package/dist/module.json
ADDED
package/dist/module.mjs
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { defineNuxtModule, createResolver, addServerImports, addImports } from '@nuxt/kit';
|
|
2
|
+
import { consola } from 'consola';
|
|
3
|
+
|
|
4
|
+
const module = defineNuxtModule({
|
|
5
|
+
meta: {
|
|
6
|
+
name: "nuxt-filer",
|
|
7
|
+
configKey: "filer"
|
|
8
|
+
},
|
|
9
|
+
defaults: {
|
|
10
|
+
storageName: "documents",
|
|
11
|
+
storagePath: ".data/documents",
|
|
12
|
+
provider: "unstorage"
|
|
13
|
+
},
|
|
14
|
+
setup(options, nuxt) {
|
|
15
|
+
const resolver = createResolver(import.meta.url);
|
|
16
|
+
addServerImports([
|
|
17
|
+
{
|
|
18
|
+
name: "useFileStorage",
|
|
19
|
+
from: resolver.resolve("./runtime/server/utils/storage")
|
|
20
|
+
}
|
|
21
|
+
]);
|
|
22
|
+
const providerSpecifier = resolver.resolve("./runtime/server/provider");
|
|
23
|
+
addServerImports([
|
|
24
|
+
{ name: "setFileStorageProvider", from: providerSpecifier },
|
|
25
|
+
{ name: "useFileStorageProvider", from: providerSpecifier }
|
|
26
|
+
]);
|
|
27
|
+
addServerImports([
|
|
28
|
+
{
|
|
29
|
+
name: "createUnstorageProvider",
|
|
30
|
+
from: resolver.resolve("./runtime/server/providers/unstorage")
|
|
31
|
+
}
|
|
32
|
+
]);
|
|
33
|
+
const typesSpecifier = "nuxt-filer/runtime/types";
|
|
34
|
+
addImports([
|
|
35
|
+
{ name: "FileMeta", from: typesSpecifier, type: true },
|
|
36
|
+
{ name: "StoredFile", from: typesSpecifier, type: true },
|
|
37
|
+
{ name: "ExternalRef", from: typesSpecifier, type: true },
|
|
38
|
+
{ name: "FileStorageProvider", from: typesSpecifier, type: true },
|
|
39
|
+
{
|
|
40
|
+
name: "FileStorageExternalProvider",
|
|
41
|
+
from: typesSpecifier,
|
|
42
|
+
type: true
|
|
43
|
+
}
|
|
44
|
+
]);
|
|
45
|
+
nuxt.hook("nitro:config", (nitroConfig) => {
|
|
46
|
+
nitroConfig.virtual = nitroConfig.virtual || {};
|
|
47
|
+
nitroConfig.virtual["#nuxt-filer-options"] = `export const storageName = ${JSON.stringify(options.storageName)}`;
|
|
48
|
+
if (options.provider === "unstorage") {
|
|
49
|
+
nitroConfig.storage = nitroConfig.storage || {};
|
|
50
|
+
nitroConfig.storage[options.storageName] = {
|
|
51
|
+
driver: "fsLite",
|
|
52
|
+
base: options.storagePath
|
|
53
|
+
};
|
|
54
|
+
nitroConfig.plugins = nitroConfig.plugins || [];
|
|
55
|
+
nitroConfig.plugins.push(
|
|
56
|
+
resolver.resolve("./runtime/server/plugins/default-provider")
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
consola.success("nuxt-filer ready");
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
export { module as default };
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { storageName } from "#nuxt-filer-options";
|
|
2
|
+
import { createUnstorageProvider } from "../providers/unstorage.js";
|
|
3
|
+
import { setFileStorageProvider } from "../provider.js";
|
|
4
|
+
export default defineNitroPlugin(() => {
|
|
5
|
+
setFileStorageProvider(createUnstorageProvider(storageName));
|
|
6
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
let _provider = null;
|
|
2
|
+
export function setFileStorageProvider(provider) {
|
|
3
|
+
_provider = provider;
|
|
4
|
+
}
|
|
5
|
+
export function useFileStorageProvider() {
|
|
6
|
+
if (!_provider) {
|
|
7
|
+
throw new Error(
|
|
8
|
+
'No file storage provider configured. Call setFileStorageProvider() in a Nitro plugin or set provider option to "unstorage" in module config.'
|
|
9
|
+
);
|
|
10
|
+
}
|
|
11
|
+
return _provider;
|
|
12
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { FileStorageProvider } from '../../../runtime/types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Built-in provider that uses Nitro's useStorage() for both binary data and metadata.
|
|
4
|
+
* Metadata is stored as JSON sidecar files alongside the binary data.
|
|
5
|
+
* No database dependency required.
|
|
6
|
+
*/
|
|
7
|
+
export declare function createUnstorageProvider(storageName: string): FileStorageProvider;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
export function createUnstorageProvider(storageName) {
|
|
3
|
+
function getStorage() {
|
|
4
|
+
return useStorage(storageName);
|
|
5
|
+
}
|
|
6
|
+
function dataKey(groupId, id) {
|
|
7
|
+
return `${groupId}:data:${id}`;
|
|
8
|
+
}
|
|
9
|
+
function metaKey(groupId, id) {
|
|
10
|
+
return `${groupId}:meta:${id}`;
|
|
11
|
+
}
|
|
12
|
+
return {
|
|
13
|
+
async create(groupId, data, meta) {
|
|
14
|
+
const storage = getStorage();
|
|
15
|
+
const id = randomUUID();
|
|
16
|
+
await storage.setItemRaw(dataKey(groupId, id), data);
|
|
17
|
+
if (meta) {
|
|
18
|
+
await storage.setItem(metaKey(groupId, id), {
|
|
19
|
+
...meta,
|
|
20
|
+
_createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
21
|
+
_updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
return { id };
|
|
25
|
+
},
|
|
26
|
+
async get(groupId, id) {
|
|
27
|
+
const storage = getStorage();
|
|
28
|
+
const data = await storage.getItemRaw(dataKey(groupId, id));
|
|
29
|
+
const metaData = await storage.getItem(metaKey(groupId, id));
|
|
30
|
+
if (!data && !metaData) return null;
|
|
31
|
+
return {
|
|
32
|
+
id,
|
|
33
|
+
groupId,
|
|
34
|
+
data: data ?? void 0,
|
|
35
|
+
meta: stripInternal(metaData),
|
|
36
|
+
createdAt: metaData?._createdAt ? new Date(metaData._createdAt) : void 0,
|
|
37
|
+
updatedAt: metaData?._updatedAt ? new Date(metaData._updatedAt) : void 0
|
|
38
|
+
};
|
|
39
|
+
},
|
|
40
|
+
async getData(groupId, id) {
|
|
41
|
+
return await getStorage().getItemRaw(dataKey(groupId, id));
|
|
42
|
+
},
|
|
43
|
+
async getMeta(id) {
|
|
44
|
+
const storage = getStorage();
|
|
45
|
+
const keys = await storage.getKeys();
|
|
46
|
+
const key = keys.find((k) => k.endsWith(`:meta:${id}`));
|
|
47
|
+
if (!key) return null;
|
|
48
|
+
const metaData = await storage.getItem(key);
|
|
49
|
+
return metaData ? stripInternal(metaData) : null;
|
|
50
|
+
},
|
|
51
|
+
async list(groupId) {
|
|
52
|
+
const storage = getStorage();
|
|
53
|
+
const keys = await storage.getKeys(groupId);
|
|
54
|
+
const metaKeys = keys.filter((k) => k.includes(":meta:"));
|
|
55
|
+
const files = [];
|
|
56
|
+
for (const key of metaKeys) {
|
|
57
|
+
const parts = key.split(":");
|
|
58
|
+
const id = parts[parts.length - 1];
|
|
59
|
+
const metaData = await storage.getItem(key);
|
|
60
|
+
if (!metaData) continue;
|
|
61
|
+
files.push({
|
|
62
|
+
id,
|
|
63
|
+
groupId,
|
|
64
|
+
meta: stripInternal(metaData),
|
|
65
|
+
createdAt: metaData._createdAt ? new Date(metaData._createdAt) : void 0,
|
|
66
|
+
updatedAt: metaData._updatedAt ? new Date(metaData._updatedAt) : void 0
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return files;
|
|
70
|
+
},
|
|
71
|
+
async update(id, meta) {
|
|
72
|
+
const storage = getStorage();
|
|
73
|
+
const keys = await storage.getKeys();
|
|
74
|
+
const key = keys.find((k) => k.endsWith(`:meta:${id}`));
|
|
75
|
+
if (!key) throw new Error(`File metadata not found: ${id}`);
|
|
76
|
+
const existing = await storage.getItem(key);
|
|
77
|
+
await storage.setItem(key, {
|
|
78
|
+
...existing,
|
|
79
|
+
...meta,
|
|
80
|
+
_updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
81
|
+
});
|
|
82
|
+
},
|
|
83
|
+
async remove(groupId, id) {
|
|
84
|
+
const storage = getStorage();
|
|
85
|
+
await storage.removeItem(dataKey(groupId, id));
|
|
86
|
+
await storage.removeItem(metaKey(groupId, id));
|
|
87
|
+
},
|
|
88
|
+
async clear(groupId) {
|
|
89
|
+
const storage = getStorage();
|
|
90
|
+
const keys = await storage.getKeys(groupId);
|
|
91
|
+
for (const key of keys) {
|
|
92
|
+
await storage.removeItem(key);
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
async has(groupId, id) {
|
|
96
|
+
return await getStorage().hasItem(dataKey(groupId, id));
|
|
97
|
+
},
|
|
98
|
+
async findByMeta(filter) {
|
|
99
|
+
const storage = getStorage();
|
|
100
|
+
const prefix = filter.groupId ?? "";
|
|
101
|
+
const keys = await storage.getKeys(prefix);
|
|
102
|
+
const metaKeys = keys.filter((k) => k.includes(":meta:"));
|
|
103
|
+
for (const key of metaKeys) {
|
|
104
|
+
const metaData = await storage.getItem(key);
|
|
105
|
+
if (!metaData) continue;
|
|
106
|
+
if (metaData[filter.key] === filter.value) {
|
|
107
|
+
const parts = key.split(":");
|
|
108
|
+
const id = parts[parts.length - 1];
|
|
109
|
+
const metaIdx = parts.indexOf("meta");
|
|
110
|
+
const groupId = parts.slice(0, metaIdx).join(":");
|
|
111
|
+
return {
|
|
112
|
+
id,
|
|
113
|
+
groupId,
|
|
114
|
+
meta: stripInternal(metaData),
|
|
115
|
+
createdAt: metaData._createdAt ? new Date(metaData._createdAt) : void 0,
|
|
116
|
+
updatedAt: metaData._updatedAt ? new Date(metaData._updatedAt) : void 0
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
function stripInternal(meta) {
|
|
125
|
+
if (!meta) return { name: "", mime: "", type: "", version: 0 };
|
|
126
|
+
const { _createdAt, _updatedAt, ...rest } = meta;
|
|
127
|
+
return rest;
|
|
128
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { FileMeta, StoredFile, ExternalRef, FileStorageProvider } from '../../../runtime/types.js';
|
|
2
|
+
export type { FileMeta, StoredFile, ExternalRef, FileStorageProvider };
|
|
3
|
+
export declare const useFileStorage: () => {
|
|
4
|
+
upload: (groupId: string, data: Buffer | Uint8Array, options?: {
|
|
5
|
+
meta?: FileMeta;
|
|
6
|
+
}) => Promise<string>;
|
|
7
|
+
list: (groupId: string) => Promise<StoredFile[]>;
|
|
8
|
+
get: (groupId: string, id: string) => Promise<StoredFile | null>;
|
|
9
|
+
getData: (groupId: string, id: string) => Promise<Buffer | null>;
|
|
10
|
+
getMeta: (id: string) => Promise<FileMeta | null>;
|
|
11
|
+
updateMeta: (id: string, meta: Partial<FileMeta>) => Promise<void>;
|
|
12
|
+
remove: (groupId: string, id: string) => Promise<void>;
|
|
13
|
+
clear: (groupId: string) => Promise<void>;
|
|
14
|
+
has: (groupId: string, id: string) => Promise<boolean>;
|
|
15
|
+
findByMeta: (key: string, value: unknown, groupId?: string) => Promise<StoredFile | null>;
|
|
16
|
+
checkDuplicate: (groupId: string, key: string, value: unknown) => Promise<boolean>;
|
|
17
|
+
getLatestVersions: (groupId: string) => Promise<StoredFile[]>;
|
|
18
|
+
getNextVersionNumber: (files: StoredFile[], name: string) => number;
|
|
19
|
+
external: {
|
|
20
|
+
sync: (groupId: string, id: string) => Promise<void>;
|
|
21
|
+
push: (groupId: string, id: string, data: Buffer, meta: FileMeta) => Promise<void>;
|
|
22
|
+
pull: (groupId: string, externalRef: ExternalRef) => Promise<StoredFile>;
|
|
23
|
+
} | undefined;
|
|
24
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { defu } from "defu";
|
|
2
|
+
import { useFileStorageProvider } from "../provider.js";
|
|
3
|
+
export const useFileStorage = () => {
|
|
4
|
+
const provider = useFileStorageProvider();
|
|
5
|
+
async function upload(groupId, data, options = {}) {
|
|
6
|
+
const { id } = await provider.create(groupId, data, options.meta);
|
|
7
|
+
return id;
|
|
8
|
+
}
|
|
9
|
+
async function list(groupId) {
|
|
10
|
+
return await provider.list(groupId);
|
|
11
|
+
}
|
|
12
|
+
async function get(groupId, id) {
|
|
13
|
+
return await provider.get(groupId, id);
|
|
14
|
+
}
|
|
15
|
+
async function getData(groupId, id) {
|
|
16
|
+
return await provider.getData(groupId, id);
|
|
17
|
+
}
|
|
18
|
+
async function getMeta(id) {
|
|
19
|
+
return await provider.getMeta(id);
|
|
20
|
+
}
|
|
21
|
+
async function updateMeta(id, meta) {
|
|
22
|
+
const existing = await provider.getMeta(id);
|
|
23
|
+
const merged = defu(meta, existing);
|
|
24
|
+
await provider.update(id, merged);
|
|
25
|
+
}
|
|
26
|
+
async function remove(groupId, id) {
|
|
27
|
+
await provider.remove(groupId, id);
|
|
28
|
+
}
|
|
29
|
+
async function clear(groupId) {
|
|
30
|
+
await provider.clear(groupId);
|
|
31
|
+
}
|
|
32
|
+
async function has(groupId, id) {
|
|
33
|
+
return await provider.has(groupId, id);
|
|
34
|
+
}
|
|
35
|
+
async function findByMeta(key, value, groupId) {
|
|
36
|
+
return await provider.findByMeta({ key, value, groupId });
|
|
37
|
+
}
|
|
38
|
+
async function checkDuplicate(groupId, key, value) {
|
|
39
|
+
const file = await provider.findByMeta({ key, value, groupId });
|
|
40
|
+
return !!file;
|
|
41
|
+
}
|
|
42
|
+
async function getLatestVersions(groupId) {
|
|
43
|
+
const files = await list(groupId);
|
|
44
|
+
const fileMap = /* @__PURE__ */ new Map();
|
|
45
|
+
for (const file of files) {
|
|
46
|
+
const existing = fileMap.get(file.meta.name);
|
|
47
|
+
const version = file.meta.version ?? 0;
|
|
48
|
+
const existingVersion = existing?.meta.version ?? 0;
|
|
49
|
+
if (!existing || version > existingVersion) {
|
|
50
|
+
fileMap.set(file.meta.name, file);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return Array.from(fileMap.values());
|
|
54
|
+
}
|
|
55
|
+
function getNextVersionNumber(files, name) {
|
|
56
|
+
const matching = files.filter((f) => f.meta.name === name).sort((a, b) => a.meta.version - b.meta.version);
|
|
57
|
+
const newest = matching[matching.length - 1];
|
|
58
|
+
if (!newest) return 1;
|
|
59
|
+
return newest.meta.version + 1;
|
|
60
|
+
}
|
|
61
|
+
const external = provider.external ? {
|
|
62
|
+
sync: provider.external.sync.bind(provider.external),
|
|
63
|
+
push: provider.external.push.bind(provider.external),
|
|
64
|
+
pull: provider.external.pull.bind(provider.external)
|
|
65
|
+
} : void 0;
|
|
66
|
+
return {
|
|
67
|
+
upload,
|
|
68
|
+
list,
|
|
69
|
+
get,
|
|
70
|
+
getData,
|
|
71
|
+
getMeta,
|
|
72
|
+
updateMeta,
|
|
73
|
+
remove,
|
|
74
|
+
clear,
|
|
75
|
+
has,
|
|
76
|
+
findByMeta,
|
|
77
|
+
checkDuplicate,
|
|
78
|
+
getLatestVersions,
|
|
79
|
+
getNextVersionNumber,
|
|
80
|
+
external
|
|
81
|
+
};
|
|
82
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export interface FileMeta {
|
|
2
|
+
name: string;
|
|
3
|
+
mime: string;
|
|
4
|
+
type: string;
|
|
5
|
+
version: number;
|
|
6
|
+
username?: string;
|
|
7
|
+
comment?: string;
|
|
8
|
+
[key: string]: unknown;
|
|
9
|
+
}
|
|
10
|
+
export interface ExternalRef {
|
|
11
|
+
/** External system identifier, e.g. 'jira', 'sharepoint' */
|
|
12
|
+
source: string;
|
|
13
|
+
/** ID of the file in the external system */
|
|
14
|
+
externalId: string;
|
|
15
|
+
/** URL to the file in the external system */
|
|
16
|
+
externalUrl?: string;
|
|
17
|
+
/** When metadata/thumbnail was last synced */
|
|
18
|
+
cachedAt?: Date;
|
|
19
|
+
}
|
|
20
|
+
export interface StoredFile {
|
|
21
|
+
id: string;
|
|
22
|
+
groupId: string;
|
|
23
|
+
data?: Buffer;
|
|
24
|
+
meta: FileMeta;
|
|
25
|
+
external?: ExternalRef;
|
|
26
|
+
createdAt?: Date;
|
|
27
|
+
updatedAt?: Date;
|
|
28
|
+
}
|
|
29
|
+
export interface FileStorageExternalProvider {
|
|
30
|
+
/** Sync local cache with the external system (pull latest metadata/thumbnail) */
|
|
31
|
+
sync(groupId: string, id: string): Promise<void>;
|
|
32
|
+
/** Push local file data and metadata to the external system */
|
|
33
|
+
push(groupId: string, id: string, data: Buffer, meta: FileMeta): Promise<void>;
|
|
34
|
+
/** Pull a file from the external system into local storage */
|
|
35
|
+
pull(groupId: string, externalRef: ExternalRef): Promise<StoredFile>;
|
|
36
|
+
}
|
|
37
|
+
export interface FileStorageProvider {
|
|
38
|
+
create(groupId: string, data: Buffer | Uint8Array, meta?: FileMeta): Promise<{
|
|
39
|
+
id: string;
|
|
40
|
+
}>;
|
|
41
|
+
get(groupId: string, id: string): Promise<StoredFile | null>;
|
|
42
|
+
getData(groupId: string, id: string): Promise<Buffer | null>;
|
|
43
|
+
getMeta(id: string): Promise<FileMeta | null>;
|
|
44
|
+
list(groupId: string): Promise<StoredFile[]>;
|
|
45
|
+
update(id: string, meta: Partial<FileMeta>): Promise<void>;
|
|
46
|
+
remove(groupId: string, id: string): Promise<void>;
|
|
47
|
+
clear(groupId: string): Promise<void>;
|
|
48
|
+
has(groupId: string, id: string): Promise<boolean>;
|
|
49
|
+
findByMeta(filter: {
|
|
50
|
+
key: string;
|
|
51
|
+
value: unknown;
|
|
52
|
+
groupId?: string;
|
|
53
|
+
}): Promise<StoredFile | null>;
|
|
54
|
+
/** Optional external file sync support */
|
|
55
|
+
external?: FileStorageExternalProvider;
|
|
56
|
+
}
|
|
File without changes
|
package/dist/types.d.mts
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "nuxt-filer",
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"description": "File storage module for Nuxt",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/Niki2k1/nuxt-filer.git"
|
|
8
|
+
},
|
|
9
|
+
"homepage": "https://github.com/Niki2k1/nuxt-filer#readme",
|
|
10
|
+
"bugs": {
|
|
11
|
+
"url": "https://github.com/Niki2k1/nuxt-filer/issues"
|
|
12
|
+
},
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"author": "Niki2k1",
|
|
15
|
+
"type": "module",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/types.d.mts",
|
|
19
|
+
"import": "./dist/module.mjs"
|
|
20
|
+
},
|
|
21
|
+
"./runtime/types": {
|
|
22
|
+
"types": "./src/runtime/types.ts",
|
|
23
|
+
"import": "./src/runtime/types.ts",
|
|
24
|
+
"default": "./src/runtime/types.ts"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"main": "./dist/module.mjs",
|
|
28
|
+
"typesVersions": {
|
|
29
|
+
"*": {
|
|
30
|
+
".": [
|
|
31
|
+
"./dist/types.d.mts"
|
|
32
|
+
]
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"dist",
|
|
37
|
+
"src/runtime"
|
|
38
|
+
],
|
|
39
|
+
"scripts": {
|
|
40
|
+
"prepack": "nuxt-module-build build",
|
|
41
|
+
"dev": "npm run dev:prepare && nuxi dev playground",
|
|
42
|
+
"dev:build": "nuxi build playground",
|
|
43
|
+
"dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxi prepare playground",
|
|
44
|
+
"release": "npm run lint && npm run test && npm run prepack && changelogen --release && npm publish && git push --follow-tags",
|
|
45
|
+
"lint": "eslint .",
|
|
46
|
+
"test": "vitest run",
|
|
47
|
+
"test:watch": "vitest watch",
|
|
48
|
+
"test:types": "vue-tsc --noEmit && cd playground && vue-tsc --noEmit"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"@nuxt/kit": "^4.4.2",
|
|
52
|
+
"consola": "^3.4.2",
|
|
53
|
+
"defu": "^6.1.4"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@nuxt/devtools": "^3.2.4",
|
|
57
|
+
"@nuxt/eslint-config": "^1.15.2",
|
|
58
|
+
"@nuxt/module-builder": "^1.0.2",
|
|
59
|
+
"@nuxt/schema": "^4.4.2",
|
|
60
|
+
"@nuxt/test-utils": "^4.0.0",
|
|
61
|
+
"@types/node": "latest",
|
|
62
|
+
"changelogen": "^0.6.2",
|
|
63
|
+
"eslint": "^10.1.0",
|
|
64
|
+
"nuxt": "^4.4.2",
|
|
65
|
+
"typescript": "~6.0.2",
|
|
66
|
+
"vitest": "^4.1.2",
|
|
67
|
+
"vue-tsc": "^3.2.6"
|
|
68
|
+
},
|
|
69
|
+
"packageManager": "pnpm@10.33.0",
|
|
70
|
+
"pnpm": {
|
|
71
|
+
"onlyBuiltDependencies": [
|
|
72
|
+
"esbuild"
|
|
73
|
+
]
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// @ts-expect-error virtual module
|
|
2
|
+
import { storageName } from '#nuxt-filer-options';
|
|
3
|
+
import { createUnstorageProvider } from '../providers/unstorage';
|
|
4
|
+
import { setFileStorageProvider } from '../provider';
|
|
5
|
+
|
|
6
|
+
export default defineNitroPlugin(() => {
|
|
7
|
+
setFileStorageProvider(createUnstorageProvider(storageName));
|
|
8
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { FileStorageProvider } from '../../runtime/types';
|
|
2
|
+
|
|
3
|
+
let _provider: FileStorageProvider | null = null;
|
|
4
|
+
|
|
5
|
+
export function setFileStorageProvider(provider: FileStorageProvider) {
|
|
6
|
+
_provider = provider;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function useFileStorageProvider(): FileStorageProvider {
|
|
10
|
+
if (!_provider) {
|
|
11
|
+
throw new Error(
|
|
12
|
+
'No file storage provider configured. Call setFileStorageProvider() in a Nitro plugin or set provider option to "unstorage" in module config.'
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
return _provider;
|
|
16
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import type {
|
|
3
|
+
FileStorageProvider,
|
|
4
|
+
FileMeta,
|
|
5
|
+
StoredFile,
|
|
6
|
+
} from '../../../runtime/types';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Built-in provider that uses Nitro's useStorage() for both binary data and metadata.
|
|
10
|
+
* Metadata is stored as JSON sidecar files alongside the binary data.
|
|
11
|
+
* No database dependency required.
|
|
12
|
+
*/
|
|
13
|
+
export function createUnstorageProvider(
|
|
14
|
+
storageName: string
|
|
15
|
+
): FileStorageProvider {
|
|
16
|
+
function getStorage() {
|
|
17
|
+
return useStorage(storageName);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function dataKey(groupId: string, id: string) {
|
|
21
|
+
return `${groupId}:data:${id}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function metaKey(groupId: string, id: string) {
|
|
25
|
+
return `${groupId}:meta:${id}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
async create(groupId, data, meta) {
|
|
30
|
+
const storage = getStorage();
|
|
31
|
+
const id = randomUUID();
|
|
32
|
+
|
|
33
|
+
await storage.setItemRaw(dataKey(groupId, id), data);
|
|
34
|
+
|
|
35
|
+
if (meta) {
|
|
36
|
+
await storage.setItem(metaKey(groupId, id), {
|
|
37
|
+
...meta,
|
|
38
|
+
_createdAt: new Date().toISOString(),
|
|
39
|
+
_updatedAt: new Date().toISOString(),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return { id };
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
async get(groupId, id) {
|
|
47
|
+
const storage = getStorage();
|
|
48
|
+
const data = await storage.getItemRaw<Buffer>(dataKey(groupId, id));
|
|
49
|
+
const metaData = await storage.getItem<
|
|
50
|
+
FileMeta & { _createdAt?: string; _updatedAt?: string }
|
|
51
|
+
>(metaKey(groupId, id));
|
|
52
|
+
|
|
53
|
+
if (!data && !metaData) return null;
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
id,
|
|
57
|
+
groupId,
|
|
58
|
+
data: data ?? undefined,
|
|
59
|
+
meta: stripInternal(metaData),
|
|
60
|
+
createdAt: metaData?._createdAt
|
|
61
|
+
? new Date(metaData._createdAt)
|
|
62
|
+
: undefined,
|
|
63
|
+
updatedAt: metaData?._updatedAt
|
|
64
|
+
? new Date(metaData._updatedAt)
|
|
65
|
+
: undefined,
|
|
66
|
+
};
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
async getData(groupId, id) {
|
|
70
|
+
return await getStorage().getItemRaw<Buffer>(dataKey(groupId, id));
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
async getMeta(id) {
|
|
74
|
+
const storage = getStorage();
|
|
75
|
+
const keys = await storage.getKeys();
|
|
76
|
+
const key = keys.find((k) => k.endsWith(`:meta:${id}`));
|
|
77
|
+
if (!key) return null;
|
|
78
|
+
|
|
79
|
+
const metaData = await storage.getItem<FileMeta>(key);
|
|
80
|
+
return metaData ? stripInternal(metaData) : null;
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
async list(groupId) {
|
|
84
|
+
const storage = getStorage();
|
|
85
|
+
const keys = await storage.getKeys(groupId);
|
|
86
|
+
|
|
87
|
+
// Get unique file IDs by filtering meta keys (format: groupId:meta:id)
|
|
88
|
+
const metaKeys = keys.filter((k) => k.includes(':meta:'));
|
|
89
|
+
const files: StoredFile[] = [];
|
|
90
|
+
|
|
91
|
+
for (const key of metaKeys) {
|
|
92
|
+
const parts = key.split(':');
|
|
93
|
+
const id = parts[parts.length - 1]!;
|
|
94
|
+
|
|
95
|
+
const metaData = await storage.getItem<
|
|
96
|
+
FileMeta & { _createdAt?: string; _updatedAt?: string }
|
|
97
|
+
>(key);
|
|
98
|
+
if (!metaData) continue;
|
|
99
|
+
|
|
100
|
+
files.push({
|
|
101
|
+
id,
|
|
102
|
+
groupId,
|
|
103
|
+
meta: stripInternal(metaData),
|
|
104
|
+
createdAt: metaData._createdAt
|
|
105
|
+
? new Date(metaData._createdAt)
|
|
106
|
+
: undefined,
|
|
107
|
+
updatedAt: metaData._updatedAt
|
|
108
|
+
? new Date(metaData._updatedAt)
|
|
109
|
+
: undefined,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return files;
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
async update(id, meta) {
|
|
117
|
+
const storage = getStorage();
|
|
118
|
+
const keys = await storage.getKeys();
|
|
119
|
+
const key = keys.find((k) => k.endsWith(`:meta:${id}`));
|
|
120
|
+
if (!key) throw new Error(`File metadata not found: ${id}`);
|
|
121
|
+
|
|
122
|
+
const existing = await storage.getItem<
|
|
123
|
+
FileMeta & { _createdAt?: string; _updatedAt?: string }
|
|
124
|
+
>(key);
|
|
125
|
+
|
|
126
|
+
await storage.setItem(key, {
|
|
127
|
+
...existing,
|
|
128
|
+
...meta,
|
|
129
|
+
_updatedAt: new Date().toISOString(),
|
|
130
|
+
});
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
async remove(groupId, id) {
|
|
134
|
+
const storage = getStorage();
|
|
135
|
+
await storage.removeItem(dataKey(groupId, id));
|
|
136
|
+
await storage.removeItem(metaKey(groupId, id));
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
async clear(groupId) {
|
|
140
|
+
const storage = getStorage();
|
|
141
|
+
const keys = await storage.getKeys(groupId);
|
|
142
|
+
for (const key of keys) {
|
|
143
|
+
await storage.removeItem(key);
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
async has(groupId, id) {
|
|
148
|
+
return await getStorage().hasItem(dataKey(groupId, id));
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
async findByMeta(filter) {
|
|
152
|
+
const storage = getStorage();
|
|
153
|
+
const prefix = filter.groupId ?? '';
|
|
154
|
+
const keys = await storage.getKeys(prefix);
|
|
155
|
+
const metaKeys = keys.filter((k) => k.includes(':meta:'));
|
|
156
|
+
|
|
157
|
+
for (const key of metaKeys) {
|
|
158
|
+
const metaData = await storage.getItem<
|
|
159
|
+
FileMeta & { _createdAt?: string; _updatedAt?: string }
|
|
160
|
+
>(key);
|
|
161
|
+
if (!metaData) continue;
|
|
162
|
+
|
|
163
|
+
if (metaData[filter.key] === filter.value) {
|
|
164
|
+
const parts = key.split(':');
|
|
165
|
+
const id = parts[parts.length - 1]!;
|
|
166
|
+
const metaIdx = parts.indexOf('meta');
|
|
167
|
+
const groupId = parts.slice(0, metaIdx).join(':');
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
id,
|
|
171
|
+
groupId,
|
|
172
|
+
meta: stripInternal(metaData),
|
|
173
|
+
createdAt: metaData._createdAt
|
|
174
|
+
? new Date(metaData._createdAt)
|
|
175
|
+
: undefined,
|
|
176
|
+
updatedAt: metaData._updatedAt
|
|
177
|
+
? new Date(metaData._updatedAt)
|
|
178
|
+
: undefined,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return null;
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function stripInternal(
|
|
189
|
+
meta: (FileMeta & { _createdAt?: string; _updatedAt?: string }) | null
|
|
190
|
+
): FileMeta {
|
|
191
|
+
if (!meta) return { name: '', mime: '', type: '', version: 0 };
|
|
192
|
+
const { _createdAt, _updatedAt, ...rest } = meta;
|
|
193
|
+
return rest as FileMeta;
|
|
194
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { defu } from 'defu';
|
|
2
|
+
import type {
|
|
3
|
+
FileMeta,
|
|
4
|
+
StoredFile,
|
|
5
|
+
ExternalRef,
|
|
6
|
+
FileStorageProvider,
|
|
7
|
+
} from '../../../runtime/types';
|
|
8
|
+
import { useFileStorageProvider } from '../provider';
|
|
9
|
+
|
|
10
|
+
export type { FileMeta, StoredFile, ExternalRef, FileStorageProvider };
|
|
11
|
+
|
|
12
|
+
export const useFileStorage = () => {
|
|
13
|
+
const provider = useFileStorageProvider();
|
|
14
|
+
|
|
15
|
+
async function upload(
|
|
16
|
+
groupId: string,
|
|
17
|
+
data: Buffer | Uint8Array,
|
|
18
|
+
options: { meta?: FileMeta } = {}
|
|
19
|
+
): Promise<string> {
|
|
20
|
+
const { id } = await provider.create(groupId, data, options.meta);
|
|
21
|
+
return id;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function list(groupId: string): Promise<StoredFile[]> {
|
|
25
|
+
return await provider.list(groupId);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function get(
|
|
29
|
+
groupId: string,
|
|
30
|
+
id: string
|
|
31
|
+
): Promise<StoredFile | null> {
|
|
32
|
+
return await provider.get(groupId, id);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function getData(
|
|
36
|
+
groupId: string,
|
|
37
|
+
id: string
|
|
38
|
+
): Promise<Buffer | null> {
|
|
39
|
+
return await provider.getData(groupId, id);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function getMeta(id: string): Promise<FileMeta | null> {
|
|
43
|
+
return await provider.getMeta(id);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function updateMeta(id: string, meta: Partial<FileMeta>) {
|
|
47
|
+
const existing = await provider.getMeta(id);
|
|
48
|
+
const merged = defu(meta, existing) as Partial<FileMeta>;
|
|
49
|
+
await provider.update(id, merged);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function remove(groupId: string, id: string) {
|
|
53
|
+
await provider.remove(groupId, id);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function clear(groupId: string) {
|
|
57
|
+
await provider.clear(groupId);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function has(groupId: string, id: string): Promise<boolean> {
|
|
61
|
+
return await provider.has(groupId, id);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function findByMeta(
|
|
65
|
+
key: string,
|
|
66
|
+
value: unknown,
|
|
67
|
+
groupId?: string
|
|
68
|
+
): Promise<StoredFile | null> {
|
|
69
|
+
return await provider.findByMeta({ key, value, groupId });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function checkDuplicate(
|
|
73
|
+
groupId: string,
|
|
74
|
+
key: string,
|
|
75
|
+
value: unknown
|
|
76
|
+
): Promise<boolean> {
|
|
77
|
+
const file = await provider.findByMeta({ key, value, groupId });
|
|
78
|
+
return !!file;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Get the latest version of each file (by name) within a group.
|
|
83
|
+
*/
|
|
84
|
+
async function getLatestVersions(groupId: string): Promise<StoredFile[]> {
|
|
85
|
+
const files = await list(groupId);
|
|
86
|
+
const fileMap = new Map<string, StoredFile>();
|
|
87
|
+
|
|
88
|
+
for (const file of files) {
|
|
89
|
+
const existing = fileMap.get(file.meta.name);
|
|
90
|
+
const version = file.meta.version ?? 0;
|
|
91
|
+
const existingVersion = existing?.meta.version ?? 0;
|
|
92
|
+
|
|
93
|
+
if (!existing || version > existingVersion) {
|
|
94
|
+
fileMap.set(file.meta.name, file);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return Array.from(fileMap.values());
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Determine the next version number for a file name.
|
|
103
|
+
*/
|
|
104
|
+
function getNextVersionNumber(files: StoredFile[], name: string): number {
|
|
105
|
+
const matching = files
|
|
106
|
+
.filter((f) => f.meta.name === name)
|
|
107
|
+
.sort((a, b) => a.meta.version - b.meta.version);
|
|
108
|
+
|
|
109
|
+
const newest = matching[matching.length - 1];
|
|
110
|
+
if (!newest) return 1;
|
|
111
|
+
|
|
112
|
+
return newest.meta.version + 1;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// External file operations (only available if provider supports it)
|
|
116
|
+
const external = provider.external
|
|
117
|
+
? {
|
|
118
|
+
sync: provider.external.sync.bind(provider.external),
|
|
119
|
+
push: provider.external.push.bind(provider.external),
|
|
120
|
+
pull: provider.external.pull.bind(provider.external),
|
|
121
|
+
}
|
|
122
|
+
: undefined;
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
upload,
|
|
126
|
+
list,
|
|
127
|
+
get,
|
|
128
|
+
getData,
|
|
129
|
+
getMeta,
|
|
130
|
+
updateMeta,
|
|
131
|
+
remove,
|
|
132
|
+
clear,
|
|
133
|
+
has,
|
|
134
|
+
findByMeta,
|
|
135
|
+
checkDuplicate,
|
|
136
|
+
getLatestVersions,
|
|
137
|
+
getNextVersionNumber,
|
|
138
|
+
external,
|
|
139
|
+
};
|
|
140
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export interface FileMeta {
|
|
2
|
+
name: string;
|
|
3
|
+
mime: string;
|
|
4
|
+
type: string;
|
|
5
|
+
version: number;
|
|
6
|
+
username?: string;
|
|
7
|
+
comment?: string;
|
|
8
|
+
[key: string]: unknown;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ExternalRef {
|
|
12
|
+
/** External system identifier, e.g. 'jira', 'sharepoint' */
|
|
13
|
+
source: string;
|
|
14
|
+
/** ID of the file in the external system */
|
|
15
|
+
externalId: string;
|
|
16
|
+
/** URL to the file in the external system */
|
|
17
|
+
externalUrl?: string;
|
|
18
|
+
/** When metadata/thumbnail was last synced */
|
|
19
|
+
cachedAt?: Date;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface StoredFile {
|
|
23
|
+
id: string;
|
|
24
|
+
groupId: string;
|
|
25
|
+
data?: Buffer;
|
|
26
|
+
meta: FileMeta;
|
|
27
|
+
external?: ExternalRef;
|
|
28
|
+
createdAt?: Date;
|
|
29
|
+
updatedAt?: Date;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface FileStorageExternalProvider {
|
|
33
|
+
/** Sync local cache with the external system (pull latest metadata/thumbnail) */
|
|
34
|
+
sync(groupId: string, id: string): Promise<void>;
|
|
35
|
+
/** Push local file data and metadata to the external system */
|
|
36
|
+
push(
|
|
37
|
+
groupId: string,
|
|
38
|
+
id: string,
|
|
39
|
+
data: Buffer,
|
|
40
|
+
meta: FileMeta
|
|
41
|
+
): Promise<void>;
|
|
42
|
+
/** Pull a file from the external system into local storage */
|
|
43
|
+
pull(groupId: string, externalRef: ExternalRef): Promise<StoredFile>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface FileStorageProvider {
|
|
47
|
+
create(
|
|
48
|
+
groupId: string,
|
|
49
|
+
data: Buffer | Uint8Array,
|
|
50
|
+
meta?: FileMeta
|
|
51
|
+
): Promise<{ id: string }>;
|
|
52
|
+
get(groupId: string, id: string): Promise<StoredFile | null>;
|
|
53
|
+
getData(groupId: string, id: string): Promise<Buffer | null>;
|
|
54
|
+
getMeta(id: string): Promise<FileMeta | null>;
|
|
55
|
+
list(groupId: string): Promise<StoredFile[]>;
|
|
56
|
+
update(id: string, meta: Partial<FileMeta>): Promise<void>;
|
|
57
|
+
remove(groupId: string, id: string): Promise<void>;
|
|
58
|
+
clear(groupId: string): Promise<void>;
|
|
59
|
+
has(groupId: string, id: string): Promise<boolean>;
|
|
60
|
+
findByMeta(filter: {
|
|
61
|
+
key: string;
|
|
62
|
+
value: unknown;
|
|
63
|
+
groupId?: string;
|
|
64
|
+
}): Promise<StoredFile | null>;
|
|
65
|
+
|
|
66
|
+
/** Optional external file sync support */
|
|
67
|
+
external?: FileStorageExternalProvider;
|
|
68
|
+
}
|