diff --git a/.env.example b/.env.example index 51e03c323..5c9a02948 100644 --- a/.env.example +++ b/.env.example @@ -8,3 +8,8 @@ VITE_CLOUDFLARE_KV_NS_ID= VITE_CLOUDFLARE_TOKEN= VITE_UPLOADTHING_TOKEN= + +VITE_APPWRITE_ENDPOINT= +VITE_APPWRITE_PROJECT_ID= +VITE_APPWRITE_BUCKET_ID= +VITE_APPWRITE_API_KEY= diff --git a/package.json b/package.json index dca0ecc1d..838dd7d33 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "@vitest/coverage-v8": "^4.0.12", "aws4fetch": "^1.0.20", "azurite": "^3.35.0", + "base-x": "^5.0.1", "changelogen": "^0.6.2", "chokidar": "^4.0.3", "citty": "^0.1.6", @@ -74,6 +75,7 @@ "mlly": "^1.8.0", "mongodb": "^7.0.0", "mongodb-memory-server": "^10.3.0", + "node-appwrite": "^21.1.0", "obuild": "^0.4.2", "ofetch": "^1.5.1", "prettier": "^3.6.2", @@ -107,6 +109,7 @@ "ioredis": "^5.8.2", "lru-cache": "^11.2.2", "mongodb": "^6|^7", + "node-appwrite": "^21.1.0", "ofetch": "*", "uploadthing": "^7.7.4" }, @@ -153,6 +156,9 @@ "@vercel/kv": { "optional": true }, + "node-appwrite": { + "optional": true + }, "aws4fetch": { "optional": true }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bd89ee0f1..d6ba055b1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,6 +80,9 @@ importers: azurite: specifier: ^3.35.0 version: 3.35.0 + base-x: + specifier: ^5.0.1 + version: 5.0.1 changelogen: specifier: ^0.6.2 version: 0.6.2(magicast@0.5.1) @@ -137,6 +140,9 @@ importers: mongodb-memory-server: specifier: ^10.3.0 version: 10.3.0 + node-appwrite: + specifier: ^21.1.0 + version: 21.1.0 obuild: specifier: ^0.4.2 version: 0.4.2(magicast@0.5.1)(typescript@5.9.3) @@ -2088,6 +2094,9 @@ packages: bare-abort-controller: optional: true + base-x@5.0.1: + resolution: {integrity: sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -3678,11 +3687,17 @@ packages: node-abort-controller@3.1.1: resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} + node-appwrite@21.1.0: + resolution: {integrity: sha512-HRK5BzN19vgvaH/EeNsigK24t4ngJ1AoiltK5JtahxP6uyMRztzkD8cXP+z9jj/xOjz7ySfQ9YypNyhNr6zVkA==} + node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} deprecated: Use your platform's native DOMException instead + node-fetch-native-with-agent@1.7.2: + resolution: {integrity: sha512-5MaOOCuJEvcckoz7/tjdx1M6OusOY6Xc5f459IaruGStWnKzlI1qpNgaAwmn4LmFYcsSlj+jBMk84wmmRxfk5g==} + node-fetch-native@1.6.7: resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} @@ -6642,6 +6657,8 @@ snapshots: bare-events@2.8.2: {} + base-x@5.0.1: {} + base64-js@1.5.1: {} baseline-browser-mapping@2.8.30: {} @@ -8433,8 +8450,14 @@ snapshots: node-abort-controller@3.1.1: {} + node-appwrite@21.1.0: + dependencies: + node-fetch-native-with-agent: 1.7.2 + node-domexception@1.0.0: {} + node-fetch-native-with-agent@1.7.2: {} + node-fetch-native@1.6.7: {} node-fetch@3.3.2: diff --git a/src/_drivers.ts b/src/_drivers.ts index 13ac1f028..95d046385 100644 --- a/src/_drivers.ts +++ b/src/_drivers.ts @@ -1,6 +1,7 @@ // Auto-generated using scripts/gen-drivers. // Do not manually edit! +import type { AppwriteStorageConfigurationOptions as AppwriteStorageConfigurationOptions } from "unstorage/drivers/appwrite-storage"; import type { AzureAppConfigurationOptions as AzureAppConfigurationOptions } from "unstorage/drivers/azure-app-configuration"; import type { AzureCosmosOptions as AzureCosmosOptions } from "unstorage/drivers/azure-cosmos"; import type { AzureKeyVaultOptions as AzureKeyVaultOptions } from "unstorage/drivers/azure-key-vault"; @@ -33,9 +34,11 @@ import type { VercelBlobOptions as VercelBlobOptions } from "unstorage/drivers/v import type { VercelKVOptions as VercelKVOptions } from "unstorage/drivers/vercel-kv"; import type { VercelCacheOptions as VercelRuntimeCacheOptions } from "unstorage/drivers/vercel-runtime-cache"; -export type BuiltinDriverName = "azure-app-configuration" | "azureAppConfiguration" | "azure-cosmos" | "azureCosmos" | "azure-key-vault" | "azureKeyVault" | "azure-storage-blob" | "azureStorageBlob" | "azure-storage-table" | "azureStorageTable" | "capacitor-preferences" | "capacitorPreferences" | "cloudflare-kv-binding" | "cloudflareKVBinding" | "cloudflare-kv-http" | "cloudflareKVHttp" | "cloudflare-r2-binding" | "cloudflareR2Binding" | "db0" | "deno-kv-node" | "denoKVNode" | "deno-kv" | "denoKV" | "fs-lite" | "fsLite" | "fs" | "github" | "http" | "indexedb" | "localstorage" | "lru-cache" | "lruCache" | "memory" | "mongodb" | "netlify-blobs" | "netlifyBlobs" | "null" | "overlay" | "planetscale" | "redis" | "s3" | "session-storage" | "sessionStorage" | "uploadthing" | "upstash" | "vercel-blob" | "vercelBlob" | "vercel-kv" | "vercelKV" | "vercel-runtime-cache" | "vercelRuntimeCache"; +export type BuiltinDriverName = "appwrite-storage" | "azure-app-configuration" | "azureAppConfiguration" | "azure-cosmos" | "azureCosmos" | "azure-key-vault" | "azureKeyVault" | "azure-storage-blob" | "azureStorageBlob" | "azure-storage-table" | "azureStorageTable" | "capacitor-preferences" | "capacitorPreferences" | "cloudflare-kv-binding" | "cloudflareKVBinding" | "cloudflare-kv-http" | "cloudflareKVHttp" | "cloudflare-r2-binding" | "cloudflareR2Binding" | "db0" | "deno-kv-node" | "denoKVNode" | "deno-kv" | "denoKV" | "fs-lite" | "fsLite" | "fs" | "github" | "http" | "indexedb" | "localstorage" | "lru-cache" | "lruCache" | "memory" | "mongodb" | "netlify-blobs" | "netlifyBlobs" | "null" | "overlay" | "planetscale" | "redis" | "s3" | "session-storage" | "sessionStorage" | "uploadthing" | "upstash" | "vercel-blob" | "vercelBlob" | "vercel-kv" | "vercelKV" | "vercel-runtime-cache" | "vercelRuntimeCache"; export type BuiltinDriverOptions = { + "appwrite-storage": AppwriteStorageConfigurationOptions; + "appwriteStorage": AppwriteStorageConfigurationOptions; "azure-app-configuration": AzureAppConfigurationOptions; "azureAppConfiguration": AzureAppConfigurationOptions; "azure-cosmos": AzureCosmosOptions; @@ -88,6 +91,8 @@ export type BuiltinDriverOptions = { }; export const builtinDrivers = { + "appwrite-storage": "unstorage/drivers/appwrite-storage", + "appwriteStorage": "unstorage/drivers/appwrite-storage", "azure-app-configuration": "unstorage/drivers/azure-app-configuration", "azureAppConfiguration": "unstorage/drivers/azure-app-configuration", "azure-cosmos": "unstorage/drivers/azure-cosmos", diff --git a/src/drivers/appwrite-storage.ts b/src/drivers/appwrite-storage.ts new file mode 100644 index 000000000..a9a76ab5a --- /dev/null +++ b/src/drivers/appwrite-storage.ts @@ -0,0 +1,593 @@ +import type { + Driver, + DriverFlags, + StorageValue, + TransactionOptions, +} from "../types.ts"; +import { + callAppwriteApi, + provideAppwriteClient, + type AppwriteClientOptions, + type AppwriteProjectOptions, + type AppwriteStorageKeyOptions, + type MaybePromise, + type RequireAllOrNone, +} from "./utils/appwrite.ts"; +import { + createRequiredError, + defineDriver, + normalizeKey, +} from "./utils/index.ts"; +import { + Storage as AppwriteStorage, + type Models as AppwriteModels, + Query as AppwriteQuery, + ID as AppwriteID, +} from "node-appwrite"; + +/** + * Configuration options for using file ID strategy. + * This strategy uses file IDs as the primary key for storage operations. + */ +export type AppwriteStorageFileIdOptions = + RequireAllOrNone & { + /** + * The key strategy to use - 'id' means using Appwrite file IDs as keys + */ + keyStrategy: "id"; + }; + +/** + * Configuration options for using file name strategy. + * This strategy uses file names as the primary key for storage operations. + */ +export type AppwriteStorageNameOptions = { + /** + * The key strategy to use - 'name' means using file names as keys + */ + keyStrategy: "name"; +}; + +/** + * Common configuration options shared by all Appwrite storage strategies. + * This includes either project options (endpoint + projectId) or a shared client, + * plus the bucket ID that will be used for storage operations. + */ +export type AppwriteStorageCommonOptions = ( + | AppwriteProjectOptions + | AppwriteClientOptions +) & { + /** + * The bucket ID to use. + * This identifies the specific Appwrite storage bucket where files will be stored. + */ + bucketId: string; +}; + +/** + * Complete configuration options for the Appwrite storage driver. + * This combines common options with either file ID strategy or name strategy options. + */ +export type AppwriteStorageConfigurationOptions = AppwriteStorageCommonOptions & + (AppwriteStorageFileIdOptions | AppwriteStorageNameOptions); + +export type AppwritePermissionOptions = { + /** + * Optional array of permission strings to apply to files. + * These permissions control access to the files in Appwrite storage. + * Each permission string follows Appwrite's permission format. + * @example ['read("user:123")', 'write("team:456")'] + */ + permissions?: string[]; +}; + +export type AppwriteStorageSetTransactionOptions = TransactionOptions & + AppwritePermissionOptions; + +const DRIVER_NAME = "appwrite-storage"; + +export default defineDriver((options: AppwriteStorageConfigurationOptions) => { + let storage: AppwriteStorage; + + const getStorage = () => { + if (!storage) { + const client = provideAppwriteClient(options, DRIVER_NAME); + storage = new AppwriteStorage(client); + } + + return storage; + }; + + const driver = selectDriverVariant(getStorage, options); + + return { + hasItem: driver.hasItem.bind(driver), + getItem: driver.getItem.bind(driver), + getKeys: driver.getKeys.bind(driver), + setItem: driver.setItem.bind(driver), + removeItem: driver.removeItem.bind(driver), + getMeta: driver.getMeta.bind(driver), + clear: driver.clear.bind(driver), + } satisfies Driver; +}); + +/** + * Factory function to create the appropriate driver variant based on configuration. + * This selects between FileIdAppwriteStorageDriver and FileNameAppwriteStorageDriver + * based on the keyStrategy option. + * + * @param getStorage - Function to get the Appwrite Storage instance + * @param options - Configuration options for the driver + * @returns An instance of the appropriate Appwrite storage driver + * @throws Will throw an error if keyStrategy is not provided or is invalid + */ +function selectDriverVariant( + getStorage: () => AppwriteStorage, + options: AppwriteStorageConfigurationOptions +) { + switch (options.keyStrategy) { + case "id": { + return new FileIdAppwriteStorageDriver(getStorage, options); + } + + case "name": { + return new FileNameAppwriteStorageDriver(getStorage, options); + } + + default: { + throw createRequiredError(DRIVER_NAME, "keyStrategy"); + } + } +} + +/** + * Abstract base class for Appwrite storage drivers. + * This class implements the Driver interface and provides common functionality + * for both file ID and file name strategies. + * + * @template Options - The specific configuration options type for the driver variant + */ +abstract class AppwriteStorageDriver< + Options extends AppwriteStorageConfigurationOptions, +> implements Driver +{ + readonly name = DRIVER_NAME; + readonly flags: DriverFlags = { + ttl: false, + maxDepth: false, + }; + readonly options: Options; + readonly getInstance: () => AppwriteStorage; + + constructor(getStorage: () => AppwriteStorage, options: Options) { + this.getInstance = getStorage; + this.options = options; + } + + /** + * Resolves a file ID to the corresponding Appwrite file object. + * This method handles the API call and 404 (not found) errors gracefully. + * + * @param fileId - The Appwrite file ID to resolve + * @returns A promise that resolves with the file object, or null if not found + */ + protected async getFile(fileId: string): Promise { + return await callAppwriteApi( + async () => { + return await this.getInstance().getFile({ + bucketId: this.options.bucketId, + fileId, + }); + }, + { + driverName: DRIVER_NAME, + onNotFound() { + return null; + }, + } + ); + } + + protected abstract generateFileId(key: string): string; + protected abstract decodeFileToKey(file: AppwriteModels.File): string; + + protected abstract resolveKeyToFileId( + key: string + ): MaybePromise; + protected abstract resolveKeyToFile( + key: string + ): Promise; + + protected abstract createQueriesForBase( + base: string | undefined + ): [string, ...string[]] | undefined; + + /** + * Checks if an item exists in storage. + * + * @param key - The storage key to check + * @returns A promise that resolves with true if the item exists, false otherwise + */ + async hasItem(key: string) { + const file = await this.resolveKeyToFile(key); + return null != file; + } + + /** + * Gets metadata for a stored item. + * + * @param key - The storage key to get metadata for + * @returns A promise that resolves with metadata object containing mtime, birthtime, and size, + * or null if the item doesn't exist + */ + async getMeta(key: string) { + const file = await this.resolveKeyToFile(key); + if (!file) return null; + return { + mtime: new Date(file.$updatedAt), + birthtime: new Date(file.$createdAt), + size: file.sizeOriginal, + }; + } + + /** + * Gets an item from storage. + * + * @param key - The storage key to retrieve + * @returns A promise that resolves with the stored value, or null if not found + */ + async getItem(key: string): Promise { + const fileId = await this.resolveKeyToFileId(key); + if (!fileId) return null; + + return await callAppwriteApi( + async () => { + const fileContent = await this.getInstance().getFileView({ + bucketId: this.options.bucketId, + fileId, + }); + const file = new File([fileContent], key); + return JSON.parse(await file.text()); + }, + { + driverName: DRIVER_NAME, + onNotFound() { + return null; + }, + } + ); + } + + // getItems?: ((items: { key: string; options?: TransactionOptions; }[], commonOptions?: TransactionOptions) => { key: string; value: StorageValue; }[] | Promise<{ key: string; value: StorageValue; }[]>) | undefined; + // getItemRaw?: ((key: string, opts: TransactionOptions) => unknown) | undefined; + + /** + * Creates a new file in the Appwrite storage bucket. + * + * @param fileId - The unique identifier for the file + * @param file - The File object to upload + * @param permissions - Optional array of permission strings to apply to the file + * @returns A promise that resolves when the file has been created + * @throws Will throw an error if the Appwrite API call fails + */ + protected async createFile( + fileId: string, + file: File, + permissions?: string[] + ) { + await callAppwriteApi( + async () => { + await this.getInstance().createFile({ + bucketId: this.options.bucketId, + fileId, + file, + permissions, + }); + }, + { + driverName: DRIVER_NAME, + } + ); + } + + /** + * Upserts (inserts or updates) a file in the Appwrite storage bucket. + * + * This method handles the logic for either creating a new file or updating an existing one + * in the configured Appwrite storage bucket. The exact implementation may vary between + * subclasses, but the end result is that the file is stored with the given fileId. + * + * @param fileId - The unique identifier for the file in the bucket. This must be a valid + * string that complies with Appwrite's file ID requirements. + * @param file - The File object to upload. This should contain the file data and metadata + * such as name and type. + * @param permissions - Optional array of permission strings to apply to the file. If not + * provided, default permissions will be used. + * @returns A Promise that resolves when the file has been successfully upserted. + * @throws {Error} Throws an error if the Appwrite API call fails, wrapped in a custom error + * with driver name and cause. This may include network errors, authentication + * issues, or invalid parameters. + * + * @example + * ```typescript + * const file = new File(['content'], 'example.txt', { type: 'text/plain' }); + * await this.upsertFile('unique-file-id', file, ['read("user:123")']); + * ``` + * + * @note This method assumes a valid Appwrite client and bucket ID are configured. + * It modifies the storage state by uploading the file, potentially overwriting + * any existing file with the same fileId. + */ + protected abstract upsertFile( + fileId: string, + file: File, + permissions?: string[] + ): Promise; + + /** + * Sets an item in storage. + * + * @param key - The storage key to set + * @param value - The value to store + * @param opts - Transaction options, which may include permissions + * @returns A promise that resolves when the item has been stored + */ + async setItem( + key: string, + value: StorageValue, + opts?: AppwriteStorageSetTransactionOptions + ) { + const serializedValue = JSON.stringify(value); + const file = new File([serializedValue], key, { + type: "application/json", + }); + + const fileId = await this.resolveKeyToFileId(key); + + if (fileId) { + await this.upsertFile(fileId, file, opts?.permissions); + } else { + const newFileId = this.generateFileId(key); + await this.createFile(newFileId, file, opts?.permissions); + } + } + + // setItems?: ((items: { key: string; value: string; options?: TransactionOptions; }[], commonOptions?: TransactionOptions) => void | Promise) | undefined; + // setItemRaw?: ((key: string, value: any, opts: TransactionOptions) => void | Promise) | undefined; + + /** + * Removes a file from the Appwrite storage bucket. + * + * @param fileId - The unique identifier for the file to remove + * @returns A promise that resolves when the file has been removed + * @throws Will throw an error if the Appwrite API call fails (except for 404 errors) + */ + protected async removeFile(fileId: string) { + await callAppwriteApi( + async () => { + await this.getInstance().deleteFile({ + bucketId: this.options.bucketId, + fileId, + }); + }, + { + driverName: DRIVER_NAME, + onNotFound() {}, + } + ); + } + + /** + * Removes an item from storage. + * + * @param key - The storage key to remove + * @param opts - Transaction options (currently unused in this implementation) + * @returns A promise that resolves when the item has been removed + */ + async removeItem(key: string) { + const fileId = await this.resolveKeyToFileId(key); + if (!fileId) return; + await this.removeFile(fileId); + } + + /** + * Gets all keys that match the specified base path. + * + * @param base - Optional base path to filter keys + * @param opts - Options for getting keys, including maxDepth + * @returns A promise that resolves with an array of matching keys + */ + async getKeys(base: string | undefined) { + const queries = this.createQueriesForBase(base); + const fileList = await callAppwriteApi( + async () => { + return await this.getInstance().listFiles({ + bucketId: this.options.bucketId, + queries, + }); + }, + { + driverName: DRIVER_NAME, + } + ); + return fileList.files.map((file) => { + return this.decodeFileToKey(file); + }); + } + + /** + * Clears all items that match the specified base path. + * + * @param base - Optional base path to filter items to clear + * @returns A promise that resolves when all matching items have been cleared + */ + async clear(base: string | undefined) { + const queries = this.createQueriesForBase(base); + await callAppwriteApi( + async () => { + const files = await this.getInstance().listFiles({ + bucketId: this.options.bucketId, + queries, + }); + + await Promise.all( + files.files.map((file: AppwriteModels.File) => + this.getInstance().deleteFile({ + bucketId: this.options.bucketId, + fileId: file.$id, + }) + ) + ); + }, + { + driverName: DRIVER_NAME, + } + ); + } + // dispose?: (() => void | Promise) | undefined; + /** + * @todo Implement watch method based on Appwrite Realtime API + */ + // watch?: ((callback: WatchCallback) => Unwatch | Promise) | undefined; +} + +/** + * Appwrite storage driver that uses file IDs as the primary key strategy. + * This strategy maps storage keys directly to Appwrite file IDs, allowing for + * efficient lookups and hierarchical key structures. + */ +class FileIdAppwriteStorageDriver extends AppwriteStorageDriver< + AppwriteStorageCommonOptions & AppwriteStorageFileIdOptions +> { + constructor( + getStorage: () => AppwriteStorage, + options: AppwriteStorageCommonOptions & AppwriteStorageFileIdOptions + ) { + super(getStorage, options); + } + + private static KEY_SEPARATOR = "/" as const; + + protected override resolveKeyToFileId(key: string): string { + return this.generateFileId(key); + } + + protected override async resolveKeyToFile( + key: string + ): Promise { + const fileId = this.generateFileId(key); + return await this.getFile(fileId); + } + + protected override generateFileId(key: string): string { + key = normalizeKey(key, FileIdAppwriteStorageDriver.KEY_SEPARATOR); + return ( + this.options.encodeKey?.( + key, + FileIdAppwriteStorageDriver.KEY_SEPARATOR + ) ?? key + ); + } + + protected override decodeFileToKey(file: AppwriteModels.File): string { + return ( + this.options.decodeKey?.( + file.$id, + FileIdAppwriteStorageDriver.KEY_SEPARATOR + ) ?? file.$id + ); + } + + protected override createQueriesForBase( + base: string | undefined + ): [string, ...string[]] | undefined { + base = normalizeKey(base, FileIdAppwriteStorageDriver.KEY_SEPARATOR); + + if (!base) return; + + base = base + FileIdAppwriteStorageDriver.KEY_SEPARATOR; + base = + this.options.encodeKey?.( + base, + FileIdAppwriteStorageDriver.KEY_SEPARATOR + ) ?? base; + + return [AppwriteQuery.startsWith("$id", base)]; + } + + protected override async upsertFile( + fileId: string, + file: File, + permissions?: string[] + ): Promise { + await this.removeFile(fileId); + await this.createFile(fileId, file, permissions); + } +} + +/** + * Appwrite storage driver that uses file names as the primary key strategy. + * This strategy uses Appwrite's auto-generated file IDs and relies on file names + * for key mapping. It's useful when you want Appwrite to manage file IDs. + */ +class FileNameAppwriteStorageDriver extends AppwriteStorageDriver< + AppwriteStorageCommonOptions & AppwriteStorageNameOptions +> { + protected override generateFileId(): string { + return AppwriteID.unique(); + } + + protected override decodeFileToKey(file: AppwriteModels.File): string { + return file.name; + } + + protected override async resolveKeyToFileId( + key: string + ): Promise { + const file = await this.resolveKeyToFile(key); + return file?.$id ?? null; + } + + protected override async resolveKeyToFile( + key: string + ): Promise { + const fileList = await callAppwriteApi( + async () => { + return await this.getInstance().listFiles({ + bucketId: this.options.bucketId, + queries: [ + AppwriteQuery.equal("name", key), + AppwriteQuery.orderDesc("$createdAt"), + AppwriteQuery.limit(1), + ], + }); + }, + { + driverName: DRIVER_NAME, + onNotFound() { + return null; + }, + } + ); + + const file = fileList?.files.at(0); + + return file || null; + } + + protected override createQueriesForBase( + base: string | undefined + ): [string, ...string[]] | undefined { + if (!base) return; + return [AppwriteQuery.startsWith("name", base)]; + } + + protected override async upsertFile( + fileId: string, + file: File, + permissions?: string[] + ): Promise { + await this.createFile(fileId, file, permissions); + await this.removeFile(fileId); + } +} diff --git a/src/drivers/utils/appwrite.ts b/src/drivers/utils/appwrite.ts new file mode 100644 index 000000000..c9b8d5762 --- /dev/null +++ b/src/drivers/utils/appwrite.ts @@ -0,0 +1,156 @@ +import { Client as AppwriteClient, AppwriteException } from "node-appwrite"; +import { createError, createRequiredError } from "./index.ts"; + +export type AppwriteClientOptions = { + /** + * Shared Appwrite client instance. + * This allows reusing an existing Appwrite client across multiple drivers. + */ + client: AppwriteClient; +}; + +export type AppwriteProjectOptions = { + /** + * The Appwrite endpoint URL. + * This is the base URL for your Appwrite server. + * @example 'https://fra.cloud.appwrite.io/v1' + */ + endpoint: URL | string; + + /** + * The Appwrite project ID. + * This identifies your specific project within the Appwrite server. + */ + projectId: string; + + /** + * Optional API key for authentication with the Appwrite server. + */ + apiKey?: string; +}; + +export type MaybePromise = T | Promise; + +export type FetchAppwriteStorageOptions = { + driverName: string; + onNotFound?: () => MaybePromise; +}; + +/** + * Function type for transforming strings, typically used for encoding/decoding file IDs. + * @param value - The input string to transform + * @param keySeparator - The separator character used in key paths + * @returns The transformed string + */ +export type StringTransformer = (value: string, keySeparator: string) => string; + +export type RequireAllOrNone = T | { [K in keyof T]?: never }; +/** + * Type for transforming between storage keys and Appwrite file IDs. + * This allows custom encoding/decoding logic for file ID generation. + */ +export type AppwriteStorageKeyOptions = { + /** + * Encodes a storage key to an Appwrite file ID. + * @param value - The storage key to encode + * @param keySeparator - The separator character used in key paths + * @returns The encoded file ID + */ + encodeKey: StringTransformer; + + /** + * Decodes an Appwrite file ID back to a storage key. + * @param value - The file ID to decode + * @param keySeparator - The separator character used in key paths + * @returns The decoded storage key + */ + decodeKey: StringTransformer; +}; + +/** + * Wrapper function for calling Appwrite Storage API with error handling. + * This function handles API calls and provides a consistent way to handle 404 (not found) errors. + * + * @template T - The return type of the API call + * @param call - The function that makes the actual API call + * @param options - Optional configuration for handling not found errors + * @returns A promise that resolves with the API call result or the onNotFound fallback + * @throws Will throw an error if the API call fails (except when handled by onNotFound) + */ +export async function callAppwriteApi( + call: () => MaybePromise, + options: FetchAppwriteStorageOptions +): Promise { + try { + return await call(); + } catch (cause) { + if ( + options?.onNotFound && + isAppwriteException(cause) && + 404 == cause.code + ) { + return await options.onNotFound(); + } + + throw createError(options.driverName, "Failed to call Appwrite API", { + cause, + }); + } +} + +/** + * Type guard to check if a value is an AppwriteException. + * This helps with proper error handling for Appwrite-specific exceptions. + * + * @param value - The value to check + * @returns true if the value is an AppwriteException, false otherwise + */ +export function isAppwriteException( + value: unknown +): value is AppwriteException { + return ( + value instanceof AppwriteException || + (value instanceof Error && AppwriteException.name == value.name) + ); +} + +/** + * Provides an Appwrite client instance based on the provided options. + * This function either reuses an existing client or creates a new one using the provided configuration. + * + * @param options - Configuration options for the Appwrite client + * @param driverName - The name of the driver using this client (for error reporting) + * @returns An initialized Appwrite client instance + * @throws Will throw an error if required options are missing + */ +export function provideAppwriteClient( + options: AppwriteClientOptions | AppwriteProjectOptions, + driverName: string +) { + let client: AppwriteClient; + + if ("client" in options) { + if (!options.client) { + throw createRequiredError(driverName, "client"); + } + + client = options.client; + } else { + if (!options.endpoint) { + throw createRequiredError(driverName, "endpoint"); + } + if (!options.projectId) { + throw createRequiredError(driverName, "project"); + } + + client = new AppwriteClient() + .setEndpoint(options.endpoint.toString()) + .setProject(options.projectId); + + if (options.apiKey) { + client.setKey(options.apiKey); + } + } + + return client; +} diff --git a/test/drivers/appwrite-storage.test.ts b/test/drivers/appwrite-storage.test.ts new file mode 100644 index 000000000..51db9c2a0 --- /dev/null +++ b/test/drivers/appwrite-storage.test.ts @@ -0,0 +1,43 @@ +import { describe } from "vitest"; +import appwriteStorageDriver from "../../src/drivers/appwrite-storage.ts"; +import { testDriver } from "./utils.ts"; +import { keyOptions } from "./appwrite.fixture.ts"; + +const endpoint = process.env.VITE_APPWRITE_ENDPOINT; +const projectId = process.env.VITE_APPWRITE_PROJECT_ID; +const bucketId = process.env.VITE_APPWRITE_BUCKET_ID; +const apiKey = process.env.VITE_APPWRITE_API_KEY; + +describe.skipIf(!endpoint || !projectId || !bucketId)( + "drivers: appwrite-storage", + () => { + const projectOptions = { + endpoint: endpoint!, + projectId: projectId!, + bucketId: bucketId!, + apiKey, + }; + + describe("keyStrategy: fileId", () => { + testDriver({ + driver: () => + appwriteStorageDriver({ + keyStrategy: "id", + ...projectOptions, + ...keyOptions, + }), + }); + }); + + describe("keyStrategy: name", () => { + testDriver({ + driver: () => + appwriteStorageDriver({ + keyStrategy: "name", + ...projectOptions, + apiKey, + }), + }); + }); + } +); diff --git a/test/drivers/appwrite.fixture.ts b/test/drivers/appwrite.fixture.ts new file mode 100644 index 000000000..75a625c21 --- /dev/null +++ b/test/drivers/appwrite.fixture.ts @@ -0,0 +1,56 @@ +import type { AppwriteStorageKeyOptions } from "unstorage/drivers/utils/appwrite"; +import basex from "base-x"; + +/** + * Base62 encoding/decoding instance for Appwrite file/row IDs. + * Uses characters 0-9, a-z, and A-Z for encoding. + */ +const base62 = basex( + "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" +); +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); +const ID_SEPARATOR = "_" as const; + +export const keyOptions = { + /** + * Encodes a storage key to an Appwrite file ID using base62 encoding. + * Splits the key by the separator, encodes each part, and joins with underscore. + * + * @param key - The storage key to encode + * @param keySeparator - The separator character used in key paths + * @returns The encoded file ID + * @example + * ```typescript + * encodeKey("path/to/file", "/") // returns "23B5LG_7KL_1Shkqh" + * ``` + */ + encodeKey(key, keySeparator) { + return key + .split(keySeparator) + .map((part) => { + return base62.encode(textEncoder.encode(part)); + }) + .join(ID_SEPARATOR); + }, + /** + * Decodes an Appwrite file ID back to a storage key using base62 decoding. + * Splits the file ID by underscore, decodes each part, and joins with the original separator. + * + * @param fileId - The file ID to decode + * @param keySeparator - The separator character used in key paths + * @returns The decoded storage key + * @example + * ```typescript + * decodeKey("23B5LG_7KL_1Shkqh", "/") // returns "path/to/file" + * ``` + */ + decodeKey(fileId, keySeparator) { + return fileId + .split(ID_SEPARATOR) + .map((part) => { + return textDecoder.decode(base62.decode(part)); + }) + .join(keySeparator); + }, +} satisfies AppwriteStorageKeyOptions;