1use serde_json::{json, Map, Value};
2use syncular_runtime::app_schema::{
3 unknown_table_adapter, AppSchema, AppTableMetadata, ColumnMetadata, EmbeddedMigration,
4 ScopeMetadata, ScopeSource,
5};
6use syncular_runtime::client::{SubscriptionSpec, SyncularClientConfig};
7use syncular_runtime::error::Result;
8use syncular_runtime::protocol::{
9 BlobRef, IntoSyncularMutation, PendingSyncularMutation, SyncularMutationKind,
10};
11use syncular_runtime::transport::SyncTransport;
12
13use crate::app::{
14 open_app_client_with_options, open_app_client_with_server, open_app_client_with_transport,
15 AppFixture, AppFixtureOptions, TestAppFixture,
16};
17use crate::app_server::AppTestServer;
18
19pub const FILES_TABLE: &str = "files";
20pub const FILE_VERSIONS_TABLE: &str = "file_versions";
21pub const FILES_SUBSCRIPTION_ID: &str = "sub-files";
22pub const FILE_VERSIONS_SUBSCRIPTION_ID: &str = "sub-file-versions";
23
24const FILE_ASSET_MIGRATION_SQL: &str = r#"
25CREATE TABLE IF NOT EXISTS files (
26 id TEXT PRIMARY KEY,
27 parent_id TEXT NULL,
28 name TEXT NOT NULL,
29 kind TEXT NOT NULL,
30 current_version_id TEXT NULL,
31 owner_id TEXT NOT NULL,
32 project_id TEXT NULL,
33 deleted INTEGER NOT NULL DEFAULT 0,
34 trashed_at BIGINT NULL,
35 server_version BIGINT NOT NULL DEFAULT 0
36) WITHOUT ROWID;
37
38CREATE TABLE IF NOT EXISTS file_versions (
39 id TEXT PRIMARY KEY,
40 file_id TEXT NOT NULL,
41 blob_ref TEXT NOT NULL,
42 content_hash TEXT NOT NULL,
43 byte_size BIGINT NOT NULL,
44 mime_type TEXT NULL,
45 actor_id TEXT NOT NULL,
46 owner_id TEXT NOT NULL,
47 project_id TEXT NULL,
48 previous_version_id TEXT NULL,
49 created_at BIGINT NOT NULL,
50 server_version BIGINT NOT NULL DEFAULT 0
51) WITHOUT ROWID;
52
53CREATE INDEX IF NOT EXISTS idx_files_scope_parent_name
54 ON files (owner_id, project_id, parent_id, name);
55
56CREATE INDEX IF NOT EXISTS idx_file_versions_file_created
57 ON file_versions (file_id, created_at);
58"#;
59
60const FILE_ASSET_MIGRATIONS: &[EmbeddedMigration] = &[EmbeddedMigration {
61 version: "0001",
62 schema_version: 1,
63 name: "file_asset_reference_schema",
64 up_sql: FILE_ASSET_MIGRATION_SQL,
65}];
66
67const FILE_ASSET_TABLES: &[&str] = &[FILES_TABLE, FILE_VERSIONS_TABLE];
68
69const FILES_COLUMNS: &[ColumnMetadata] = &[
70 ColumnMetadata {
71 name: "id",
72 type_family: "text",
73 notnull_required: false,
74 primary_key: true,
75 },
76 ColumnMetadata {
77 name: "parent_id",
78 type_family: "text",
79 notnull_required: false,
80 primary_key: false,
81 },
82 ColumnMetadata {
83 name: "name",
84 type_family: "text",
85 notnull_required: true,
86 primary_key: false,
87 },
88 ColumnMetadata {
89 name: "kind",
90 type_family: "text",
91 notnull_required: true,
92 primary_key: false,
93 },
94 ColumnMetadata {
95 name: "current_version_id",
96 type_family: "text",
97 notnull_required: false,
98 primary_key: false,
99 },
100 ColumnMetadata {
101 name: "owner_id",
102 type_family: "text",
103 notnull_required: true,
104 primary_key: false,
105 },
106 ColumnMetadata {
107 name: "project_id",
108 type_family: "text",
109 notnull_required: false,
110 primary_key: false,
111 },
112 ColumnMetadata {
113 name: "deleted",
114 type_family: "integer",
115 notnull_required: true,
116 primary_key: false,
117 },
118 ColumnMetadata {
119 name: "trashed_at",
120 type_family: "integer",
121 notnull_required: false,
122 primary_key: false,
123 },
124 ColumnMetadata {
125 name: "server_version",
126 type_family: "integer",
127 notnull_required: true,
128 primary_key: false,
129 },
130];
131
132const FILE_VERSIONS_COLUMNS: &[ColumnMetadata] = &[
133 ColumnMetadata {
134 name: "id",
135 type_family: "text",
136 notnull_required: false,
137 primary_key: true,
138 },
139 ColumnMetadata {
140 name: "file_id",
141 type_family: "text",
142 notnull_required: true,
143 primary_key: false,
144 },
145 ColumnMetadata {
146 name: "blob_ref",
147 type_family: "text",
148 notnull_required: true,
149 primary_key: false,
150 },
151 ColumnMetadata {
152 name: "content_hash",
153 type_family: "text",
154 notnull_required: true,
155 primary_key: false,
156 },
157 ColumnMetadata {
158 name: "byte_size",
159 type_family: "integer",
160 notnull_required: true,
161 primary_key: false,
162 },
163 ColumnMetadata {
164 name: "mime_type",
165 type_family: "text",
166 notnull_required: false,
167 primary_key: false,
168 },
169 ColumnMetadata {
170 name: "actor_id",
171 type_family: "text",
172 notnull_required: true,
173 primary_key: false,
174 },
175 ColumnMetadata {
176 name: "owner_id",
177 type_family: "text",
178 notnull_required: true,
179 primary_key: false,
180 },
181 ColumnMetadata {
182 name: "project_id",
183 type_family: "text",
184 notnull_required: false,
185 primary_key: false,
186 },
187 ColumnMetadata {
188 name: "previous_version_id",
189 type_family: "text",
190 notnull_required: false,
191 primary_key: false,
192 },
193 ColumnMetadata {
194 name: "created_at",
195 type_family: "integer",
196 notnull_required: true,
197 primary_key: false,
198 },
199 ColumnMetadata {
200 name: "server_version",
201 type_family: "integer",
202 notnull_required: true,
203 primary_key: false,
204 },
205];
206
207const FILE_ASSET_SCOPES: &[ScopeMetadata] = &[
208 ScopeMetadata {
209 name: "user_id",
210 column: "owner_id",
211 source: ScopeSource::ActorId,
212 required: true,
213 },
214 ScopeMetadata {
215 name: "project_id",
216 column: "project_id",
217 source: ScopeSource::ProjectId,
218 required: false,
219 },
220];
221
222const FILES_METADATA: AppTableMetadata = AppTableMetadata {
223 name: FILES_TABLE,
224 primary_key_column: "id",
225 server_version_column: "server_version",
226 soft_delete_column: Some("deleted"),
227 subscription_id: FILES_SUBSCRIPTION_ID,
228 columns: FILES_COLUMNS,
229 blob_columns: &[],
230 crdt_yjs_fields: &[],
231 encrypted_fields: &[],
232 scopes: FILE_ASSET_SCOPES,
233};
234
235const FILE_VERSIONS_METADATA: AppTableMetadata = AppTableMetadata {
236 name: FILE_VERSIONS_TABLE,
237 primary_key_column: "id",
238 server_version_column: "server_version",
239 soft_delete_column: None,
240 subscription_id: FILE_VERSIONS_SUBSCRIPTION_ID,
241 columns: FILE_VERSIONS_COLUMNS,
242 blob_columns: &["blob_ref"],
243 crdt_yjs_fields: &[],
244 encrypted_fields: &[],
245 scopes: FILE_ASSET_SCOPES,
246};
247
248const FILE_ASSET_METADATA: &[AppTableMetadata] = &[FILES_METADATA, FILE_VERSIONS_METADATA];
249
250pub type FileAssetFixture<T> = AppFixture<T>;
251pub type TestFileAssetFixture = TestAppFixture;
252
253pub fn file_asset_app_schema() -> AppSchema {
254 AppSchema {
255 app_tables: FILE_ASSET_TABLES,
256 app_table_metadata: FILE_ASSET_METADATA,
257 migrations: FILE_ASSET_MIGRATIONS,
258 schema_version: Some(1),
259 default_subscriptions: default_file_asset_subscriptions,
260 adapter_for: unknown_table_adapter,
261 }
262}
263
264pub fn open_file_asset_client() -> Result<TestFileAssetFixture> {
265 open_file_asset_client_with_options(AppFixtureOptions {
266 db_prefix: "syncular-file-assets-test".to_string(),
267 ..AppFixtureOptions::default()
268 })
269}
270
271pub fn open_file_asset_client_with_options(
272 options: AppFixtureOptions,
273) -> Result<TestFileAssetFixture> {
274 open_app_client_with_options(file_asset_app_schema(), file_asset_fixture_options(options))
275}
276
277pub fn open_file_asset_client_with_transport<T>(
278 transport: T,
279 options: AppFixtureOptions,
280) -> Result<FileAssetFixture<T>>
281where
282 T: SyncTransport,
283{
284 open_app_client_with_transport(
285 file_asset_app_schema(),
286 transport,
287 file_asset_fixture_options(options),
288 )
289}
290
291pub fn open_file_asset_client_with_server(
292 server: AppTestServer,
293 options: AppFixtureOptions,
294) -> Result<FileAssetFixture<AppTestServer>> {
295 open_app_client_with_server(
296 file_asset_app_schema(),
297 server,
298 file_asset_fixture_options(options),
299 )
300}
301
302fn file_asset_fixture_options(mut options: AppFixtureOptions) -> AppFixtureOptions {
303 if options.db_prefix == AppFixtureOptions::default().db_prefix {
304 options.db_prefix = "syncular-file-assets-test".to_string();
305 }
306 options
307}
308
309fn default_file_asset_subscriptions(config: &SyncularClientConfig) -> Vec<SubscriptionSpec> {
310 let mut scopes = Map::new();
311 scopes.insert(
312 "user_id".to_string(),
313 Value::String(config.actor_id.clone()),
314 );
315 if let Some(project_id) = &config.project_id {
316 scopes.insert("project_id".to_string(), Value::String(project_id.clone()));
317 }
318 vec![
319 SubscriptionSpec {
320 id: FILES_SUBSCRIPTION_ID.to_string(),
321 table: FILES_TABLE.to_string(),
322 scopes: scopes.clone(),
323 params: Map::new(),
324 bootstrap_phase: 0,
325 },
326 SubscriptionSpec {
327 id: FILE_VERSIONS_SUBSCRIPTION_ID.to_string(),
328 table: FILE_VERSIONS_TABLE.to_string(),
329 scopes,
330 params: Map::new(),
331 bootstrap_phase: 0,
332 },
333 ]
334}
335
336#[derive(Debug, Clone)]
337pub struct NewFileAsset {
338 id: String,
339 parent_id: Option<String>,
340 name: String,
341 kind: String,
342 current_version_id: Option<String>,
343 owner_id: String,
344 project_id: Option<String>,
345}
346
347impl NewFileAsset {
348 pub fn file(id: &str, name: &str, owner_id: &str, project_id: Option<&str>) -> Self {
349 Self::new(id, name, "file", owner_id, project_id)
350 }
351
352 pub fn folder(id: &str, name: &str, owner_id: &str, project_id: Option<&str>) -> Self {
353 Self::new(id, name, "folder", owner_id, project_id)
354 }
355
356 fn new(id: &str, name: &str, kind: &str, owner_id: &str, project_id: Option<&str>) -> Self {
357 Self {
358 id: id.to_string(),
359 parent_id: None,
360 name: name.to_string(),
361 kind: kind.to_string(),
362 current_version_id: None,
363 owner_id: owner_id.to_string(),
364 project_id: project_id.map(str::to_string),
365 }
366 }
367
368 pub fn parent_id(mut self, parent_id: Option<&str>) -> Self {
369 self.parent_id = parent_id.map(str::to_string);
370 self
371 }
372
373 pub fn current_version_id(mut self, version_id: Option<&str>) -> Self {
374 self.current_version_id = version_id.map(str::to_string);
375 self
376 }
377
378 pub fn row_json(&self) -> Value {
379 let mut row = Map::new();
380 row.insert("id".to_string(), json!(&self.id));
381 row.insert("parent_id".to_string(), json!(&self.parent_id));
382 row.insert("name".to_string(), json!(&self.name));
383 row.insert("kind".to_string(), json!(&self.kind));
384 row.insert(
385 "current_version_id".to_string(),
386 json!(&self.current_version_id),
387 );
388 row.insert("owner_id".to_string(), json!(&self.owner_id));
389 row.insert("project_id".to_string(), json!(&self.project_id));
390 row.insert("deleted".to_string(), json!(0));
391 row.insert("trashed_at".to_string(), Value::Null);
392 Value::Object(row)
393 }
394
395 fn payload_json(&self) -> Value {
396 self.row_json()
397 }
398}
399
400impl IntoSyncularMutation for NewFileAsset {
401 fn into_syncular_mutation(self) -> PendingSyncularMutation {
402 PendingSyncularMutation {
403 kind: SyncularMutationKind::Insert,
404 table: FILES_TABLE.to_string(),
405 row_id: self.id.clone(),
406 payload: Some(self.payload_json()),
407 base_version: None,
408 local_row: Some(self.row_json()),
409 }
410 }
411}
412
413#[derive(Debug, Clone)]
414pub struct FileAssetPatch {
415 row_id: String,
416 base_version: Option<i64>,
417 parent_id: Option<Option<String>>,
418 name: Option<String>,
419 current_version_id: Option<Option<String>>,
420 deleted: Option<i32>,
421 trashed_at: Option<Option<i64>>,
422}
423
424impl FileAssetPatch {
425 pub fn new(row_id: &str) -> Self {
426 Self {
427 row_id: row_id.to_string(),
428 base_version: None,
429 parent_id: None,
430 name: None,
431 current_version_id: None,
432 deleted: None,
433 trashed_at: None,
434 }
435 }
436
437 pub fn base_version(mut self, base_version: i64) -> Self {
438 self.base_version = Some(base_version);
439 self
440 }
441
442 pub fn rename(mut self, name: &str) -> Self {
443 self.name = Some(name.to_string());
444 self
445 }
446
447 pub fn move_to(mut self, parent_id: Option<&str>) -> Self {
448 self.parent_id = Some(parent_id.map(str::to_string));
449 self
450 }
451
452 pub fn current_version_id(mut self, version_id: Option<&str>) -> Self {
453 self.current_version_id = Some(version_id.map(str::to_string));
454 self
455 }
456
457 pub fn soft_delete(mut self, trashed_at: i64) -> Self {
458 self.deleted = Some(1);
459 self.trashed_at = Some(Some(trashed_at));
460 self
461 }
462
463 pub fn restore(mut self) -> Self {
464 self.deleted = Some(0);
465 self.trashed_at = Some(None);
466 self
467 }
468
469 pub fn payload_json(&self) -> Value {
470 let mut payload = Map::new();
471 if let Some(parent_id) = &self.parent_id {
472 payload.insert("parent_id".to_string(), json!(parent_id));
473 }
474 if let Some(name) = &self.name {
475 payload.insert("name".to_string(), json!(name));
476 }
477 if let Some(version_id) = &self.current_version_id {
478 payload.insert("current_version_id".to_string(), json!(version_id));
479 }
480 if let Some(deleted) = self.deleted {
481 payload.insert("deleted".to_string(), json!(deleted));
482 }
483 if let Some(trashed_at) = self.trashed_at {
484 payload.insert("trashed_at".to_string(), json!(trashed_at));
485 }
486 Value::Object(payload)
487 }
488}
489
490impl IntoSyncularMutation for FileAssetPatch {
491 fn into_syncular_mutation(self) -> PendingSyncularMutation {
492 let payload = self.payload_json();
493 PendingSyncularMutation {
494 kind: SyncularMutationKind::Update,
495 table: FILES_TABLE.to_string(),
496 row_id: self.row_id,
497 payload: Some(payload),
498 base_version: self.base_version,
499 local_row: None,
500 }
501 }
502}
503
504#[derive(Debug, Clone)]
505pub struct FileAssetHardDelete {
506 row_id: String,
507 base_version: Option<i64>,
508}
509
510impl FileAssetHardDelete {
511 pub fn new(row_id: &str) -> Self {
512 Self {
513 row_id: row_id.to_string(),
514 base_version: None,
515 }
516 }
517
518 pub fn base_version(mut self, base_version: i64) -> Self {
519 self.base_version = Some(base_version);
520 self
521 }
522}
523
524impl IntoSyncularMutation for FileAssetHardDelete {
525 fn into_syncular_mutation(self) -> PendingSyncularMutation {
526 PendingSyncularMutation {
527 kind: SyncularMutationKind::Delete,
528 table: FILES_TABLE.to_string(),
529 row_id: self.row_id,
530 payload: None,
531 base_version: self.base_version,
532 local_row: None,
533 }
534 }
535}
536
537#[derive(Debug, Clone)]
538pub struct NewFileVersion {
539 id: String,
540 file_id: String,
541 blob_ref: BlobRef,
542 actor_id: String,
543 owner_id: String,
544 project_id: Option<String>,
545 previous_version_id: Option<String>,
546 created_at: i64,
547}
548
549impl NewFileVersion {
550 pub fn new(
551 id: &str,
552 file_id: &str,
553 blob_ref: BlobRef,
554 actor_id: &str,
555 owner_id: &str,
556 project_id: Option<&str>,
557 created_at: i64,
558 ) -> Self {
559 Self {
560 id: id.to_string(),
561 file_id: file_id.to_string(),
562 blob_ref,
563 actor_id: actor_id.to_string(),
564 owner_id: owner_id.to_string(),
565 project_id: project_id.map(str::to_string),
566 previous_version_id: None,
567 created_at,
568 }
569 }
570
571 pub fn previous_version_id(mut self, version_id: Option<&str>) -> Self {
572 self.previous_version_id = version_id.map(str::to_string);
573 self
574 }
575
576 pub fn row_json(&self) -> Value {
577 let mut row = Map::new();
578 row.insert("id".to_string(), json!(&self.id));
579 row.insert("file_id".to_string(), json!(&self.file_id));
580 row.insert("blob_ref".to_string(), json!(&self.blob_ref));
581 row.insert("content_hash".to_string(), json!(&self.blob_ref.hash));
582 row.insert("byte_size".to_string(), json!(self.blob_ref.size));
583 row.insert("mime_type".to_string(), json!(&self.blob_ref.mime_type));
584 row.insert("actor_id".to_string(), json!(&self.actor_id));
585 row.insert("owner_id".to_string(), json!(&self.owner_id));
586 row.insert("project_id".to_string(), json!(&self.project_id));
587 row.insert(
588 "previous_version_id".to_string(),
589 json!(&self.previous_version_id),
590 );
591 row.insert("created_at".to_string(), json!(self.created_at));
592 Value::Object(row)
593 }
594
595 fn payload_json(&self) -> Value {
596 self.row_json()
597 }
598}
599
600impl IntoSyncularMutation for NewFileVersion {
601 fn into_syncular_mutation(self) -> PendingSyncularMutation {
602 PendingSyncularMutation {
603 kind: SyncularMutationKind::Insert,
604 table: FILE_VERSIONS_TABLE.to_string(),
605 row_id: self.id.clone(),
606 payload: Some(self.payload_json()),
607 base_version: None,
608 local_row: Some(self.row_json()),
609 }
610 }
611}