musora-content-services 2.107.8 → 2.110.3
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.
- package/CHANGELOG.md +32 -0
- package/CLAUDE.md +2 -2
- package/package.json +1 -1
- package/src/contentMetaData.js +0 -5
- package/src/contentTypeConfig.js +13 -73
- package/src/index.d.ts +0 -20
- package/src/index.js +0 -20
- package/src/services/content.js +1 -1
- package/src/services/contentAggregator.js +1 -4
- package/src/services/contentProgress.js +13 -8
- package/src/services/railcontent.js +0 -163
- package/src/services/sanity.js +258 -7
- package/src/services/sync/adapters/factory.ts +6 -6
- package/src/services/sync/adapters/lokijs.ts +174 -1
- package/src/services/sync/context/providers/durability.ts +1 -0
- package/src/services/sync/database/factory.ts +12 -5
- package/src/services/sync/effects/index.ts +6 -0
- package/src/services/sync/effects/logout-warning.ts +47 -0
- package/src/services/sync/errors/boundary.ts +4 -6
- package/src/services/sync/errors/index.ts +16 -0
- package/src/services/sync/fetch.ts +5 -5
- package/src/services/sync/manager.ts +80 -40
- package/src/services/sync/models/ContentProgress.ts +6 -0
- package/src/services/sync/repositories/base.ts +1 -8
- package/src/services/sync/repositories/content-progress.ts +8 -2
- package/src/services/sync/retry.ts +4 -4
- package/src/services/sync/schema/index.ts +1 -0
- package/src/services/sync/store/index.ts +34 -31
- package/src/services/sync/store/push-coalescer.ts +3 -3
- package/src/services/sync/store-configs.ts +10 -8
- package/src/services/sync/telemetry/flood-prevention.ts +27 -0
- package/src/services/sync/telemetry/index.ts +71 -9
- package/src/services/sync/telemetry/sampling.ts +2 -6
- package/src/services/user/types.d.ts +0 -7
- package/test/sync/adapter.ts +2 -34
- package/test/sync/initialize-sync-manager.js +8 -25
- package/.claude/settings.local.json +0 -9
- package/src/services/sync/concurrency-safety.ts +0 -4
|
@@ -2,9 +2,16 @@ import type { DatabaseAdapter } from '../adapters/factory'
|
|
|
2
2
|
import { Database, } from '@nozbe/watermelondb'
|
|
3
3
|
import * as modelClasses from '../models'
|
|
4
4
|
|
|
5
|
-
export default function syncDatabaseFactory(adapter: () => DatabaseAdapter) {
|
|
6
|
-
return () =>
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
export default function syncDatabaseFactory(adapter: () => DatabaseAdapter, { onInitError }: { onInitError?: (error: Error) => void } = {}) {
|
|
6
|
+
return () => {
|
|
7
|
+
try {
|
|
8
|
+
return new Database({
|
|
9
|
+
adapter: adapter(),
|
|
10
|
+
modelClasses: Object.values(modelClasses)
|
|
11
|
+
})
|
|
12
|
+
} catch (error) {
|
|
13
|
+
onInitError?.(error as Error)
|
|
14
|
+
throw error
|
|
15
|
+
}
|
|
16
|
+
}
|
|
10
17
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Subscription } from 'rxjs'
|
|
2
|
+
import { Q } from '@nozbe/watermelondb'
|
|
3
|
+
|
|
4
|
+
import { type SyncEffect } from '.'
|
|
5
|
+
|
|
6
|
+
import { type ModelClass } from '../index'
|
|
7
|
+
|
|
8
|
+
// notifies a subscriber that unsynced records exist
|
|
9
|
+
// ideally used by a logout interrupt prompt to tell the user that logging out
|
|
10
|
+
// now would make them lose data
|
|
11
|
+
|
|
12
|
+
// we notify eagerly so that the prompt can be shown as soon as user clicks logout,
|
|
13
|
+
// instead of waiting for a lazy query at that moment
|
|
14
|
+
|
|
15
|
+
const createLogoutWarningEffect = (notifyCallback: (unsyncedModels: ModelClass[]) => void) => {
|
|
16
|
+
const logoutWarning: SyncEffect = function (context, stores) {
|
|
17
|
+
const unsyncedModels = new Set<ModelClass>()
|
|
18
|
+
const subscriptions: Subscription[] = []
|
|
19
|
+
|
|
20
|
+
const notifyFromAll = () => {
|
|
21
|
+
notifyCallback(Array.from(unsyncedModels))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
stores.forEach((store) => {
|
|
25
|
+
const sub = store.collection
|
|
26
|
+
.query(Q.where('_status', Q.notEq('synced')), Q.take(1)) // todo - doesn't consider deleted records ??
|
|
27
|
+
.observe()
|
|
28
|
+
.subscribe((records) => {
|
|
29
|
+
if (records.length > 0) {
|
|
30
|
+
unsyncedModels.add(store.model)
|
|
31
|
+
} else {
|
|
32
|
+
unsyncedModels.delete(store.model)
|
|
33
|
+
}
|
|
34
|
+
notifyFromAll()
|
|
35
|
+
})
|
|
36
|
+
subscriptions.push(sub)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
return () => {
|
|
40
|
+
subscriptions.forEach((sub) => sub.unsubscribe())
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return logoutWarning
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export default createLogoutWarningEffect
|
|
@@ -28,18 +28,16 @@ export function inBoundary<T, TContext extends Record<string, any>>(fn: (context
|
|
|
28
28
|
|
|
29
29
|
if (result instanceof Promise) {
|
|
30
30
|
return result.catch((err: unknown) => {
|
|
31
|
-
|
|
32
|
-
SyncTelemetry.getInstance()?.capture(wrapped)
|
|
31
|
+
SyncTelemetry.getInstance()?.capture(err as Error, context)
|
|
33
32
|
|
|
34
|
-
throw
|
|
33
|
+
throw err;
|
|
35
34
|
});
|
|
36
35
|
}
|
|
37
36
|
|
|
38
37
|
return result;
|
|
39
38
|
} catch (err: unknown) {
|
|
40
|
-
|
|
41
|
-
SyncTelemetry.getInstance()?.capture(wrapped);
|
|
39
|
+
SyncTelemetry.getInstance()?.capture(err as Error, context);
|
|
42
40
|
|
|
43
|
-
throw
|
|
41
|
+
throw err;
|
|
44
42
|
}
|
|
45
43
|
}
|
|
@@ -38,6 +38,22 @@ export class SyncStoreError extends SyncError {
|
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
export class SyncInitError extends SyncError {
|
|
42
|
+
constructor(error: unknown) {
|
|
43
|
+
super('initError', { cause: error })
|
|
44
|
+
this.name = 'SyncInitError'
|
|
45
|
+
Object.setPrototypeOf(this, new.target.prototype)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class SyncSetupError extends SyncError {
|
|
50
|
+
constructor(error: unknown) {
|
|
51
|
+
super('setupError', { cause: error })
|
|
52
|
+
this.name = 'SyncSetupError'
|
|
53
|
+
Object.setPrototypeOf(this, new.target.prototype)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
41
57
|
// useful for transforming non-sync-related errors into one
|
|
42
58
|
// that captures surrounding details (e.g., table name)
|
|
43
59
|
export class SyncUnexpectedError extends SyncError {
|
|
@@ -34,18 +34,18 @@ type SyncPushFetchFailureResponse = SyncResponseBase & {
|
|
|
34
34
|
failureType: 'fetch'
|
|
35
35
|
isRetryable: boolean
|
|
36
36
|
}
|
|
37
|
-
|
|
37
|
+
type SyncPushFailureResponse = SyncResponseBase & {
|
|
38
38
|
ok: false,
|
|
39
39
|
failureType: 'error'
|
|
40
40
|
originalError: Error
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
type SyncStorePushResult<TRecordKey extends string = 'id'> = SyncStorePushResultSuccess<TRecordKey> | SyncStorePushResultFailure<TRecordKey>
|
|
44
|
-
type SyncStorePushResultSuccess<TRecordKey extends string = 'id'> = SyncStorePushResultBase & {
|
|
43
|
+
export type SyncStorePushResult<TRecordKey extends string = 'id'> = SyncStorePushResultSuccess<TRecordKey> | SyncStorePushResultFailure<TRecordKey>
|
|
44
|
+
export type SyncStorePushResultSuccess<TRecordKey extends string = 'id'> = SyncStorePushResultBase & {
|
|
45
45
|
type: 'success'
|
|
46
46
|
entry: SyncEntry<BaseModel, TRecordKey>
|
|
47
47
|
}
|
|
48
|
-
type SyncStorePushResultFailure<TRecordKey extends string = 'id'> = SyncStorePushResultProcessingFailure<TRecordKey> | SyncStorePushResultValidationFailure<TRecordKey>
|
|
48
|
+
export type SyncStorePushResultFailure<TRecordKey extends string = 'id'> = SyncStorePushResultProcessingFailure<TRecordKey> | SyncStorePushResultValidationFailure<TRecordKey>
|
|
49
49
|
type SyncStorePushResultProcessingFailure<TRecordKey extends string = 'id'> = SyncStorePushResultFailureBase<TRecordKey> & {
|
|
50
50
|
failureType: 'processing'
|
|
51
51
|
error: any
|
|
@@ -71,7 +71,7 @@ type SyncPullSuccessResponse = SyncResponseBase & {
|
|
|
71
71
|
token: SyncToken
|
|
72
72
|
previousToken: SyncToken | null
|
|
73
73
|
}
|
|
74
|
-
type SyncPullFetchFailureResponse = SyncResponseBase & {
|
|
74
|
+
export type SyncPullFetchFailureResponse = SyncResponseBase & {
|
|
75
75
|
ok: false,
|
|
76
76
|
failureType: 'fetch'
|
|
77
77
|
isRetryable: boolean
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import BaseModel from './models/Base'
|
|
2
2
|
import { Database } from '@nozbe/watermelondb'
|
|
3
|
+
import { DatabaseAdapter } from '@nozbe/watermelondb/adapters/type'
|
|
3
4
|
import SyncRunScope from './run-scope'
|
|
4
5
|
import { SyncStrategy } from './strategies'
|
|
5
6
|
import { default as SyncStore, SyncStoreConfig } from './store'
|
|
@@ -8,10 +9,9 @@ import { ModelClass } from './index'
|
|
|
8
9
|
import SyncRetry from './retry'
|
|
9
10
|
import SyncContext from './context'
|
|
10
11
|
import { SyncError } from './errors'
|
|
11
|
-
import {
|
|
12
|
+
import { SyncEffect } from './effects'
|
|
12
13
|
import { SyncTelemetry } from './telemetry/index'
|
|
13
|
-
import
|
|
14
|
-
import createStoresFromConfig from './store-configs'
|
|
14
|
+
import createStoreConfigs from './store-configs'
|
|
15
15
|
import { contentProgressObserver } from '../awards/internal/content-progress-observer'
|
|
16
16
|
|
|
17
17
|
export default class SyncManager {
|
|
@@ -22,11 +22,16 @@ export default class SyncManager {
|
|
|
22
22
|
if (SyncManager.instance) {
|
|
23
23
|
throw new SyncError('SyncManager already initialized')
|
|
24
24
|
}
|
|
25
|
+
|
|
25
26
|
SyncManager.instance = instance
|
|
26
27
|
const teardown = instance.setup()
|
|
27
|
-
|
|
28
|
+
|
|
29
|
+
return (force = false) => {
|
|
28
30
|
SyncManager.instance = null
|
|
29
|
-
|
|
31
|
+
return teardown(force).catch(error => {
|
|
32
|
+
SyncManager.instance = instance // restore instance on teardown failure
|
|
33
|
+
throw error
|
|
34
|
+
})
|
|
30
35
|
}
|
|
31
36
|
}
|
|
32
37
|
|
|
@@ -43,29 +48,34 @@ export default class SyncManager {
|
|
|
43
48
|
|
|
44
49
|
private id: string
|
|
45
50
|
public telemetry: SyncTelemetry
|
|
46
|
-
private database: Database
|
|
47
51
|
private context: SyncContext
|
|
52
|
+
private storeConfigsRegistry: Record<string, SyncStoreConfig<any>>
|
|
48
53
|
private storesRegistry: Record<string, SyncStore<any>>
|
|
49
54
|
private runScope: SyncRunScope
|
|
50
55
|
private retry: SyncRetry
|
|
51
|
-
private strategyMap: {
|
|
52
|
-
private
|
|
56
|
+
private strategyMap: { models: ModelClass[]; strategies: SyncStrategy[] }[]
|
|
57
|
+
private effectMap: { models: ModelClass[]; effects: SyncEffect[] }[]
|
|
58
|
+
|
|
59
|
+
private initDatabase: () => Database
|
|
60
|
+
private destroyDatabase?: (dbName: string, adapter: DatabaseAdapter) => Promise<void>
|
|
53
61
|
|
|
54
|
-
constructor(context: SyncContext, initDatabase: () => Database) {
|
|
62
|
+
constructor(context: SyncContext, initDatabase: () => Database, destroyDatabase?: (dbName: string, adapter: DatabaseAdapter) => Promise<void>) {
|
|
55
63
|
this.id = (SyncManager.counter++).toString()
|
|
56
64
|
|
|
57
65
|
this.telemetry = SyncTelemetry.getInstance()!
|
|
58
66
|
this.context = context
|
|
59
67
|
|
|
60
|
-
this.
|
|
68
|
+
this.initDatabase = initDatabase
|
|
69
|
+
this.destroyDatabase = destroyDatabase
|
|
70
|
+
|
|
71
|
+
this.storeConfigsRegistry = this.registerStoreConfigs(createStoreConfigs())
|
|
72
|
+
this.storesRegistry = {}
|
|
61
73
|
|
|
62
74
|
this.runScope = new SyncRunScope()
|
|
63
75
|
this.retry = new SyncRetry(this.context, this.telemetry)
|
|
64
76
|
|
|
65
|
-
this.storesRegistry = this.registerStores(createStoresFromConfig(this.createStore.bind(this)))
|
|
66
|
-
|
|
67
77
|
this.strategyMap = []
|
|
68
|
-
this.
|
|
78
|
+
this.effectMap = []
|
|
69
79
|
}
|
|
70
80
|
|
|
71
81
|
/**
|
|
@@ -75,27 +85,23 @@ export default class SyncManager {
|
|
|
75
85
|
return this.id
|
|
76
86
|
}
|
|
77
87
|
|
|
78
|
-
createStore
|
|
79
|
-
return new SyncStore
|
|
88
|
+
createStore(config: SyncStoreConfig, database: Database) {
|
|
89
|
+
return new SyncStore(
|
|
80
90
|
config,
|
|
81
91
|
this.context,
|
|
82
|
-
|
|
92
|
+
database,
|
|
83
93
|
this.retry,
|
|
84
94
|
this.runScope,
|
|
85
95
|
this.telemetry
|
|
86
96
|
)
|
|
87
97
|
}
|
|
88
98
|
|
|
89
|
-
|
|
99
|
+
registerStoreConfigs(stores: SyncStoreConfig[]) {
|
|
90
100
|
return Object.fromEntries(
|
|
91
101
|
stores.map((store) => {
|
|
92
102
|
return [store.model.table, store]
|
|
93
103
|
})
|
|
94
|
-
) as Record<string,
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
storesForModels(models: ModelClass[]) {
|
|
98
|
-
return models.map((model) => this.storesRegistry[model.table])
|
|
104
|
+
) as Record<string, SyncStoreConfig>
|
|
99
105
|
}
|
|
100
106
|
|
|
101
107
|
createStrategy<T extends SyncStrategy, U extends any[]>(
|
|
@@ -105,24 +111,32 @@ export default class SyncManager {
|
|
|
105
111
|
return new strategyClass(this.context, ...args)
|
|
106
112
|
}
|
|
107
113
|
|
|
108
|
-
|
|
109
|
-
this.strategyMap.push({
|
|
114
|
+
registerStrategies(models: ModelClass[], strategies: SyncStrategy[]) {
|
|
115
|
+
this.strategyMap.push({ models, strategies })
|
|
110
116
|
}
|
|
111
117
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
this.safetyMap.push({ stores, mechanisms: teardowns })
|
|
118
|
+
registerEffects(models: ModelClass[], effects: SyncEffect[]) {
|
|
119
|
+
this.effectMap.push({ models, effects })
|
|
115
120
|
}
|
|
116
121
|
|
|
117
122
|
setup() {
|
|
118
123
|
this.telemetry.debug('[SyncManager] Setting up')
|
|
119
124
|
|
|
125
|
+
// can fail synchronously immediately (e.g., schema/migration validation errors)
|
|
126
|
+
// or asynchronously (e.g., indexedDB errors synchronously OR asynchronously (!))
|
|
127
|
+
const database = this.telemetry.trace({ name: 'db:init' }, this.initDatabase)
|
|
128
|
+
|
|
129
|
+
Object.entries(this.storeConfigsRegistry).forEach(([table, storeConfig]) => {
|
|
130
|
+
this.storesRegistry[table] = this.createStore(storeConfig, database)
|
|
131
|
+
})
|
|
132
|
+
|
|
120
133
|
this.context.start()
|
|
121
134
|
this.retry.start()
|
|
122
135
|
|
|
123
|
-
this.strategyMap.forEach(({
|
|
136
|
+
this.strategyMap.forEach(({ models, strategies }) => {
|
|
124
137
|
strategies.forEach((strategy) => {
|
|
125
|
-
|
|
138
|
+
models.forEach((model) => {
|
|
139
|
+
const store = this.storesRegistry[model.table]
|
|
126
140
|
strategy.onTrigger(store, (reason) => {
|
|
127
141
|
store.requestSync(reason)
|
|
128
142
|
})
|
|
@@ -131,22 +145,48 @@ export default class SyncManager {
|
|
|
131
145
|
})
|
|
132
146
|
})
|
|
133
147
|
|
|
134
|
-
|
|
148
|
+
const effectTeardowns = this.effectMap.flatMap(({ models, effects }) => {
|
|
149
|
+
return effects.map((effect) => effect(this.context, models.map(model => this.storesRegistry[model.table])))
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
contentProgressObserver.start(database).catch((error) => {
|
|
135
153
|
this.telemetry.error('[SyncManager] Failed to start contentProgressObserver', error)
|
|
136
154
|
})
|
|
137
155
|
|
|
138
|
-
const teardown = async () => {
|
|
156
|
+
const teardown = async (force = false) => {
|
|
139
157
|
this.telemetry.debug('[SyncManager] Tearing down')
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
158
|
+
|
|
159
|
+
const clear = (force = false) => {
|
|
160
|
+
if (force && this.destroyDatabase && database.adapter.dbName && database.adapter.underlyingAdapter) {
|
|
161
|
+
return this.destroyDatabase(database.adapter.dbName, database.adapter.underlyingAdapter)
|
|
162
|
+
} else {
|
|
163
|
+
return database.write(() => database.unsafeResetDatabase())
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
this.runScope.abort()
|
|
169
|
+
this.strategyMap.forEach(({ strategies }) => strategies.forEach((strategy) => strategy.stop()))
|
|
170
|
+
effectTeardowns.forEach((teardown) => teardown())
|
|
171
|
+
this.retry.stop()
|
|
172
|
+
this.context.stop()
|
|
173
|
+
|
|
174
|
+
contentProgressObserver.stop()
|
|
175
|
+
} catch (error) {
|
|
176
|
+
// capture, but don't rethrow
|
|
177
|
+
this.telemetry.capture(error)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
return clear(force);
|
|
182
|
+
} catch (error) {
|
|
183
|
+
if (!force) {
|
|
184
|
+
return clear(true);
|
|
185
|
+
}
|
|
186
|
+
throw error
|
|
187
|
+
}
|
|
149
188
|
}
|
|
189
|
+
|
|
150
190
|
return teardown
|
|
151
191
|
}
|
|
152
192
|
|
|
@@ -53,6 +53,9 @@ export default class ContentProgress extends BaseModel<{
|
|
|
53
53
|
get resume_time_seconds() {
|
|
54
54
|
return (this._getRaw('resume_time_seconds') as number) || null
|
|
55
55
|
}
|
|
56
|
+
get hide_from_progress_row() {
|
|
57
|
+
return this._getRaw('hide_from_progress_row') as boolean
|
|
58
|
+
}
|
|
56
59
|
|
|
57
60
|
set content_id(value: number) {
|
|
58
61
|
// unsigned int
|
|
@@ -87,5 +90,8 @@ export default class ContentProgress extends BaseModel<{
|
|
|
87
90
|
throwIfNotNullableNumber(value)
|
|
88
91
|
this._setRaw('resume_time_seconds', value !== null ? throwIfOutsideRange(value, 0, 65535) : value)
|
|
89
92
|
}
|
|
93
|
+
set hide_from_progress_row(value: boolean) {
|
|
94
|
+
this._setRaw('hide_from_progress_row', value)
|
|
95
|
+
}
|
|
90
96
|
|
|
91
97
|
}
|
|
@@ -89,13 +89,6 @@ export default class SyncRepository<TModel extends BaseModel> {
|
|
|
89
89
|
)
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
protected async upsertOneRemote(id: RecordId, builder: (record: TModel) => void) {
|
|
93
|
-
return this.store.telemetry.trace(
|
|
94
|
-
{ name: `upsertOneRemote:${this.store.model.table}`, op: 'upsert' },
|
|
95
|
-
(span) => this._respondToRemoteWriteOne(() => this.store.upsertOneRemote(id, builder, span), id, span)
|
|
96
|
-
)
|
|
97
|
-
}
|
|
98
|
-
|
|
99
92
|
protected async upsertOne(id: RecordId, builder: (record: TModel) => void, { skipPush = false } = {}) {
|
|
100
93
|
return this.store.telemetry.trace(
|
|
101
94
|
{ name: `upsertOne:${this.store.model.table}`, op: 'upsert' },
|
|
@@ -213,7 +206,7 @@ export default class SyncRepository<TModel extends BaseModel> {
|
|
|
213
206
|
const result: SyncReadDTO<TModel, T> = {
|
|
214
207
|
data,
|
|
215
208
|
status: pull?.ok ? 'fresh' : 'stale',
|
|
216
|
-
pullStatus: pull
|
|
209
|
+
pullStatus: pull ? (pull.ok ? 'success' : 'failure') : null,
|
|
217
210
|
lastFetchToken: fetchToken,
|
|
218
211
|
}
|
|
219
212
|
return result
|
|
@@ -62,6 +62,8 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
|
|
|
62
62
|
Q.where('collection_type', COLLECTION_TYPE.SELF),
|
|
63
63
|
Q.where('collection_id', COLLECTION_ID_SELF),
|
|
64
64
|
|
|
65
|
+
Q.where('hide_from_progress_row', false),
|
|
66
|
+
|
|
65
67
|
Q.or(Q.where('state', STATE.STARTED), Q.where('state', STATE.COMPLETED)),
|
|
66
68
|
Q.sortBy('updated_at', 'desc'),
|
|
67
69
|
]
|
|
@@ -133,7 +135,7 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
|
|
|
133
135
|
}
|
|
134
136
|
}
|
|
135
137
|
|
|
136
|
-
recordProgress(contentId: number, collection: CollectionParameter | null, progressPct: number, resumeTime?: number, {skipPush = false} = {}) {
|
|
138
|
+
recordProgress(contentId: number, collection: CollectionParameter | null, progressPct: number, resumeTime?: number, {skipPush = false, hideFromProgressRow = false} = {}) {
|
|
137
139
|
const id = ProgressRepository.generateId(contentId, collection)
|
|
138
140
|
|
|
139
141
|
const result = this.upsertOne(id, (r) => {
|
|
@@ -146,6 +148,8 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
|
|
|
146
148
|
if (typeof resumeTime != 'undefined') {
|
|
147
149
|
r.resume_time_seconds = Math.floor(resumeTime)
|
|
148
150
|
}
|
|
151
|
+
|
|
152
|
+
r.hide_from_progress_row = hideFromProgressRow
|
|
149
153
|
}, { skipPush })
|
|
150
154
|
|
|
151
155
|
// Emit event AFTER database write completes
|
|
@@ -176,7 +180,7 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
|
|
|
176
180
|
recordProgressMany(
|
|
177
181
|
contentProgresses: Record<string, number>, // Accept plain object
|
|
178
182
|
collection: CollectionParameter | null,
|
|
179
|
-
{ tentative = true, skipPush = false }: { tentative?: boolean; skipPush?: boolean } = {}
|
|
183
|
+
{ tentative = true, skipPush = false, hideFromProgressRow = false }: { tentative?: boolean; skipPush?: boolean; hideFromProgressRow?: boolean } = {}
|
|
180
184
|
) {
|
|
181
185
|
|
|
182
186
|
const data = Object.fromEntries(
|
|
@@ -188,6 +192,8 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
|
|
|
188
192
|
r.collection_id = collection?.id ?? COLLECTION_ID_SELF
|
|
189
193
|
|
|
190
194
|
r.progress_percent = progressPct
|
|
195
|
+
|
|
196
|
+
r.hide_from_progress_row = hideFromProgressRow
|
|
191
197
|
},
|
|
192
198
|
])
|
|
193
199
|
)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import SyncContext from "./context"
|
|
2
|
-
import { SyncResponse } from "./fetch"
|
|
2
|
+
import { SyncResponse, SyncPushResponse } from "./fetch"
|
|
3
3
|
import { SyncTelemetry, Span, StartSpanOptions } from "./telemetry/index"
|
|
4
4
|
|
|
5
5
|
export default class SyncRetry {
|
|
@@ -32,7 +32,7 @@ export default class SyncRetry {
|
|
|
32
32
|
* Runs the given syncFn with automatic retries.
|
|
33
33
|
* Returns the first successful result or the last failed result after retries.
|
|
34
34
|
*/
|
|
35
|
-
async request<T extends SyncResponse>(spanOpts: StartSpanOptions, syncFn: (span: Span) => Promise<T
|
|
35
|
+
async request<T extends SyncResponse>(spanOpts: StartSpanOptions, syncFn: (span: Span) => Promise<T>) {
|
|
36
36
|
let attempt = 0
|
|
37
37
|
|
|
38
38
|
while (true) {
|
|
@@ -47,7 +47,7 @@ export default class SyncRetry {
|
|
|
47
47
|
const result = await this.telemetry.trace(spanOptions, span => {
|
|
48
48
|
if (!this.context.connectivity.getValue()) {
|
|
49
49
|
this.telemetry.debug('[Retry] No connectivity - skipping')
|
|
50
|
-
return { ok: false } as T
|
|
50
|
+
return { ok: false, failureType: 'fetch', isRetryable: false } as T
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
return syncFn(span)
|
|
@@ -55,7 +55,7 @@ export default class SyncRetry {
|
|
|
55
55
|
|
|
56
56
|
if (!result) return result
|
|
57
57
|
|
|
58
|
-
if (result.ok) {
|
|
58
|
+
if (result.ok === true) {
|
|
59
59
|
this.resetBackoff()
|
|
60
60
|
return result
|
|
61
61
|
} else {
|
|
@@ -26,6 +26,7 @@ const contentProgressTable = tableSchema({
|
|
|
26
26
|
{ name: 'state', type: 'string', isIndexed: true },
|
|
27
27
|
{ name: 'progress_percent', type: 'number' },
|
|
28
28
|
{ name: 'resume_time_seconds', type: 'number', isOptional: true },
|
|
29
|
+
{ name: 'hide_from_progress_row', type: 'boolean'},
|
|
29
30
|
{ name: 'created_at', type: 'number' },
|
|
30
31
|
{ name: 'updated_at', type: 'number', isIndexed: true }
|
|
31
32
|
]
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Database, Q, type Collection, type RecordId } from '@nozbe/watermelondb'
|
|
2
2
|
import { RawSerializer, ModelSerializer } from '../serializers'
|
|
3
3
|
import { ModelClass, SyncToken, SyncEntry, SyncContext, EpochMs } from '..'
|
|
4
|
-
import { SyncPullResponse, SyncPushResponse,
|
|
4
|
+
import { SyncPullResponse, SyncPushResponse, SyncPullFetchFailureResponse, PushPayload, SyncStorePushResultSuccess, SyncStorePushResultFailure } from '../fetch'
|
|
5
5
|
import type SyncRetry from '../retry'
|
|
6
6
|
import type SyncRunScope from '../run-scope'
|
|
7
7
|
import EventEmitter from '../utils/event-emitter'
|
|
@@ -29,7 +29,7 @@ type SyncPush = (
|
|
|
29
29
|
signal: AbortSignal
|
|
30
30
|
) => Promise<SyncPushResponse>
|
|
31
31
|
|
|
32
|
-
export type SyncStoreConfig<TModel extends BaseModel> = {
|
|
32
|
+
export type SyncStoreConfig<TModel extends BaseModel = BaseModel> = {
|
|
33
33
|
model: ModelClass<TModel>
|
|
34
34
|
comparator?: TModel extends BaseModel ? SyncResolverComparator<TModel> : SyncResolverComparator
|
|
35
35
|
pull: SyncPull
|
|
@@ -122,6 +122,17 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
122
122
|
}, { table: this.model.table, reason })
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
async requestPush(reason: string) {
|
|
126
|
+
inBoundary(ctx => {
|
|
127
|
+
this.telemetry.trace(
|
|
128
|
+
{ name: `sync:${this.model.table}`, op: 'push', attributes: ctx },
|
|
129
|
+
async span => {
|
|
130
|
+
await this.pushUnsyncedWithRetry(span)
|
|
131
|
+
}
|
|
132
|
+
)
|
|
133
|
+
}, { table: this.model.table, reason })
|
|
134
|
+
}
|
|
135
|
+
|
|
125
136
|
async getLastFetchToken() {
|
|
126
137
|
return (await this.db.localStorage.get<SyncToken | null>(this.lastFetchTokenKey)) ?? null
|
|
127
138
|
}
|
|
@@ -210,31 +221,6 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
210
221
|
})
|
|
211
222
|
}
|
|
212
223
|
|
|
213
|
-
async upsertOneRemote(id: RecordId, builder: (record: TModel) => void, span?: Span) {
|
|
214
|
-
return await this.runScope.abortable(async () => {
|
|
215
|
-
let record: TModel
|
|
216
|
-
const existing = await this.queryMaybeDeletedRecords(Q.where('id', id)).then(r => r[0] || null)
|
|
217
|
-
|
|
218
|
-
if (existing) {
|
|
219
|
-
existing._isEditing = true
|
|
220
|
-
builder(existing)
|
|
221
|
-
existing._isEditing = false
|
|
222
|
-
record = existing
|
|
223
|
-
} else {
|
|
224
|
-
const attrs = new this.model(this.collection, { id })
|
|
225
|
-
attrs._isEditing = true
|
|
226
|
-
builder(attrs)
|
|
227
|
-
attrs._isEditing = false
|
|
228
|
-
record = this.collection.disposableFromDirtyRaw(attrs._raw)
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
return await this.pushCoalescer.push(
|
|
232
|
-
[record],
|
|
233
|
-
() => this.executePush([record], span)
|
|
234
|
-
)
|
|
235
|
-
})
|
|
236
|
-
}
|
|
237
|
-
|
|
238
224
|
async upsertSome(builders: Record<RecordId, (record: TModel) => void>, span?: Span, { skipPush = false } = {}) {
|
|
239
225
|
if (Object.keys(builders).length === 0) return []
|
|
240
226
|
|
|
@@ -501,9 +487,11 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
501
487
|
const currentRecords = await this.queryMaybeDeletedRecords(Q.where('id', Q.oneOf(recordIds)))
|
|
502
488
|
const recordsToPush = currentRecords.filter(r => r._raw.updated_at <= (updatedAtMap.get(r.id) || 0))
|
|
503
489
|
|
|
504
|
-
if (recordsToPush.length) {
|
|
505
|
-
return
|
|
490
|
+
if (!recordsToPush.length) {
|
|
491
|
+
return { ok: true, results: [] }
|
|
506
492
|
}
|
|
493
|
+
|
|
494
|
+
return this.executePush(recordsToPush, span)
|
|
507
495
|
}
|
|
508
496
|
)
|
|
509
497
|
})
|
|
@@ -514,7 +502,7 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
514
502
|
private async executePull(span?: Span) {
|
|
515
503
|
if (!this.context.connectivity.getValue()) {
|
|
516
504
|
this.telemetry.debug('[Retry] No connectivity - skipping')
|
|
517
|
-
return { ok: false } as
|
|
505
|
+
return { ok: false, failureType: 'fetch', isRetryable: false } as SyncPullFetchFailureResponse
|
|
518
506
|
}
|
|
519
507
|
|
|
520
508
|
return this.telemetry.trace(
|
|
@@ -570,7 +558,22 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
570
558
|
)
|
|
571
559
|
|
|
572
560
|
if (response.ok) {
|
|
573
|
-
const successfulResults = response.results.
|
|
561
|
+
const [failedResults, successfulResults] = response.results.reduce((acc, result) => {
|
|
562
|
+
if (result.type === 'success') {
|
|
563
|
+
acc[1].push(result)
|
|
564
|
+
} else {
|
|
565
|
+
acc[0].push(result)
|
|
566
|
+
}
|
|
567
|
+
return acc
|
|
568
|
+
}, [[], []] as [SyncStorePushResultFailure[], SyncStorePushResultSuccess[]])
|
|
569
|
+
|
|
570
|
+
if (failedResults.length) {
|
|
571
|
+
this.telemetry.warn(
|
|
572
|
+
`[store:${this.model.table}] Push completed with failed records`,
|
|
573
|
+
{ results: failedResults }
|
|
574
|
+
)
|
|
575
|
+
}
|
|
576
|
+
|
|
574
577
|
const successfulEntries = successfulResults.map((result) => result.entry)
|
|
575
578
|
await this.writeEntries(successfulEntries, false, pushSpan)
|
|
576
579
|
|
|
@@ -4,7 +4,7 @@ import { EpochMs } from ".."
|
|
|
4
4
|
import { SyncPushResponse } from "../fetch"
|
|
5
5
|
|
|
6
6
|
type PushIntent = {
|
|
7
|
-
promise: Promise<
|
|
7
|
+
promise: Promise<SyncPushResponse>
|
|
8
8
|
records: {
|
|
9
9
|
id: RecordId
|
|
10
10
|
updatedAt: EpochMs
|
|
@@ -18,7 +18,7 @@ export default class PushCoalescer {
|
|
|
18
18
|
this.intents = []
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
push(records: BaseModel[], pusher: (records: BaseModel[]) => Promise<
|
|
21
|
+
push(records: BaseModel[], pusher: (records: BaseModel[]) => Promise<SyncPushResponse>) {
|
|
22
22
|
const found = this.find(records)
|
|
23
23
|
|
|
24
24
|
if (found) {
|
|
@@ -28,7 +28,7 @@ export default class PushCoalescer {
|
|
|
28
28
|
return this.add(pusher(records), records)
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
private add(promise: Promise<
|
|
31
|
+
private add(promise: Promise<SyncPushResponse>, records: BaseModel[]) {
|
|
32
32
|
const intent = {
|
|
33
33
|
promise,
|
|
34
34
|
records: records.map(record => ({
|