diff --git a/CHANGELOG.md b/CHANGELOG.md index 3339132..ea06e40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,33 @@ All notable changes to the InsForge Dart/Flutter SDK are documented here. This project follows [Semantic Versioning](https://semver.org) and [Keep a Changelog](https://keepachangelog.com). +## 0.2.0 + +Syncs with InsForge JS SDK v1.5.0 (storage: standard PUT create-or-replace +semantics). Requires an InsForge backend that includes the standard-PUT +storage change (InsForge/InsForge#1760); do not run against a pre-change +backend. + +### Changed + +- **Storage `upload(path, bytes)`** — uploading to a key that already exists + now replaces the object in place (standard PUT semantics). Previously the + server silently auto-renamed the key (`photo.png` → `photo (1).png`). The + method signature is unchanged; the friendly auto-rename UX now lives in the + InsForge dashboard rather than the API. +- **Storage `uploadAutoKey(filename, bytes)`** — now generates a unique, + collision-free key client-side (sanitized base + timestamp + random suffix) + and uploads through the standard `upload` path, so repeated uploads of the + same file never overwrite each other. The backend no longer mints keys + server-side. + +### Deprecated + +- The `upsert` flag on `upload`, `uploadAutoKey`, and `FileOptions` — uploads + always replace an existing object now, so the flag is a no-op (the + `x-upsert` header is no longer sent) and will be removed in a future + release. + ## 0.1.0 Initial release. diff --git a/README.md b/README.md index 3f1d8a3..02a8a9e 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ final total = await client.database.from('posts').count(); ```dart final stored = await client.storage .from('avatars') - .upload('users/me.png', bytes, upsert: true); + .upload('users/me.png', bytes); // replaces any existing object at this key final url = client.storage.from('avatars').getPublicUrl(stored.key); final files = await client.storage.from('avatars').list(prefix: 'users/'); final data = await client.storage.from('avatars').download('users/me.png'); diff --git a/integration_tests/pubspec.yaml b/integration_tests/pubspec.yaml index a0d79cc..5dadff5 100644 --- a/integration_tests/pubspec.yaml +++ b/integration_tests/pubspec.yaml @@ -9,7 +9,7 @@ environment: dependencies: dio: ^5.7.0 - insforge: ^0.1.0 + insforge: ^0.2.0 dev_dependencies: lints: ^4.0.0 diff --git a/integration_tests/test/storage_test.dart b/integration_tests/test/storage_test.dart index b22b6dc..0b94213 100644 --- a/integration_tests/test/storage_test.dart +++ b/integration_tests/test/storage_test.dart @@ -36,7 +36,6 @@ void main() { probeKey, Uint8List.fromList(utf8.encode('probe')), contentType: 'text/plain', - upsert: true, ); // Best-effort cleanup. try { @@ -79,45 +78,51 @@ void main() { path, bytes, contentType: 'text/plain', - upsert: true, ); expect(stored.key, isNotEmpty); + // Re-uploading to the same key replaces the object in place + // (standard PUT semantics — no server-side auto-rename). + final replacement = 'Replaced – ${DateTime.now().toIso8601String()}'; + final replaced = await storage.from(_bucket).upload( + path, + Uint8List.fromList(utf8.encode(replacement)), + contentType: 'text/plain', + ); + expect(replaced.key, stored.key); + // List (with prefix). final listed = await storage.from(_bucket).list(prefix: 'sdk-test/', limit: 50); expect(listed, isA>()); - // Download and verify bytes. + // Download and verify bytes — the replacement content wins. final downloaded = await storage.from(_bucket).download(path); - expect(utf8.decode(downloaded), content); + expect(utf8.decode(downloaded), replacement); // Delete. await storage.from(_bucket).delete(path); }); - test('uploadAutoKey returns a server-generated key', () async { + test('uploadAutoKey uploads under a unique client-generated key', + () async { if (!bucketAvailable) return; final content = 'Auto upload – ${DateTime.now().microsecondsSinceEpoch}'; final bytes = Uint8List.fromList(utf8.encode(content)); + final stored = await storage.from(_bucket).uploadAutoKey( + 'auto.txt', + bytes, + contentType: 'text/plain', + ); + // Client-generated key: sanitized base + timestamp + random suffix. + expect(stored.key, matches(RegExp(r'^auto-\d+-[a-z0-9]{6}\.txt$'))); + // Best-effort cleanup. try { - final stored = await storage.from(_bucket).uploadAutoKey( - 'auto.txt', - bytes, - contentType: 'text/plain', - ); - expect(stored.key, isNotEmpty); - // Best-effort cleanup. - try { - await storage.from(_bucket).delete(stored.key); - } catch (_) {} - } on InsforgeHttpException catch (e) { - // Some backends may not support auto-key generation. - expect(e.statusCode, greaterThanOrEqualTo(400)); - } + await storage.from(_bucket).delete(stored.key); + } catch (_) {} }); test('download of a non-existent object throws', () async { diff --git a/packages/insforge/CHANGELOG.md b/packages/insforge/CHANGELOG.md index bd05a0f..d2f7ed8 100644 --- a/packages/insforge/CHANGELOG.md +++ b/packages/insforge/CHANGELOG.md @@ -1,3 +1,18 @@ +## 0.2.0 + +Syncs with InsForge JS SDK v1.5.0 (storage: standard PUT create-or-replace +semantics). Requires an InsForge backend that includes the standard-PUT +storage change (InsForge/InsForge#1760). + +- **Changed:** storage `upload(path, bytes)` — uploading to an existing key + now replaces the object in place instead of the server auto-renaming the + key. Signature unchanged. +- **Changed:** storage `uploadAutoKey(filename, bytes)` — now generates a + unique, collision-free key client-side (sanitized base + timestamp + + random suffix) and uploads through the standard `upload` path. +- **Deprecated:** the no-op `upsert` flag on `upload`, `uploadAutoKey`, and + `FileOptions` (the `x-upsert` header is no longer sent). + ## 0.1.0 Initial release. diff --git a/packages/insforge/lib/src/core/version.dart b/packages/insforge/lib/src/core/version.dart index 4f54b84..1ae723f 100644 --- a/packages/insforge/lib/src/core/version.dart +++ b/packages/insforge/lib/src/core/version.dart @@ -5,7 +5,7 @@ // both packages in lockstep) with: dart run tool/set_version.dart /// The current InsForge Dart SDK version (mirrors `pubspec.yaml`). -const String insforgeSdkVersion = '0.1.0'; +const String insforgeSdkVersion = '0.2.0'; /// `User-Agent` sent on every request from this SDK: `InsForge-Dart/`. const String insforgeUserAgent = 'InsForge-Dart/$insforgeSdkVersion'; diff --git a/packages/insforge/lib/src/storage/models.dart b/packages/insforge/lib/src/storage/models.dart index b1a4305..e3e7113 100644 --- a/packages/insforge/lib/src/storage/models.dart +++ b/packages/insforge/lib/src/storage/models.dart @@ -195,16 +195,27 @@ class DownloadStrategy { } /// Options for an upload: an explicit [contentType] (otherwise inferred from -/// the filename extension), whether to [upsert] over an existing object, and -/// optional [metadata]. +/// the filename extension) and optional [metadata]. class FileOptions { const FileOptions({ this.contentType, + @Deprecated( + 'Uploads follow standard PUT semantics and always replace an existing ' + 'object; this flag is a no-op and will be removed in a future release.', + ) this.upsert = false, this.metadata, }); final String? contentType; + + /// No-op: uploads follow standard PUT semantics and always replace an + /// existing object. + @Deprecated( + 'Uploads follow standard PUT semantics and always replace an existing ' + 'object; this flag is a no-op and will be removed in a future release.', + ) final bool upsert; + final Map? metadata; } diff --git a/packages/insforge/lib/src/storage/storage_file_api.dart b/packages/insforge/lib/src/storage/storage_file_api.dart index e8aff5c..5a8398b 100644 --- a/packages/insforge/lib/src/storage/storage_file_api.dart +++ b/packages/insforge/lib/src/storage/storage_file_api.dart @@ -1,4 +1,5 @@ // packages/insforge_storage/lib/src/storage_file_api.dart +import 'dart:math'; import 'dart:typed_data'; import 'package:dio/dio.dart'; @@ -23,6 +24,35 @@ class StorageFileApi { return i >= 0 ? path.substring(i + 1) : path; } + static const String _keyAlphabet = '0123456789abcdefghijklmnopqrstuvwxyz'; + static final Random _keyRandom = Random(); + + /// Generates a unique object key from [filename]: + /// `--`. + /// + /// Auto-key generation is a client-side convenience — the storage API has + /// no server-side key minting — so [uploadAutoKey] produces the key here + /// and then uploads through the standard [upload] path. + static String _generateObjectKey(String filename) { + final dotIndex = filename.lastIndexOf('.'); + final hasExt = dotIndex > 0; + final ext = hasExt ? filename.substring(dotIndex) : ''; + final base = hasExt ? filename.substring(0, dotIndex) : filename; + var sanitizedBase = base.replaceAll(RegExp('[^a-zA-Z0-9_-]'), '-'); + if (sanitizedBase.length > 32) { + sanitizedBase = sanitizedBase.substring(0, 32); + } + if (sanitizedBase.isEmpty) { + sanitizedBase = 'file'; + } + final timestamp = DateTime.now().millisecondsSinceEpoch; + final random = List.generate( + 6, + (_) => _keyAlphabet[_keyRandom.nextInt(_keyAlphabet.length)], + ).join(); + return '$sanitizedBase-$timestamp-$random$ext'; + } + /// Builds a single-part `FormData` whose `file` field carries [bytes] with /// the given [filename] and [contentType]. static FormData _fileFormData( @@ -47,12 +77,17 @@ class StorageFileApi { /// Uploads [bytes] to [path] (a specific key) via a multipart PUT. /// - /// [contentType] is inferred from the extension when omitted. Set [upsert] - /// to overwrite an existing object (`x-upsert: true`). + /// Standard PUT semantics: uploading to a key that already exists replaces + /// the object in place. [contentType] is inferred from the extension when + /// omitted. Future upload( String path, Uint8List bytes, { String? contentType, + @Deprecated( + 'Uploads follow standard PUT semantics and always replace an existing ' + 'object; this flag is a no-op and will be removed in a future release.', + ) bool upsert = false, Map? metadata, }) async { @@ -62,30 +97,36 @@ class StorageFileApi { 'PUT', '$_bucketPath/objects/$path', data: form, - headers: {if (upsert) 'x-upsert': 'true'}, ); return StoredFile.fromJson(Map.from(response.data as Map)); } - /// Uploads [bytes] with a server-generated key via a multipart POST. + /// Uploads [bytes] under an automatically generated, collision-free key. + /// + /// The key is derived client-side from [filename] (sanitized base + + /// timestamp + random suffix) — the storage API has no server-side key + /// minting — and uploaded through the standard [upload] path, so repeated + /// uploads of the same file never overwrite each other. /// /// [filename] is used for content-type inference and key generation. Future uploadAutoKey( String filename, Uint8List bytes, { String? contentType, + @Deprecated( + 'Auto-generated keys are unique, so there is never an existing object ' + 'to replace; this flag is a no-op and will be removed in a future ' + 'release.', + ) bool upsert = false, Map? metadata, - }) async { - final resolved = contentType ?? contentTypeForFilename(filename); - final form = _fileFormData(bytes, _lastSegment(filename), resolved); - final response = await _http.request( - 'POST', - '$_bucketPath/objects', - data: form, - headers: {if (upsert) 'x-upsert': 'true'}, + }) { + return upload( + _generateObjectKey(filename), + bytes, + contentType: contentType ?? contentTypeForFilename(filename), + metadata: metadata, ); - return StoredFile.fromJson(Map.from(response.data as Map)); } // ----- download / list / delete / public url ----- @@ -191,6 +232,9 @@ class StorageFileApi { } /// Confirms a presigned upload of [objectKey], returning the [StoredFile]. + /// + /// Confirm create-or-replaces the metadata row, matching the standard PUT + /// semantics of [upload]. Future confirmUpload( String objectKey, { required int size, diff --git a/packages/insforge/pubspec.yaml b/packages/insforge/pubspec.yaml index 0f198fa..7ac8d8b 100644 --- a/packages/insforge/pubspec.yaml +++ b/packages/insforge/pubspec.yaml @@ -1,7 +1,7 @@ # packages/insforge/pubspec.yaml name: insforge description: Pure-Dart SDK for InsForge — auth, database, storage, functions, and AI. -version: 0.1.0 +version: 0.2.0 repository: https://github.com/InsForge/insforge-flutter homepage: https://insforge.dev resolution: workspace diff --git a/packages/insforge/test/storage/file_upload_test.dart b/packages/insforge/test/storage/file_upload_test.dart index ec4b41a..35ef968 100644 --- a/packages/insforge/test/storage/file_upload_test.dart +++ b/packages/insforge/test/storage/file_upload_test.dart @@ -50,20 +50,22 @@ void main() { expect(result.bucket, 'avatars'); }); - test('upload sets x-upsert:true when upsert is requested', () async { + test('upload treats the deprecated upsert flag as a no-op (no x-upsert)', + () async { final adapter = _storedFileAdapter(); final api = StorageClient(_client(adapter)).from('avatars'); await api.upload( 'users/me.png', Uint8List.fromList([1, 2, 3]), + // ignore: deprecated_member_use_from_same_package upsert: true, ); - expect(adapter.single.headers['x-upsert'], 'true'); + expect(adapter.single.headers.containsKey('x-upsert'), isFalse); }); - test('uploadAutoKey POSTs multipart to /objects (no key in path)', () async { + test('uploadAutoKey mints a unique key client-side and PUTs to it', () async { final adapter = _storedFileAdapter(); final api = StorageClient(_client(adapter)).from('avatars'); @@ -73,12 +75,81 @@ void main() { ); final req = adapter.single; - expect(req.method, 'POST'); - expect(req.path, '/api/storage/buckets/avatars/objects'); + expect(req.method, 'PUT'); + // Client-generated key: sanitized base + timestamp + random, keeping the + // extension — the backend no longer mints keys. + expect( + req.path, + matches( + RegExp(r'^/api/storage/buckets/avatars/objects/' + r'photo-\d+-[a-z0-9]{6}\.png$'), + ), + ); expect(req.isFormData, isTrue); expect(req.hasFileField, isTrue); }); + test('uploadAutoKey sanitizes the filename base and keeps the extension', + () async { + final adapter = _storedFileAdapter(); + final api = StorageClient(_client(adapter)).from('avatars'); + + await api.uploadAutoKey( + 'my photo (1).png', + Uint8List.fromList([9]), + ); + + expect( + adapter.single.path, + matches( + RegExp(r'^/api/storage/buckets/avatars/objects/' + r'my-photo--1--\d+-[a-z0-9]{6}\.png$'), + ), + ); + }); + + test('uploadAutoKey handles filenames without an extension', () async { + final adapter = _storedFileAdapter(); + final api = StorageClient(_client(adapter)).from('avatars'); + + await api.uploadAutoKey('README', Uint8List.fromList([9])); + + expect( + adapter.single.path, + matches( + RegExp(r'^/api/storage/buckets/avatars/objects/' + r'README-\d+-[a-z0-9]{6}$'), + ), + ); + }); + + test('uploadAutoKey falls back to a "file" base for an empty filename', + () async { + final adapter = _storedFileAdapter(); + final api = StorageClient(_client(adapter)).from('avatars'); + + await api.uploadAutoKey('', Uint8List.fromList([9])); + + expect( + adapter.single.path, + matches( + RegExp(r'^/api/storage/buckets/avatars/objects/' + r'file-\d+-[a-z0-9]{6}$'), + ), + ); + }); + + test('uploadAutoKey generates distinct keys for the same filename', () async { + final adapter = _storedFileAdapter(); + final api = StorageClient(_client(adapter)).from('avatars'); + + await api.uploadAutoKey('photo.png', Uint8List.fromList([1])); + await api.uploadAutoKey('photo.png', Uint8List.fromList([2])); + + expect(adapter.requests, hasLength(2)); + expect(adapter.requests[0].path, isNot(adapter.requests[1].path)); + }); + test('from() returns the same cached StorageFileApi per bucket', () { final adapter = _storedFileAdapter(); final storage = StorageClient(_client(adapter)); diff --git a/packages/insforge/test/storage/models_test.dart b/packages/insforge/test/storage/models_test.dart index 98f5ae5..430e4ca 100644 --- a/packages/insforge/test/storage/models_test.dart +++ b/packages/insforge/test/storage/models_test.dart @@ -127,6 +127,7 @@ void main() { test('defaults upsert to false', () { const o = FileOptions(); expect(o.contentType, isNull); + // ignore: deprecated_member_use_from_same_package expect(o.upsert, isFalse); expect(o.metadata, isNull); }); diff --git a/packages/insforge_flutter/CHANGELOG.md b/packages/insforge_flutter/CHANGELOG.md index bd05a0f..46c93bc 100644 --- a/packages/insforge_flutter/CHANGELOG.md +++ b/packages/insforge_flutter/CHANGELOG.md @@ -1,3 +1,9 @@ +## 0.2.0 + +Lockstep release with `insforge` 0.2.0 (storage: standard PUT +create-or-replace semantics; see the `insforge` changelog). No +Flutter-layer changes. + ## 0.1.0 Initial release. diff --git a/packages/insforge_flutter/pubspec.yaml b/packages/insforge_flutter/pubspec.yaml index f851337..738f25c 100644 --- a/packages/insforge_flutter/pubspec.yaml +++ b/packages/insforge_flutter/pubspec.yaml @@ -1,7 +1,7 @@ # packages/insforge_flutter/pubspec.yaml name: insforge_flutter description: Flutter integration for the InsForge SDK — secure session storage and a one-line initializer. -version: 0.1.0 +version: 0.2.0 repository: https://github.com/InsForge/insforge-flutter homepage: https://insforge.dev resolution: workspace @@ -14,7 +14,7 @@ dependencies: flutter: sdk: flutter flutter_secure_storage: ^9.2.2 - insforge: ^0.1.0 + insforge: ^0.2.0 dev_dependencies: flutter_test: diff --git a/samples/twitter_app/pubspec.yaml b/samples/twitter_app/pubspec.yaml index 7f9d558..57ec11e 100644 --- a/samples/twitter_app/pubspec.yaml +++ b/samples/twitter_app/pubspec.yaml @@ -12,7 +12,7 @@ environment: dependencies: flutter: sdk: flutter - insforge_flutter: ^0.1.0 + insforge_flutter: ^0.2.0 flutter_riverpod: ^2.5.1 url_launcher: ^6.3.0 app_links: ^6.3.0