diff --git a/src/All.slnx b/src/All.slnx
index 44def7fe344..cce54994251 100644
--- a/src/All.slnx
+++ b/src/All.slnx
@@ -219,6 +219,7 @@
+
@@ -227,6 +228,7 @@
+
diff --git a/src/HotChocolate/Fusion-vnext/HotChocolate.Fusion-vnext.slnx b/src/HotChocolate/Fusion-vnext/HotChocolate.Fusion-vnext.slnx
index a9e77bdf2b3..9e2a631d796 100644
--- a/src/HotChocolate/Fusion-vnext/HotChocolate.Fusion-vnext.slnx
+++ b/src/HotChocolate/Fusion-vnext/HotChocolate.Fusion-vnext.slnx
@@ -10,6 +10,7 @@
+
@@ -19,6 +20,7 @@
+
diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/ArchiveMetadata.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/ArchiveMetadata.cs
new file mode 100644
index 00000000000..c1aa97e9483
--- /dev/null
+++ b/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/ArchiveMetadata.cs
@@ -0,0 +1,13 @@
+namespace HotChocolate.Fusion.SourceSchema.Packaging;
+
+///
+/// Contains metadata about a Fusion Source schema archive.
+///
+public record ArchiveMetadata
+{
+ ///
+ /// Gets or sets the version of the Fusion source schema archive format specification.
+ /// Used to ensure compatibility between different versions of tooling.
+ ///
+ public Version FormatVersion { get; init; } = new("1.0.0");
+}
diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/FileKind.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/FileKind.cs
new file mode 100644
index 00000000000..f7f6d76be8c
--- /dev/null
+++ b/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/FileKind.cs
@@ -0,0 +1,9 @@
+namespace HotChocolate.Fusion.SourceSchema.Packaging;
+
+internal enum FileKind
+{
+ Unknown,
+ GraphQLSchema,
+ Settings,
+ Metadata
+}
diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/FileNames.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/FileNames.cs
new file mode 100644
index 00000000000..dd971a69486
--- /dev/null
+++ b/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/FileNames.cs
@@ -0,0 +1,8 @@
+namespace HotChocolate.Fusion.SourceSchema.Packaging;
+
+internal static class FileNames
+{
+ public const string ArchiveMetadata = "archive-metadata.json";
+ public const string GraphQLSchema = "schema.graphqls";
+ public const string Settings = "schema-settings.json";
+}
diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/FusionSourceSchemaArchive.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/FusionSourceSchemaArchive.cs
new file mode 100644
index 00000000000..8931fdd0699
--- /dev/null
+++ b/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/FusionSourceSchemaArchive.cs
@@ -0,0 +1,443 @@
+using System.Buffers;
+using System.IO.Compression;
+using System.IO.Pipelines;
+using System.Text.Json;
+using HotChocolate.Fusion.SourceSchema.Packaging.Serializers;
+
+namespace HotChocolate.Fusion.SourceSchema.Packaging;
+
+///
+/// Provides functionality for creating, reading, and modifying a Fusion source schema archive.
+/// An Fusion source schema archive is a ZIP-based container format
+/// that packages a GraphQL schema and source schema settings.
+///
+public sealed class FusionSourceSchemaArchive : IDisposable
+{
+ private readonly Stream _stream;
+ private readonly bool _leaveOpen;
+ private readonly FusionSourceSchemaArchiveSession _session;
+ private ZipArchive _archive;
+ private FusionSourceSchemaArchiveMode _mode;
+ private ArrayBufferWriter? _buffer;
+ private ArchiveMetadata? _metadata;
+ private bool _disposed;
+
+ private FusionSourceSchemaArchive(
+ Stream stream,
+ FusionSourceSchemaArchiveMode mode,
+ bool leaveOpen,
+ FusionSourceSchemaArchiveReadOptions options)
+ {
+ _stream = stream;
+ _mode = mode;
+ _leaveOpen = leaveOpen;
+ _archive = new ZipArchive(stream, (ZipArchiveMode)mode, leaveOpen);
+ _session = new FusionSourceSchemaArchiveSession(_archive, mode, options);
+ }
+
+ ///
+ /// Creates a new Fusion source schema archive with the specified filename.
+ ///
+ /// The path to the archive file to create.
+ /// A new FusionSourceSchemaArchive instance in Create mode.
+ /// Thrown when filename is null.
+ public static FusionSourceSchemaArchive Create(string filename)
+ {
+ ArgumentNullException.ThrowIfNull(filename);
+ return Create(File.Create(filename));
+ }
+
+ ///
+ /// Creates a new Fusion source schema archive using the provided stream.
+ ///
+ /// The stream to write the archive to.
+ /// True to leave the stream open after disposal; otherwise, false.
+ /// A new FusionSourceSchemaArchive instance in Create mode.
+ /// Thrown when stream is null.
+ public static FusionSourceSchemaArchive Create(Stream stream, bool leaveOpen = false)
+ {
+ ArgumentNullException.ThrowIfNull(stream);
+ return new FusionSourceSchemaArchive(stream, FusionSourceSchemaArchiveMode.Create, leaveOpen, FusionSourceSchemaArchiveReadOptions.Default);
+ }
+
+ ///
+ /// Opens an existing Fusion source schema archive from a file.
+ ///
+ /// The path to the archive file to open.
+ /// The mode to open the archive in.
+ /// A FusionSourceSchemaArchive instance opened in the specified mode.
+ /// Thrown when filename is null.
+ /// Thrown when mode is invalid.
+ public static FusionSourceSchemaArchive Open(
+ string filename,
+ FusionSourceSchemaArchiveMode mode = FusionSourceSchemaArchiveMode.Read)
+ {
+ ArgumentNullException.ThrowIfNull(filename);
+
+ return mode switch
+ {
+ FusionSourceSchemaArchiveMode.Read => Open(File.OpenRead(filename), mode),
+ FusionSourceSchemaArchiveMode.Create => Create(File.Create(filename)),
+ FusionSourceSchemaArchiveMode.Update => Open(File.Open(filename, FileMode.Open, FileAccess.ReadWrite), mode),
+ _ => throw new ArgumentException("Invalid mode.", nameof(mode))
+ };
+ }
+
+ ///
+ /// Opens a Fusion source schema archive from a stream.
+ ///
+ /// The stream containing the archive data.
+ /// The mode to open the archive in.
+ /// True to leave the stream open after disposal; otherwise, false.
+ /// The options to use when reading from the archive.
+ /// A FusionSourceSchemaArchive instance opened in the specified mode.
+ /// Thrown when stream is null.
+ public static FusionSourceSchemaArchive Open(
+ Stream stream,
+ FusionSourceSchemaArchiveMode mode = FusionSourceSchemaArchiveMode.Read,
+ bool leaveOpen = false,
+ FusionSourceSchemaArchiveOptions options = default)
+ {
+ ArgumentNullException.ThrowIfNull(stream);
+ var readOptions = new FusionSourceSchemaArchiveReadOptions(
+ options.MaxAllowedSchemaSize ?? FusionSourceSchemaArchiveReadOptions.Default.MaxAllowedSchemaSize,
+ options.MaxAllowedSettingsSize ?? FusionSourceSchemaArchiveReadOptions.Default.MaxAllowedSettingsSize);
+ return new FusionSourceSchemaArchive(stream, mode, leaveOpen, readOptions);
+ }
+
+ ///
+ /// Sets the archive metadata.
+ ///
+ /// The metadata to store in the archive.
+ /// Token to cancel the operation.
+ /// Thrown when metadata is null.
+ /// Thrown when the archive has been disposed.
+ /// Thrown when the archive is read-only.
+ public async Task SetArchiveMetadataAsync(
+ ArchiveMetadata metadata,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(metadata);
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ EnsureMutable();
+
+ Exception? exception = null;
+
+ await using var stream = _session.OpenWrite(FileNames.ArchiveMetadata);
+
+ var writer = PipeWriter.Create(stream);
+
+ try
+ {
+ ArchiveMetadataSerializer.Format(metadata, writer);
+ await writer.FlushAsync(cancellationToken);
+ _metadata = metadata;
+ }
+ catch (Exception ex)
+ {
+ exception = ex;
+ throw;
+ }
+ finally
+ {
+ await writer.CompleteAsync(exception);
+ }
+ }
+
+ ///
+ /// Gets the archive metadata.
+ /// Returns null if no metadata is present in the archive.
+ ///
+ /// Token to cancel the operation.
+ /// The archive metadata or null if not present.
+ /// Thrown when the archive has been disposed.
+ public async Task GetArchiveMetadataAsync(
+ CancellationToken cancellationToken = default)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
+ if (_metadata is not null)
+ {
+ return _metadata;
+ }
+
+ if (!await _session.ExistsAsync(FileNames.ArchiveMetadata, FileKind.Metadata, cancellationToken))
+ {
+ return null;
+ }
+
+ var buffer = TryRentBuffer();
+
+ try
+ {
+ await using var stream = await _session.OpenReadAsync(
+ FileNames.ArchiveMetadata,
+ FileKind.Metadata,
+ cancellationToken);
+ await stream.CopyToAsync(buffer, cancellationToken);
+ var metadata = ArchiveMetadataSerializer.Parse(buffer.WrittenMemory);
+ _metadata = metadata;
+ return metadata;
+ }
+ finally
+ {
+ TryReturnBuffer(buffer);
+ }
+ }
+
+ ///
+ /// Sets the GraphQL schema of the source schema.
+ ///
+ /// The GraphQL schema to store.
+ /// Token to cancel the operation.
+ /// Thrown when the GraphQL schema is empty.
+ /// Thrown when the archive has been disposed.
+ /// Thrown when the archive is read-only.
+ public async Task SetSchemaAsync(
+ ReadOnlyMemory schema,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(schema.Length, 0);
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
+ EnsureMutable();
+
+ await using (var stream = _session.OpenWrite(FileNames.GraphQLSchema))
+ {
+ await stream.WriteAsync(schema, cancellationToken);
+ }
+ }
+
+ ///
+ /// Tries to get the GraphQL schema of the source schema.
+ ///
+ /// Token to cancel the operation.
+ /// The GraphQL schema of the source schema if found, or null if not found.
+ /// Thrown when the archive has been disposed.
+ public async Task?> TryGetSchemaAsync(
+ CancellationToken cancellationToken = default)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
+ const string schemaPath = FileNames.GraphQLSchema;
+
+ if (!_session.Exists(schemaPath))
+ {
+ return null;
+ }
+
+ var buffer = TryRentBuffer();
+
+ try
+ {
+ await using var schemaStream = await _session.OpenReadAsync(
+ schemaPath,
+ FileKind.GraphQLSchema,
+ cancellationToken);
+ await schemaStream.CopyToAsync(buffer, cancellationToken);
+
+ return buffer.WrittenMemory.ToArray();
+ }
+ finally
+ {
+ TryReturnBuffer(buffer);
+ }
+ }
+
+ ///
+ /// Sets the settings of the source schema.
+ ///
+ /// The source schema settings to store.
+ /// Token to cancel the operation.
+ /// Thrown when settings is null.
+ /// Thrown when the archive has been disposed.
+ /// Thrown when the archive is read-only.
+ public async Task SetSettingsAsync(
+ JsonDocument settings,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(settings);
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
+ EnsureMutable();
+
+ await using (var stream = _session.OpenWrite(FileNames.Settings))
+ {
+ await using var jsonWriter = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true });
+ settings.WriteTo(jsonWriter);
+ await jsonWriter.FlushAsync(cancellationToken);
+ }
+ }
+
+ ///
+ /// Tries to get the settings of the source schema.
+ ///
+ /// Token to cancel the operation.
+ /// The settings of the source schema if found, or null if not found.
+ /// Thrown when the archive has been disposed.
+ public async Task TryGetSettingsAsync(
+ CancellationToken cancellationToken = default)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
+ const string settingsPath = FileNames.Settings;
+
+ if (!_session.Exists(settingsPath))
+ {
+ return null;
+ }
+
+ var buffer = TryRentBuffer();
+
+ try
+ {
+ await using var settingsStream = await _session.OpenReadAsync(
+ settingsPath,
+ FileKind.Settings,
+ cancellationToken);
+ var settings = await JsonDocument.ParseAsync(settingsStream, cancellationToken: cancellationToken);
+
+ return settings;
+ }
+ finally
+ {
+ TryReturnBuffer(buffer);
+ }
+ }
+
+ ///
+ /// Commits any pending changes to the archive and flushes them to the underlying stream.
+ /// After committing, the archive may transition to Update mode if the stream supports it.
+ ///
+ /// Token to cancel the operation.
+ /// Thrown when the archive has been disposed.
+ ///
+ /// Thrown when the archive is read-only, or one of the following has not been set in the archive:
+ /// GraphQL schema, settings or archive metadata.
+ ///
+ public async Task CommitAsync(CancellationToken cancellationToken = default)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
+ if (_mode is FusionSourceSchemaArchiveMode.Read)
+ {
+ throw new InvalidOperationException("Cannot commit changes to a read-only archive.");
+ }
+
+ if (!_session.Exists(FileNames.GraphQLSchema)
+ || !_session.Exists(FileNames.Settings)
+ || !_session.Exists(FileNames.ArchiveMetadata))
+ {
+ throw new InvalidOperationException(
+ "Cannot commit changes as long as one of the following has not been set: GraphQL schema, settings or archive metadata.");
+ }
+
+ if (_session.HasUncommittedChanges)
+ {
+ await _session.CommitAsync(cancellationToken);
+#if NET10_0_OR_GREATER
+ await _archive.DisposeAsync();
+#else
+ _archive.Dispose();
+#endif
+
+ if (_stream is { CanSeek: true, CanRead: true, CanWrite: true })
+ {
+ _stream.Seek(0, SeekOrigin.Begin);
+ _archive = new ZipArchive(_stream, ZipArchiveMode.Update, _leaveOpen);
+ _mode = FusionSourceSchemaArchiveMode.Update;
+ _session.SetMode(_mode);
+ }
+ else
+ {
+ _mode = FusionSourceSchemaArchiveMode.Read;
+ }
+ }
+ }
+
+ ///
+ /// Releases all resources used by the FusionSourceSchemaArchive.
+ /// If leaveOpen was false when opening the archive, the underlying stream is also disposed.
+ ///
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+
+ _session.Dispose();
+ _archive.Dispose();
+
+ if (!_leaveOpen)
+ {
+ _stream.Dispose();
+ }
+ }
+
+ ///
+ /// We will try to work with a single buffer for all file interactions.
+ ///
+ private ArrayBufferWriter TryRentBuffer()
+ {
+ return Interlocked.Exchange(ref _buffer, null) ?? new ArrayBufferWriter(4096);
+ }
+
+ ///
+ /// Tries to preserve a used buffer.
+ ///
+ ///
+ /// The buffer that shall be preserved.
+ ///
+ private void TryReturnBuffer(ArrayBufferWriter buffer)
+ {
+ buffer.Clear();
+
+ var currentBuffer = _buffer;
+ var currentCapacity = currentBuffer?.Capacity ?? 0;
+
+ if (currentCapacity < buffer.Capacity)
+ {
+ Interlocked.CompareExchange(ref _buffer, buffer, currentBuffer);
+ }
+ }
+
+ private void EnsureMutable()
+ {
+ if (_mode is FusionSourceSchemaArchiveMode.Read)
+ {
+ throw new InvalidOperationException("Cannot modify a read-only archive.");
+ }
+ }
+}
+
+file static class Extensions
+{
+ public static Task CopyToAsync(
+ this Stream stream,
+ IBufferWriter buffer,
+ CancellationToken cancellationToken)
+ => stream.CopyToAsync(buffer, 4096, cancellationToken);
+
+ public static async Task CopyToAsync(
+ this Stream stream,
+ IBufferWriter buffer,
+ int expectedStreamLength,
+ CancellationToken cancellationToken)
+ {
+ int bytesRead;
+ var bufferSize = Math.Min(expectedStreamLength, 4096);
+
+ do
+ {
+ var memory = buffer.GetMemory(bufferSize);
+ bytesRead = await stream.ReadAsync(memory, cancellationToken);
+ if (bytesRead > 0)
+ {
+ buffer.Advance(bytesRead);
+ }
+ } while (bytesRead > 0);
+ }
+}
diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/FusionSourceSchemaArchiveMode.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/FusionSourceSchemaArchiveMode.cs
new file mode 100644
index 00000000000..780fda605d9
--- /dev/null
+++ b/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/FusionSourceSchemaArchiveMode.cs
@@ -0,0 +1,27 @@
+using System.IO.Compression;
+
+namespace HotChocolate.Fusion.SourceSchema.Packaging;
+
+///
+/// Specifies the mode for opening or creating a Fusion source schema archive.
+///
+public enum FusionSourceSchemaArchiveMode
+{
+ ///
+ /// Opens an existing archive for reading only. No modifications are allowed.
+ /// The archive must already exist and contain valid data.
+ ///
+ Read = ZipArchiveMode.Read,
+
+ ///
+ /// Creates a new archive for writing. If the target already exists, it will be overwritten.
+ ///
+ Create = ZipArchiveMode.Create,
+
+ ///
+ /// Opens an existing archive for both reading and writing. Allows modification of
+ /// existing entries and addition of new entries. The archive must already exist.
+ /// Use this mode when you need to modify an existing archive.
+ ///
+ Update = ZipArchiveMode.Update
+}
diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/FusionSourceSchemaArchiveOptions.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/FusionSourceSchemaArchiveOptions.cs
new file mode 100644
index 00000000000..0cf28a8bf3f
--- /dev/null
+++ b/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/FusionSourceSchemaArchiveOptions.cs
@@ -0,0 +1,17 @@
+namespace HotChocolate.Fusion.SourceSchema.Packaging;
+
+///
+/// Specifies the options for a Fusion source schema archive.
+///
+public struct FusionSourceSchemaArchiveOptions
+{
+ ///
+ /// Gets or sets the maximum allowed size of the GraphQL schema in the archive.
+ ///
+ public int? MaxAllowedSchemaSize { get; set; }
+
+ ///
+ /// Gets or sets the maximum allowed size of the settings in the archive.
+ ///
+ public int? MaxAllowedSettingsSize { get; set; }
+}
diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/FusionSourceSchemaArchiveReadOptions.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/FusionSourceSchemaArchiveReadOptions.cs
new file mode 100644
index 00000000000..4365ff7eb99
--- /dev/null
+++ b/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/FusionSourceSchemaArchiveReadOptions.cs
@@ -0,0 +1,15 @@
+namespace HotChocolate.Fusion.SourceSchema.Packaging;
+
+///
+/// Specifies the read options for a Fusion source schema archive.
+///
+internal readonly record struct FusionSourceSchemaArchiveReadOptions(
+ int MaxAllowedSchemaSize,
+ int MaxAllowedSettingsSize)
+{
+ ///
+ /// Gets the default read options.
+ ///
+ public static FusionSourceSchemaArchiveReadOptions Default { get; }
+ = new(50_000_000, 512_000);
+}
diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/FusionSourceSchemaArchiveSession.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/FusionSourceSchemaArchiveSession.cs
new file mode 100644
index 00000000000..bd5bbebd717
--- /dev/null
+++ b/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/FusionSourceSchemaArchiveSession.cs
@@ -0,0 +1,295 @@
+using System.Buffers;
+using System.IO.Compression;
+
+namespace HotChocolate.Fusion.SourceSchema.Packaging;
+
+internal sealed class FusionSourceSchemaArchiveSession : IDisposable
+{
+ private readonly Dictionary _files = [];
+ private readonly ZipArchive _archive;
+ private readonly FusionSourceSchemaArchiveReadOptions _readOptions;
+ private FusionSourceSchemaArchiveMode _mode;
+ private bool _disposed;
+
+ public FusionSourceSchemaArchiveSession(
+ ZipArchive archive,
+ FusionSourceSchemaArchiveMode mode,
+ FusionSourceSchemaArchiveReadOptions readOptions)
+ {
+ ArgumentNullException.ThrowIfNull(archive);
+
+ _archive = archive;
+ _mode = mode;
+ _readOptions = readOptions;
+ }
+
+ public bool HasUncommittedChanges
+ => _files.Values.Any(file => file.State is not FileState.Read);
+
+ public IEnumerable GetFiles()
+ {
+ var tempFiles = _files.Where(file => file.Value.State is not FileState.Deleted).Select(file => file.Key);
+
+ if (_mode is FusionSourceSchemaArchiveMode.Create)
+ {
+ return tempFiles;
+ }
+
+ var files = new HashSet(tempFiles);
+
+ foreach (var entry in _archive.Entries)
+ {
+ files.Add(entry.FullName);
+ }
+
+ return files;
+ }
+
+ public async Task ExistsAsync(string path, FileKind kind, CancellationToken cancellationToken)
+ {
+ if (_files.TryGetValue(path, out var file))
+ {
+ return file.State is not FileState.Deleted;
+ }
+
+ if (_mode is not FusionSourceSchemaArchiveMode.Create && _archive.GetEntry(path) is { } entry)
+ {
+ file = FileEntry.Read(path);
+ await ExtractFileAsync(entry, file, GetAllowedSize(kind), cancellationToken);
+ _files.Add(path, file);
+ return true;
+ }
+
+ return false;
+ }
+
+ public bool Exists(string path)
+ {
+ if (_files.TryGetValue(path, out var file))
+ {
+ return file.State is not FileState.Deleted;
+ }
+
+ return _mode is not FusionSourceSchemaArchiveMode.Create && _archive.GetEntry(path) is not null;
+ }
+
+ public async Task OpenReadAsync(string path, FileKind kind, CancellationToken cancellationToken)
+ {
+ if (_files.TryGetValue(path, out var file))
+ {
+ if (file.State is FileState.Deleted)
+ {
+ throw new FileNotFoundException(path);
+ }
+
+ return File.OpenRead(file.TempPath);
+ }
+
+ if (_mode is not FusionSourceSchemaArchiveMode.Create && _archive.GetEntry(path) is { } entry)
+ {
+ file = FileEntry.Read(path);
+ await ExtractFileAsync(entry, file, GetAllowedSize(kind), cancellationToken);
+ var stream = File.OpenRead(file.TempPath);
+ _files.Add(path, file);
+ return stream;
+ }
+
+ throw new FileNotFoundException(path);
+ }
+
+ public Stream OpenWrite(string path)
+ {
+ if (_mode is FusionSourceSchemaArchiveMode.Read)
+ {
+ throw new InvalidOperationException("Cannot write to a read-only archive.");
+ }
+
+ if (_files.TryGetValue(path, out var file))
+ {
+ file.MarkMutated();
+ return File.Open(file.TempPath, FileMode.Create, FileAccess.Write);
+ }
+
+ if (_mode is not FusionSourceSchemaArchiveMode.Create && _archive.GetEntry(path) is not null)
+ {
+ file = FileEntry.Read(path);
+ file.MarkMutated();
+ }
+
+ file ??= FileEntry.Created(path);
+ var stream = File.Open(file.TempPath, FileMode.Create, FileAccess.Write);
+ _files.Add(path, file);
+
+ return stream;
+ }
+
+ public void SetMode(FusionSourceSchemaArchiveMode mode)
+ {
+ _mode = mode;
+ }
+
+ public async Task CommitAsync(CancellationToken cancellationToken)
+ {
+ foreach (var file in _files.Values.OrderBy(f => f.Path, StringComparer.Ordinal))
+ {
+ switch (file.State)
+ {
+ case FileState.Created:
+ await CreateEntryFromFileAsync(file.TempPath, file.Path, cancellationToken);
+ break;
+
+ case FileState.Replaced:
+ _archive.GetEntry(file.Path)?.Delete();
+ await CreateEntryFromFileAsync(file.TempPath, file.Path, cancellationToken);
+ break;
+
+ case FileState.Deleted:
+ _archive.GetEntry(file.Path)?.Delete();
+ break;
+ }
+
+ file.MarkRead();
+ }
+ }
+
+ ///
+ /// Creates a ZIP entry from a file with a deterministic timestamp.
+ /// Using a fixed timestamp ensures binary reproducibility of the archive.
+ ///
+ private async Task CreateEntryFromFileAsync(
+ string sourceFileName,
+ string entryName,
+ CancellationToken cancellationToken)
+ {
+ var entry = _archive.CreateEntry(entryName);
+ // Use a fixed timestamp to ensure deterministic archive output
+ entry.LastWriteTime = new DateTimeOffset(2000, 1, 1, 0, 0, 0, TimeSpan.Zero);
+
+ await using var source = File.OpenRead(sourceFileName);
+#if NET10_0_OR_GREATER
+ await using var destination = await entry.OpenAsync(cancellationToken);
+#else
+ await using var destination = entry.Open();
+#endif
+ await source.CopyToAsync(destination, cancellationToken);
+ }
+
+ private static async Task ExtractFileAsync(
+ ZipArchiveEntry zipEntry,
+ FileEntry fileEntry,
+ int maxAllowedSize,
+ CancellationToken cancellationToken)
+ {
+ var buffer = ArrayPool.Shared.Rent(4096);
+ var consumed = 0;
+
+ try
+ {
+ await using var readStream = zipEntry.Open();
+ await using var writeStream = File.Open(fileEntry.TempPath, FileMode.Create, FileAccess.Write);
+
+ int read;
+ while ((read = await readStream.ReadAsync(buffer, cancellationToken)) > 0)
+ {
+ consumed += read;
+
+ if (consumed > maxAllowedSize)
+ {
+ throw new InvalidOperationException(
+ $"File is too large and exceeds the allowed size of {maxAllowedSize}.");
+ }
+
+ await writeStream.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
+ }
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(buffer);
+ }
+ }
+
+ private int GetAllowedSize(FileKind kind)
+ => kind switch
+ {
+ FileKind.GraphQLSchema
+ => _readOptions.MaxAllowedSchemaSize,
+ FileKind.Settings or FileKind.Metadata
+ => _readOptions.MaxAllowedSettingsSize,
+ _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null)
+ };
+
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ foreach (var file in _files.Values)
+ {
+ if (file.State is not FileState.Deleted && File.Exists(file.TempPath))
+ {
+ try
+ {
+ File.Delete(file.TempPath);
+ }
+ catch
+ {
+ // ignore
+ }
+ }
+ }
+
+ _disposed = true;
+ }
+
+ private class FileEntry
+ {
+ private FileEntry(string path, string tempPath, FileState state)
+ {
+ Path = path;
+ TempPath = tempPath;
+ State = state;
+ }
+
+ public string Path { get; }
+
+ public string TempPath { get; }
+
+ public FileState State { get; private set; }
+
+ public void MarkMutated()
+ {
+ if (State is FileState.Read or FileState.Deleted)
+ {
+ State = FileState.Replaced;
+ }
+ }
+
+ public void MarkRead()
+ {
+ State = FileState.Read;
+ }
+
+ public static FileEntry Created(string path)
+ => new(path, GetRandomTempFileName(), FileState.Created);
+
+ public static FileEntry Read(string path)
+ => new(path, GetRandomTempFileName(), FileState.Read);
+
+ private static string GetRandomTempFileName()
+ {
+ var tempDir = System.IO.Path.GetTempPath();
+ var fileName = System.IO.Path.GetRandomFileName();
+ return System.IO.Path.Combine(tempDir, fileName);
+ }
+ }
+
+ private enum FileState
+ {
+ Read,
+ Created,
+ Replaced,
+ Deleted
+ }
+}
diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/HotChocolate.Fusion.SourceSchema.Packaging.csproj b/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/HotChocolate.Fusion.SourceSchema.Packaging.csproj
new file mode 100644
index 00000000000..f258e4e6093
--- /dev/null
+++ b/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/HotChocolate.Fusion.SourceSchema.Packaging.csproj
@@ -0,0 +1,12 @@
+
+
+
+ HotChocolate.Fusion.SourceSchema.Packaging
+ HotChocolate.Fusion.SourceSchema.Packaging
+
+
+
+
+
+
+
diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/Serializers/ArchiveMetadataSerializer.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/Serializers/ArchiveMetadataSerializer.cs
new file mode 100644
index 00000000000..c1d473100fa
--- /dev/null
+++ b/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/Serializers/ArchiveMetadataSerializer.cs
@@ -0,0 +1,42 @@
+using System.Buffers;
+using System.Collections.Immutable;
+using System.Text.Json;
+
+namespace HotChocolate.Fusion.SourceSchema.Packaging.Serializers;
+
+internal static class ArchiveMetadataSerializer
+{
+ public static void Format(ArchiveMetadata archiveMetadata, IBufferWriter writer)
+ {
+ using var jsonWriter = new Utf8JsonWriter(writer);
+
+ jsonWriter.WriteStartObject();
+
+ jsonWriter.WriteString("formatVersion", archiveMetadata.FormatVersion.ToString());
+
+ jsonWriter.WriteEndObject();
+ jsonWriter.Flush();
+ }
+
+ public static ArchiveMetadata Parse(ReadOnlyMemory data)
+ {
+ using var document = JsonDocument.Parse(data);
+ var root = document.RootElement;
+
+ if (root.ValueKind is not JsonValueKind.Object)
+ {
+ throw new JsonException("Invalid archive metadata format.");
+ }
+
+ var formatVersionProp = root.GetProperty("formatVersion");
+ if (formatVersionProp.ValueKind is not JsonValueKind.String)
+ {
+ throw new JsonException("The archive metadata must contain a formatVersion property.");
+ }
+
+ return new ArchiveMetadata
+ {
+ FormatVersion = new Version(formatVersionProp.GetString()!)
+ };
+ }
+}
diff --git a/src/HotChocolate/Fusion-vnext/test/Fusion.SourceSchema.Packaging.Tests/FusionSourceSchemaArchiveTests.cs b/src/HotChocolate/Fusion-vnext/test/Fusion.SourceSchema.Packaging.Tests/FusionSourceSchemaArchiveTests.cs
new file mode 100644
index 00000000000..fc6a06e905f
--- /dev/null
+++ b/src/HotChocolate/Fusion-vnext/test/Fusion.SourceSchema.Packaging.Tests/FusionSourceSchemaArchiveTests.cs
@@ -0,0 +1,340 @@
+using System.Text.Json;
+
+namespace HotChocolate.Fusion.SourceSchema.Packaging;
+
+public class FusionSourceSchemaArchiveTests : IDisposable
+{
+ private readonly List _streamsToDispose = [];
+
+ [Fact]
+ public void Create_WithNullStream_ThrowsArgumentNullException()
+ {
+ // act & Assert
+ Assert.Throws(() => FusionSourceSchemaArchive.Create(null!));
+ }
+
+ [Fact]
+ public void Open_WithNullStream_ThrowsArgumentNullException()
+ {
+ // act & Assert
+ Assert.Throws(() => FusionSourceSchemaArchive.Open(default(Stream)!));
+ }
+
+ [Fact]
+ public void Open_WithNullString_ThrowsArgumentNullException()
+ {
+ // act & Assert
+ Assert.Throws(() => FusionSourceSchemaArchive.Open(default(string)!));
+ }
+
+ [Fact]
+ public async Task SetArchiveMetadata_WithValidData_StoresCorrectly()
+ {
+ // arrange
+ await using var stream = CreateStream();
+ var metadata = new ArchiveMetadata
+ {
+ FormatVersion = new Version("2.0.0")
+ };
+
+ // act & Assert
+ using var archive = FusionSourceSchemaArchive.Create(stream, leaveOpen: true);
+ await archive.SetArchiveMetadataAsync(metadata);
+
+ // Can read immediately within the same session
+ var retrieved = await archive.GetArchiveMetadataAsync();
+ Assert.NotNull(retrieved);
+ Assert.Equal(metadata.FormatVersion, retrieved.FormatVersion);
+ }
+
+ [Fact]
+ public async Task GetArchiveMetadata_WhenNotSet_ReturnsNull()
+ {
+ // arrange
+ await using var stream = CreateStream();
+
+ // act & Assert
+ using var archive = FusionSourceSchemaArchive.Create(stream);
+ var result = await archive.GetArchiveMetadataAsync();
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public async Task SetArchiveMetadata_WithNullMetadata_ThrowsArgumentNullException()
+ {
+ // arrange
+ await using var stream = CreateStream();
+
+ // act & Assert
+ using var archive = FusionSourceSchemaArchive.Create(stream);
+ await Assert.ThrowsAsync(
+ () => archive.SetArchiveMetadataAsync(null!));
+ }
+
+ [Fact]
+ public async Task SetSchema_WithValidData_StoresCorrectly()
+ {
+ // arrange
+ await using var stream = CreateStream();
+ var schema = "type Query { hello: String }"u8.ToArray();
+
+ // act
+ using var archive = FusionSourceSchemaArchive.Create(stream, leaveOpen: true);
+ await archive.SetSchemaAsync(schema);
+
+ // assert - Can read immediately within the same session
+ var retrieved = await archive.TryGetSchemaAsync();
+
+ Assert.NotNull(retrieved);
+ Assert.Equal(schema, retrieved.Value.ToArray());
+ }
+
+ [Fact]
+ public async Task SetSchema_WithEmptySchema_ThrowsArgumentOutOfRangeException()
+ {
+ // arrange
+ await using var stream = CreateStream();
+ var schema = ReadOnlyMemory.Empty;
+
+ // act & assert
+ using var archive = FusionSourceSchemaArchive.Create(stream, leaveOpen: true);
+ await Assert.ThrowsAsync(
+ () => archive.SetSchemaAsync(schema));
+ }
+
+ [Fact]
+ public async Task TryGetSchema_WhenNotSet_ReturnsNull()
+ {
+ // arrange
+ await using var stream = CreateStream();
+
+ // act
+ using var archive = FusionSourceSchemaArchive.Create(stream);
+ var result = await archive.TryGetSchemaAsync();
+
+ // assert
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public async Task SetSettings_WithValidData_StoresCorrectly()
+ {
+ // arrange
+ await using var stream = CreateStream();
+ using var settings = JsonDocument.Parse("""{"version": "1.0", "name": "test"}""");
+
+ // act
+ using var archive = FusionSourceSchemaArchive.Create(stream, leaveOpen: true);
+ await archive.SetSettingsAsync(settings);
+
+ // assert - Can read immediately within the same session
+ using var retrieved = await archive.TryGetSettingsAsync();
+
+ Assert.NotNull(retrieved);
+ Assert.Equal("1.0", retrieved.RootElement.GetProperty("version").GetString());
+ Assert.Equal("test", retrieved.RootElement.GetProperty("name").GetString());
+ }
+
+ [Fact]
+ public async Task SetSettings_WithNullSettings_ThrowsArgumentNullException()
+ {
+ // arrange
+ await using var stream = CreateStream();
+
+ // act & assert
+ using var archive = FusionSourceSchemaArchive.Create(stream, leaveOpen: true);
+ await Assert.ThrowsAsync(
+ () => archive.SetSettingsAsync(null!));
+ }
+
+ [Fact]
+ public async Task TryGetSettings_WhenNotSet_ReturnsNull()
+ {
+ // arrange
+ await using var stream = CreateStream();
+
+ // act
+ using var archive = FusionSourceSchemaArchive.Create(stream);
+ var result = await archive.TryGetSettingsAsync();
+
+ // assert
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public async Task CommitAsync_WhenSchemaNotSet_ThrowsInvalidOperationException()
+ {
+ // arrange
+ await using var stream = CreateStream();
+ var metadata = new ArchiveMetadata();
+ using var settings = JsonDocument.Parse("""{"version": "1.0", "name": "test"}""");
+
+ // act & assert
+ using var archive = FusionSourceSchemaArchive.Create(stream, leaveOpen: true);
+ await archive.SetArchiveMetadataAsync(metadata);
+ await archive.SetSettingsAsync(settings);
+
+ var exception = await Assert.ThrowsAsync(
+ () => archive.CommitAsync());
+ Assert.Equal(
+ "Cannot commit changes as long as one of the following has not been set: GraphQL schema, settings or archive metadata.",
+ exception.Message);
+ }
+
+ [Fact]
+ public async Task CommitAsync_WhenSettingsNotSet_ThrowsInvalidOperationException()
+ {
+ // arrange
+ await using var stream = CreateStream();
+ var metadata = new ArchiveMetadata();
+ var schema = "type Query { hello: String }"u8.ToArray();
+
+ // act & assert
+ using var archive = FusionSourceSchemaArchive.Create(stream, leaveOpen: true);
+ await archive.SetArchiveMetadataAsync(metadata);
+ await archive.SetSchemaAsync(schema);
+
+ var exception = await Assert.ThrowsAsync(
+ () => archive.CommitAsync());
+ Assert.Equal(
+ "Cannot commit changes as long as one of the following has not been set: GraphQL schema, settings or archive metadata.",
+ exception.Message);
+ }
+
+ [Fact]
+ public async Task CommitAsync_WhenArchiveMetadataNotSet_ThrowsInvalidOperationException()
+ {
+ // arrange
+ await using var stream = CreateStream();
+ var schema = "type Query { hello: String }"u8.ToArray();
+ using var settings = JsonDocument.Parse("""{"version": "1.0", "name": "test"}""");
+
+ // act & assert
+ using var archive = FusionSourceSchemaArchive.Create(stream, leaveOpen: true);
+ await archive.SetSchemaAsync(schema);
+ await archive.SetSettingsAsync(settings);
+
+ var exception = await Assert.ThrowsAsync(
+ () => archive.CommitAsync());
+ Assert.Equal(
+ "Cannot commit changes as long as one of the following has not been set: GraphQL schema, settings or archive metadata.",
+ exception.Message);
+ }
+
+ [Fact]
+ public async Task CommitAsync_WhenReadOnly_ThrowsInvalidOperationException()
+ {
+ // arrange
+ await using var stream = CreateStream();
+ var metadata = new ArchiveMetadata();
+ var schema = "type Query { hello: String }"u8.ToArray();
+ using var settings = JsonDocument.Parse("""{"version": "1.0", "name": "test"}""");
+
+ // Create and commit a valid archive first
+ using (var archive = FusionSourceSchemaArchive.Create(stream, leaveOpen: true))
+ {
+ await archive.SetArchiveMetadataAsync(metadata);
+ await archive.SetSchemaAsync(schema);
+ await archive.SetSettingsAsync(settings);
+ await archive.CommitAsync();
+ }
+
+ // act & assert - Open in read mode and try to commit
+ stream.Position = 0;
+ using (var readArchive = FusionSourceSchemaArchive.Open(stream, FusionSourceSchemaArchiveMode.Read, leaveOpen: true))
+ {
+ var exception = await Assert.ThrowsAsync(
+ () => readArchive.CommitAsync());
+ Assert.Equal("Cannot commit changes to a read-only archive.", exception.Message);
+ }
+ }
+
+ [Fact]
+ public async Task CommitAndReopen_PersistsAllData()
+ {
+ // arrange
+ await using var stream = CreateStream();
+ var metadata = new ArchiveMetadata();
+ var schema = "type Query { users: [User] } type User { id: ID! name: String! }"u8.ToArray();
+ using var settings = JsonDocument.Parse("""{"version": "1.0", "name": "users"}""");
+
+ // act - Create and commit
+ using (var archive = FusionSourceSchemaArchive.Create(stream, leaveOpen: true))
+ {
+ await archive.SetArchiveMetadataAsync(metadata);
+ await archive.SetSchemaAsync(schema);
+ await archive.SetSettingsAsync(settings);
+ await archive.CommitAsync();
+ }
+
+ // assert - Reopen and verify persistence
+ stream.Position = 0;
+ using (var readArchive = FusionSourceSchemaArchive.Open(stream, leaveOpen: true))
+ {
+ var retrievedMetadata = await readArchive.GetArchiveMetadataAsync();
+ Assert.NotNull(retrievedMetadata);
+ Assert.Equal(metadata.FormatVersion, retrievedMetadata.FormatVersion);
+
+ var retrievedSchema = await readArchive.TryGetSchemaAsync();
+ Assert.NotNull(retrievedSchema);
+ Assert.Equal(schema, retrievedSchema.Value.ToArray());
+
+ using var retrievedSettings = await readArchive.TryGetSettingsAsync();
+ Assert.NotNull(retrievedSettings);
+ Assert.Equal("1.0", retrievedSettings.RootElement.GetProperty("version").GetString());
+ Assert.Equal("users", retrievedSettings.RootElement.GetProperty("name").GetString());
+ }
+ }
+
+ [Fact]
+ public async Task UpdateMode_CanModifyExistingArchive()
+ {
+ // arrange
+ await using var stream = CreateStream();
+ var metadata = new ArchiveMetadata();
+ var schema1 = "type Query { hello: String }"u8.ToArray();
+ var schema2 = "type Query { hello: String! goodbye: String }"u8.ToArray();
+ using var settings = JsonDocument.Parse("""{"version": "1.0", "name": "test"}""");
+
+ // act - Create initial archive
+ using (var archive = FusionSourceSchemaArchive.Create(stream, leaveOpen: true))
+ {
+ await archive.SetArchiveMetadataAsync(metadata);
+ await archive.SetSchemaAsync(schema1);
+ await archive.SetSettingsAsync(settings);
+ await archive.CommitAsync();
+ }
+
+ // act - Update existing archive with new schema
+ stream.Position = 0;
+ using (var updateArchive = FusionSourceSchemaArchive.Open(stream, FusionSourceSchemaArchiveMode.Update, leaveOpen: true))
+ {
+ await updateArchive.SetSchemaAsync(schema2);
+ await updateArchive.CommitAsync();
+ }
+
+ // assert - Verify updated schema
+ stream.Position = 0;
+ using (var readArchive = FusionSourceSchemaArchive.Open(stream, leaveOpen: true))
+ {
+ var retrievedSchema = await readArchive.TryGetSchemaAsync();
+ Assert.NotNull(retrievedSchema);
+ Assert.Equal(schema2, retrievedSchema.Value.ToArray());
+ }
+ }
+
+ private Stream CreateStream()
+ {
+ var stream = new MemoryStream();
+ _streamsToDispose.Add(stream);
+ return stream;
+ }
+
+ public void Dispose()
+ {
+ foreach (var stream in _streamsToDispose)
+ {
+ stream.Dispose();
+ }
+ }
+}
diff --git a/src/HotChocolate/Fusion-vnext/test/Fusion.SourceSchema.Packaging.Tests/HotChocolate.Fusion.SourceSchema.Packaging.Tests.csproj b/src/HotChocolate/Fusion-vnext/test/Fusion.SourceSchema.Packaging.Tests/HotChocolate.Fusion.SourceSchema.Packaging.Tests.csproj
new file mode 100644
index 00000000000..fb5d8ae3e0a
--- /dev/null
+++ b/src/HotChocolate/Fusion-vnext/test/Fusion.SourceSchema.Packaging.Tests/HotChocolate.Fusion.SourceSchema.Packaging.Tests.csproj
@@ -0,0 +1,17 @@
+
+
+
+
+ HotChocolate.Fusion.SourceSchema.Packaging.Tests
+ HotChocolate.Fusion.SourceSchema.Packaging
+
+
+
+
+
+
+
+
+
+
+