@_nazmiforreal/flutter-ota 0.1.11 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. package/README.md +1 -1
  2. package/bin/flutter-ota-linux-x64 +0 -0
  3. package/dart-src/packages/cli-tools/.dart_tool/package_config.json +46 -76
  4. package/dart-src/packages/cli-tools/.dart_tool/package_graph.json +140 -166
  5. package/dart-src/packages/cli-tools/README.md +1 -1
  6. package/dart-src/packages/cli-tools/lib/flutter_ota_kit_cli.dart +30 -17
  7. package/dart-src/packages/cli-tools/lib/src/backend.dart +30 -31
  8. package/dart-src/packages/cli-tools/lib/src/cli_base.dart +3 -1
  9. package/dart-src/packages/cli-tools/lib/src/commands/build.dart +44 -43
  10. package/dart-src/packages/cli-tools/lib/src/commands/bundle.dart +265 -239
  11. package/dart-src/packages/cli-tools/lib/src/commands/channel.dart +75 -66
  12. package/dart-src/packages/cli-tools/lib/src/commands/config_command.dart +39 -35
  13. package/dart-src/packages/cli-tools/lib/src/commands/console.dart +30 -27
  14. package/dart-src/packages/cli-tools/lib/src/commands/deploy.dart +88 -72
  15. package/dart-src/packages/cli-tools/lib/src/commands/doctor.dart +66 -29
  16. package/dart-src/packages/cli-tools/lib/src/commands/fingerprint.dart +10 -6
  17. package/dart-src/packages/cli-tools/lib/src/commands/init.dart +299 -190
  18. package/dart-src/packages/cli-tools/lib/src/commands/keys.dart +24 -23
  19. package/dart-src/packages/cli-tools/lib/src/commands/migrate.dart +111 -97
  20. package/dart-src/packages/cli-tools/lib/src/commands/pocketbase.dart +237 -0
  21. package/dart-src/packages/cli-tools/lib/src/commands/rollback.dart +43 -34
  22. package/dart-src/packages/cli-tools/lib/src/commands/storage.dart +57 -53
  23. package/dart-src/packages/cli-tools/lib/src/config.dart +227 -93
  24. package/dart-src/packages/cli-tools/lib/src/operations.dart +9 -8
  25. package/dart-src/packages/cli-tools/lib/src/pack.dart +26 -25
  26. package/dart-src/packages/cli-tools/lib/src/pocketbase/data_bootstrap.dart +39 -0
  27. package/dart-src/packages/cli-tools/lib/src/pocketbase/hooks/bundles.pb.js +56 -0
  28. package/dart-src/packages/cli-tools/lib/src/pocketbase/installer.dart +189 -0
  29. package/dart-src/packages/cli-tools/lib/src/pocketbase/process_manager.dart +123 -0
  30. package/dart-src/packages/cli-tools/lib/src/pocketbase/schema_installer.dart +237 -0
  31. package/dart-src/packages/cli-tools/lib/src/runner.dart +36 -15
  32. package/dart-src/packages/cli-tools/lib/src/sign.dart +13 -5
  33. package/dart-src/packages/cli-tools/lib/src/ui/ui.dart +10 -13
  34. package/dart-src/packages/cli-tools/lib/src/util.dart +3 -1
  35. package/dart-src/packages/cli-tools/pubspec.lock +69 -116
  36. package/dart-src/packages/cli-tools/pubspec.yaml +25 -7
  37. package/dart-src/packages/cli-tools/test/backends_test.dart +62 -32
  38. package/dart-src/packages/cli-tools/test/build_pack_test.dart +4 -4
  39. package/dart-src/packages/cli-tools/test/cli_test.dart +89 -42
  40. package/dart-src/packages/cli-tools/test/mocks/mock_aws_s3_client.dart +8 -13
  41. package/dart-src/packages/cli-tools/test/mocks/mock_d1_client.dart +23 -18
  42. package/dart-src/packages/cli-tools/test/mocks/mock_pocketbase_client.dart +197 -0
  43. package/dart-src/packages/cli-tools/test/mocks/mock_postgres_client.dart +70 -46
  44. package/dart-src/packages/cli-tools/test/pocketbase_installer_test.dart +72 -0
  45. package/dart-src/packages/cli-tools/test/pocketbase_schema_test.dart +107 -0
  46. package/dart-src/packages/cli-tools/test/pocketbase_smoke_test.dart +59 -0
  47. package/dart-src/packages/core/README.md +2 -2
  48. package/dart-src/packages/core/example/lib/main.dart +69 -0
  49. package/dart-src/packages/core/lib/flutter_ota_kit_core.dart +1 -1
  50. package/dart-src/packages/core/lib/src/app_update_info.dart +17 -15
  51. package/dart-src/packages/core/lib/src/bundle.dart +102 -94
  52. package/dart-src/packages/core/lib/src/bundle_artifacts.dart +1 -2
  53. package/dart-src/packages/core/lib/src/bundle_patch_artifact.dart +15 -16
  54. package/dart-src/packages/core/lib/src/changed_asset.dart +24 -22
  55. package/dart-src/packages/core/lib/src/get_bundles_args.dart +13 -13
  56. package/dart-src/packages/core/lib/src/metadata.dart +9 -7
  57. package/dart-src/packages/core/lib/src/patch_info.dart +8 -7
  58. package/dart-src/packages/core/lib/src/platform.dart +4 -3
  59. package/dart-src/packages/core/lib/src/rollout.dart +14 -11
  60. package/dart-src/packages/core/lib/src/semver_range.dart +49 -39
  61. package/dart-src/packages/core/lib/src/semver_version.dart +8 -7
  62. package/dart-src/packages/core/lib/src/status.dart +4 -3
  63. package/dart-src/packages/core/lib/src/update_bundle_params.dart +7 -7
  64. package/dart-src/packages/core/lib/src/uuid.dart +1 -2
  65. package/dart-src/packages/core/pubspec.yaml +6 -7
  66. package/dart-src/packages/core/test/rollout_test.dart +15 -8
  67. package/dart-src/packages/core/test/semver_test.dart +5 -2
  68. package/dart-src/packages/core/test/types_test.dart +26 -24
  69. package/dart-src/packages/core/test/uuid_test.dart +3 -1
  70. package/dart-src/packages/core/tool/dbg.dart +1 -0
  71. package/dart-src/plugins/aws/CHANGELOG.md +6 -0
  72. package/dart-src/plugins/aws/example/lib/main.dart +60 -0
  73. package/dart-src/plugins/aws/lib/flutter_ota_kit_aws.dart +5 -1
  74. package/dart-src/plugins/aws/lib/src/aws_cloudfront_client.dart +11 -16
  75. package/dart-src/plugins/aws/lib/src/aws_cloudfront_signer.dart +7 -2
  76. package/dart-src/plugins/aws/lib/src/aws_database.dart +14 -18
  77. package/dart-src/plugins/aws/lib/src/aws_s3_client.dart +23 -18
  78. package/dart-src/plugins/aws/lib/src/aws_ssm_client.dart +5 -4
  79. package/dart-src/plugins/aws/lib/src/aws_storage.dart +2 -3
  80. package/dart-src/plugins/aws/lib/src/aws_storage_profile.dart +10 -16
  81. package/dart-src/plugins/aws/lib/src/with_cloudfront_signed_url.dart +6 -9
  82. package/dart-src/plugins/aws/pubspec.yaml +15 -10
  83. package/dart-src/plugins/aws/test/aws_database_test.dart +51 -57
  84. package/dart-src/plugins/aws/test/mock_aws_s3_client.dart +8 -13
  85. package/dart-src/plugins/cloudflare/CHANGELOG.md +7 -0
  86. package/dart-src/plugins/cloudflare/example/lib/main.dart +62 -0
  87. package/dart-src/plugins/cloudflare/lib/flutter_ota_kit_cloudflare.dart +4 -2
  88. package/dart-src/plugins/cloudflare/lib/src/cloudflare_worker_database.dart +3 -3
  89. package/dart-src/plugins/cloudflare/lib/src/d1_build_where.dart +3 -2
  90. package/dart-src/plugins/cloudflare/lib/src/d1_bundle_mapper.dart +24 -27
  91. package/dart-src/plugins/cloudflare/lib/src/d1_database_plugin.dart +22 -30
  92. package/dart-src/plugins/cloudflare/lib/src/r2_config.dart +1 -1
  93. package/dart-src/plugins/cloudflare/lib/src/r2_s3_client.dart +21 -29
  94. package/dart-src/plugins/cloudflare/lib/src/r2_storage.dart +7 -7
  95. package/dart-src/plugins/cloudflare/lib/src/r2_storage_profile.dart +7 -15
  96. package/dart-src/plugins/cloudflare/pubspec.yaml +14 -9
  97. package/dart-src/plugins/cloudflare/test/cloudflare_worker_database_test.dart +46 -42
  98. package/dart-src/plugins/cloudflare/test/d1_database_test.dart +100 -90
  99. package/dart-src/plugins/cloudflare/test/mock_d1_client.dart +23 -18
  100. package/dart-src/plugins/cloudflare/test/mock_r2_client.dart +8 -13
  101. package/dart-src/plugins/cloudflare/test/r2_storage_test.dart +1 -4
  102. package/dart-src/plugins/plugin-core/CHANGELOG.md +16 -0
  103. package/dart-src/plugins/plugin-core/example/lib/main.dart +61 -0
  104. package/dart-src/plugins/plugin-core/lib/flutter_ota_kit_plugin_core.dart +3 -7
  105. package/dart-src/plugins/plugin-core/lib/src/asset_storage_layout.dart +5 -5
  106. package/dart-src/plugins/plugin-core/lib/src/bundle_helpers.dart +23 -0
  107. package/dart-src/plugins/plugin-core/lib/src/bundle_storage_layout.dart +16 -10
  108. package/dart-src/plugins/plugin-core/lib/src/bundle_unit_of_work.dart +31 -26
  109. package/dart-src/plugins/plugin-core/lib/src/calculate_pagination.dart +5 -1
  110. package/dart-src/plugins/plugin-core/lib/src/content_addressed_assets.dart +1 -1
  111. package/dart-src/plugins/plugin-core/lib/src/create_blob_database_plugin.dart +78 -71
  112. package/dart-src/plugins/plugin-core/lib/src/create_database_plugin.dart +56 -35
  113. package/dart-src/plugins/plugin-core/lib/src/create_storage_plugin.dart +10 -13
  114. package/dart-src/plugins/plugin-core/lib/src/paginate_bundles.dart +30 -17
  115. package/dart-src/plugins/plugin-core/lib/src/parse_storage_uri.dart +3 -1
  116. package/dart-src/plugins/plugin-core/lib/src/query_bundles.dart +5 -3
  117. package/dart-src/plugins/plugin-core/lib/src/request_update_bundle_state.dart +2 -4
  118. package/dart-src/plugins/plugin-core/lib/src/storage_helpers.dart +18 -0
  119. package/dart-src/plugins/plugin-core/lib/src/types.dart +7 -6
  120. package/dart-src/plugins/plugin-core/pubspec.yaml +12 -9
  121. package/dart-src/plugins/plugin-core/test/asset_storage_layout_test.dart +13 -9
  122. package/dart-src/plugins/plugin-core/test/bundle_unit_of_work_test.dart +28 -15
  123. package/dart-src/plugins/plugin-core/test/create_blob_database_plugin_test.dart +254 -122
  124. package/dart-src/plugins/plugin-core/test/create_database_plugin_test.dart +36 -32
  125. package/dart-src/plugins/plugin-core/test/create_storage_plugin_test.dart +18 -15
  126. package/dart-src/plugins/plugin-core/test/generate_min_bundle_id_test.dart +8 -3
  127. package/dart-src/plugins/plugin-core/test/get_update_info_test.dart +682 -693
  128. package/dart-src/plugins/plugin-core/test/helpers_test.dart +45 -21
  129. package/dart-src/plugins/plugin-core/test/query_bundles_test.dart +76 -13
  130. package/dart-src/plugins/plugin-core/test/uuidv7_test.dart +20 -9
  131. package/dart-src/plugins/pocketbase/CHANGELOG.md +8 -0
  132. package/dart-src/plugins/pocketbase/LICENSE +21 -0
  133. package/dart-src/plugins/pocketbase/README.md +54 -0
  134. package/dart-src/plugins/pocketbase/example/lib/main.dart +62 -0
  135. package/dart-src/plugins/pocketbase/lib/flutter_ota_kit_pocketbase.dart +28 -0
  136. package/dart-src/plugins/pocketbase/lib/src/pocketbase_bundle_mapper.dart +121 -0
  137. package/dart-src/plugins/pocketbase/lib/src/pocketbase_client.dart +349 -0
  138. package/dart-src/plugins/pocketbase/lib/src/pocketbase_config.dart +61 -0
  139. package/dart-src/plugins/pocketbase/lib/src/pocketbase_database.dart +278 -0
  140. package/dart-src/plugins/pocketbase/lib/src/pocketbase_storage.dart +255 -0
  141. package/dart-src/plugins/pocketbase/pubspec.yaml +26 -0
  142. package/dart-src/plugins/pocketbase/test/pb_uri_test.dart +12 -0
  143. package/dart-src/plugins/postgres/CHANGELOG.md +6 -0
  144. package/dart-src/plugins/postgres/example/lib/main.dart +67 -0
  145. package/dart-src/plugins/postgres/lib/flutter_ota_kit_postgres.dart +3 -6
  146. package/dart-src/plugins/postgres/lib/src/postgres_bundle_mapper.dart +32 -52
  147. package/dart-src/plugins/postgres/lib/src/postgres_config.dart +3 -1
  148. package/dart-src/plugins/postgres/lib/src/postgres_database.dart +28 -38
  149. package/dart-src/plugins/postgres/lib/src/postgres_get_update_info.dart +16 -14
  150. package/dart-src/plugins/postgres/lib/src/postgres_storage.dart +26 -14
  151. package/dart-src/plugins/postgres/lib/src/postgres_types.dart +27 -28
  152. package/dart-src/plugins/postgres/pubspec.yaml +14 -9
  153. package/dart-src/plugins/postgres/test/mock_postgres_client.dart +62 -39
  154. package/dart-src/plugins/postgres/test/postgres_bundle_mapper_test.dart +3 -4
  155. package/dart-src/plugins/postgres/test/postgres_database_test.dart +102 -73
  156. package/dart-src/plugins/postgres/test/postgres_storage_runtime_test.dart +60 -0
  157. package/dart-src/plugins/supabase/CHANGELOG.md +17 -0
  158. package/dart-src/plugins/supabase/README.md +2 -2
  159. package/dart-src/plugins/supabase/example/lib/main.dart +63 -0
  160. package/dart-src/plugins/supabase/lib/flutter_ota_kit_supabase.dart +2 -0
  161. package/dart-src/plugins/supabase/lib/src/error_message.dart +11 -0
  162. package/dart-src/plugins/supabase/lib/src/supabase_bundle_mapper.dart +34 -58
  163. package/dart-src/plugins/supabase/lib/src/supabase_client_adapter.dart +3 -13
  164. package/dart-src/plugins/supabase/lib/src/supabase_client_http.dart +34 -52
  165. package/dart-src/plugins/supabase/lib/src/supabase_database.dart +38 -30
  166. package/dart-src/plugins/supabase/lib/src/supabase_edge_function_storage.dart +21 -29
  167. package/dart-src/plugins/supabase/lib/src/supabase_signed_url_batcher.dart +7 -19
  168. package/dart-src/plugins/supabase/lib/src/supabase_storage.dart +18 -29
  169. package/dart-src/plugins/supabase/lib/src/types.dart +26 -26
  170. package/dart-src/plugins/supabase/pubspec.yaml +14 -9
  171. package/dart-src/plugins/supabase/test/mock_supabase_client.dart +57 -57
  172. package/dart-src/plugins/supabase/test/supabase_bundle_mapper_test.dart +30 -27
  173. package/dart-src/plugins/supabase/test/supabase_database_test.dart +26 -35
  174. package/dart-src/plugins/supabase/test/supabase_storage_test.dart +27 -34
  175. package/package.json +2 -2
  176. package/scripts/vendor.sh +1 -1
