From 59f553fb86498ac78fbb206735b9105d2a41e359 Mon Sep 17 00:00:00 2001 From: Alexei Myshkouski Date: Wed, 31 Dec 2025 15:27:00 +0300 Subject: [PATCH 1/7] feat(storage): add appwrite storage driver support - Add new Appwrite storage driver implementation - Include configuration options and type definitions - Add environment variables for Appwrite integration - Include test suite for the new driver - Update package dependencies with node-appwrite and base-x --- .env.example | 5 + package.json | 6 + pnpm-lock.yaml | 23 + src/_drivers.ts | 7 +- src/drivers/appwrite-storage.ts | 705 ++++++++++++++++++++++++++ test/drivers/appwrite-storage.test.ts | 63 +++ 6 files changed, 808 insertions(+), 1 deletion(-) create mode 100644 src/drivers/appwrite-storage.ts create mode 100644 test/drivers/appwrite-storage.test.ts 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..7c8b2d74c --- /dev/null +++ b/src/drivers/appwrite-storage.ts @@ -0,0 +1,705 @@ +import type { + Driver, + DriverFlags, + StorageValue, + TransactionOptions, +} from "../types.ts"; +import { + createError, + createRequiredError, + defineDriver, + normalizeKey, +} from "./utils/index.ts"; +import { + AppwriteException, + Client as AppwriteClient, + Storage as AppwriteStorage, + type Models as AppwriteModels, + Query as AppwriteQuery, + ID as AppwriteID, +} from "node-appwrite"; + +type AppwriteClientOptions = { + /** + * Shared Appwrite client instance. + * This allows reusing an existing Appwrite client across multiple drivers. + */ + client: AppwriteClient; +}; + +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 StringTransformer = (value: string) => string | Promise; +/** + * 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; + +type Impl = Flag extends true ? T : never; + +/** + * Interface 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: Impl; + + /** + * 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: Impl; +}; + +/** + * Configuration options for using file ID strategy. + * This strategy uses file IDs as the primary key for storage operations. + */ +export type AppwriteStorageFileIdOptions = ( + | AppwriteStorageKeyOptions + | AppwriteStorageKeyOptions +) & { + /** + * 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); + +type MaybePromise = T | Promise; +type FetchAppwriteStorageOptions = { + onNotFound?: () => MaybePromise; +}; + +/** + * 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) + */ +async function callAppwriteStorageApi( + 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(DRIVER_NAME, "Failed to call Appwrite Storage 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 + */ +function isAppwriteException(value: unknown): value is AppwriteException { + return ( + value instanceof AppwriteException || + (value instanceof Error && AppwriteException.name == value.name) + ); +} + +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) { + let client: AppwriteClient; + + if ("client" in options) { + if (!options.client) { + throw createRequiredError(DRIVER_NAME, "client"); + } + + client = options.client; + } else { + if (!options.endpoint) { + throw createRequiredError(DRIVER_NAME, "endpoint"); + } + if (!options.projectId) { + throw createRequiredError(DRIVER_NAME, "project"); + } + + client = new AppwriteClient() + .setEndpoint(options.endpoint.toString()) + .setProject(options.projectId); + + if (options.apiKey) { + client.setKey(options.apiKey); + } + } + + 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 callAppwriteStorageApi( + async () => { + return await this.getInstance().getFile({ + bucketId: this.options.bucketId, + fileId, + }); + }, + { + 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 callAppwriteStorageApi( + async () => { + const fileContent = await this.getInstance().getFileView({ + bucketId: this.options.bucketId, + fileId, + }); + const file = new File([fileContent], key); + return JSON.parse(await file.text()); + }, + { + 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 callAppwriteStorageApi(async () => { + await this.getInstance().createFile({ + bucketId: this.options.bucketId, + fileId, + file, + permissions, + }); + }); + } + + /** + * 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 callAppwriteStorageApi( + async () => { + await this.getInstance().deleteFile({ + bucketId: this.options.bucketId, + fileId, + }); + }, + { + 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 callAppwriteStorageApi(async () => { + return await this.getInstance().listFiles({ + bucketId: this.options.bucketId, + queries, + }); + }); + 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 callAppwriteStorageApi(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, + }) + ) + ); + }); + } + // 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 callAppwriteStorageApi( + async () => { + return await this.getInstance().listFiles({ + bucketId: this.options.bucketId, + queries: [ + AppwriteQuery.equal("name", key), + AppwriteQuery.orderDesc("$createdAt"), + AppwriteQuery.limit(1), + ], + }); + }, + { + 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/test/drivers/appwrite-storage.test.ts b/test/drivers/appwrite-storage.test.ts new file mode 100644 index 000000000..03cd57bd6 --- /dev/null +++ b/test/drivers/appwrite-storage.test.ts @@ -0,0 +1,63 @@ +import { describe } from "vitest"; +import appwriteStorageDriver from "../../src/drivers/appwrite-storage.ts"; +import { testDriver } from "./utils.ts"; +import basex from "base-x"; + +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", + () => { + describe("keyStrategy: fileId", () => { + const base62 = basex( + "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + ); + const textEncoder = new TextEncoder(); + const textDecoder = new TextDecoder(); + const FILE_ID_SEPARATOR = "_" as const; + + testDriver({ + driver: () => + appwriteStorageDriver({ + keyStrategy: "id", + endpoint: endpoint!, + projectId: projectId!, + bucketId: bucketId!, + apiKey: apiKey, + encodeKey(key, keySeparator) { + return key + .split(keySeparator) + .map((part) => { + return base62.encode(textEncoder.encode(part)); + }) + .join(FILE_ID_SEPARATOR); + }, + decodeKey(fileId, keySeparator) { + return fileId + .split(FILE_ID_SEPARATOR) + .map((part) => { + return textDecoder.decode(base62.decode(part)); + }) + .join(keySeparator); + }, + }), + }); + }); + + describe("keyStrategy: name", () => { + testDriver({ + driver: () => + appwriteStorageDriver({ + keyStrategy: "name", + endpoint: endpoint!, + projectId: projectId!, + bucketId: bucketId!, + apiKey: apiKey, + }), + }); + }); + } +); From 3fbd7e3a22ae819450ad55c230090666f7ad225e Mon Sep 17 00:00:00 2001 From: Alexei Myshkouski Date: Fri, 2 Jan 2026 00:00:03 +0300 Subject: [PATCH 2/7] chore(appwrite): modularize storage utilities and enhance error handling - Introduce dedicated utility module for Appwrite client management - Implement RequireAllOrNone type constraint for storage key configuration - Unify error handling patterns across all storage operations - Include driver context in API error reporting --- src/drivers/appwrite-storage.ts | 250 +++++++++----------------------- src/drivers/utils/appwrite.ts | 156 ++++++++++++++++++++ 2 files changed, 225 insertions(+), 181 deletions(-) create mode 100644 src/drivers/utils/appwrite.ts diff --git a/src/drivers/appwrite-storage.ts b/src/drivers/appwrite-storage.ts index 7c8b2d74c..a9a76ab5a 100644 --- a/src/drivers/appwrite-storage.ts +++ b/src/drivers/appwrite-storage.ts @@ -5,94 +5,37 @@ import type { TransactionOptions, } from "../types.ts"; import { - createError, + 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 { - AppwriteException, - Client as AppwriteClient, Storage as AppwriteStorage, type Models as AppwriteModels, Query as AppwriteQuery, ID as AppwriteID, } from "node-appwrite"; -type AppwriteClientOptions = { - /** - * Shared Appwrite client instance. - * This allows reusing an existing Appwrite client across multiple drivers. - */ - client: AppwriteClient; -}; - -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 StringTransformer = (value: string) => string | Promise; -/** - * 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; - -type Impl = Flag extends true ? T : never; - -/** - * Interface 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: Impl; - - /** - * 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: Impl; -}; - /** * Configuration options for using file ID strategy. * This strategy uses file IDs as the primary key for storage operations. */ -export type AppwriteStorageFileIdOptions = ( - | AppwriteStorageKeyOptions - | AppwriteStorageKeyOptions -) & { - /** - * The key strategy to use - 'id' means using Appwrite file IDs as keys - */ - keyStrategy: "id"; -}; +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. @@ -128,56 +71,6 @@ export type AppwriteStorageCommonOptions = ( export type AppwriteStorageConfigurationOptions = AppwriteStorageCommonOptions & (AppwriteStorageFileIdOptions | AppwriteStorageNameOptions); -type MaybePromise = T | Promise; -type FetchAppwriteStorageOptions = { - onNotFound?: () => MaybePromise; -}; - -/** - * 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) - */ -async function callAppwriteStorageApi( - 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(DRIVER_NAME, "Failed to call Appwrite Storage 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 - */ -function isAppwriteException(value: unknown): value is AppwriteException { - return ( - value instanceof AppwriteException || - (value instanceof Error && AppwriteException.name == value.name) - ); -} - export type AppwritePermissionOptions = { /** * Optional array of permission strings to apply to files. @@ -198,31 +91,7 @@ export default defineDriver((options: AppwriteStorageConfigurationOptions) => { const getStorage = () => { if (!storage) { - let client: AppwriteClient; - - if ("client" in options) { - if (!options.client) { - throw createRequiredError(DRIVER_NAME, "client"); - } - - client = options.client; - } else { - if (!options.endpoint) { - throw createRequiredError(DRIVER_NAME, "endpoint"); - } - if (!options.projectId) { - throw createRequiredError(DRIVER_NAME, "project"); - } - - client = new AppwriteClient() - .setEndpoint(options.endpoint.toString()) - .setProject(options.projectId); - - if (options.apiKey) { - client.setKey(options.apiKey); - } - } - + const client = provideAppwriteClient(options, DRIVER_NAME); storage = new AppwriteStorage(client); } @@ -303,7 +172,7 @@ abstract class AppwriteStorageDriver< * @returns A promise that resolves with the file object, or null if not found */ protected async getFile(fileId: string): Promise { - return await callAppwriteStorageApi( + return await callAppwriteApi( async () => { return await this.getInstance().getFile({ bucketId: this.options.bucketId, @@ -311,6 +180,7 @@ abstract class AppwriteStorageDriver< }); }, { + driverName: DRIVER_NAME, onNotFound() { return null; }, @@ -370,7 +240,7 @@ abstract class AppwriteStorageDriver< const fileId = await this.resolveKeyToFileId(key); if (!fileId) return null; - return await callAppwriteStorageApi( + return await callAppwriteApi( async () => { const fileContent = await this.getInstance().getFileView({ bucketId: this.options.bucketId, @@ -380,6 +250,7 @@ abstract class AppwriteStorageDriver< return JSON.parse(await file.text()); }, { + driverName: DRIVER_NAME, onNotFound() { return null; }, @@ -404,14 +275,19 @@ abstract class AppwriteStorageDriver< file: File, permissions?: string[] ) { - await callAppwriteStorageApi(async () => { - await this.getInstance().createFile({ - bucketId: this.options.bucketId, - fileId, - file, - permissions, - }); - }); + await callAppwriteApi( + async () => { + await this.getInstance().createFile({ + bucketId: this.options.bucketId, + fileId, + file, + permissions, + }); + }, + { + driverName: DRIVER_NAME, + } + ); } /** @@ -487,7 +363,7 @@ abstract class AppwriteStorageDriver< * @throws Will throw an error if the Appwrite API call fails (except for 404 errors) */ protected async removeFile(fileId: string) { - await callAppwriteStorageApi( + await callAppwriteApi( async () => { await this.getInstance().deleteFile({ bucketId: this.options.bucketId, @@ -495,6 +371,7 @@ abstract class AppwriteStorageDriver< }); }, { + driverName: DRIVER_NAME, onNotFound() {}, } ); @@ -522,12 +399,17 @@ abstract class AppwriteStorageDriver< */ async getKeys(base: string | undefined) { const queries = this.createQueriesForBase(base); - const fileList = await callAppwriteStorageApi(async () => { - return await this.getInstance().listFiles({ - bucketId: this.options.bucketId, - queries, - }); - }); + 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); }); @@ -541,21 +423,26 @@ abstract class AppwriteStorageDriver< */ async clear(base: string | undefined) { const queries = this.createQueriesForBase(base); - await callAppwriteStorageApi(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, - }) - ) - ); - }); + 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; /** @@ -664,7 +551,7 @@ class FileNameAppwriteStorageDriver extends AppwriteStorageDriver< protected override async resolveKeyToFile( key: string ): Promise { - const fileList = await callAppwriteStorageApi( + const fileList = await callAppwriteApi( async () => { return await this.getInstance().listFiles({ bucketId: this.options.bucketId, @@ -676,6 +563,7 @@ class FileNameAppwriteStorageDriver extends AppwriteStorageDriver< }); }, { + driverName: DRIVER_NAME, onNotFound() { return null; }, 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; +} From 98d44575c403bd79811b4db50e03c2c9aeaa45b1 Mon Sep 17 00:00:00 2001 From: Alexei Myshkouski Date: Fri, 2 Jan 2026 00:00:39 +0300 Subject: [PATCH 3/7] refactor(test): extract appwrite storage key utilities Extract key encoding/decoding logic into reusable utilities and simplify test configuration by centralizing project options --- test/drivers/appwrite-storage.test.ts | 44 ++++++++------------------- test/drivers/appwrite-utils.ts | 28 +++++++++++++++++ 2 files changed, 40 insertions(+), 32 deletions(-) create mode 100644 test/drivers/appwrite-utils.ts diff --git a/test/drivers/appwrite-storage.test.ts b/test/drivers/appwrite-storage.test.ts index 03cd57bd6..35c33063e 100644 --- a/test/drivers/appwrite-storage.test.ts +++ b/test/drivers/appwrite-storage.test.ts @@ -1,7 +1,7 @@ import { describe } from "vitest"; import appwriteStorageDriver from "../../src/drivers/appwrite-storage.ts"; import { testDriver } from "./utils.ts"; -import basex from "base-x"; +import { keyOptions } from "./appwrite-utils.ts"; const endpoint = process.env.VITE_APPWRITE_ENDPOINT; const projectId = process.env.VITE_APPWRITE_PROJECT_ID; @@ -11,38 +11,20 @@ const apiKey = process.env.VITE_APPWRITE_API_KEY; describe.skipIf(!endpoint || !projectId || !bucketId)( "drivers: appwrite-storage", () => { - describe("keyStrategy: fileId", () => { - const base62 = basex( - "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" - ); - const textEncoder = new TextEncoder(); - const textDecoder = new TextDecoder(); - const FILE_ID_SEPARATOR = "_" as const; + const projectOptions = { + endpoint: endpoint!, + projectId: projectId!, + bucketId: bucketId!, + apiKey, + }; + describe("keyStrategy: fileId", () => { testDriver({ driver: () => appwriteStorageDriver({ keyStrategy: "id", - endpoint: endpoint!, - projectId: projectId!, - bucketId: bucketId!, - apiKey: apiKey, - encodeKey(key, keySeparator) { - return key - .split(keySeparator) - .map((part) => { - return base62.encode(textEncoder.encode(part)); - }) - .join(FILE_ID_SEPARATOR); - }, - decodeKey(fileId, keySeparator) { - return fileId - .split(FILE_ID_SEPARATOR) - .map((part) => { - return textDecoder.decode(base62.decode(part)); - }) - .join(keySeparator); - }, + ...projectOptions, + ...keyOptions, }), }); }); @@ -52,10 +34,8 @@ describe.skipIf(!endpoint || !projectId || !bucketId)( driver: () => appwriteStorageDriver({ keyStrategy: "name", - endpoint: endpoint!, - projectId: projectId!, - bucketId: bucketId!, - apiKey: apiKey, + ...projectOptions, + apiKey, }), }); }); diff --git a/test/drivers/appwrite-utils.ts b/test/drivers/appwrite-utils.ts new file mode 100644 index 000000000..caa7b53a8 --- /dev/null +++ b/test/drivers/appwrite-utils.ts @@ -0,0 +1,28 @@ +import type { AppwriteStorageKeyOptions } from "unstorage/drivers/utils/appwrite"; +import basex from "base-x"; + +const base62 = basex( + "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" +); +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); +const ID_SEPARATOR = "_" as const; + +export const keyOptions = { + encodeKey(key, keySeparator) { + return key + .split(keySeparator) + .map((part) => { + return base62.encode(textEncoder.encode(part)); + }) + .join(ID_SEPARATOR); + }, + decodeKey(fileId, keySeparator) { + return fileId + .split(ID_SEPARATOR) + .map((part) => { + return textDecoder.decode(base62.decode(part)); + }) + .join(keySeparator); + }, +} satisfies AppwriteStorageKeyOptions; From df70e8205c2fa882cf284830dbaaa856cf3a3ca8 Mon Sep 17 00:00:00 2001 From: Alexei Myshkouski Date: Fri, 2 Jan 2026 01:47:51 +0300 Subject: [PATCH 4/7] refactor(test): extract appwrite storage key utilities Extract key encoding/decoding logic into reusable utilities and simplify test configuration by centralizing project options --- test/drivers/appwrite-storage.test.ts | 44 ++++++--------------- test/drivers/appwrite.fixture.ts | 56 +++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 32 deletions(-) create mode 100644 test/drivers/appwrite.fixture.ts diff --git a/test/drivers/appwrite-storage.test.ts b/test/drivers/appwrite-storage.test.ts index 03cd57bd6..51db9c2a0 100644 --- a/test/drivers/appwrite-storage.test.ts +++ b/test/drivers/appwrite-storage.test.ts @@ -1,7 +1,7 @@ import { describe } from "vitest"; import appwriteStorageDriver from "../../src/drivers/appwrite-storage.ts"; import { testDriver } from "./utils.ts"; -import basex from "base-x"; +import { keyOptions } from "./appwrite.fixture.ts"; const endpoint = process.env.VITE_APPWRITE_ENDPOINT; const projectId = process.env.VITE_APPWRITE_PROJECT_ID; @@ -11,38 +11,20 @@ const apiKey = process.env.VITE_APPWRITE_API_KEY; describe.skipIf(!endpoint || !projectId || !bucketId)( "drivers: appwrite-storage", () => { - describe("keyStrategy: fileId", () => { - const base62 = basex( - "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" - ); - const textEncoder = new TextEncoder(); - const textDecoder = new TextDecoder(); - const FILE_ID_SEPARATOR = "_" as const; + const projectOptions = { + endpoint: endpoint!, + projectId: projectId!, + bucketId: bucketId!, + apiKey, + }; + describe("keyStrategy: fileId", () => { testDriver({ driver: () => appwriteStorageDriver({ keyStrategy: "id", - endpoint: endpoint!, - projectId: projectId!, - bucketId: bucketId!, - apiKey: apiKey, - encodeKey(key, keySeparator) { - return key - .split(keySeparator) - .map((part) => { - return base62.encode(textEncoder.encode(part)); - }) - .join(FILE_ID_SEPARATOR); - }, - decodeKey(fileId, keySeparator) { - return fileId - .split(FILE_ID_SEPARATOR) - .map((part) => { - return textDecoder.decode(base62.decode(part)); - }) - .join(keySeparator); - }, + ...projectOptions, + ...keyOptions, }), }); }); @@ -52,10 +34,8 @@ describe.skipIf(!endpoint || !projectId || !bucketId)( driver: () => appwriteStorageDriver({ keyStrategy: "name", - endpoint: endpoint!, - projectId: projectId!, - bucketId: bucketId!, - apiKey: apiKey, + ...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; From ee1ac3423fa08d1ad65c829e29d501ebdef1fa54 Mon Sep 17 00:00:00 2001 From: Alexei Myshkouski Date: Fri, 2 Jan 2026 19:20:17 +0300 Subject: [PATCH 5/7] feat(appwrite): introduce Appwrite TablesDb storage integration Introduce a new storage driver for Appwrite TablesDb. Changes includes: - New driver implementation for Appwrite TablesDb API operations - Modularized Appwrite client initialization for improved reusability - Comprehensive test coverage with shared test fixtures --- .env.example | 1 + src/_drivers.ts | 7 +- src/drivers/appwrite-tables-db.ts | 212 ++++++++++++++++++++++++ src/drivers/utils/appwrite.ts | 46 +++-- test/drivers/appwrite-storage.test.ts | 2 +- test/drivers/appwrite-tables-db.test.ts | 82 +++++++++ test/drivers/appwrite.fixture.ts | 196 ++++++++++++++++++++++ 7 files changed, 530 insertions(+), 16 deletions(-) create mode 100644 src/drivers/appwrite-tables-db.ts create mode 100644 test/drivers/appwrite-tables-db.test.ts create mode 100644 test/drivers/appwrite.fixture.ts diff --git a/.env.example b/.env.example index 5c9a02948..287b5f757 100644 --- a/.env.example +++ b/.env.example @@ -12,4 +12,5 @@ VITE_UPLOADTHING_TOKEN= VITE_APPWRITE_ENDPOINT= VITE_APPWRITE_PROJECT_ID= VITE_APPWRITE_BUCKET_ID= +VITE_APPWRITE_DATABASE_ID= VITE_APPWRITE_API_KEY= diff --git a/src/_drivers.ts b/src/_drivers.ts index 95d046385..59bf44a9b 100644 --- a/src/_drivers.ts +++ b/src/_drivers.ts @@ -2,6 +2,7 @@ // Do not manually edit! import type { AppwriteStorageConfigurationOptions as AppwriteStorageConfigurationOptions } from "unstorage/drivers/appwrite-storage"; +import type { AppwriteTablesDbConfigurationOptions as AppwriteTablesDbConfigurationOptions } from "unstorage/drivers/appwrite-tables-db"; 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"; @@ -34,11 +35,13 @@ 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 = "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 BuiltinDriverName = "appwrite-storage" | "appwrite-tables-db" | "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; + "appwrite-tables-db": AppwriteTablesDbConfigurationOptions; + "appwriteTablesDb": AppwriteTablesDbConfigurationOptions; "azure-app-configuration": AzureAppConfigurationOptions; "azureAppConfiguration": AzureAppConfigurationOptions; "azure-cosmos": AzureCosmosOptions; @@ -93,6 +96,8 @@ export type BuiltinDriverOptions = { export const builtinDrivers = { "appwrite-storage": "unstorage/drivers/appwrite-storage", "appwriteStorage": "unstorage/drivers/appwrite-storage", + "appwrite-tables-db": "unstorage/drivers/appwrite-tables-db", + "appwriteTablesDb": "unstorage/drivers/appwrite-tables-db", "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-tables-db.ts b/src/drivers/appwrite-tables-db.ts new file mode 100644 index 000000000..cccc091a8 --- /dev/null +++ b/src/drivers/appwrite-tables-db.ts @@ -0,0 +1,212 @@ +import type { Driver, StorageMeta, StorageValue } from "../types.ts"; +import { normalizeKey, defineDriver } from "./utils/index.ts"; +import { + callAppwriteApi, + provideAppwriteClient, + type AppwriteClientOptions, + type AppwriteProjectOptions, + type AppwriteStorageKeyOptions, + type RequireAllOrNone, +} from "./utils/appwrite.ts"; +import { Models as AppwriteModels, Query, TablesDB } from "node-appwrite"; + +export type AppwriteTablesDbAttributesOptions = + RequireAllOrNone & { + attributes?: { + key?: "$id" | string; + value?: "value" | string; + }; + }; + +export type AppwriteTablesDbConfigurationOptions = ( + | AppwriteProjectOptions + | AppwriteClientOptions +) & + AppwriteTablesDbAttributesOptions & { + databaseId: string; + tableId: string; + }; + +const DRIVER_NAME = "appwrite-tablesdb"; + +export default defineDriver((options: AppwriteTablesDbConfigurationOptions) => { + let tablesDB: TablesDB; + + const getInstance = () => { + if (!tablesDB) { + const client = provideAppwriteClient(options, DRIVER_NAME); + tablesDB = new TablesDB(client); + } + + return tablesDB; + }; + + const KEY_SEPARATOR = ":" as const; + + const rowIdAttribute = options.attributes?.key ?? "$id"; + const valueAttribute = options.attributes?.value ?? "value"; + + const tryEncodeKey = (key: string) => { + key = normalizeKey(key, KEY_SEPARATOR); + return options.encodeKey?.(key, KEY_SEPARATOR) ?? key; + }; + + const tryDecodeKey = (rowId: string) => { + return options.decodeKey?.(rowId, KEY_SEPARATOR) ?? rowId; + }; + + const encodeBase = (base: string | undefined) => { + if (base) { + return tryEncodeKey(base + KEY_SEPARATOR); + } + }; + + const listRows = async (queries: string[] | undefined) => { + const instance = getInstance(); + return await callAppwriteApi( + async () => { + return await instance.listRows({ + databaseId: options.databaseId, + tableId: options.tableId, + queries, + }); + }, + { + driverName: DRIVER_NAME, + } + ); + }; + + const getRowAttributes = async ( + key: string, + attributes: T + ) => { + const rowId = tryEncodeKey(key); + const queries = [ + Query.equal(rowIdAttribute, rowId), + Query.select(attributes), + ]; + const rowList = await listRows(queries); + type RowType = { + [K in (typeof attributes)[number]]: AppwriteModels.DefaultRow[K]; + }; + + const rows = rowList.rows as RowType[]; + return rows.at(0) || null; + }; + + const getRowValue = async (key: string) => { + const row = await getRowAttributes(key, [valueAttribute] as const); + const value = row?.[valueAttribute]; + if (!value) return null; + return JSON.parse(value); + }; + + const getRowMeta = async (key: string) => { + return await getRowAttributes(key, ["$createdAt", "$updatedAt"] as const); + }; + + const upsertRow = async (key: string, value: T) => { + const instance = getInstance(); + + const rows = [ + { + // $id: AppwriteID.unique(), + [rowIdAttribute]: tryEncodeKey(key), + [valueAttribute]: JSON.stringify(value), + }, + ]; + + await callAppwriteApi( + async () => { + return await instance.upsertRows({ + databaseId: options.databaseId, + tableId: options.tableId, + rows, + }); + }, + { + driverName: DRIVER_NAME, + } + ); + }; + + const deleteRows = async (queries: string[] | undefined) => { + return await callAppwriteApi( + async () => { + return await tablesDB.deleteRows({ + databaseId: options.databaseId, + tableId: options.tableId, + queries, + }); + }, + { + driverName: DRIVER_NAME, + onNotFound() { + return null; + }, + } + ); + }; + + const deleteRow = async (key: string) => { + const keys = [key].map((key) => tryEncodeKey(key)); + const queries = [Query.equal(rowIdAttribute, keys)]; + return await deleteRows(queries); + }; + + return { + getInstance, + + flags: { + maxDepth: false, + ttl: false, + }, + + async hasItem(key): Promise { + const row = await getRowAttributes(key, [rowIdAttribute]); + return null != row; + }, + + async getMeta(key): Promise { + const row = await getRowMeta(key); + if (!row) return null; + return { + atime: new Date(row.$createdAt), + mtime: new Date(row.$updatedAt), + }; + }, + + async getItem(key): Promise { + const value = await getRowValue(key); + if (!value) return null; + return value; + }, + + async getKeys(base: string | undefined): Promise { + base = encodeBase(base); + const baseQueries = base ? [Query.startsWith(rowIdAttribute, base)] : []; + const queries = [Query.select([rowIdAttribute]), ...baseQueries]; + const { rows } = await listRows(queries); + return rows.map((row) => { + return tryDecodeKey(row[rowIdAttribute]); + }); + }, + + async setItem(key, value) { + await upsertRow(key, value); + }, + + async removeItem(key) { + await deleteRow(key); + }, + + async clear(base: string | undefined) { + base = encodeBase(base); + const queries = base + ? [Query.startsWith(rowIdAttribute, base)] + : undefined; + await deleteRows(queries); + }, + } satisfies Driver; +}); diff --git a/src/drivers/utils/appwrite.ts b/src/drivers/utils/appwrite.ts index c9b8d5762..506713b33 100644 --- a/src/drivers/utils/appwrite.ts +++ b/src/drivers/utils/appwrite.ts @@ -37,7 +37,7 @@ export type FetchAppwriteStorageOptions = { }; /** - * Function type for transforming strings, typically used for encoding/decoding file IDs. + * Function type for transforming strings, typically used for encoding/decoding file/row IDs. * @param value - The input string to transform * @param keySeparator - The separator character used in key paths * @returns The transformed string @@ -46,21 +46,21 @@ 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. + * Type for transforming between storage keys and Appwrite file/row IDs. + * This allows custom encoding/decoding logic for file/row ID generation. */ export type AppwriteStorageKeyOptions = { /** - * Encodes a storage key to an Appwrite file ID. + * Encodes a storage key to an Appwrite file/row ID. * @param value - The storage key to encode * @param keySeparator - The separator character used in key paths - * @returns The encoded file ID + * @returns The encoded file/row ID */ encodeKey: StringTransformer; /** - * Decodes an Appwrite file ID back to a storage key. - * @param value - The file ID to decode + * Decodes an Appwrite file/row ID back to a storage key. + * @param value - The file/row ID to decode * @param keySeparator - The separator character used in key paths * @returns The decoded storage key */ @@ -114,6 +114,30 @@ export function isAppwriteException( ); } +/** + * Creates and configures an Appwrite client instance. + * This function initializes a new Appwrite client with the provided project configuration, + * including endpoint, project ID, and optional API key for authentication. + * + * @param options - Configuration options for the Appwrite client + * @param options.endpoint - The Appwrite endpoint URL (will be converted to string) + * @param options.projectId - The Appwrite project ID + * @param options.apiKey - Optional API key for authentication + * @returns A configured Appwrite client instance ready for use + * @see AppwriteProjectOptions for detailed parameter descriptions + */ +export function createAppwriteClient(options: AppwriteProjectOptions) { + const client = new AppwriteClient() + .setEndpoint(options.endpoint.toString()) + .setProject(options.projectId); + + if (options.apiKey) { + client.setKey(options.apiKey); + } + + return client; +} + /** * 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. @@ -143,13 +167,7 @@ export function provideAppwriteClient( throw createRequiredError(driverName, "project"); } - client = new AppwriteClient() - .setEndpoint(options.endpoint.toString()) - .setProject(options.projectId); - - if (options.apiKey) { - client.setKey(options.apiKey); - } + client = createAppwriteClient(options); } return client; diff --git a/test/drivers/appwrite-storage.test.ts b/test/drivers/appwrite-storage.test.ts index 35c33063e..51db9c2a0 100644 --- a/test/drivers/appwrite-storage.test.ts +++ b/test/drivers/appwrite-storage.test.ts @@ -1,7 +1,7 @@ import { describe } from "vitest"; import appwriteStorageDriver from "../../src/drivers/appwrite-storage.ts"; import { testDriver } from "./utils.ts"; -import { keyOptions } from "./appwrite-utils.ts"; +import { keyOptions } from "./appwrite.fixture.ts"; const endpoint = process.env.VITE_APPWRITE_ENDPOINT; const projectId = process.env.VITE_APPWRITE_PROJECT_ID; diff --git a/test/drivers/appwrite-tables-db.test.ts b/test/drivers/appwrite-tables-db.test.ts new file mode 100644 index 000000000..997f99c64 --- /dev/null +++ b/test/drivers/appwrite-tables-db.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import appwriteTablesDbDriver from "../../src/drivers/appwrite-tables-db.ts"; +import { testDriver, type TestContext } from "./utils.ts"; +import { provideTestTable, keyOptions } from "./appwrite.fixture.ts"; +import { createAppwriteClient } from "../../src/drivers/utils/appwrite.ts"; + +const endpoint = process.env.VITE_APPWRITE_ENDPOINT; +const projectId = process.env.VITE_APPWRITE_PROJECT_ID; +const databaseId = process.env.VITE_APPWRITE_DATABASE_ID; +const apiKey = process.env.VITE_APPWRITE_API_KEY; + +describe.skipIf(!endpoint || !projectId || !databaseId)( + "drivers: appwrite-tablesdb", + () => { + const additionalTests = (ctx: TestContext) => { + it("upsert item", async () => { + await ctx.storage.setItem("l0:l1:l2", "initial_value"); + await ctx.storage.setItem("l0:l1:l2", "new_value"); + expect(await ctx.storage.getItem("l0:l1:l2")).toBe("new_value"); + }); + }; + + const client = createAppwriteClient({ + endpoint: endpoint!, + projectId: projectId!, + apiKey, + }); + + describe("key attribute: '$id' (default)", (test) => { + const tableId = provideTestTable(test, { + client, + databaseId: databaseId!, + tableName: "unstorage_test_key_default", + }); + + testDriver({ + driver: () => + appwriteTablesDbDriver({ + client, + databaseId: databaseId!, + tableId, + ...keyOptions, + }), + additionalTests, + }); + }); + + describe("key attribute: 'key' (custom)", (test) => { + const customKeyAttribute = "primary_key"; + + const tableId = provideTestTable(test, { + client, + databaseId: databaseId!, + tableName: "unstorage_test_key_custom", + keyColumn: { + key: customKeyAttribute, + type: "string", + size: 64, + }, + keyIndex: { + key: "unique_primary_key", + type: "unique", + attributes: [customKeyAttribute], + }, + }); + + testDriver({ + driver: () => + appwriteTablesDbDriver({ + client, + databaseId: databaseId!, + tableId, + attributes: { + key: customKeyAttribute, + value: "value", + }, + }), + additionalTests, + }); + }); + } +); diff --git a/test/drivers/appwrite.fixture.ts b/test/drivers/appwrite.fixture.ts new file mode 100644 index 000000000..661ffeb46 --- /dev/null +++ b/test/drivers/appwrite.fixture.ts @@ -0,0 +1,196 @@ +import basex from "base-x"; +import { TablesDB } from "node-appwrite"; +import type { + AppwriteClientOptions, + AppwriteStorageKeyOptions, + RequireAllOrNone, +} from "../../src/drivers/utils/appwrite.ts"; +import { type TestAPI } from "vitest"; + +function createRandomNumericString(length: number = 16) { + const random = Math.floor(Math.random() * Math.pow(10, length)); + return random.toString().padStart(length, "0"); +} + +/** + * 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; + +export type DatabaseOptions = { + databaseId: string; +}; + +type TablesDbOptions = DatabaseOptions & { + tablesDb: TablesDB; +}; + +export type AppwriteColumn = { + key: string; + type: + | "string" + | "integer" + | "float" + | "boolean" + | "datetime" + | "relationship"; + size?: number; + required?: boolean; + default?: unknown; + array?: boolean; +}; + +export type AppwriteIndex = { + key: string; + type: "key" | "fulltext" | "unique" | "spatial"; + attributes: [string, ...string[]]; + orders?: string[]; + lengths?: number[]; +}; + +export type TableNameOptions = { + tableName: string; +}; + +export type TableOptions = TableNameOptions & { + tableId: string; + columns: AppwriteColumn[]; + indexes?: AppwriteIndex[]; +}; + +async function createTable(options: TablesDbOptions & TableOptions) { + const table = await options.tablesDb.createTable({ + databaseId: options.databaseId, + tableId: options.tableId, + name: options.tableName, + columns: options.columns, + indexes: options.indexes, + }); + return table.name; +} + +export async function deleteTable( + options: TablesDbOptions & { tableId: string } +) { + await options.tablesDb.deleteTable({ + databaseId: options.databaseId, + tableId: options.tableId, + }); +} + +type CreateTestTableOptions = AppwriteClientOptions & + DatabaseOptions & + TableNameOptions & + RequireAllOrNone<{ + keyColumn: AppwriteColumn; + keyIndex: AppwriteIndex; + }>; + +/** + * Provides a test table for Appwrite database testing. + * This function creates a test table before tests run and removes it afterward. + * It automatically generates a unique table ID and sets up the necessary columns and indexes. + * + * @param test - The Vitest test API instance for setting up before/after hooks + * @param options - Configuration options for creating the test table + * @param options.client - Appwrite client instance + * @param options.databaseId - ID of the database where the table will be created + * @param options.tableName - Name of the test table + * @param options.keyColumn - Optional custom key column definition + * @param options.keyIndex - Optional custom key index definition + * @returns The generated table ID that can be used in tests + * @see CreateTestTableOptions for detailed parameter descriptions + */ +export function provideTestTable( + test: TestAPI, + options: CreateTestTableOptions +) { + const tableId = "unstorage-test-" + createRandomNumericString(); + + const columns: AppwriteColumn[] = [ + { + key: "value", + type: "string", + size: 4096, + }, + ]; + if (options.keyColumn) { + columns.push(options.keyColumn); + } + const indexes: AppwriteIndex[] = []; + if (options.keyIndex) { + indexes.push(options.keyIndex); + } + + const tablesDb = new TablesDB(options.client); + + test.beforeAll(async () => { + await createTable({ + tableId, + tableName: options.tableName, + tablesDb, + databaseId: options.databaseId, + columns, + indexes, + }); + }); + + test.afterAll(async () => { + await deleteTable({ + tablesDb, + databaseId: options.databaseId, + tableId, + }); + }); + + return tableId; +} From d5ae130418b1695bddd68f52926f6fdb40dfca30 Mon Sep 17 00:00:00 2001 From: Alexei Myshkouski Date: Fri, 2 Jan 2026 19:54:30 +0300 Subject: [PATCH 6/7] chore(test): remove appwrite storage key utilities file --- test/drivers/appwrite-utils.ts | 28 ---------------------------- 1 file changed, 28 deletions(-) delete mode 100644 test/drivers/appwrite-utils.ts diff --git a/test/drivers/appwrite-utils.ts b/test/drivers/appwrite-utils.ts deleted file mode 100644 index caa7b53a8..000000000 --- a/test/drivers/appwrite-utils.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { AppwriteStorageKeyOptions } from "unstorage/drivers/utils/appwrite"; -import basex from "base-x"; - -const base62 = basex( - "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" -); -const textEncoder = new TextEncoder(); -const textDecoder = new TextDecoder(); -const ID_SEPARATOR = "_" as const; - -export const keyOptions = { - encodeKey(key, keySeparator) { - return key - .split(keySeparator) - .map((part) => { - return base62.encode(textEncoder.encode(part)); - }) - .join(ID_SEPARATOR); - }, - decodeKey(fileId, keySeparator) { - return fileId - .split(ID_SEPARATOR) - .map((part) => { - return textDecoder.decode(base62.decode(part)); - }) - .join(keySeparator); - }, -} satisfies AppwriteStorageKeyOptions; From c20c24deaeadd1d01d452b5da7c186ca118b86f0 Mon Sep 17 00:00:00 2001 From: Alexei Date: Sat, 3 Jan 2026 16:42:50 +0300 Subject: [PATCH 7/7] refactor(appwrite): add hyphen to driver name for consistency --- src/drivers/appwrite-tables-db.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/drivers/appwrite-tables-db.ts b/src/drivers/appwrite-tables-db.ts index cccc091a8..53ed06319 100644 --- a/src/drivers/appwrite-tables-db.ts +++ b/src/drivers/appwrite-tables-db.ts @@ -27,7 +27,7 @@ export type AppwriteTablesDbConfigurationOptions = ( tableId: string; }; -const DRIVER_NAME = "appwrite-tablesdb"; +const DRIVER_NAME = "appwrite-tables-db"; export default defineDriver((options: AppwriteTablesDbConfigurationOptions) => { let tablesDB: TablesDB;