diff --git a/package-lock.json b/package-lock.json index c4dd7385..ff929482 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ravendb", - "version": "7.1.6", + "version": "7.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ravendb", - "version": "7.1.6", + "version": "7.2.0", "license": "MIT", "dependencies": { "@types/node": "^20.14.14", diff --git a/package.json b/package.json index 135153e7..ec6133e4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ravendb", - "version": "7.1.6", + "version": "7.2.0", "description": "RavenDB client for Node.js", "files": [ "dist" diff --git a/src/Constants.ts b/src/Constants.ts index 6fa9c51a..709e997a 100644 --- a/src/Constants.ts +++ b/src/Constants.ts @@ -51,6 +51,13 @@ export const CONSTANTS = { SHARD_CONTEXT_PARAMETER_NAME: "__shardContext", }, + JavaScript: { + VectorPropertyName: "$vector", + LoadVectorPropertyName: "$loadvector", + LoadVectorEmbeddingSourceDocumentId: "$embeddingSourceDocumentId", + LoadVectorEmbeddingSourceDocumentCollectionName: "$embeddingSourceDocumentCollectionName" + }, + PeriodicBackup: { FULL_BACKUP_EXTENSION: "ravendb-full-backup", SNAPSHOT_EXTENSION: "ravendb-snapshot", @@ -97,6 +104,9 @@ export const HEADERS = { SHARDED: "Sharded", ATTACHMENT_HASH: "Attachment-Hash", ATTACHMENT_SIZE: "Attachment-Size", + ATTACHMENT_REMOTE_PARAMETERS_AT: "Attachment-RemoteParameters-At", + ATTACHMENT_REMOTE_PARAMETERS_FLAGS: "Attachment-RemoteParameters-Flags", + ATTACHMENT_REMOTE_PARAMETERS_IDENTIFIER: "Attachment-RemoteParameters-Identifier", DATABASE_MISSING: "Database-Missing" } as const; diff --git a/src/Documents/Attachments/RemoteAttachmentFlags.ts b/src/Documents/Attachments/RemoteAttachmentFlags.ts new file mode 100644 index 00000000..bf28de41 --- /dev/null +++ b/src/Documents/Attachments/RemoteAttachmentFlags.ts @@ -0,0 +1,4 @@ +/** + * Flags that indicate the location and characteristics of an attachment. + */ +export type RemoteAttachmentFlags = "None" | "Remote" diff --git a/src/Documents/Attachments/RemoteAttachmentsAzureSettings.ts b/src/Documents/Attachments/RemoteAttachmentsAzureSettings.ts new file mode 100644 index 00000000..4a11dff7 --- /dev/null +++ b/src/Documents/Attachments/RemoteAttachmentsAzureSettings.ts @@ -0,0 +1,70 @@ +/** + * Configuration settings for storing attachments in Azure Blob Storage as part of the remote attachments feature. + * + * This class configures the remote storage destination for attachments using Microsoft Azure Blob Storage. + * Remote attachments allow offloading large attachment files from the local RavenDB database to cloud storage, + * helping to reduce database size and improve performance for scenarios with many or large attachments. + * + * Azure authentication can be configured using either: + * - Account Key: Using accountName and accountKey + * - Shared Access Signature (SAS): Using accountName and sasToken + */ +export class RemoteAttachmentsAzureSettings { + /** + * The name of the Azure Blob Storage container where attachments will be stored. + * + * The storage container name must follow Azure Blob Storage naming rules: + * - Must be between 3 and 63 characters long + * - Must contain only lowercase letters, numbers, and dashes + * - Must start and end with a letter or number + * - Cannot contain consecutive dashes + * + * This property is required for the configuration to be valid. + */ + public storageContainer: string; + + /** + * Optional subfolder path within the storage container for organizing attachments. + * + * This property allows you to organize attachments into a specific folder structure within the Azure container. + * For example, you might use different folder names for different databases, environments, or tenants. + * The folder path can include forward slashes to create a multi-level directory structure. + * + * Example: "production/database1" or "attachments/2024" + */ + public remoteFolderName?: string; + + /** + * The Azure storage account name. + * + * This is required for authentication and is used in combination with either accountKey or sasToken. + * The storage account must have the appropriate permissions to create and write blobs to the specified container. + */ + public accountName: string; + + /** + * The Azure storage account key for authentication. + * + * The account key provides full access to the storage account. Use this property when authenticating + * with the primary or secondary access key from your Azure storage account. + * + * Security Warning: Store this securely and never commit to source control. + * Either accountKey or sasToken must be provided, but not both. + */ + public accountKey?: string; + + /** + * The Shared Access Signature (SAS) token for authentication. + * + * SAS tokens provide limited access to resources in your storage account with specific permissions + * and expiration time. This is the recommended approach for production environments as it provides + * more granular control over access. + * + * Either accountKey or sasToken must be provided, but not both. + */ + public sasToken?: string; + + public isConfigured(): boolean { + return !!this.storageContainer && !!this.accountName && (!!this.accountKey || !!this.sasToken); + } +} diff --git a/src/Documents/Attachments/RemoteAttachmentsConfiguration.ts b/src/Documents/Attachments/RemoteAttachmentsConfiguration.ts new file mode 100644 index 00000000..10c4fc12 --- /dev/null +++ b/src/Documents/Attachments/RemoteAttachmentsConfiguration.ts @@ -0,0 +1,104 @@ +import { RemoteAttachmentsDestinationConfiguration } from "./RemoteAttachmentsDestinationConfiguration.js"; +import { throwError } from "../../Exceptions/index.js"; + +/** + * Configuration for remote attachments functionality, including destinations, frequency, and upload settings. + */ +export class RemoteAttachmentsConfiguration { + /** + * Dictionary of named remote attachment destinations (case-insensitive keys). + * Key: destination identifier/name + * Value: destination configuration + */ + public destinations: { [key: string]: RemoteAttachmentsDestinationConfiguration }; + + /** + * The frequency (in seconds) at which the remote attachments process checks for new items to upload. + */ + public checkFrequencyInSec?: number; + + /** + * The maximum number of items to process in a single batch. + */ + public maxItemsToProcess?: number; + + /** + * The number of concurrent uploads allowed. + */ + public concurrentUploads?: number; + + /** + * Whether remote attachments functionality is disabled. + */ + public disabled: boolean; + + constructor() { + this.destinations = {}; + this.disabled = false; + } + + /** + * Checks if this configuration has any valid destination. + */ + public hasDestination(): boolean { + if (this.disabled) { + return false; + } + + if (!this.destinations || Object.keys(this.destinations).length === 0) { + return false; + } + + for (const key of Object.keys(this.destinations)) { + const destination = this.destinations[key]; + if (destination?.hasUploader()) { + return true; + } + } + + return false; + } + + /** + * Validates the entire configuration. + * @param databaseName Optional database name for error messages + * @throws Error if configuration is invalid + */ + public assertConfiguration(databaseName?: string): void { + const databaseStr = databaseName ? ` for database '${databaseName}'` : ""; + + if (this.checkFrequencyInSec !== undefined && this.checkFrequencyInSec <= 0) { + throwError("InvalidOperationException", `Remote attachments check frequency${databaseStr} must be greater than 0.`); + } + + if (this.maxItemsToProcess !== undefined && this.maxItemsToProcess <= 0) { + throwError("InvalidOperationException", `Max items to process${databaseStr} must be greater than 0.`); + } + + if (this.concurrentUploads !== undefined && this.concurrentUploads <= 0) { + throwError("InvalidOperationException", `Concurrent attachments uploads${databaseStr} must be greater than 0.`); + } + + if (!this.destinations || Object.keys(this.destinations).length === 0) { + // No destinations configured + return; + } + + const keys = new Set(); + for (const key of Object.keys(this.destinations)) { + const destination = this.destinations[key]; + + const lowerKey = key.toLowerCase(); + if (keys.has(lowerKey)) { + throwError("InvalidOperationException", `Destination key '${key}' is duplicate. Duplicate keys are not allowed in remote attachments configuration${databaseStr}.`); + } + keys.add(lowerKey); + + if (!destination) { + throwError("InvalidOperationException", `Destination configuration for key ${key} is null${databaseStr}.`); + } + + destination.assertConfiguration(key, databaseName); + } + } +} diff --git a/src/Documents/Attachments/RemoteAttachmentsDestinationConfiguration.ts b/src/Documents/Attachments/RemoteAttachmentsDestinationConfiguration.ts new file mode 100644 index 00000000..6f280ed1 --- /dev/null +++ b/src/Documents/Attachments/RemoteAttachmentsDestinationConfiguration.ts @@ -0,0 +1,59 @@ +import { RemoteAttachmentsAzureSettings } from "./RemoteAttachmentsAzureSettings.js"; +import { RemoteAttachmentsS3Settings } from "./RemoteAttachmentsS3Settings.js"; +import { throwError } from "../../Exceptions/index.js"; + +/** + * Configuration for a single remote attachment storage destination. + * + * A destination can be either Azure Blob Storage or Amazon S3, but not both. + * Each destination can be individually disabled without removing its configuration. + */ +export class RemoteAttachmentsDestinationConfiguration { + /** + * Whether this destination is disabled. + * When true, attachments will not be uploaded to this destination. + */ + public disabled: boolean; + + /** + * Amazon S3 storage settings. + * Either s3Settings or azureSettings must be configured, but not both. + */ + public s3Settings?: RemoteAttachmentsS3Settings; + + /** + * Azure Blob Storage settings. + * Either s3Settings or azureSettings must be configured, but not both. + */ + public azureSettings?: RemoteAttachmentsAzureSettings; + + /** + * Checks if this destination has a configured uploader. + */ + public hasUploader(): boolean { + if (this.disabled) { + return false; + } + + return (this.s3Settings?.isConfigured() ?? false) || (this.azureSettings?.isConfigured() ?? false); + } + + /** + * Validates the destination configuration. + * @throws Error if configuration is invalid + */ + public assertConfiguration(key: string, databaseName?: string): void { + const databaseStr = databaseName ? ` for database '${databaseName}'` : ""; + + const s3Configured = this.s3Settings?.isConfigured() ?? false; + const azureConfigured = this.azureSettings?.isConfigured() ?? false; + + if (!s3Configured && !azureConfigured) { + throwError("InvalidOperationException", `Exactly one uploader for RemoteAttachmentsDestinationConfiguration '${key}'${databaseStr} must be configured.`); + } + + if (s3Configured && azureConfigured) { + throwError("InvalidOperationException", `Only one uploader for RemoteAttachmentsDestinationConfiguration '${key}'${databaseStr} can be configured.`); + } + } +} diff --git a/src/Documents/Attachments/RemoteAttachmentsS3Settings.ts b/src/Documents/Attachments/RemoteAttachmentsS3Settings.ts new file mode 100644 index 00000000..65066d0c --- /dev/null +++ b/src/Documents/Attachments/RemoteAttachmentsS3Settings.ts @@ -0,0 +1,98 @@ +/** + * Configuration settings for storing remote attachments in Amazon S3 or S3-compatible storage. + * + * This class provides the configuration required to upload and store RavenDB attachments in Amazon S3 + * or S3-compatible object storage services. + * + * The configuration supports authentication via AWS access keys, secret keys, and optional session tokens + * for temporary credentials. It also allows customization of storage class, server URL, and path style options + * for compatibility with various S3-compatible storage providers. + */ +export class RemoteAttachmentsS3Settings { + /** + * AWS access key ID used for authentication with Amazon S3. + * + * This is the first part of the AWS credentials pair (Access Key ID and Secret Access Key). + * The access key ID is used to identify the AWS account or IAM user making requests to S3. + * + * For security best practices, consider using IAM roles or temporary credentials via + * awsSessionToken instead of long-term access keys when possible. + */ + public awsAccessKey: string; + + /** + * AWS secret access key used for authentication with Amazon S3. + * + * This is the second part of the AWS credentials pair (Access Key ID and Secret Access Key). + * The secret key is used to sign requests to AWS services and should be kept confidential. + * + * Security Warning: Never commit secret keys to source control or expose them in logs or error messages. + * Store them securely using environment variables, configuration management systems, or secret management services. + */ + public awsSecretKey: string; + + /** + * AWS session token for temporary security credentials. + * + * Session tokens are used when working with temporary security credentials obtained from + * AWS Security Token Service (STS). These are typically used with IAM roles, federated users, + * or when assuming roles across AWS accounts. + * + * Temporary credentials automatically expire after a specified duration, providing enhanced + * security compared to long-term access keys. This is the recommended authentication method + * for production environments. + */ + public awsSessionToken?: string; + + /** + * AWS region name where the S3 bucket is located. + * + * Examples: "us-east-1", "eu-west-1", "ap-southeast-2" + * If not specified, the default region will be used. + */ + public awsRegionName?: string; + + /** + * The name of the S3 bucket where attachments will be stored. + * + * The bucket must already exist and the configured credentials must have permission + * to write objects to this bucket. + */ + public bucketName: string; + + /** + * Optional subfolder path within the S3 bucket for organizing attachments. + * + * This property allows you to organize attachments into a specific folder structure within the S3 bucket. + * For example, you might use different folder names for different databases, environments, or tenants. + * The folder path can include forward slashes to create a multi-level directory structure. + * + * Example: "production/database1" or "attachments/2024" + */ + public remoteFolderName?: string; + + /** + * Custom S3 server URL for S3-compatible storage services. + * + * Use this when connecting to S3-compatible storage providers like MinIO, Wasabi, DigitalOcean Spaces, + * or on-premises S3-compatible solutions. Leave undefined for standard AWS S3. + * + * Example: "https://s3.wasabisys.com" or "https://nyc3.digitaloceanspaces.com" + */ + public customServerUrl?: string; + + /** + * Force path-style requests instead of virtual-hosted-style requests. + * + * Path-style: https://s3.amazonaws.com/bucket-name/key + * Virtual-hosted-style: https://bucket-name.s3.amazonaws.com/key + * + * Some S3-compatible services require path-style requests. Set to true for compatibility + * with these services or when using custom domain names. + */ + public forcePathStyle?: boolean; + + public isConfigured(): boolean { + return !!this.bucketName && !!this.awsAccessKey && !!this.awsSecretKey; + } +} diff --git a/src/Documents/Attachments/index.ts b/src/Documents/Attachments/index.ts index 36dff46b..cdaf0250 100644 --- a/src/Documents/Attachments/index.ts +++ b/src/Documents/Attachments/index.ts @@ -2,6 +2,7 @@ import { Readable } from "node:stream"; import { HttpResponse } from "../../Primitives/Http.js"; import { closeHttpResponse } from "../../Utility/HttpUtil.js"; import { CapitalizeType } from "../../Types/index.js"; +import { RemoteAttachmentParameters } from "../Operations/Attachments/RemoteAttachmentParameters.js"; export type AttachmentType = "Document" | "Revision"; @@ -10,6 +11,7 @@ export interface AttachmentName { hash: string; contentType: string; size: number; + remoteParameters?: RemoteAttachmentParameters; } export interface AttachmentNameWithCount extends AttachmentName { diff --git a/src/Documents/BulkInsertOperation.ts b/src/Documents/BulkInsertOperation.ts index 3b091076..1edd21c2 100644 --- a/src/Documents/BulkInsertOperation.ts +++ b/src/Documents/BulkInsertOperation.ts @@ -35,6 +35,7 @@ import { BulkInsertOperationBase } from "./BulkInsert/BulkInsertOperationBase.js import { BulkInsertOptions } from "./BulkInsert/BulkInsertOptions.js"; import { BulkInsertWriter } from "./BulkInsert/BulkInsertWriter.js"; import { HttpCompressionAlgorithm } from "../Http/HttpCompressionAlgorithm.js"; +import { RemoteAttachmentParameters } from "./Operations/Attachments/RemoteAttachmentParameters.js"; export class BulkInsertOperation extends BulkInsertOperationBase { @@ -284,8 +285,8 @@ export class BulkInsertOperation extends BulkInsertOperationBase { public store(name: string, bytes: Buffer): Promise; public store(name: string, bytes: Buffer, contentType: string): Promise; - public store(name: string, bytes: Buffer, contentType?: string): Promise { - return this._operation._attachmentsOperation.store(this._id, name, bytes, contentType); + public store(name: string, bytes: Buffer, contentType?: string, remoteParameters?: RemoteAttachmentParameters): Promise { + return this._operation._attachmentsOperation.store(this._id, name, bytes, contentType, remoteParameters); } } @@ -298,7 +299,8 @@ export class BulkInsertOperation extends BulkInsertOperationBase { public async store(id: string, name: string, bytes: Buffer): Promise; public async store(id: string, name: string, bytes: Buffer, contentType: string): Promise; - public async store(id: string, name: string, bytes: Buffer, contentType?: string): Promise { + public async store(id: string, name: string, bytes: Buffer, contentType: string, remoteParameters: RemoteAttachmentParameters): Promise; + public async store(id: string, name: string, bytes: Buffer, contentType?: string, remoteParameters?: RemoteAttachmentParameters): Promise { const check = await this._operation._concurrencyCheck(); try { @@ -315,13 +317,33 @@ export class BulkInsertOperation extends BulkInsertOperationBase { this._operation._writeString(id); this._operation._writer.write(`","Type":"AttachmentPUT","Name":"`); this._operation._writeString(name); + this._operation._writer.write(`"`); if (contentType) { - this._operation._writer.write(`","ContentType":"`); + this._operation._writer.write(`,"ContentType":"`); this._operation._writeString(contentType); + this._operation._writer.write(`"`); + } + + if (remoteParameters) { + this._operation._writer.write(`,"RemoteParameters":{"Identifier":"`); + this._operation._writeString(remoteParameters.identifier); + this._operation._writer.write(`"`) + + this._operation._writer.write(`,"Flags":"`); + this._operation._writeString(remoteParameters.flags); + this._operation._writer.write(`"`) + + if (remoteParameters.at) { + this._operation._writer.write(`,"At":"`); + this._operation._writeString(remoteParameters.at.toISOString()); + this._operation._writer.write(`"`); + } + + this._operation._writer.write(`}`); } - this._operation._writer.write(`","ContentLength":`); + this._operation._writer.write(`,"ContentLength":`); this._operation._writer.write(bytes.length.toString()); this._operation._writer.write("}"); @@ -946,6 +968,7 @@ export interface ITypedTimeSeriesBulkInsert extends IDisposabl export interface IAttachmentsBulkInsert { store(name: string, bytes: Buffer): Promise; store(name: string, bytes: Buffer, contentType: string): Promise; + store(name: string, bytes: Buffer, contentType: string, remoteParameters: RemoteAttachmentParameters): Promise; } export class BulkInsertCommand extends RavenCommand { diff --git a/src/Documents/Commands/Batches/PutAttachmentCommandData.ts b/src/Documents/Commands/Batches/PutAttachmentCommandData.ts index dc6b6603..c168f083 100644 --- a/src/Documents/Commands/Batches/PutAttachmentCommandData.ts +++ b/src/Documents/Commands/Batches/PutAttachmentCommandData.ts @@ -1,8 +1,25 @@ import { ICommandData, CommandType } from "../CommandData.js"; import { AttachmentData } from "../../Attachments/index.js"; +import { RemoteAttachmentParameters } from "../../Operations/Attachments/RemoteAttachmentParameters.js"; import { StringUtil } from "../../../Utility/StringUtil.js"; import { throwError } from "../../../Exceptions/index.js"; import { DocumentConventions } from "../../Conventions/DocumentConventions.js"; +import { RemoteAttachmentFlags } from "../../Attachments/RemoteAttachmentFlags.js"; + +interface DocumentAttachmentDto { + Id: string; + ChangeVector: string; + Name: string; + ContentType: string; + Type: CommandType; + RemoteParameters?: RemoteParametersDto; +} + +interface RemoteParametersDto { + Identifier: string; + Flags: RemoteAttachmentFlags; + At?: Date; +} export class PutAttachmentCommandData implements ICommandData { public id: string; @@ -11,13 +28,15 @@ export class PutAttachmentCommandData implements ICommandData { public type: CommandType = "AttachmentPUT"; public contentType: string; public attStream: AttachmentData; + public remoteParameters?: RemoteAttachmentParameters; public constructor( documentId: string, name: string, stream: AttachmentData, contentType: string, - changeVector: string) { + changeVector: string, + remoteParameters?: RemoteAttachmentParameters) { if (StringUtil.isNullOrWhitespace(documentId)) { throwError("InvalidArgumentException", "DocumentId cannot be null."); @@ -32,15 +51,26 @@ export class PutAttachmentCommandData implements ICommandData { this.attStream = stream; this.contentType = contentType; this.changeVector = changeVector; + this.remoteParameters = remoteParameters; } public serialize(conventions: DocumentConventions): object { - return { + const result: DocumentAttachmentDto = { Id: this.id, Name: this.name, ChangeVector: this.changeVector, Type: "AttachmentPUT" as CommandType, ContentType: this.contentType }; + + if (this.remoteParameters) { + result.RemoteParameters = { + Identifier: this.remoteParameters.identifier, + Flags: this.remoteParameters.flags, + At: this.remoteParameters.at + }; + } + + return result; } -} +} \ No newline at end of file diff --git a/src/Documents/Operations/Attachments/GetAttachmentOperation.ts b/src/Documents/Operations/Attachments/GetAttachmentOperation.ts index 25df4c8e..a453678a 100644 --- a/src/Documents/Operations/Attachments/GetAttachmentOperation.ts +++ b/src/Documents/Operations/Attachments/GetAttachmentOperation.ts @@ -1,8 +1,7 @@ import { IOperation, OperationResultType } from "../OperationAbstractions.js"; -import { AttachmentDetails } from "../../Attachments/index.js"; +import { AttachmentDetails, AttachmentResult, AttachmentType } from "../../Attachments/index.js"; import { getEtagHeader } from "../../../Utility/HttpUtil.js"; import { HttpRequestParameters, HttpResponse } from "../../../Primitives/Http.js"; -import { AttachmentResult, AttachmentType } from "../../Attachments/index.js"; import { RavenCommand, ResponseDisposeHandling } from "../../../Http/RavenCommand.js"; import { HttpCache } from "../../../Http/HttpCache.js"; import { IDocumentStore } from "../../IDocumentStore.js"; @@ -11,6 +10,10 @@ import { throwError } from "../../../Exceptions/index.js"; import { StringUtil } from "../../../Utility/StringUtil.js"; import { ServerNode } from "../../../Http/ServerNode.js"; import { Readable } from "node:stream"; +import { HEADERS } from "../../../Constants.js"; +import { RemoteAttachmentFlags } from "../../Attachments/RemoteAttachmentFlags.js"; +import { RemoteAttachmentParameters } from "./RemoteAttachmentParameters.js"; +import { DateUtil } from "../../../Utility/DateUtil.js"; export class GetAttachmentOperation implements IOperation { private readonly _documentId: string; @@ -37,6 +40,12 @@ export class GetAttachmentOperation implements IOperation { } +interface InternalRemoteAttachmentParameters { + identifier: string; + flags: RemoteAttachmentFlags; + at: Date; +} + export class GetAttachmentCommand extends RavenCommand { private readonly _documentId: string; private readonly _name: string; @@ -91,6 +100,10 @@ export class GetAttachmentCommand extends RavenCommand { const hash = response.headers.get("attachment-hash") as string; let size = 0; const sizeHeader = response.headers.get("attachment-size") as string; + const remoteParametersIdentifier = response.headers.get(HEADERS.ATTACHMENT_REMOTE_PARAMETERS_IDENTIFIER) as string; + const remoteParametersAt = response.headers.get(HEADERS.ATTACHMENT_REMOTE_PARAMETERS_AT) as string; // iso date + const remoteParametersFlags = response.headers.get(HEADERS.ATTACHMENT_REMOTE_PARAMETERS_FLAGS) as RemoteAttachmentFlags; + if (sizeHeader) { size = Number.parseInt(sizeHeader, 10); } @@ -104,6 +117,17 @@ export class GetAttachmentCommand extends RavenCommand { size }; + if (remoteParametersIdentifier && remoteParametersAt && remoteParametersFlags) { + const remoteParameters: InternalRemoteAttachmentParameters = { + at: DateUtil.utc.parse(remoteParametersAt), + identifier: remoteParametersIdentifier, + flags: remoteParametersFlags, + } + + details.remoteParameters = remoteParameters as RemoteAttachmentParameters + } + + this.result = new AttachmentResult(bodyStream, details, response); return "Manually"; } @@ -111,4 +135,4 @@ export class GetAttachmentCommand extends RavenCommand { public get isReadRequest() { return true; } -} +} \ No newline at end of file diff --git a/src/Documents/Operations/Attachments/IStoreAttachmentParameters.ts b/src/Documents/Operations/Attachments/IStoreAttachmentParameters.ts new file mode 100644 index 00000000..9f0c9c82 --- /dev/null +++ b/src/Documents/Operations/Attachments/IStoreAttachmentParameters.ts @@ -0,0 +1,32 @@ +import { AttachmentData } from "../../Attachments/index.js"; +import { RemoteAttachmentParameters } from "./RemoteAttachmentParameters.js"; + +/** + * Interface for storing attachment parameters. + */ +export interface IStoreAttachmentParameters { + /** + * The name of the attachment. + */ + name: string; + + /** + * The attachment content stream or buffer. + */ + stream: AttachmentData; + + /** + * Optional content type (MIME type) of the attachment. + */ + contentType?: string; + + /** + * Optional change vector for optimistic concurrency control. + */ + changeVector?: string; + + /** + * Optional parameters for remote cloud storage upload. + */ + remoteParameters?: RemoteAttachmentParameters; +} diff --git a/src/Documents/Operations/Attachments/Remote/ConfigureRemoteAttachmentsOperation.ts b/src/Documents/Operations/Attachments/Remote/ConfigureRemoteAttachmentsOperation.ts new file mode 100644 index 00000000..0dc274da --- /dev/null +++ b/src/Documents/Operations/Attachments/Remote/ConfigureRemoteAttachmentsOperation.ts @@ -0,0 +1,96 @@ +import { IMaintenanceOperation, OperationResultType } from "../../OperationAbstractions.js"; +import { RemoteAttachmentsConfiguration } from "../../../Attachments/RemoteAttachmentsConfiguration.js"; +import { ConfigureRemoteAttachmentsOperationResult } from "./ConfigureRemoteAttachmentsOperationResult.js"; +import { HttpRequestParameters } from "../../../../Primitives/Http.js"; +import { Stream } from "node:stream"; +import { DocumentConventions } from "../../../Conventions/DocumentConventions.js"; +import { RavenCommand } from "../../../../Http/RavenCommand.js"; +import { ServerNode } from "../../../../Http/ServerNode.js"; +import { IRaftCommand } from "../../../../Http/IRaftCommand.js"; +import { RaftIdGenerator } from "../../../../Utility/RaftIdGenerator.js"; +import { throwError } from "../../../../Exceptions/index.js"; +import { ObjectUtil } from "../../../../Utility/ObjectUtil.js"; +import { JsonSerializer } from "../../../../Mapping/Json/Serializer.js"; + +/** + * Operation to configure remote attachments for a database. + * + * This operation allows you to configure cloud storage destinations (S3 or Azure Blob Storage) + * where attachments can be automatically uploaded and stored, reducing local database size. + */ +export class ConfigureRemoteAttachmentsOperation implements IMaintenanceOperation { + private readonly _configuration: RemoteAttachmentsConfiguration; + + /** + * Creates a new ConfigureRemoteAttachmentsOperation. + * @param configuration The remote attachments configuration to apply + * @throws Error if configuration is null or invalid + */ + public constructor(configuration: RemoteAttachmentsConfiguration) { + if (!configuration) { + throwError("InvalidArgumentException", "Configuration cannot be null"); + } + + configuration.assertConfiguration(); + this._configuration = configuration; + } + + public get resultType(): OperationResultType { + return "CommandResult"; + } + + getCommand(conventions: DocumentConventions): RavenCommand { + return new ConfigureRemoteAttachmentsCommand(this._configuration); + } +} + +class ConfigureRemoteAttachmentsCommand extends RavenCommand implements IRaftCommand { + private readonly _configuration: RemoteAttachmentsConfiguration; + + public constructor(configuration: RemoteAttachmentsConfiguration) { + super(); + + if (!configuration) { + throwError("InvalidArgumentException", "Configuration cannot be null"); + } + + this._configuration = configuration; + } + + get isReadRequest(): boolean { + return false; + } + + createRequest(node: ServerNode): HttpRequestParameters { + const uri = node.url + "/databases/" + node.database + "/admin/attachments/remote/config"; + + const serialized = ObjectUtil.transformObjectKeys(this._configuration, { + defaultTransform: ObjectUtil.pascalCase, + paths: [ + { + path: /^destinations$/i, + transform: (key: string) => key + } + ] + }) + + return { + uri, + method: "PUT", + headers: this._headers().typeAppJson().build(), + body: JsonSerializer.getDefault().serialize(serialized) + } + } + + async setResponseAsync(bodyStream: Stream, fromCache: boolean): Promise { + if (!bodyStream) { + this._throwInvalidResponse(); + } + + return this._parseResponseDefaultAsync(bodyStream); + } + + public getRaftUniqueRequestId(): string { + return RaftIdGenerator.newId(); + } +} diff --git a/src/Documents/Operations/Attachments/Remote/ConfigureRemoteAttachmentsOperationResult.ts b/src/Documents/Operations/Attachments/Remote/ConfigureRemoteAttachmentsOperationResult.ts new file mode 100644 index 00000000..d64a94cc --- /dev/null +++ b/src/Documents/Operations/Attachments/Remote/ConfigureRemoteAttachmentsOperationResult.ts @@ -0,0 +1,11 @@ +/** + * Result of a configure remote attachments operation. + * Contains information about the Raft command execution. + */ +export class ConfigureRemoteAttachmentsOperationResult { + /** + * The index of the Raft command that was executed to configure remote attachments. + * This value is null if the operation was not executed through the Raft consensus mechanism. + */ + public raftCommandIndex?: number; +} diff --git a/src/Documents/Operations/Attachments/Remote/GetRemoteAttachmentsConfigurationOperation.ts b/src/Documents/Operations/Attachments/Remote/GetRemoteAttachmentsConfigurationOperation.ts new file mode 100644 index 00000000..b8263014 --- /dev/null +++ b/src/Documents/Operations/Attachments/Remote/GetRemoteAttachmentsConfigurationOperation.ts @@ -0,0 +1,66 @@ +import { Stream } from "node:stream"; +import { ServerNode } from "../../../../Http/ServerNode.js"; +import { RavenCommand } from "../../../../Http/RavenCommand.js"; +import { HttpRequestParameters } from "../../../../Primitives/Http.js"; +import { RemoteAttachmentsConfiguration } from "../../../Attachments/RemoteAttachmentsConfiguration.js"; +import { DocumentConventions } from "../../../Conventions/DocumentConventions.js"; +import { IMaintenanceOperation, OperationResultType } from "../../OperationAbstractions.js"; +import { RavenCommandResponsePipeline } from "../../../../Http/RavenCommandResponsePipeline.js"; +import { ObjectUtil } from "../../../../Utility/ObjectUtil.js"; + +/** + * Operation to retrieve the current remote attachments configuration for a database. + */ +export class GetRemoteAttachmentsConfigurationOperation implements IMaintenanceOperation { + + public get resultType(): OperationResultType { + return "CommandResult"; + } + + public getCommand(conventions: DocumentConventions): RavenCommand { + return new GetRemoteAttachmentsConfigurationCommand(); + } +} + +class GetRemoteAttachmentsConfigurationCommand extends RavenCommand { + + constructor() { + super(); + } + + public get isReadRequest(): boolean { + return true; + } + + public createRequest(node: ServerNode): HttpRequestParameters { + const uri = `${node.url}/databases/${node.database}/admin/attachments/remote/config`; + return { uri, method: "GET" }; + } + + public async setResponseAsync(bodyStream: Stream, fromCache: boolean): Promise { + if (!bodyStream) { + this._throwInvalidResponse(); + } + + let body: string = null; + + this.result = await RavenCommandResponsePipeline.create() + .collectBody(_ => body = _) + .parseJsonSync() + .objectKeysTransform({ + defaultTransform: ObjectUtil.camel, + paths: [ + { + // Match destination keys (direct children of "destinations") + // This regex matches paths like "destinations.S3-Users", "destinations.s3-backup", etc. + path: /^destinations$/i, + // Identity transform - return the key as-is + transform: (key: string) => key + } + ] + }) + .process(bodyStream); + + return body; + } +} diff --git a/src/Documents/Operations/Attachments/RemoteAttachmentParameters.ts b/src/Documents/Operations/Attachments/RemoteAttachmentParameters.ts new file mode 100644 index 00000000..1fcfa91d --- /dev/null +++ b/src/Documents/Operations/Attachments/RemoteAttachmentParameters.ts @@ -0,0 +1,41 @@ +import { RemoteAttachmentFlags } from "../../Attachments/RemoteAttachmentFlags.js"; + +/** + * Parameters for configuring remote attachment upload behavior. + * + * These parameters specify when and where an attachment should be uploaded to remote cloud storage. + */ +export class RemoteAttachmentParameters { + /** + * The identifier of the remote storage destination configuration. + * This references a configured destination in RemoteAttachmentsConfiguration. + */ + public identifier: string; + + /** + * Flags controlling remote attachment behavior. + * @internal + */ + private readonly _flags: RemoteAttachmentFlags; + + /** + * Optional scheduled upload time (UTC). + * When specified, the attachment will be uploaded at this time rather than immediately. + */ + public at?: Date; + + constructor(identifier: string, at?: Date) { + this.identifier = identifier; + this._flags = "None"; // Always set to None for user-created instances + this.at = at; + } + + /** + * Gets the flags value (for internal use only). + * @internal + */ + public get flags(): RemoteAttachmentFlags { + return this._flags; + } +} + diff --git a/src/Documents/Operations/Attachments/StoreAttachmentParameters.ts b/src/Documents/Operations/Attachments/StoreAttachmentParameters.ts new file mode 100644 index 00000000..cc0b36ee --- /dev/null +++ b/src/Documents/Operations/Attachments/StoreAttachmentParameters.ts @@ -0,0 +1,49 @@ +import { AttachmentData } from "../../Attachments/index.js"; +import { IStoreAttachmentParameters } from "./IStoreAttachmentParameters.js"; +import { RemoteAttachmentParameters } from "./RemoteAttachmentParameters.js"; + +/** + * Parameters for storing an attachment with optional remote cloud storage upload configuration. + * + * This class encapsulates all properties needed to store an attachment, including optional + * settings like content type, change vector for concurrency control, and remote parameters + * for scheduling cloud storage uploads. + */ +export class StoreAttachmentParameters implements IStoreAttachmentParameters { + /** + * The name of the attachment. + */ + public name: string; + + /** + * The attachment content stream or buffer. + */ + public stream: AttachmentData; + + /** + * Optional content type (MIME type) of the attachment. + * Examples: "image/png", "application/pdf", "text/plain" + */ + public contentType?: string; + + /** + * Optional change vector for optimistic concurrency control. + * When specified, the attachment will only be stored if the document's + * change vector matches this value. + */ + public changeVector?: string; + + /** + * Optional parameters for scheduling remote cloud storage upload. + * When specified, the attachment can be uploaded to configured S3 or Azure storage. + */ + public remoteParameters?: RemoteAttachmentParameters; + + constructor(name: string, stream: AttachmentData, contentType?: string, changeVector?: string, remoteParameters?: RemoteAttachmentParameters) { + this.name = name; + this.stream = stream; + this.contentType = contentType; + this.changeVector = changeVector; + this.remoteParameters = remoteParameters; + } +} diff --git a/src/Documents/Operations/SchemaValidation/ConfigureSchemaValidationOperation.ts b/src/Documents/Operations/SchemaValidation/ConfigureSchemaValidationOperation.ts new file mode 100644 index 00000000..22f9c626 --- /dev/null +++ b/src/Documents/Operations/SchemaValidation/ConfigureSchemaValidationOperation.ts @@ -0,0 +1,80 @@ +import { IMaintenanceOperation, OperationResultType } from "../OperationAbstractions.js"; +import { SchemaValidationConfiguration } from "./SchemaValidationConfiguration.js"; +import { ConfigureSchemaValidationOperationResult } from "./ConfigureSchemaValidationOperationResult.js"; +import { HttpRequestParameters } from "../../../Primitives/Http.js"; +import { Stream } from "node:stream"; +import { DocumentConventions } from "../../Conventions/DocumentConventions.js"; +import { RavenCommand } from "../../../Http/RavenCommand.js"; +import { ServerNode } from "../../../Http/ServerNode.js"; +import { IRaftCommand } from "../../../Http/IRaftCommand.js"; +import { RaftIdGenerator } from "../../../Utility/RaftIdGenerator.js"; +import { throwError } from "../../../Exceptions/index.js"; +import { ObjectUtil } from "../../../Utility/ObjectUtil.js"; +import { JsonSerializer } from "../../../Mapping/Json/Serializer.js"; + +export class ConfigureSchemaValidationOperation implements IMaintenanceOperation { + private readonly _configuration: SchemaValidationConfiguration; + + public constructor(configuration: SchemaValidationConfiguration) { + this._configuration = configuration; + } + + public get resultType(): OperationResultType { + return "CommandResult"; + } + + getCommand(conventions: DocumentConventions): RavenCommand { + return new ConfigureSchemaValidationCommand(this._configuration); + } +} + +class ConfigureSchemaValidationCommand extends RavenCommand implements IRaftCommand { + private readonly _configuration: SchemaValidationConfiguration; + + public constructor(configuration: SchemaValidationConfiguration) { + super(); + + if (!configuration) { + throwError("InvalidArgumentException", "Configuration cannot be null"); + } + + this._configuration = configuration; + } + + get isReadRequest(): boolean { + return false; + } + + createRequest(node: ServerNode): HttpRequestParameters { + const uri = node.url + "/databases/" + node.database + "/admin/schema-validation/config"; + + const serialized = ObjectUtil.transformObjectKeys(this._configuration, { + defaultTransform: ObjectUtil.pascalCase, + paths: [ + { + path: /^validatorsPerCollection$/i, + transform: (key: string) => key + } + ] + }); + + return { + uri, + method: "POST", + headers: this._headers().typeAppJson().build(), + body: JsonSerializer.getDefault().serialize(serialized) + } + } + + async setResponseAsync(bodyStream: Stream, fromCache: boolean): Promise { + if (!bodyStream) { + this._throwInvalidResponse(); + } + + return this._parseResponseDefaultAsync(bodyStream); + } + + public getRaftUniqueRequestId(): string { + return RaftIdGenerator.newId(); + } +} diff --git a/src/Documents/Operations/SchemaValidation/ConfigureSchemaValidationOperationResult.ts b/src/Documents/Operations/SchemaValidation/ConfigureSchemaValidationOperationResult.ts new file mode 100644 index 00000000..bb8a1965 --- /dev/null +++ b/src/Documents/Operations/SchemaValidation/ConfigureSchemaValidationOperationResult.ts @@ -0,0 +1,3 @@ +export interface ConfigureSchemaValidationOperationResult { + raftCommandIndex?: number; +} diff --git a/src/Documents/Operations/SchemaValidation/GetSchemaValidationConfiguration.ts b/src/Documents/Operations/SchemaValidation/GetSchemaValidationConfiguration.ts new file mode 100644 index 00000000..ed235e4f --- /dev/null +++ b/src/Documents/Operations/SchemaValidation/GetSchemaValidationConfiguration.ts @@ -0,0 +1,61 @@ +import { IMaintenanceOperation, OperationResultType } from "../OperationAbstractions.js"; +import { SchemaValidationConfiguration } from "./SchemaValidationConfiguration.js"; +import { HttpRequestParameters } from "../../../Primitives/Http.js"; +import { DocumentConventions } from "../../Conventions/DocumentConventions.js"; +import { RavenCommand } from "../../../Http/RavenCommand.js"; +import { ServerNode } from "../../../Http/ServerNode.js"; +import { Stream } from "node:stream"; +import { RavenCommandResponsePipeline } from "../../../Http/RavenCommandResponsePipeline.js"; +import { ObjectUtil } from "../../../Utility/ObjectUtil.js"; + +export class GetSchemaValidationConfiguration implements IMaintenanceOperation { + + public get resultType(): OperationResultType { + return "CommandResult"; + } + + public getCommand(conventions: DocumentConventions): RavenCommand { + return new GetSchemaValidationConfigurationCommand(); + } +} + +export class GetSchemaValidationConfigurationCommand extends RavenCommand { + + public constructor() { + super(); + } + + public get isReadRequest() { + return true; + } + + public createRequest(node: ServerNode): HttpRequestParameters { + const uri = `${node.url}/databases/${node.database}/schema-validation/config`; + + return {uri}; + } + + public async setResponseAsync(bodyStream: Stream, fromCache: boolean): Promise { + if (!bodyStream) { + this._throwInvalidResponse(); + } + + let body: string = null; + + this.result = await RavenCommandResponsePipeline.create() + .collectBody(_ => body = _) + .parseJsonSync() + .objectKeysTransform({ + defaultTransform: ObjectUtil.camel, + paths: [ + { + path: /^validatorsPerCollection$/i, + transform: (key: string) => key + } + ] + }) + .process(bodyStream); + + return body; + } +} diff --git a/src/Documents/Operations/SchemaValidation/SchemaValidationCollectionConfiguration.ts b/src/Documents/Operations/SchemaValidation/SchemaValidationCollectionConfiguration.ts new file mode 100644 index 00000000..5c132584 --- /dev/null +++ b/src/Documents/Operations/SchemaValidation/SchemaValidationCollectionConfiguration.ts @@ -0,0 +1,4 @@ +export interface SchemaValidationCollectionConfiguration { + disabled?: boolean; + schema: string; // JSON Schema as string +} diff --git a/src/Documents/Operations/SchemaValidation/SchemaValidationConfiguration.ts b/src/Documents/Operations/SchemaValidation/SchemaValidationConfiguration.ts new file mode 100644 index 00000000..168ccbd3 --- /dev/null +++ b/src/Documents/Operations/SchemaValidation/SchemaValidationConfiguration.ts @@ -0,0 +1,5 @@ +import { SchemaValidationCollectionConfiguration } from "./SchemaValidationCollectionConfiguration.js"; + +export interface SchemaValidationConfiguration { + validatorsPerCollection: { [key: string]: SchemaValidationCollectionConfiguration }; +} diff --git a/src/Documents/Session/DocumentSessionAttachmentsBase.ts b/src/Documents/Session/DocumentSessionAttachmentsBase.ts index d5511be0..12d86a8c 100644 --- a/src/Documents/Session/DocumentSessionAttachmentsBase.ts +++ b/src/Documents/Session/DocumentSessionAttachmentsBase.ts @@ -1,5 +1,7 @@ import { AdvancedSessionExtensionBase } from "./AdvancedSessionExtensionBase.js"; import { AttachmentName, AttachmentData } from "./../Attachments/index.js"; +import { RemoteAttachmentParameters } from "../Operations/Attachments/RemoteAttachmentParameters.js"; +import { StoreAttachmentParameters } from "../Operations/Attachments/StoreAttachmentParameters.js"; import { CONSTANTS } from "./../../Constants.js"; import { InMemoryDocumentSessionOperations } from "./InMemoryDocumentSessionOperations.js"; import { StringUtil } from "../../Utility/StringUtil.js"; @@ -37,19 +39,52 @@ export abstract class DocumentSessionAttachmentsBase extends AdvancedSessionExte public store(documentId: string, name: string, stream: AttachmentData): void; public store(documentId: string, name: string, stream: AttachmentData, contentType: string): void; + public store(documentId: string, parameters: StoreAttachmentParameters): void; public store(entity: object, name: string, stream: AttachmentData): void; public store(entity: object, name: string, stream: AttachmentData, contentType: string): void; public store( documentIdOrEntity: string | object, - name: string, - stream: AttachmentData, + nameOrParameters: string | StoreAttachmentParameters, + stream?: AttachmentData, contentType: string = null): void { - if (typeof documentIdOrEntity === "object") { - return this._storeAttachmentByEntity(documentIdOrEntity, name, stream, contentType); + if (typeof documentIdOrEntity === "string" && typeof nameOrParameters === "object" && "name" in nameOrParameters) { + const params = nameOrParameters as StoreAttachmentParameters; + return this._storeInternal( + documentIdOrEntity, + params.name, + params.stream, + params.contentType, + params.remoteParameters + ); } - if (StringUtil.isNullOrWhitespace(documentIdOrEntity)) { + if (typeof documentIdOrEntity === "object") { + return this._storeAttachmentByEntity( + documentIdOrEntity, + nameOrParameters as string, + stream, + contentType + ); + } + + return this._storeInternal( + documentIdOrEntity, + nameOrParameters as string, + stream, + contentType, + null + ); + } + + private _storeInternal( + documentId: string, + name: string, + stream: AttachmentData, + contentType: string, + remoteParameters: RemoteAttachmentParameters): void { + + if (StringUtil.isNullOrWhitespace(documentId)) { throwError("InvalidArgumentException", "DocumentId cannot be null"); } @@ -57,33 +92,33 @@ export abstract class DocumentSessionAttachmentsBase extends AdvancedSessionExte throwError("InvalidArgumentException", "Name cannot be null"); } - if (this._deferredCommandsMap.has(IdTypeAndName.keyFor(documentIdOrEntity, "DELETE", null))) { + if (this._deferredCommandsMap.has(IdTypeAndName.keyFor(documentId, "DELETE", null))) { DocumentSessionAttachmentsBase._throwOtherDeferredCommandException( - documentIdOrEntity, name, "store", "delete"); + documentId, name, "store", "delete"); } - if (this._deferredCommandsMap.has(IdTypeAndName.keyFor(documentIdOrEntity, "AttachmentPUT", name))) { + if (this._deferredCommandsMap.has(IdTypeAndName.keyFor(documentId, "AttachmentPUT", name))) { DocumentSessionAttachmentsBase._throwOtherDeferredCommandException( - documentIdOrEntity, name, "store", "create"); + documentId, name, "store", "create"); } - if (this._deferredCommandsMap.has(IdTypeAndName.keyFor(documentIdOrEntity, "AttachmentDELETE", name))) { + if (this._deferredCommandsMap.has(IdTypeAndName.keyFor(documentId, "AttachmentDELETE", name))) { DocumentSessionAttachmentsBase._throwOtherDeferredCommandException( - documentIdOrEntity, name, "store", "delete"); + documentId, name, "store", "delete"); } - if (this._deferredCommandsMap.has(IdTypeAndName.keyFor(documentIdOrEntity, "AttachmentMOVE", name))) { + if (this._deferredCommandsMap.has(IdTypeAndName.keyFor(documentId, "AttachmentMOVE", name))) { DocumentSessionAttachmentsBase._throwOtherDeferredCommandException( - documentIdOrEntity, name, "store", "rename"); + documentId, name, "store", "rename"); } - const documentInfo: DocumentInfo = this._documentsById.getValue(documentIdOrEntity); + const documentInfo: DocumentInfo = this._documentsById.getValue(documentId); if (documentInfo && this._session.deletedEntities.contains(documentInfo.entity)) { DocumentSessionAttachmentsBase._throwDocumentAlreadyDeleted( - documentIdOrEntity, name, "store", null, documentIdOrEntity); + documentId, name, "store", null, documentId); } - this.defer(new PutAttachmentCommandData(documentIdOrEntity, name, stream, contentType, null)); + this.defer(new PutAttachmentCommandData(documentId, name, stream, contentType, null, remoteParameters)); } private _storeAttachmentByEntity( @@ -93,7 +128,7 @@ export abstract class DocumentSessionAttachmentsBase extends AdvancedSessionExte this._throwEntityNotInSessionOrMissingId(entity); } - return this.store(document.id, name, stream, contentType); + return this._storeInternal(document.id, name, stream, contentType, null); } protected _throwEntityNotInSessionOrMissingId(entity: object): never { diff --git a/src/Documents/Session/IAttachmentsSessionOperationsBase.ts b/src/Documents/Session/IAttachmentsSessionOperationsBase.ts index aae3c27c..f4e8d2aa 100644 --- a/src/Documents/Session/IAttachmentsSessionOperationsBase.ts +++ b/src/Documents/Session/IAttachmentsSessionOperationsBase.ts @@ -1,4 +1,5 @@ import { AttachmentName, AttachmentData } from "../Attachments/index.js"; +import { StoreAttachmentParameters } from "../Operations/Attachments/StoreAttachmentParameters.js"; export interface IAttachmentsSessionOperationsBase { /** @@ -16,6 +17,14 @@ export interface IAttachmentsSessionOperationsBase { */ store(documentId: string, name: string, stream: AttachmentData, contentType: string): void; + /** + * Stores attachment to be sent in the session using the provided parameters. + * This overload provides a convenient way to store an attachment using a StoreAttachmentParameters object, + * which encapsulates all attachment properties including optional settings like content type, + * change vector for concurrency control, and remote parameters for scheduling remote cloud storage uploads. + */ + store(documentId: string, parameters: StoreAttachmentParameters): void; + /** * Stores attachment to be sent in the session. */ diff --git a/src/Documents/Smuggler/DatabaseRecordItemType.ts b/src/Documents/Smuggler/DatabaseRecordItemType.ts index 3808bdc5..900c1bf7 100644 --- a/src/Documents/Smuggler/DatabaseRecordItemType.ts +++ b/src/Documents/Smuggler/DatabaseRecordItemType.ts @@ -28,4 +28,6 @@ export type DatabaseRecordItemType = | "IndexesHistory" | "Refresh" | "QueueSinks" - | "DataArchival"; + | "DataArchival" + | "RemoteAttachments" + | "SchemaValidation"; diff --git a/src/Http/RequestExecutor.ts b/src/Http/RequestExecutor.ts index 2c7b9198..74923e43 100644 --- a/src/Http/RequestExecutor.ts +++ b/src/Http/RequestExecutor.ts @@ -148,7 +148,7 @@ export class RequestExecutor implements IDisposable { private _log: ILogger; - public static readonly CLIENT_VERSION = "7.1.5"; + public static readonly CLIENT_VERSION = "7.2.0"; private _updateDatabaseTopologySemaphore = new Semaphore(); private _updateClientConfigurationSemaphore = new Semaphore(); diff --git a/src/Utility/ObjectUtil.ts b/src/Utility/ObjectUtil.ts index 791db3bd..0127a460 100644 --- a/src/Utility/ObjectUtil.ts +++ b/src/Utility/ObjectUtil.ts @@ -128,15 +128,19 @@ export class ObjectUtil { for (const [key, value] of Object.entries(metadata)) { if (key === CONSTANTS.Documents.Metadata.ATTACHMENTS) { - result[CONSTANTS.Documents.Metadata.ATTACHMENTS] = value ? value.map(x => ObjectUtil.mapAttachmentDetailsToLocalObject(x)) : null - } else if (key[0] === "@" || key === "Raven-Node-Type") { + result[CONSTANTS.Documents.Metadata.ATTACHMENTS] = ObjectUtil.transformAttachments(value, conventions); + continue; + } + + if (ObjectUtil.isSystemMetadataKey(key)) { result[key] = value; + continue; + } + + if (needsCaseTransformation) { + userMetadataFieldsToTransform[key] = value; } else { - if (needsCaseTransformation) { - userMetadataFieldsToTransform[key] = value; - } else { - result[key] = value; - } + result[key] = value; } } @@ -151,6 +155,16 @@ export class ObjectUtil { return result; } + private static transformAttachments(attachments: any[], conventions: DocumentConventions): any[] { + return attachments?.map(x => ObjectUtil.transformObjectKeys(x, { + defaultTransform: conventions.serverToLocalFieldNameConverter ?? ObjectUtil.camelCase + })) ?? null; + } + + private static isSystemMetadataKey(key: string): boolean { + return key[0] === "@" || key === "Raven-Node-Type"; + } + public static mapAttachmentDetailsToLocalObject(json: any): AttachmentDetails { return { changeVector: json.ChangeVector, @@ -158,7 +172,8 @@ export class ObjectUtil { documentId: json.DocumentId, hash: json.Hash, name: json.Name, - size: json.Size + size: json.Size, + remoteParameters: json.RemoteParameters, }; } diff --git a/src/index.ts b/src/index.ts index d4abfbf3..8507f8d3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -328,6 +328,12 @@ export * from "./Documents/Operations/OngoingTasks/ToggleOngoingTaskStateOperati export * from "./Documents/Operations/Refresh/ConfigureRefreshOperation.js"; export * from "./Documents/Operations/Refresh/RefreshConfiguration.js"; export * from "./Documents/Operations/Refresh/ConfigureRefreshOperationResult.js"; +export * from "./Documents/Operations/SchemaValidation/SchemaValidationConfiguration.js"; +export * from "./Documents/Operations/SchemaValidation/SchemaValidationCollectionConfiguration.js"; +export * from "./Documents/Operations/SchemaValidation/ConfigureSchemaValidationOperation.js"; +export * from "./Documents/Operations/SchemaValidation/ConfigureSchemaValidationOperationResult.js"; +export * from "./Documents/Operations/SchemaValidation/GetSchemaValidationConfiguration.js"; + export * from "./Documents/Operations/ToggleDatabasesStateOperation.js"; export * from "./Documents/Operations/TransactionsRecording/StartTransactionsRecordingOperation.js"; export * from "./Documents/Operations/TransactionsRecording/StopTransactionsRecordingOperation.js"; @@ -694,8 +700,19 @@ export * from "./Documents/Queries/Suggestions/SuggestionSortMode.js"; // ATTACHMENTS export * from "./Documents/Attachments/index.js"; +export * from "./Documents/Attachments/RemoteAttachmentFlags.js"; +export * from "./Documents/Attachments/RemoteAttachmentsAzureSettings.js"; +export * from "./Documents/Attachments/RemoteAttachmentsS3Settings.js"; +export * from "./Documents/Attachments/RemoteAttachmentsDestinationConfiguration.js"; +export * from "./Documents/Attachments/RemoteAttachmentsConfiguration.js"; export * from "./Documents/Operations/Attachments/GetAttachmentOperation.js"; export * from "./Documents/Operations/Attachments/AttachmentRequest.js"; +export * from "./Documents/Operations/Attachments/RemoteAttachmentParameters.js"; +export * from "./Documents/Operations/Attachments/IStoreAttachmentParameters.js"; +export * from "./Documents/Operations/Attachments/StoreAttachmentParameters.js"; +export * from "./Documents/Operations/Attachments/Remote/ConfigureRemoteAttachmentsOperation.js"; +export * from "./Documents/Operations/Attachments/Remote/ConfigureRemoteAttachmentsOperationResult.js"; +export * from "./Documents/Operations/Attachments/Remote/GetRemoteAttachmentsConfigurationOperation.js"; // ANALYZERS export * from "./Documents/Operations/Analyzers/DeleteAnalyzerOperation.js"; diff --git a/test/Ported/Attachments/BulkInsertRemoteAttachmentsTests.ts b/test/Ported/Attachments/BulkInsertRemoteAttachmentsTests.ts new file mode 100644 index 00000000..eed678ae --- /dev/null +++ b/test/Ported/Attachments/BulkInsertRemoteAttachmentsTests.ts @@ -0,0 +1,292 @@ +import {disposeTestDocumentStore, RavenTestContext, testContext} from "../../Utils/TestUtil.js"; +import { + ConfigureRemoteAttachmentsOperation, + IDocumentStore, + RemoteAttachmentParameters, + RemoteAttachmentsConfiguration, + RemoteAttachmentsDestinationConfiguration, + RemoteAttachmentsS3Settings +} from "../../../src/index.js"; +import {assertThat} from "../../Utils/AssertExtensions.js"; +import {Buffer} from "node:buffer"; + +interface Order { + id?: string; + company: string; + orderedAt: Date; + shipVia?: string; +} + +(RavenTestContext.isRavenDbServerVersion("7.2") ? describe : describe.skip)("BulkInsertRemoteAttachmentsTests", () => { + let store: IDocumentStore; + + beforeEach(async () => { + store = await testContext.getDocumentStore(); + }); + + afterEach(async () => + await disposeTestDocumentStore(store)); + + async function setupRemoteAttachmentsConfig(identifier: string = "S3-BulkTest"): Promise { + const configuration = new RemoteAttachmentsConfiguration(); + const destination = new RemoteAttachmentsDestinationConfiguration(); + destination.disabled = false; + + const s3Settings = new RemoteAttachmentsS3Settings(); + s3Settings.bucketName = "test-bucket"; + s3Settings.awsAccessKey = "test-access-key"; + s3Settings.awsSecretKey = "test-secret-key"; + s3Settings.awsRegionName = "us-east-1"; + destination.s3Settings = s3Settings; + + + configuration.destinations[identifier] = destination; + await store.maintenance.send(new ConfigureRemoteAttachmentsOperation(configuration)); + + return identifier; + } + + it("can bulk insert attachments with remote parameters", async () => { + const identifier = await setupRemoteAttachmentsConfig(); + + const bulkInsert = store.bulkInsert(); + + for (let i = 0; i < 5; i++) { + const order: Order = { + company: `Company ${i}`, + orderedAt: new Date(2024, 0, i + 1) + }; + await bulkInsert.store(order, `orders/${i + 1}`); + } + + const remoteAt = new Date(Date.now() + 60000); + for (let i = 0; i < 5; i++) { + const orderId = `orders/${i + 1}`; + const attachmentData = Buffer.from(`Attachment data for order ${i + 1}`); + const remoteParams = new RemoteAttachmentParameters( + identifier, + remoteAt + ); + + await bulkInsert.attachmentsFor(orderId) + .store(`invoice-${i + 1}.pdf`, attachmentData, "application/pdf", remoteParams); + } + + await bulkInsert.finish(); + + { + const session = store.openSession(); + for (let i = 0; i < 5; i++) { + const order = await session.load(`orders/${i + 1}`); + assertThat(order) + .isNotNull(); + assertThat(order.company) + .isEqualTo(`Company ${i}`); + + const names = session.advanced.attachments.getNames(order); + assertThat(names.length) + .isEqualTo(1); + assertThat(names[0].name) + .isEqualTo(`invoice-${i + 1}.pdf`); + assertThat(names[0].contentType) + .isEqualTo("application/pdf"); + } + } + }); + + it("can bulk insert attachments without remote parameters", async () => { + await setupRemoteAttachmentsConfig(); + + const bulkInsert = store.bulkInsert(); + + for (let i = 0; i < 3; i++) { + const order: Order = { + company: `Company ${i}`, + orderedAt: new Date(2024, 1, i + 1) + }; + await bulkInsert.store(order, `orders/${i + 10}`); + } + + for (let i = 0; i < 3; i++) { + const orderId = `orders/${i + 10}`; + const attachmentData = Buffer.from(`Local attachment ${i}`); + + await bulkInsert.attachmentsFor(orderId) + .store(`document-${i}.txt`, attachmentData, "text/plain"); + } + + await bulkInsert.finish(); + + { + const session = store.openSession(); + for (let i = 0; i < 3; i++) { + const order = await session.load(`orders/${i + 10}`); + assertThat(order) + .isNotNull(); + + const attachment = await session.advanced.attachments.get(`orders/${i + 10}`, `document-${i}.txt`); + assertThat(attachment) + .isNotNull(); + assertThat(attachment.details.contentType) + .isEqualTo("text/plain"); + } + } + }); + + it("can bulk insert mixed attachments with and without remote parameters", async () => { + const identifier = await setupRemoteAttachmentsConfig(); + + const bulkInsert = store.bulkInsert(); + + for (let i = 0; i < 4; i++) { + const order: Order = { + company: `Company ${i}`, + orderedAt: new Date(2024, 2, i + 1) + }; + await bulkInsert.store(order, `orders/${i + 20}`); + } + + const remoteAt = new Date(Date.now() + 120000); + + for (let i = 0; i < 4; i++) { + const orderId = `orders/${i + 20}`; + const attachmentData = Buffer.from(`Attachment ${i}`); + + if (i % 2 === 0) { + const remoteParams = new RemoteAttachmentParameters( + identifier, + remoteAt + ); + await bulkInsert.attachmentsFor(orderId) + .store(`remote-${i}.dat`, attachmentData, "application/octet-stream", remoteParams); + } else { + await bulkInsert.attachmentsFor(orderId) + .store(`local-${i}.dat`, attachmentData, "application/octet-stream"); + } + } + + await bulkInsert.finish(); + + { + const session = store.openSession(); + for (let i = 0; i < 4; i++) { + const order = await session.load(`orders/${i + 20}`); + assertThat(order) + .isNotNull(); + + const names = session.advanced.attachments.getNames(order); + assertThat(names.length) + .isEqualTo(1); + + if (i % 2 === 0) { + assertThat(names[0].name) + .isEqualTo(`remote-${i}.dat`); + } else { + assertThat(names[0].name) + .isEqualTo(`local-${i}.dat`); + } + } + } + }); + + it("can bulk insert large number of attachments with remote parameters", async () => { + const identifier = await setupRemoteAttachmentsConfig(); + const count = 50; + + const bulkInsert = store.bulkInsert(); + + const remoteAt = new Date(Date.now() + 180000); + + for (let i = 0; i < count; i++) { + const order: Order = { + company: `Company ${i}`, + orderedAt: new Date(2024, 3, (i % 28) + 1) + }; + await bulkInsert.store(order, `orders/${i + 100}`); + + const attachmentData = Buffer.from(`Bulk attachment ${i}`); + const remoteParams = new RemoteAttachmentParameters( + identifier, + remoteAt + ); + + await bulkInsert.attachmentsFor(`orders/${i + 100}`) + .store(`file-${i}.bin`, attachmentData, "application/octet-stream", remoteParams); + } + + await bulkInsert.finish(); + + { + const session = store.openSession(); + + const first = await session.load("orders/100"); + assertThat(first) + .isNotNull(); + let names = session.advanced.attachments.getNames(first); + assertThat(names.length) + .isEqualTo(1); + + const middle = await session.load("orders/125"); + assertThat(middle) + .isNotNull(); + names = session.advanced.attachments.getNames(middle); + assertThat(names.length) + .isEqualTo(1); + + const last = await session.load(`orders/${100 + count - 1}`); + assertThat(last) + .isNotNull(); + names = session.advanced.attachments.getNames(last); + assertThat(names.length) + .isEqualTo(1); + } + }); + + it("can bulk insert multiple attachments per document with remote parameters", async () => { + const identifier = await setupRemoteAttachmentsConfig(); + + const bulkInsert = store.bulkInsert(); + + const order: Order = { + company: "Multi-Attachment Company", + orderedAt: new Date(2024, 4, 15) + }; + await bulkInsert.store(order, "orders/multi-1"); + + const remoteAt = new Date(Date.now() + 300000); + const remoteParams = new RemoteAttachmentParameters( + identifier, + remoteAt + ); + + await bulkInsert.attachmentsFor("orders/multi-1") + .store("invoice.pdf", Buffer.from("Invoice content"), "application/pdf", remoteParams); + + await bulkInsert.attachmentsFor("orders/multi-1") + .store("receipt.pdf", Buffer.from("Receipt content"), "application/pdf", remoteParams); + + await bulkInsert.attachmentsFor("orders/multi-1") + .store("contract.pdf", Buffer.from("Contract content"), "application/pdf", remoteParams); + + await bulkInsert.finish(); + + { + const session = store.openSession(); + const order = await session.load("orders/multi-1"); + assertThat(order) + .isNotNull(); + + const names = session.advanced.attachments.getNames(order); + assertThat(names.length) + .isEqualTo(3); + + const namesList = names.map(n => n.name).sort(); + assertThat(namesList[0]) + .isEqualTo("contract.pdf"); + assertThat(namesList[1]) + .isEqualTo("invoice.pdf"); + assertThat(namesList[2]) + .isEqualTo("receipt.pdf"); + } + }); +}); diff --git a/test/Ported/Attachments/DocumentSessionRemoteAttachmentsTests.ts b/test/Ported/Attachments/DocumentSessionRemoteAttachmentsTests.ts new file mode 100644 index 00000000..0e302a68 --- /dev/null +++ b/test/Ported/Attachments/DocumentSessionRemoteAttachmentsTests.ts @@ -0,0 +1,390 @@ +import {disposeTestDocumentStore, RavenTestContext, testContext} from "../../Utils/TestUtil.js"; +import { + ConfigureRemoteAttachmentsOperation, + DateUtil, + IDocumentStore, + RemoteAttachmentParameters, + RemoteAttachmentsConfiguration, + RemoteAttachmentsDestinationConfiguration, + RemoteAttachmentsS3Settings, + StoreAttachmentParameters +} from "../../../src/index.js"; +import {assertThat} from "../../Utils/AssertExtensions.js"; +import {Readable} from "node:stream"; +import {Buffer} from "node:buffer"; +import {addHours, addMinutes, format} from "date-fns"; + +interface User { + id?: string; + name: string; + email?: string; +} + +(RavenTestContext.isRavenDbServerVersion("7.2") ? describe : describe.skip)("DocumentSessionRemoteAttachmentsTests", function () { + let store: IDocumentStore; + + beforeEach(async function () { + store = await testContext.getDocumentStore(); + }); + + afterEach(async () => + await disposeTestDocumentStore(store)); + + async function setupRemoteAttachmentsConfig(identifier: string = "S3-Test"): Promise { + const configuration = new RemoteAttachmentsConfiguration(); + const destination = new RemoteAttachmentsDestinationConfiguration(); + destination.disabled = false; + + const s3Settings = new RemoteAttachmentsS3Settings(); + s3Settings.bucketName = "test-bucket"; + s3Settings.awsAccessKey = "test-access-key"; + s3Settings.awsSecretKey = "test-secret-key"; + s3Settings.awsRegionName = "us-east-1"; + destination.s3Settings = s3Settings; + + configuration.destinations[identifier] = destination; + await store.maintenance.send(new ConfigureRemoteAttachmentsOperation(configuration)); + + return identifier; + } + + it("can store attachment with remote parameters using StoreAttachmentParameters", async () => { + const identifier = await setupRemoteAttachmentsConfig(); + const userId = "users/1"; + const remoteAt = new Date(); + + { + const session = store.openSession(); + const user: User = {name: "John Doe", email: "john@example.com"}; + await session.store(user, userId); + await session.saveChanges(); + } + + { + const session = store.openSession(); + const attachmentData = Buffer.from([1, 2, 3, 4, 5]); + + + const parameters = new StoreAttachmentParameters( + "profile.png", + Readable.from(attachmentData), + "image/png", + null, + new RemoteAttachmentParameters(identifier, remoteAt) + ); + + session.advanced.attachments.store(userId, parameters); + await session.saveChanges(); + } + + { + const session = store.openSession(); + const attachment = await session.advanced.attachments.get(userId, "profile.png"); + + assertThat(attachment) + .isNotNull(); + assertThat(attachment.details.name) + .isEqualTo("profile.png"); + assertThat(attachment.details.contentType) + .isEqualTo("image/png"); + assertThat(format(attachment.details.remoteParameters.at, DateUtil.DEFAULT_DATE_TZ_FORMAT)) + .isEqualTo(format(remoteAt, DateUtil.DEFAULT_DATE_TZ_FORMAT)); + assertThat(attachment.details.remoteParameters.identifier) + .isEqualTo(identifier); + assertThat(attachment.details.remoteParameters.flags) + .isEqualTo("None"); + } + }); + + it("can store attachment with remote parameters using direct method", async () => { + const identifier = await setupRemoteAttachmentsConfig(); + const userId = "users/2"; + const remoteAt = new Date(); + + { + const session = store.openSession(); + const user: User = {name: "Jane Smith"}; + await session.store(user, userId); + await session.saveChanges(); + } + + { + const session = store.openSession(); + const attachmentData = Buffer.from([10, 20, 30]); + + const parameters = new StoreAttachmentParameters( + "document.pdf", + Readable.from(attachmentData), + "application/pdf", + null, + new RemoteAttachmentParameters(identifier, remoteAt) + ); + + session.advanced.attachments.store(userId, parameters); + await session.saveChanges(); + } + + { + const session = store.openSession(); + const user = await session.load(userId); + assertThat(user) + .isNotNull(); + + const names = session.advanced.attachments.getNames(user); + assertThat(names.length) + .isEqualTo(1); + assertThat(names[0].name) + .isEqualTo("document.pdf"); + assertThat(format(names[0].remoteParameters.at, DateUtil.DEFAULT_DATE_TZ_FORMAT)) + .isEqualTo(format(remoteAt, DateUtil.DEFAULT_DATE_TZ_FORMAT)); + assertThat(names[0].remoteParameters.identifier) + .isEqualTo(identifier); + assertThat(names[0].remoteParameters.flags) + .isEqualTo("None"); + } + }); + + it("can store attachment without remote parameters", async () => { + await setupRemoteAttachmentsConfig(); + const userId = "users/3"; + + { + const session = store.openSession(); + const user: User = {name: "Bob Wilson"}; + await session.store(user, userId); + await session.saveChanges(); + } + + { + const session = store.openSession(); + const attachmentData = Buffer.from([1, 2, 3]); + + // Store without remote parameters + session.advanced.attachments.store( + userId, + "local-file.txt", + Readable.from(attachmentData), + "text/plain" + ); + await session.saveChanges(); + } + + { + const session = store.openSession(); + const attachment = await session.advanced.attachments.get(userId, "local-file.txt"); + + assertThat(attachment) + .isNotNull(); + assertThat(attachment.details.remoteParameters) + .isUndefined(); + assertThat(attachment.details.name) + .isEqualTo("local-file.txt"); + } + }); + + it("can store multiple attachments with different remote destinations", async () => { + const configuration = new RemoteAttachmentsConfiguration(); + const destination = new RemoteAttachmentsDestinationConfiguration(); + destination.disabled = false; + const s3Settings = new RemoteAttachmentsS3Settings(); + s3Settings.bucketName = "test-bucket-2"; + s3Settings.awsAccessKey = "test-key-2"; + s3Settings.awsSecretKey = "test-secret-2"; + destination.s3Settings = s3Settings; + configuration.destinations["S3-Primary"] = destination; + + const destination2 = new RemoteAttachmentsDestinationConfiguration(); + destination2.disabled = false; + const s3Settings2 = new RemoteAttachmentsS3Settings(); + s3Settings2.bucketName = "test-bucket-secondary"; + s3Settings2.awsAccessKey = "test-key-secondary"; + s3Settings2.awsSecretKey = "test-secret-secondary"; + destination2.s3Settings = s3Settings2; + configuration.destinations["S3-Secondary"] = destination2; + + await store.maintenance.send(new ConfigureRemoteAttachmentsOperation(configuration)); + + const userId = "users/4"; + + { + const session = store.openSession(); + const user: User = {name: "Alice Cooper"}; + await session.store(user, userId); + await session.saveChanges(); + } + + { + const session = store.openSession(); + + // First attachment to primary destination + const data1 = Buffer.from([1, 2, 3]); + const params1 = new StoreAttachmentParameters( + "primary.dat", + Readable.from(data1), + "application/octet-stream", + null, + new RemoteAttachmentParameters("S3-Primary", addMinutes(new Date(), 1)) + ); + session.advanced.attachments.store(userId, params1); + + // Second attachment to secondary destination + const data2 = Buffer.from([4, 5, 6]); + const params2 = new StoreAttachmentParameters( + "secondary.dat", + Readable.from(data2), + "application/octet-stream", + null, + new RemoteAttachmentParameters("S3-Secondary", addMinutes(new Date(), 2)) + ); + session.advanced.attachments.store(userId, params2); + + await session.saveChanges(); + } + + { + const session = store.openSession(); + const user = await session.load(userId); + const names = session.advanced.attachments.getNames(user); + + assertThat(names.length) + .isEqualTo(2); + + const namesList = names.map(n => n.name).sort(); + assertThat(namesList[0]) + .isEqualTo("primary.dat"); + assertThat(namesList[1]) + .isEqualTo("secondary.dat"); + + const attachment1 = await session.advanced.attachments.get(userId, "primary.dat"); + assertThat(attachment1.details.remoteParameters.identifier) + .isEqualTo("S3-Primary"); + assertThat(attachment1.details.remoteParameters.flags) + .isEqualTo("None"); + + const attachment2 = await session.advanced.attachments.get(userId, "secondary.dat"); + assertThat(attachment2.details.remoteParameters.identifier) + .isEqualTo("S3-Secondary"); + assertThat(attachment2.details.remoteParameters.flags) + .isEqualTo("None"); + } + }); + + it("can check if attachment exists", async () => { + await setupRemoteAttachmentsConfig(); + const userId = "users/5"; + + { + const session = store.openSession(); + const user: User = {name: "Test User"}; + await session.store(user, userId); + await session.saveChanges(); + } + + { + const session = store.openSession(); + const attachmentData = Buffer.from([1, 2, 3]); + + session.advanced.attachments.store( + userId, + "test.txt", + Readable.from(attachmentData), + "text/plain" + ); + await session.saveChanges(); + } + + { + const session = store.openSession(); + const exists = await session.advanced.attachments.exists(userId, "test.txt"); + const notExists = await session.advanced.attachments.exists(userId, "nonexistent.txt"); + + assertThat(exists) + .isTrue(); + assertThat(notExists) + .isFalse(); + } + }); + + it("can delete attachment with remote parameters", async () => { + await setupRemoteAttachmentsConfig(); + const userId = "users/6"; + + { + const session = store.openSession(); + const user: User = {name: "Delete Test"}; + await session.store(user, userId); + await session.saveChanges(); + } + + { + const session = store.openSession(); + const attachmentData = Buffer.from([1, 2, 3]); + const params = new StoreAttachmentParameters( + "to-delete.txt", + Readable.from(attachmentData), + "text/plain" + ); + session.advanced.attachments.store(userId, params); + await session.saveChanges(); + } + + { + const session = store.openSession(); + session.advanced.attachments.delete(userId, "to-delete.txt"); + await session.saveChanges(); + } + + { + const session = store.openSession(); + const exists = await session.advanced.attachments.exists(userId, "to-delete.txt"); + assertThat(exists) + .isFalse(); + } + }); + + it("can store attachment with scheduled upload time", async () => { + const identifier = await setupRemoteAttachmentsConfig(); + const userId = "users/7"; + + { + const session = store.openSession(); + const user: User = {name: "Scheduled Upload User"}; + await session.store(user, userId); + await session.saveChanges(); + } + + const scheduledTime = addHours(new Date(), 1); + + { + const session = store.openSession(); + const attachmentData = Buffer.from([1, 2, 3, 4, 5]); + + const parameters = new StoreAttachmentParameters( + "scheduled.dat", + Readable.from(attachmentData), + "application/octet-stream", + null, + new RemoteAttachmentParameters(identifier, scheduledTime) + ); + + session.advanced.attachments.store(userId, parameters); + await session.saveChanges(); + } + + { + const session = store.openSession(); + const attachment = await session.advanced.attachments.get(userId, "scheduled.dat"); + + assertThat(attachment) + .isNotNull(); + assertThat(attachment.details.name) + .isEqualTo("scheduled.dat"); + assertThat(format(attachment.details.remoteParameters.at, DateUtil.DEFAULT_DATE_TZ_FORMAT)) + .isEqualTo(format(scheduledTime, DateUtil.DEFAULT_DATE_TZ_FORMAT)); + assertThat(attachment.details.remoteParameters.identifier) + .isEqualTo(identifier); + assertThat(attachment.details.remoteParameters.flags) + .isEqualTo("None"); + } + }); +}); diff --git a/test/Ported/Attachments/RemoteAttachmentsBasicTests.ts b/test/Ported/Attachments/RemoteAttachmentsBasicTests.ts new file mode 100644 index 00000000..2b8ada80 --- /dev/null +++ b/test/Ported/Attachments/RemoteAttachmentsBasicTests.ts @@ -0,0 +1,403 @@ +import {testContext, disposeTestDocumentStore, RavenTestContext} from "../../Utils/TestUtil.js"; +import { + IDocumentStore, + ConfigureRemoteAttachmentsOperation, + GetRemoteAttachmentsConfigurationOperation, + RemoteAttachmentsConfiguration, + RemoteAttachmentsDestinationConfiguration, + RemoteAttachmentsS3Settings, + RemoteAttachmentsAzureSettings +} from "../../../src/index.js"; +import { assertThat } from "../../Utils/AssertExtensions.js"; + +(RavenTestContext.isRavenDbServerVersion("7.2") ? describe : describe.skip)("RemoteAttachmentsBasicTests", function () { + let store: IDocumentStore; + + beforeEach(async function () { + store = await testContext.getDocumentStore(); + }); + + afterEach(async () => + await disposeTestDocumentStore(store)); + + it("can put and get remote attachments configuration with S3", async () => { + const configuration = new RemoteAttachmentsConfiguration(); + const destination = new RemoteAttachmentsDestinationConfiguration(); + destination.disabled = false; + const s3Settings = new RemoteAttachmentsS3Settings(); + s3Settings.bucketName = "testS3Bucket-Users"; + s3Settings.awsAccessKey = "test-access-key"; + s3Settings.awsSecretKey = "test-secret-key"; + s3Settings.awsRegionName = "us-east-1"; + destination.s3Settings = s3Settings; + + configuration.destinations["S3-Users"] = destination; + + await store.maintenance.send(new ConfigureRemoteAttachmentsOperation(configuration)); + + const config = await store.maintenance.send(new GetRemoteAttachmentsConfigurationOperation()); + + assertThat(config) + .isNotNull(); + assertThat(Object.keys(config.destinations).length) + .isEqualTo(1); + + const retrievedDestination = config.destinations["S3-Users"]; + assertThat(retrievedDestination) + .isNotNull(); + assertThat(retrievedDestination.disabled) + .isEqualTo(false); + assertThat(retrievedDestination.s3Settings) + .isNotNull(); + assertThat(retrievedDestination.s3Settings.bucketName) + .isEqualTo("testS3Bucket-Users"); + }); + + it("can put and get remote attachments configuration with Azure", async () => { + const configuration = new RemoteAttachmentsConfiguration(); + const destination = new RemoteAttachmentsDestinationConfiguration(); + destination.disabled = false; + + const azureSettings = new RemoteAttachmentsAzureSettings(); + azureSettings.storageContainer = "test-container"; + azureSettings.accountName = "teststorageaccount"; + azureSettings.accountKey = "test-account-key"; + destination.azureSettings = azureSettings; + + configuration.destinations["Azure-Users"] = destination; + + await store.maintenance.send(new ConfigureRemoteAttachmentsOperation(configuration)); + + const config = await store.maintenance.send(new GetRemoteAttachmentsConfigurationOperation()); + + assertThat(config) + .isNotNull(); + assertThat(Object.keys(config.destinations).length) + .isEqualTo(1); + + const retrievedDestination = config.destinations["Azure-Users"]; + assertThat(retrievedDestination) + .isNotNull(); + assertThat(retrievedDestination.disabled) + .isEqualTo(false); + assertThat(retrievedDestination.azureSettings) + .isNotNull(); + assertThat(retrievedDestination.azureSettings.storageContainer) + .isEqualTo("test-container"); + assertThat(retrievedDestination.azureSettings.accountName) + .isEqualTo("teststorageaccount"); + }); + + it("can put and get remote attachments configuration with case insensitive identifier", async () => { + const configuration = new RemoteAttachmentsConfiguration(); + const destination = new RemoteAttachmentsDestinationConfiguration(); + destination.disabled = false; + + const s3Settings = new RemoteAttachmentsS3Settings(); + s3Settings.bucketName = "testS3Bucket-Users"; + s3Settings.awsAccessKey = "test-access-key"; + s3Settings.awsSecretKey = "test-secret-key"; + destination.s3Settings = s3Settings; + + configuration.destinations["S3-uSeRs"] = destination; + + await store.maintenance.send(new ConfigureRemoteAttachmentsOperation(configuration)); + + const config = await store.maintenance.send(new GetRemoteAttachmentsConfigurationOperation()); + + assertThat(Object.keys(config.destinations).length) + .isEqualTo(1); + + const retrievedDestination = config.destinations["S3-uSeRs"]; + assertThat(retrievedDestination) + .isNotNull(); + assertThat(retrievedDestination.s3Settings.bucketName) + .isEqualTo("testS3Bucket-Users"); + }); + + it("can update remote attachments configuration", async () => { + // First configuration + const config1 = new RemoteAttachmentsConfiguration(); + const destination1 = new RemoteAttachmentsDestinationConfiguration(); + destination1.disabled = false; + + const s3Settings1 = new RemoteAttachmentsS3Settings(); + s3Settings1.bucketName = "testS3Bucket-Users"; + s3Settings1.awsAccessKey = "test-access-key"; + s3Settings1.awsSecretKey = "test-secret-key"; + destination1.s3Settings = s3Settings1; + + config1.destinations["S3-Users"] = destination1; + + await store.maintenance.send(new ConfigureRemoteAttachmentsOperation(config1)); + + let config = await store.maintenance.send(new GetRemoteAttachmentsConfigurationOperation()); + assertThat(Object.keys(config.destinations).length) + .isEqualTo(1); + + // Second configuration - update + const config2 = new RemoteAttachmentsConfiguration(); + const destination2 = new RemoteAttachmentsDestinationConfiguration(); + destination2.disabled = true; + + const s3Settings2 = new RemoteAttachmentsS3Settings(); + s3Settings2.bucketName = "testS3Bucket-Orders"; + s3Settings2.awsAccessKey = "test-access-key-2"; + s3Settings2.awsSecretKey = "test-secret-key-2"; + destination2.s3Settings = s3Settings2; + + config2.destinations["S3-Orders"] = destination2; + + await store.maintenance.send(new ConfigureRemoteAttachmentsOperation(config2)); + + config = await store.maintenance.send(new GetRemoteAttachmentsConfigurationOperation()); + + assertThat(Object.keys(config.destinations).length) + .isEqualTo(1); + + const retrievedDestination = config.destinations["S3-Orders"]; + assertThat(retrievedDestination) + .isNotNull(); + assertThat(retrievedDestination.disabled) + .isEqualTo(true); + assertThat(retrievedDestination.s3Settings.bucketName) + .isEqualTo("testS3Bucket-Orders"); + }); + + it("validates configuration requires exactly one uploader", async () => { + const configuration = new RemoteAttachmentsConfiguration(); + const destination = new RemoteAttachmentsDestinationConfiguration(); + destination.disabled = false; + // Neither S3 nor Azure configured + + configuration.destinations["Invalid-Destination"] = destination; + + try { + await store.maintenance.send(new ConfigureRemoteAttachmentsOperation(configuration)); + } catch (error) { + assertThat(error.message) + .contains("must be configured"); + } + }); + + it("validates configuration cannot have both S3 and Azure", async () => { + const configuration = new RemoteAttachmentsConfiguration(); + const destination = new RemoteAttachmentsDestinationConfiguration(); + destination.disabled = false; + + const s3Settings = new RemoteAttachmentsS3Settings(); + s3Settings.bucketName = "test-bucket"; + s3Settings.awsAccessKey = "test-key"; + s3Settings.awsSecretKey = "test-secret"; + destination.s3Settings = s3Settings; + + const azureSettings = new RemoteAttachmentsAzureSettings(); + azureSettings.storageContainer = "test-container"; + azureSettings.accountName = "testaccount"; + azureSettings.accountKey = "test-key"; + destination.azureSettings = azureSettings; + + configuration.destinations["Invalid-Both"] = destination; + + try { + await store.maintenance.send(new ConfigureRemoteAttachmentsOperation(configuration)); + } catch (error) { + assertThat(error.message) + .contains("Only one uploader"); + } + }); + + it("can configure multiple destinations", async () => { + const configuration = new RemoteAttachmentsConfiguration(); + + // S3 destination + const s3Destination = new RemoteAttachmentsDestinationConfiguration(); + s3Destination.disabled = false; + const s3Settings = new RemoteAttachmentsS3Settings(); + s3Settings.bucketName = "s3-bucket"; + s3Settings.awsAccessKey = "test-key"; + s3Settings.awsSecretKey = "test-secret"; + s3Destination.s3Settings = s3Settings; + + // Azure destination + const azureDestination = new RemoteAttachmentsDestinationConfiguration(); + azureDestination.disabled = false; + const azureSettings = new RemoteAttachmentsAzureSettings(); + azureSettings.storageContainer = "azure-container"; + azureSettings.accountName = "azureaccount"; + azureSettings.accountKey = "azure-key"; + azureDestination.azureSettings = azureSettings; + + configuration.destinations["S3-Backup"] = s3Destination; + configuration.destinations["Azure-Archive"] = azureDestination; + + await store.maintenance.send(new ConfigureRemoteAttachmentsOperation(configuration)); + + const config = await store.maintenance.send(new GetRemoteAttachmentsConfigurationOperation()); + + assertThat(Object.keys(config.destinations).length) + .isEqualTo(2); + assertThat(config.destinations["S3-Backup"]) + .isNotNull(); + assertThat(config.destinations["Azure-Archive"]) + .isNotNull(); + }); + + it("can configure destination with remote folder name", async () => { + const configuration = new RemoteAttachmentsConfiguration(); + const destination = new RemoteAttachmentsDestinationConfiguration(); + destination.disabled = false; + + const s3Settings = new RemoteAttachmentsS3Settings(); + s3Settings.bucketName = "testS3Bucket-Users"; + s3Settings.remoteFolderName = "production/attachments/2024"; + s3Settings.awsAccessKey = "test-access-key"; + s3Settings.awsSecretKey = "test-secret-key"; + s3Settings.awsRegionName = "us-east-1"; + destination.s3Settings = s3Settings; + + configuration.destinations["S3-WithFolder"] = destination; + + await store.maintenance.send(new ConfigureRemoteAttachmentsOperation(configuration)); + + const config = await store.maintenance.send(new GetRemoteAttachmentsConfigurationOperation()); + + const retrievedDestination = config.destinations["S3-WithFolder"]; + assertThat(retrievedDestination.s3Settings.remoteFolderName) + .isEqualTo("production/attachments/2024"); + }); + + it("can configure with frequency, max items, and concurrent uploads", async () => { + const configuration = new RemoteAttachmentsConfiguration(); + const destination = new RemoteAttachmentsDestinationConfiguration(); + destination.disabled = false; + + const s3Settings = new RemoteAttachmentsS3Settings(); + s3Settings.bucketName = "test-bucket"; + s3Settings.awsAccessKey = "test-key"; + s3Settings.awsSecretKey = "test-secret"; + destination.s3Settings = s3Settings; + + configuration.destinations["S3-Config"] = destination; + configuration.checkFrequencyInSec = 300; + configuration.maxItemsToProcess = 1000; + configuration.concurrentUploads = 5; + + await store.maintenance.send(new ConfigureRemoteAttachmentsOperation(configuration)); + + const config = await store.maintenance.send(new GetRemoteAttachmentsConfigurationOperation()); + + assertThat(config.checkFrequencyInSec) + .isEqualTo(300); + assertThat(config.maxItemsToProcess) + .isEqualTo(1000); + assertThat(config.concurrentUploads) + .isEqualTo(5); + }); + + it("validates check frequency must be greater than zero", async () => { + const configuration = new RemoteAttachmentsConfiguration(); + const destination = new RemoteAttachmentsDestinationConfiguration(); + destination.disabled = false; + + const s3Settings = new RemoteAttachmentsS3Settings(); + s3Settings.bucketName = "test-bucket"; + s3Settings.awsAccessKey = "test-key"; + s3Settings.awsSecretKey = "test-secret"; + destination.s3Settings = s3Settings; + + configuration.destinations["S3-Test"] = destination; + configuration.checkFrequencyInSec = 0; // Invalid + + try { + await store.maintenance.send(new ConfigureRemoteAttachmentsOperation(configuration)); + } catch (error) { + assertThat(error.message) + .contains("check frequency"); + assertThat(error.message) + .contains("must be greater than 0"); + } + }); + + it("validates max items to process must be greater than zero", async () => { + const configuration = new RemoteAttachmentsConfiguration(); + const destination = new RemoteAttachmentsDestinationConfiguration(); + destination.disabled = false; + + const s3Settings = new RemoteAttachmentsS3Settings(); + s3Settings.bucketName = "test-bucket"; + s3Settings.awsAccessKey = "test-key"; + s3Settings.awsSecretKey = "test-secret"; + destination.s3Settings = s3Settings; + + configuration.destinations["S3-Test"] = destination; + configuration.maxItemsToProcess = -1; // Invalid + + try { + await store.maintenance.send(new ConfigureRemoteAttachmentsOperation(configuration)); + } catch (error) { + assertThat(error.message) + .contains("Max items to process"); + assertThat(error.message) + .contains("must be greater than 0"); + } + }); + + it("validates concurrent uploads must be greater than zero", async () => { + const configuration = new RemoteAttachmentsConfiguration(); + const destination = new RemoteAttachmentsDestinationConfiguration(); + destination.disabled = false; + + const s3Settings = new RemoteAttachmentsS3Settings(); + s3Settings.bucketName = "test-bucket"; + s3Settings.awsAccessKey = "test-key"; + s3Settings.awsSecretKey = "test-secret"; + destination.s3Settings = s3Settings; + + configuration.destinations["S3-Test"] = destination; + configuration.concurrentUploads = 0; // Invalid + + try { + await store.maintenance.send(new ConfigureRemoteAttachmentsOperation(configuration)); + } catch (error) { + assertThat(error.message) + .contains("Concurrent attachments uploads"); + assertThat(error.message) + .contains("must be greater than 0"); + } + }); + + it("can configure destination with lowercase key", async () => { + const configuration = new RemoteAttachmentsConfiguration(); + const destination = new RemoteAttachmentsDestinationConfiguration(); + destination.disabled = false; + + const s3Settings = new RemoteAttachmentsS3Settings(); + s3Settings.bucketName = "test-bucket-lowercase"; + s3Settings.awsAccessKey = "test-key"; + s3Settings.awsSecretKey = "test-secret"; + s3Settings.awsRegionName = "eu-west-1"; + destination.s3Settings = s3Settings; + + configuration.destinations["s3-backup"] = destination; + + await store.maintenance.send(new ConfigureRemoteAttachmentsOperation(configuration)); + + const config = await store.maintenance.send(new GetRemoteAttachmentsConfigurationOperation()); + + assertThat(Object.keys(config.destinations).length) + .isEqualTo(1); + + const retrievedDestination = config.destinations["s3-backup"]; + assertThat(retrievedDestination) + .isNotNull(); + assertThat(retrievedDestination.disabled) + .isEqualTo(false); + assertThat(retrievedDestination.s3Settings) + .isNotNull(); + assertThat(retrievedDestination.s3Settings.bucketName) + .isEqualTo("test-bucket-lowercase"); + assertThat(retrievedDestination.s3Settings.awsRegionName) + .isEqualTo("eu-west-1"); + }); +}); diff --git a/test/Ported/SchemaValidationBasicTests.ts b/test/Ported/SchemaValidationBasicTests.ts new file mode 100644 index 00000000..e017195d --- /dev/null +++ b/test/Ported/SchemaValidationBasicTests.ts @@ -0,0 +1,480 @@ +import {disposeTestDocumentStore, RavenTestContext, testContext} from "../Utils/TestUtil.js"; +import { + ConfigureSchemaValidationOperation, + GetSchemaValidationConfiguration, + IDocumentStore, + SchemaValidationConfiguration +} from "../../src/index.js"; +import {assertThat} from "../Utils/AssertExtensions.js"; + + +(RavenTestContext.isRavenDbServerVersion("7.2") ? describe : describe.skip)("SchemaValidationBasicTests", () => { + let store: IDocumentStore; + + beforeEach(async () => { + store = await testContext.getDocumentStore(); + }); + + afterEach(async () => + await disposeTestDocumentStore(store)); + + it("store - Users collection", async () => { + const schemaData = getUserSchema(); + + const configuration: SchemaValidationConfiguration = { + validatorsPerCollection: { + "Users": { + schema: schemaData + } + } + }; + + await store.maintenance.send(new ConfigureSchemaValidationOperation(configuration)); + + { + const session = store.openSession(); + await session.store(new User(17)) + + try { + await session.saveChanges(); + + } catch (error) { + assertThat(error.message).contains("17"); + assertThat(error.message).contains("age"); + } + } + + { + const session = store.openSession(); + await session.store(new User(80)) + + try { + await session.saveChanges(); + } catch (error) { + assertThat(error.message).contains("80"); + assertThat(error.message).contains("age"); + } + } + + { + const session = store.openSession(); + await session.store(new User(39)); + await session.saveChanges(); + } + }); + + it("store - users collection (lowercase)", async () => { + const schemaData = getUserSchema(); + + const configuration: SchemaValidationConfiguration = { + validatorsPerCollection: { + "Users": { + schema: schemaData + } + } + }; + + await store.maintenance.send(new ConfigureSchemaValidationOperation(configuration)); + + // Test: Age below minimum should fail + { + const session = store.openSession(); + await session.store(new User(17)); + + try { + await session.saveChanges(); + } catch (error) { + assertThat(error.message).contains("17"); + } + } + + // Test: Valid age should succeed + { + const session = store.openSession(); + await session.store(new User(39)); + await session.saveChanges(); + } + }); + + it("store cluster transaction", async function () { + const schemaData = getUserSchema(); + + const configuration: SchemaValidationConfiguration = { + validatorsPerCollection: { + "Users": { + schema: schemaData + } + } + }; + + await store.maintenance.send(new ConfigureSchemaValidationOperation(configuration)); + + // Test: Age below minimum should fail + { + const session = store.openSession({transactionMode: "ClusterWide"}); + await session.store(new User(17)); + + try { + await session.saveChanges(); + + } catch (error) { + assertThat(error.message).contains("17"); + assertThat(error.message).contains("age"); + } + } + + // Test: Age above maximum should fail + { + const session = store.openSession({transactionMode: "ClusterWide"}); + await session.store(new User(80)); + + try { + await session.saveChanges(); + + } catch (error) { + assertThat(error.message).contains("80"); + assertThat(error.message).contains("age"); + } + } + + // Test: Valid age should succeed + { + const session = store.openSession({transactionMode: "ClusterWide"}); + await session.store(new User(39)); + await session.saveChanges(); + } + + // Get configuration and modify it + const currentConfig = await store.maintenance.send(new GetSchemaValidationConfiguration()); + currentConfig.validatorsPerCollection["Companies"] = { + schema: schemaData + }; + currentConfig.validatorsPerCollection["Users"].disabled = true; + await store.maintenance.send(new ConfigureSchemaValidationOperation(currentConfig)); + + // Test: With disabled validation, invalid age should succeed + { + const session = store.openSession({transactionMode: "ClusterWide"}); + await session.store(new User(20), "users/2"); + await session.saveChanges(); + } + }); + + it("disable schema after creation", async () => { + const schemaData = getUserSchema(); + + const configuration: SchemaValidationConfiguration = { + validatorsPerCollection: { + "Users": { + schema: schemaData + }, + "Orders": { + schema: schemaData + } + } + }; + + await store.maintenance.send(new ConfigureSchemaValidationOperation(configuration)); + + { + const session = store.openSession(); + await session.store(new User(17)); + + try { + await session.saveChanges(); + } catch (error) { + assertThat(error.message).contains("17"); + assertThat(error.message).contains("age"); + } + } + + // Disable validation for Users + configuration.validatorsPerCollection["Users"].disabled = true; + await store.maintenance.send(new ConfigureSchemaValidationOperation(configuration)); + + { + const session = store.openSession(); + await session.store(new User(80)); + await session.saveChanges(); + } + + { + const session = store.openSession(); + await session.store(new User(39)); + await session.saveChanges(); + } + }); + + it("can start with disabled schema", async () => { + const schemaData = getUserSchema(); + + const configuration: SchemaValidationConfiguration = { + validatorsPerCollection: { + "Users": { + disabled: true, + schema: schemaData + }, + "Orders": { + schema: schemaData + } + } + }; + + await store.maintenance.send(new ConfigureSchemaValidationOperation(configuration)); + + // Test: With disabled validation, invalid age should succeed + { + const session = store.openSession(); + await session.store(new User(17)); + await session.saveChanges(); + } + + // Enable validation for Users + configuration.validatorsPerCollection["Users"].disabled = false; + await store.maintenance.send(new ConfigureSchemaValidationOperation(configuration)); + + // Test: Now validation is active, invalid age should fail + { + const session = store.openSession(); + await session.store(new User(80)); + try { + await session.saveChanges(); + } catch (error) { + assertThat(error.message).contains("80"); + assertThat(error.message).contains("age"); + } + } + }); + + it("can get schema validation configuration", async () => { + const schemaData = getUserSchema(); + + const configuration: SchemaValidationConfiguration = { + validatorsPerCollection: { + "Users": { + schema: schemaData + }, + "Orders": { + disabled: true, + schema: schemaData + } + } + }; + + await store.maintenance.send(new ConfigureSchemaValidationOperation(configuration)); + + const retrievedConfig = await store.maintenance.send(new GetSchemaValidationConfiguration()); + + assertThat(retrievedConfig).isNotNull(); + assertThat(Object.keys(retrievedConfig.validatorsPerCollection).length).isEqualTo(2); + + assertThat(retrievedConfig.validatorsPerCollection["Users"]).isNotNull(); + assertThat(retrievedConfig.validatorsPerCollection["Users"].schema).isNotNull(); + assertThat(retrievedConfig.validatorsPerCollection["Users"].disabled).isFalse(); + + assertThat(retrievedConfig.validatorsPerCollection["Orders"]).isNotNull(); + assertThat(retrievedConfig.validatorsPerCollection["Orders"].disabled).isEqualTo(true); + }); + + it("can update schema validation configuration", async () => { + const schemaData = getUserSchema(); + + const config1: SchemaValidationConfiguration = { + validatorsPerCollection: { + "Users": { + schema: schemaData + } + } + }; + + await store.maintenance.send(new ConfigureSchemaValidationOperation(config1)); + + let config = await store.maintenance.send(new GetSchemaValidationConfiguration()); + assertThat(Object.keys(config.validatorsPerCollection).length).isEqualTo(1); + + const config2: SchemaValidationConfiguration = { + validatorsPerCollection: { + "Orders": { + disabled: true, + schema: schemaData + } + } + }; + + await store.maintenance.send(new ConfigureSchemaValidationOperation(config2)); + + config = await store.maintenance.send(new GetSchemaValidationConfiguration()); + + assertThat(Object.keys(config.validatorsPerCollection).length).isEqualTo(1); + assertThat(config.validatorsPerCollection["Orders"]).isNotNull(); + assertThat(config.validatorsPerCollection["Orders"].disabled).isEqualTo(true); + }); + + it("can configure multiple collections", async () => { + const userSchema = getUserSchema(); + const productSchema = JSON.stringify({ + type: "object", + properties: { + name: {type: "string", minLength: 1}, + price: {type: "number", minimum: 0} + }, + required: ["name", "price"] + }); + + const configuration: SchemaValidationConfiguration = { + validatorsPerCollection: { + "Users": { + schema: userSchema + }, + "Products": { + schema: productSchema + } + } + }; + + await store.maintenance.send(new ConfigureSchemaValidationOperation(configuration)); + + const config = await store.maintenance.send(new GetSchemaValidationConfiguration()); + + assertThat(Object.keys(config.validatorsPerCollection).length).isEqualTo(2); + assertThat(config.validatorsPerCollection["Users"]).isNotNull(); + assertThat(config.validatorsPerCollection["Products"]).isNotNull(); + }); + + it("validates against complex schema", async () => { + const complexSchema = JSON.stringify({ + type: "object", + properties: { + name: { + type: "string", + minLength: 1, + maxLength: 100 + }, + email: { + type: "string", + pattern: "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$" + }, + age: { + type: "integer", + minimum: 21, + maximum: 67 + }, + address: { + type: "object", + properties: { + street: {type: "string"}, + city: {type: "string"}, + zipCode: {type: "string"} + }, + required: ["city"] + } + }, + required: ["name", "email", "age"] + }); + + const configuration: SchemaValidationConfiguration = { + validatorsPerCollection: { + "ComplexUsers": { + schema: complexSchema + } + } + }; + + await store.maintenance.send(new ConfigureSchemaValidationOperation(configuration)); + + { + const session = store.openSession(); + const invalidUser = new ComplexUser("John Doe", null, 30, null); + await session.store(invalidUser); + + try { + await session.saveChanges(); + } catch (error) { + assertThat(error.message).contains("email"); + } + } + + { + const session = store.openSession(); + const validUser = new ComplexUser( + "John Doe", + "john@example.com", + 30, + new Address("123 Main St", "New York", "10001") + ); + await session.store(validUser); + + await session.saveChanges(); + } + }); + + it("case insensitive collection names", async () => { + const schemaData = getUserSchema(); + + const configuration: SchemaValidationConfiguration = { + validatorsPerCollection: { + "uSeRs": { + schema: schemaData + } + } + }; + + await store.maintenance.send(new ConfigureSchemaValidationOperation(configuration)); + + const config = await store.maintenance.send(new GetSchemaValidationConfiguration()); + + assertThat(Object.keys(config.validatorsPerCollection).length).isEqualTo(1); + + const retrievedValidator = config.validatorsPerCollection["uSeRs"]; + assertThat(retrievedValidator).isNotNull(); + assertThat(retrievedValidator.schema).isNotNull(); + }); +}); + +class User { + public age: number; + + constructor(age: number) { + this.age = age; + } +} + +class Address { + public street: string; + public city: string; + public zipCode: string; + + constructor(street: string, city: string, zipCode: string) { + this.street = street; + this.city = city; + this.zipCode = zipCode; + } +} + +class ComplexUser { + public name: string; + public email: string; + public age: number; + public address: Address; + + constructor(name: string, email: string, age: number, address: Address) { + this.name = name; + this.email = email; + this.age = age; + this.address = address; + } +} + +function getUserSchema(): string { + return JSON.stringify({ + type: "object", + properties: { + age: { + type: "integer", + minimum: 21, + maximum: 67 + } + }, + required: ["age"] + }); +} \ No newline at end of file diff --git a/test/Utils/AssertExtensions.ts b/test/Utils/AssertExtensions.ts index ad33b6d2..09489de9 100644 --- a/test/Utils/AssertExtensions.ts +++ b/test/Utils/AssertExtensions.ts @@ -163,4 +163,9 @@ export class JavaAssertionBuilder { assert.ok(satisfy, "None of items satisfy condition"); return this; } + + public isUndefined() { + assert.ok(this._value === undefined); + return this; + } }