@@ -0,0 +1,349 @@
1
+ /// Minimal PocketBase HTTP client (REST API).
2
+ ///
3
+ /// PocketBase is a single-binary Go backend that exposes a REST API at
4
+ /// `/api/`. The official `pocketbase` Dart package exists but pulls in
5
+ /// `dart:io`/`dart:html` polyfills that conflict with Flutter Web, so we
6
+ /// implement just the subset we need on top of `package:http`.
7
+ ///
8
+ /// See: https://pocketbase.io/docs/api-records/
9
+ library;
10
+
11
+ import 'dart:async';
12
+ import 'dart:convert';
13
+
14
+ import 'package:http/http.dart' as http;
15
+
16
+ /// Factory for creating [PocketBaseClient] instances. Allows tests to inject
17
+ /// a fake client without spinning up a real PB server.
18
+ typedef PocketBaseClientFactory = PocketBaseClient Function(
19
+ String baseUrl,
20
+ String adminEmail,
21
+ String adminPassword,
22
+ );
23
+
24
+ /// A thin REST client for PocketBase admin operations.
25
+ ///
26
+ /// Authenticates with the admin collection to obtain a long-lived auth token,
27
+ /// then exposes typed helpers for CRUD on collections and file storage.
28
+ class PocketBaseClient {
29
+ PocketBaseClient._(this._baseUrl, this._token, this._http);
30
+
31
+ /// Build a client (unauthenticated). Call [adminCredentials] and
32
+ /// [authenticate] before use, or rely on lazy auth.
33
+ factory PocketBaseClient(String baseUrl, {http.Client? httpClient}) =>
34
+ PocketBaseClient._(
35
+ _normalizeUrl(baseUrl),
36
+ '',
37
+ httpClient ?? http.Client(),
38
+ );
39
+
40
+ final String _baseUrl;
41
+ String _token;
42
+ final http.Client _http;
43
+
44
+ static String _normalizeUrl(String url) {
45
+ var u = url.trim();
46
+ if (u.endsWith('/')) u = u.substring(0, u.length - 1);
47
+ return u;
48
+ }
49
+
50
+ /// Provide admin credentials for lazy authentication.
51
+ void adminCredentials(String email, String password) {
52
+ _adminEmail = email;
53
+ _adminPassword = password;
54
+ }
55
+
56
+ /// Authenticate as [adminEmail] / [adminPassword]. Idempotent — calling
57
+ /// twice refreshes the token.
58
+ Future<PocketBaseClient> authenticate(
59
+ String adminEmail,
60
+ String adminPassword,
61
+ ) async {
62
+ final res = await _http.post(
63
+ Uri.parse('$_baseUrl/api/admins/auth-with-password'),
64
+ headers: {'content-type': 'application/json'},
65
+ body: jsonEncode({'identity': adminEmail, 'password': adminPassword}),
66
+ );
67
+ if (res.statusCode != 200) {
68
+ throw PocketBaseException(
69
+ 'PocketBase admin auth failed: HTTP ${res.statusCode} ${res.body}',
70
+ );
71
+ }
72
+ final body = jsonDecode(res.body) as Map<String, dynamic>;
73
+ _token = body['token'] as String? ?? '';
74
+ if (_token.isEmpty) {
75
+ throw PocketBaseException(
76
+ 'PocketBase admin auth returned no token. Is the admins collection '
77
+ 'set up? Run `flutter_ota_kit serve` once to install the schema.',
78
+ );
79
+ }
80
+ return this;
81
+ }
82
+
83
+ String? _adminEmail;
84
+ String? _adminPassword;
85
+ Future<void>? _authInFlight;
86
+
87
+ Map<String, String> get _authHeaders => {
88
+ 'content-type': 'application/json',
89
+ 'authorization': _token,
90
+ };
91
+
92
+ Future<void> _ensureAuth() {
93
+ if (_token.isNotEmpty) return Future.value();
94
+ if (_adminEmail == null || _adminPassword == null) {
95
+ throw PocketBaseException(
96
+ 'PocketBase client has no credentials. Call adminCredentials() first.',
97
+ );
98
+ }
99
+ _authInFlight ??= authenticate(_adminEmail!, _adminPassword!);
100
+ return _authInFlight!;
101
+ }
102
+
103
+ Future<http.Response> _get(Uri uri) async {
104
+ await _ensureAuth();
105
+ return _http.get(uri, headers: _authHeaders);
106
+ }
107
+
108
+ Future<http.Response> _post(Uri uri, {Object? body}) async {
109
+ await _ensureAuth();
110
+ return _http.post(
111
+ uri,
112
+ headers: _authHeaders,
113
+ body: body == null ? null : jsonEncode(body),
114
+ );
115
+ }
116
+
117
+ Future<http.Response> _patch(Uri uri, {Object? body}) async {
118
+ await _ensureAuth();
119
+ return _http.patch(
120
+ uri,
121
+ headers: _authHeaders,
122
+ body: body == null ? null : jsonEncode(body),
123
+ );
124
+ }
125
+
126
+ Future<http.Response> _delete(Uri uri) async {
127
+ await _ensureAuth();
128
+ return _http.delete(uri, headers: _authHeaders);
129
+ }
130
+
131
+ // ----- Records -----
132
+
133
+ /// List records from a collection with optional filter, sort, and page.
134
+ Future<PocketBaseList<T>> listRecords<T>(
135
+ String collection,
136
+ T Function(Map<String, dynamic>) fromJson, {
137
+ String? filter,
138
+ String? sort,
139
+ int page = 1,
140
+ int perPage = 50,
141
+ }) async {
142
+ final qp = <String, String>{
143
+ 'page': page.toString(),
144
+ 'perPage': perPage.toString(),
145
+ };
146
+ if (filter != null && filter.isNotEmpty) qp['filter'] = filter;
147
+ if (sort != null && sort.isNotEmpty) qp['sort'] = sort;
148
+ final uri = Uri.parse('$_baseUrl/api/collections/$collection/records')
149
+ .replace(queryParameters: qp);
150
+ final res = await _get(uri);
151
+ if (res.statusCode != 200) {
152
+ throw PocketBaseException(
153
+ 'listRecords($collection) failed: HTTP ${res.statusCode} ${res.body}',
154
+ );
155
+ }
156
+ final body = jsonDecode(res.body) as Map<String, dynamic>;
157
+ final items = (body['items'] as List? ?? [])
158
+ .cast<Map<String, dynamic>>()
159
+ .map(fromJson)
160
+ .toList();
161
+ return PocketBaseList<T>(
162
+ items: items,
163
+ page: (body['page'] as num?)?.toInt() ?? page,
164
+ perPage: (body['perPage'] as num?)?.toInt() ?? perPage,
165
+ totalItems: (body['totalItems'] as num?)?.toInt() ?? items.length,
166
+ totalPages: (body['totalPages'] as num?)?.toInt() ?? 1,
167
+ );
168
+ }
169
+
170
+ /// Get a single record by ID.
171
+ Future<T?> getRecord<T>(
172
+ String collection,
173
+ String id,
174
+ T Function(Map<String, dynamic>) fromJson,
175
+ ) async {
176
+ final res = await _get(
177
+ Uri.parse('$_baseUrl/api/collections/$collection/records/$id'),
178
+ );
179
+ if (res.statusCode == 404) return null;
180
+ if (res.statusCode != 200) {
181
+ throw PocketBaseException(
182
+ 'getRecord($collection/$id) failed: HTTP ${res.statusCode} ${res.body}',
183
+ );
184
+ }
185
+ return fromJson(jsonDecode(res.body) as Map<String, dynamic>);
186
+ }
187
+
188
+ /// Create a new record.
189
+ Future<T> createRecord<T>(
190
+ String collection,
191
+ Map<String, dynamic> body,
192
+ T Function(Map<String, dynamic>) fromJson,
193
+ ) async {
194
+ final res = await _post(
195
+ Uri.parse('$_baseUrl/api/collections/$collection/records'),
196
+ body: body,
197
+ );
198
+ if (res.statusCode != 200 && res.statusCode != 201) {
199
+ throw PocketBaseException(
200
+ 'createRecord($collection) failed: HTTP ${res.statusCode} ${res.body}',
201
+ );
202
+ }
203
+ return fromJson(jsonDecode(res.body) as Map<String, dynamic>);
204
+ }
205
+
206
+ /// Update an existing record.
207
+ Future<T> updateRecord<T>(
208
+ String collection,
209
+ String id,
210
+ Map<String, dynamic> body,
211
+ T Function(Map<String, dynamic>) fromJson,
212
+ ) async {
213
+ final res = await _patch(
214
+ Uri.parse('$_baseUrl/api/collections/$collection/records/$id'),
215
+ body: body,
216
+ );
217
+ if (res.statusCode != 200) {
218
+ throw PocketBaseException(
219
+ 'updateRecord($collection/$id) failed: HTTP ${res.statusCode} ${res.body}',
220
+ );
221
+ }
222
+ return fromJson(jsonDecode(res.body) as Map<String, dynamic>);
223
+ }
224
+
225
+ /// Delete a record.
226
+ Future<void> deleteRecord(String collection, String id) async {
227
+ final res = await _delete(
228
+ Uri.parse('$_baseUrl/api/collections/$collection/records/$id'),
229
+ );
230
+ if (res.statusCode != 204) {
231
+ throw PocketBaseException(
232
+ 'deleteRecord($collection/$id) failed: HTTP ${res.statusCode} ${res.body}',
233
+ );
234
+ }
235
+ }
236
+
237
+ // ----- Files -----
238
+
239
+ /// Upload a file to a record's file field (multipart/form-data).
240
+ Future<T> uploadFile<T>(
241
+ String collection,
242
+ String recordId,
243
+ String fieldName,
244
+ String filename,
245
+ List<int> bytes, {
246
+ Map<String, String>? extraFields,
247
+ required T Function(Map<String, dynamic>) fromJson,
248
+ }) async {
249
+ await _ensureAuth();
250
+ final req = http.MultipartRequest(
251
+ 'PATCH',
252
+ Uri.parse(
253
+ '$_baseUrl/api/collections/$collection/records/$recordId/$fieldName',
254
+ ),
255
+ );
256
+ req.headers['authorization'] = _token;
257
+ if (extraFields != null) req.fields.addAll(extraFields);
258
+ req.files.add(
259
+ http.MultipartFile.fromBytes(fieldName, bytes, filename: filename),
260
+ );
261
+ final streamed = await req.send();
262
+ final res = await http.Response.fromStream(streamed);
263
+ if (res.statusCode != 200) {
264
+ throw PocketBaseException(
265
+ 'uploadFile($collection/$recordId) failed: HTTP ${res.statusCode} ${res.body}',
266
+ );
267
+ }
268
+ return fromJson(jsonDecode(res.body) as Map<String, dynamic>);
269
+ }
270
+
271
+ /// Get a download token for a record's file field.
272
+ Future<String> getFileToken(String recordId, String filename) async {
273
+ final res = await _post(Uri.parse('$_baseUrl/api/files/token'));
274
+ if (res.statusCode != 200) {
275
+ throw PocketBaseException(
276
+ 'getFileToken failed: HTTP ${res.statusCode} ${res.body}',
277
+ );
278
+ }
279
+ final body = jsonDecode(res.body) as Map<String, dynamic>;
280
+ return body['token'] as String? ?? '';
281
+ }
282
+
283
+ /// Build a public download URL for a record's file field.
284
+ String fileUrl(
285
+ String collection,
286
+ String recordId,
287
+ String filename, [
288
+ String? token,
289
+ ]) {
290
+ final t = token == null ? '' : '?token=$token';
291
+ return '$_baseUrl/api/files/$collection/$recordId/$filename$t';
292
+ }
293
+
294
+ /// Download a file by URL (returns bytes).
295
+ Future<List<int>> downloadFile(String url) async {
296
+ final res = await _http.get(Uri.parse(url));
297
+ if (res.statusCode != 200) {
298
+ throw PocketBaseException(
299
+ 'downloadFile($url) failed: HTTP ${res.statusCode}',
300
+ );
301
+ }
302
+ return res.bodyBytes;
303
+ }
304
+
305
+ /// Check if a record exists in a collection.
306
+ Future<bool> recordExists(String collection, String id) async {
307
+ final res = await _get(
308
+ Uri.parse('$_baseUrl/api/collections/$collection/records/$id'),
309
+ );
310
+ return res.statusCode == 200;
311
+ }
312
+
313
+ /// Health check (PB exposes /api/health).
314
+ Future<bool> health() async {
315
+ try {
316
+ final res = await _http.get(Uri.parse('$_baseUrl/api/health'));
317
+ return res.statusCode == 200;
318
+ } catch (_) {
319
+ return false;
320
+ }
321
+ }
322
+
323
+ void close() => _http.close();
324
+ }
325
+
326
+ /// Paginated list response from PocketBase.
327
+ class PocketBaseList<T> {
328
+ const PocketBaseList({
329
+ required this.items,
330
+ required this.page,
331
+ required this.perPage,
332
+ required this.totalItems,
333
+ required this.totalPages,
334
+ });
335
+
336
+ final List<T> items;
337
+ final int page;
338
+ final int perPage;
339
+ final int totalItems;
340
+ final int totalPages;
341
+ }
342
+
343
+ /// Exception thrown by [PocketBaseClient] for any non-2xx response.
344
+ class PocketBaseException implements Exception {
345
+ const PocketBaseException(this.message);
346
+ final String message;
347
+ @override
348
+ String toString() => 'PocketBaseException: $message';
349
+ }
@@ -0,0 +1,61 @@
1
+ /// Configuration for the PocketBase backend plugin.
2
+ library;
3
+
4
+ import 'pocketbase_client.dart';
5
+
6
+ /// Configuration for the PocketBase database plugin.
7
+ ///
8
+ /// This is a standalone config (does not import flutter_ota_kit_cli) so the
9
+ /// plugin can be reused in any Dart application. The CLI's `resolvePocketBaseConfig`
10
+ /// helper in `cli-tools` converts a `FlutterPatcherConfig` + env vars into this.
11
+ class PocketBaseConfig {
12
+ const PocketBaseConfig({
13
+ required this.url,
14
+ required this.adminEmail,
15
+ required this.adminPassword,
16
+ this.bundlesCollection = 'bundles',
17
+ this.channelsCollection = 'channels',
18
+ this.bundlesBucket = 'bundles',
19
+ this.clientFactory,
20
+ });
21
+
22
+ /// Base URL of the PocketBase instance, e.g. `http://localhost:8090`.
23
+ final String url;
24
+
25
+ /// Admin email (used to mint auth tokens for backend operations).
26
+ final String adminEmail;
27
+
28
+ /// Admin password.
29
+ final String adminPassword;
30
+
31
+ /// Name of the PB collection that stores bundle records.
32
+ final String bundlesCollection;
33
+
34
+ /// Name of the PB collection that stores channels.
35
+ final String channelsCollection;
36
+
37
+ /// Name of the PB file storage bucket for bundle artifacts.
38
+ final String bundlesBucket;
39
+
40
+ /// Optional test seam: a factory that builds the [PocketBaseClient] used by
41
+ /// the plugin. When omitted, a default HTTP-backed client is created.
42
+ final PocketBaseClientFactory? clientFactory;
43
+
44
+ PocketBaseConfig copyWith({
45
+ String? url,
46
+ String? adminEmail,
47
+ String? adminPassword,
48
+ String? bundlesCollection,
49
+ String? channelsCollection,
50
+ String? bundlesBucket,
51
+ PocketBaseClientFactory? clientFactory,
52
+ }) => PocketBaseConfig(
53
+ url: url ?? this.url,
54
+ adminEmail: adminEmail ?? this.adminEmail,
55
+ adminPassword: adminPassword ?? this.adminPassword,
56
+ bundlesCollection: bundlesCollection ?? this.bundlesCollection,
57
+ channelsCollection: channelsCollection ?? this.channelsCollection,
58
+ bundlesBucket: bundlesBucket ?? this.bundlesBucket,
59
+ clientFactory: clientFactory ?? this.clientFactory,
60
+ );
61
+ }
@@ -0,0 +1,278 @@
1
+ /// PocketBase-backed [DatabasePlugin] implementation.
2
+ library;
3
+
4
+ import 'package:flutter_ota_kit_core/flutter_ota_kit_core.dart';
5
+ import 'package:flutter_ota_kit_plugin_core/flutter_ota_kit_plugin_core.dart';
6
+
7
+ import 'pocketbase_bundle_mapper.dart';
8
+ import 'pocketbase_client.dart';
9
+ import 'pocketbase_config.dart';
10
+
11
+ /// Configuration for the PocketBase database plugin.
12
+ typedef PocketBaseDatabaseConfig = PocketBaseConfig;
13
+
14
+ /// PocketBase-backed [AbstractDatabasePlugin] implementation.
15
+ class _PocketBaseDatabase implements AbstractDatabasePlugin {
16
+ _PocketBaseDatabase(this.config, this.client);
17
+
18
+ final PocketBaseConfig config;
19
+ final PocketBaseClient client;
20
+
21
+ static _PocketBaseDatabase build(PocketBaseConfig config) {
22
+ final client = config.clientFactory != null
23
+ ? config.clientFactory!(
24
+ config.url,
25
+ config.adminEmail,
26
+ config.adminPassword,
27
+ )
28
+ : PocketBaseClient(config.url);
29
+ client.adminCredentials(config.adminEmail, config.adminPassword);
30
+ return _PocketBaseDatabase(config, client);
31
+ }
32
+
33
+ @override
34
+ bool get supportsCursorPagination => false;
35
+
36
+ // ----- Channels -----
37
+
38
+ @override
39
+ Future<List<String>> getChannels() async {
40
+ final res = await client.listRecords<dynamic>(
41
+ config.channelsCollection,
42
+ (j) => j,
43
+ perPage: 200,
44
+ sort: 'name',
45
+ );
46
+ return res.items
47
+ .map((j) => (j as Map)['name'] as String? ?? '')
48
+ .where((s) => s.isNotEmpty)
49
+ .toList();
50
+ }
51
+
52
+ // ----- Single bundle -----
53
+
54
+ @override
55
+ Future<Bundle?> getBundleById(String bundleId) async {
56
+ final row = await client.getRecord<PocketBaseBundleRow>(
57
+ config.bundlesCollection,
58
+ bundleId,
59
+ PocketBaseBundleRow.fromJson,
60
+ );
61
+ return row == null ? null : mapRowToBundle(row);
62
+ }
63
+
64
+ @override
65
+ Future<UpdateInfo?> getUpdateInfo(GetBundlesArgs args) async {
66
+ final filter = _buildFilter(
67
+ channel: args.channel,
68
+ platform: args.platform,
69
+ enabled: true,
70
+ );
71
+ final list = await client.listRecords<PocketBaseBundleRow>(
72
+ config.bundlesCollection,
73
+ PocketBaseBundleRow.fromJson,
74
+ filter: filter.isEmpty ? null : filter,
75
+ sort: '-created',
76
+ perPage: 1,
77
+ );
78
+ if (list.items.isEmpty) return null;
79
+ final bundle = mapRowToBundle(list.items.first);
80
+ return UpdateInfo(
81
+ id: bundle.id,
82
+ shouldForceUpdate: bundle.shouldForceUpdate,
83
+ message: bundle.message,
84
+ status: UpdateStatus.update,
85
+ storageUri: bundle.storageUri,
86
+ fileHash: bundle.fileHash,
87
+ );
88
+ }
89
+
90
+ // ----- Query bundles -----
91
+
92
+ @override
93
+ Future<Paginated<List<Bundle>>> getBundles(
94
+ DatabaseBundleQueryOptions options,
95
+ ) async {
96
+ final where = options.where;
97
+ final filter = _buildFilter(
98
+ channel: where?.channel,
99
+ platform: where?.platform,
100
+ enabled: where?.enabled,
101
+ idEq: where?.id?.eq,
102
+ idGt: where?.id?.gt,
103
+ idLt: where?.id?.lt,
104
+ targetAppVersion: where?.targetAppVersion,
105
+ fingerprintHash: where?.fingerprintHash,
106
+ );
107
+ final orderField = options.orderBy?.field ?? 'id';
108
+ final desc = options.orderBy?.direction != 'asc';
109
+ // PB sort syntax: `field` for asc, `-field` for desc.
110
+ final sort = (desc ? '-' : '') + orderField;
111
+ final limit = options.limit == 0 ? 50 : options.limit;
112
+ final page = (options.offset ?? 0) ~/ limit + 1;
113
+ final list = await client.listRecords<PocketBaseBundleRow>(
114
+ config.bundlesCollection,
115
+ PocketBaseBundleRow.fromJson,
116
+ filter: filter.isEmpty ? null : filter,
117
+ sort: sort,
118
+ page: page,
119
+ perPage: limit,
120
+ );
121
+ final total = list.totalItems;
122
+ final totalPages = list.totalPages == 0 ? 1 : list.totalPages;
123
+ final currentPage = list.page;
124
+ final hasNextPage = currentPage < totalPages;
125
+ final hasPreviousPage = currentPage > 1;
126
+ return Paginated(
127
+ data: list.items.map(mapRowToBundle).toList(),
128
+ pagination: PaginationInfo(
129
+ total: total,
130
+ hasNextPage: hasNextPage,
131
+ hasPreviousPage: hasPreviousPage,
132
+ currentPage: currentPage,
133
+ totalPages: totalPages,
134
+ nextCursor: list.items.isNotEmpty && hasNextPage
135
+ ? list.items.last.id
136
+ : null,
137
+ previousCursor: list.items.isNotEmpty && hasPreviousPage
138
+ ? list.items.first.id
139
+ : null,
140
+ ),
141
+ );
142
+ }
143
+
144
+ // ----- Mutations (UnitOfWork) -----
145
+
146
+ @override
147
+ Future<void> commitBundle({required List<BundleChange> changedSets}) async {
148
+ for (final change in changedSets) {
149
+ switch (change.operation) {
150
+ case BundleChangeOperation.insert:
151
+ final row = _rowFromBundle(change.data);
152
+ final exists = await client.recordExists(
153
+ config.bundlesCollection,
154
+ change.data.id,
155
+ );
156
+ if (exists) {
157
+ // The storage plugin may have pre-created the record (to host
158
+ // the artifact file). Update the metadata fields rather than
159
+ // failing on a duplicate id.
160
+ await client.updateRecord<dynamic>(
161
+ config.bundlesCollection,
162
+ change.data.id,
163
+ row.toCreateJson(),
164
+ (j) => j,
165
+ );
166
+ } else {
167
+ await client.createRecord<dynamic>(
168
+ config.bundlesCollection,
169
+ row.toCreateJson(),
170
+ (j) => j,
171
+ );
172
+ }
173
+ break;
174
+ case BundleChangeOperation.update:
175
+ final patch = _buildUpdatePatch(change.data);
176
+ if (patch.isEmpty) continue;
177
+ await client.updateRecord<dynamic>(
178
+ config.bundlesCollection,
179
+ change.data.id,
180
+ patch,
181
+ (j) => j,
182
+ );
183
+ break;
184
+ case BundleChangeOperation.delete:
185
+ await client.deleteRecord(config.bundlesCollection, change.data.id);
186
+ break;
187
+ }
188
+ }
189
+ }
190
+
191
+ @override
192
+ Future<void> onUnmount() async {
193
+ client.close();
194
+ }
195
+
196
+ // ----- Helpers -----
197
+
198
+ PocketBaseBundleRow _rowFromBundle(Bundle v) => PocketBaseBundleRow(
199
+ id: v.id,
200
+ channel: v.channel,
201
+ enabled: v.enabled,
202
+ platform: v.platform.value,
203
+ shouldForceUpdate: v.shouldForceUpdate,
204
+ fileHash: v.fileHash,
205
+ storageUri: v.storageUri,
206
+ rolloutCohortCount: v.rolloutCohortCount ?? 1000,
207
+ gitCommitHash: v.gitCommitHash,
208
+ message: v.message,
209
+ fingerprintHash: v.fingerprintHash,
210
+ targetAppVersion: v.targetAppVersion,
211
+ manifestStorageUri: v.manifestStorageUri,
212
+ manifestFileHash: v.manifestFileHash,
213
+ assetBaseStorageUri: v.assetBaseStorageUri,
214
+ targetCohorts: v.targetCohorts,
215
+ metadata: v.metadata?.toJson(),
216
+ );
217
+
218
+ String _buildFilter({
219
+ String? channel,
220
+ Platform? platform,
221
+ bool? enabled,
222
+ String? idEq,
223
+ String? idGt,
224
+ String? idLt,
225
+ String? targetAppVersion,
226
+ String? fingerprintHash,
227
+ }) {
228
+ final clauses = <String>[];
229
+ if (channel != null && channel.isNotEmpty) {
230
+ clauses.add('channel = "${_escape(channel)}"');
231
+ }
232
+ if (platform != null) {
233
+ clauses.add('platform = "${_escape(platform.value)}"');
234
+ }
235
+ if (enabled != null) {
236
+ clauses.add('enabled = ${enabled ? 'true' : 'false'}');
237
+ }
238
+ if (idEq != null) clauses.add('id = "${_escape(idEq)}"');
239
+ if (idGt != null) clauses.add('id > "${_escape(idGt)}"');
240
+ if (idLt != null) clauses.add('id < "${_escape(idLt)}"');
241
+ if (targetAppVersion != null) {
242
+ clauses.add('target_app_version = "${_escape(targetAppVersion)}"');
243
+ }
244
+ if (fingerprintHash != null) {
245
+ clauses.add('fingerprint_hash = "${_escape(fingerprintHash)}"');
246
+ }
247
+ return clauses.join(' && ');
248
+ }
249
+
250
+ String _escape(String s) => s.replaceAll(r'\', r'\\').replaceAll('"', r'\"');
251
+
252
+ Map<String, dynamic> _buildUpdatePatch(Bundle v) => {
253
+ if (v.channel.isNotEmpty) 'channel': v.channel,
254
+ 'enabled': v.enabled,
255
+ 'platform': v.platform.value,
256
+ 'should_force_update': v.shouldForceUpdate,
257
+ 'rollout_cohort_count': v.rolloutCohortCount ?? 1000,
258
+ 'file_hash': v.fileHash,
259
+ 'storage_uri': v.storageUri,
260
+ if (v.gitCommitHash != null) 'git_commit_hash': v.gitCommitHash,
261
+ if (v.message != null) 'message': v.message,
262
+ if (v.fingerprintHash != null) 'fingerprint_hash': v.fingerprintHash,
263
+ if (v.targetAppVersion != null) 'target_app_version': v.targetAppVersion,
264
+ if (v.manifestStorageUri != null)
265
+ 'manifest_storage_uri': v.manifestStorageUri,
266
+ if (v.manifestFileHash != null) 'manifest_file_hash': v.manifestFileHash,
267
+ if (v.assetBaseStorageUri != null)
268
+ 'asset_base_storage_uri': v.assetBaseStorageUri,
269
+ if (v.targetCohorts != null) 'target_cohorts': v.targetCohorts,
270
+ if (v.metadata != null) 'metadata': v.metadata!.toJson(),
271
+ };
272
+ }
273
+
274
+ /// Build a `pocketbaseDatabase` plugin factory.
275
+ final pocketbaseDatabase = createDatabasePlugin<PocketBaseConfig>(
276
+ name: 'pocketbaseDatabase',
277
+ factory: _PocketBaseDatabase.build,
278
+ );