Skip to main content

syncular_testkit/
file_assets.rs

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}