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.
Files changed (38) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/CLAUDE.md +2 -2
  3. package/package.json +1 -1
  4. package/src/contentMetaData.js +0 -5
  5. package/src/contentTypeConfig.js +13 -73
  6. package/src/index.d.ts +0 -20
  7. package/src/index.js +0 -20
  8. package/src/services/content.js +1 -1
  9. package/src/services/contentAggregator.js +1 -4
  10. package/src/services/contentProgress.js +13 -8
  11. package/src/services/railcontent.js +0 -163
  12. package/src/services/sanity.js +258 -7
  13. package/src/services/sync/adapters/factory.ts +6 -6
  14. package/src/services/sync/adapters/lokijs.ts +174 -1
  15. package/src/services/sync/context/providers/durability.ts +1 -0
  16. package/src/services/sync/database/factory.ts +12 -5
  17. package/src/services/sync/effects/index.ts +6 -0
  18. package/src/services/sync/effects/logout-warning.ts +47 -0
  19. package/src/services/sync/errors/boundary.ts +4 -6
  20. package/src/services/sync/errors/index.ts +16 -0
  21. package/src/services/sync/fetch.ts +5 -5
  22. package/src/services/sync/manager.ts +80 -40
  23. package/src/services/sync/models/ContentProgress.ts +6 -0
  24. package/src/services/sync/repositories/base.ts +1 -8
  25. package/src/services/sync/repositories/content-progress.ts +8 -2
  26. package/src/services/sync/retry.ts +4 -4
  27. package/src/services/sync/schema/index.ts +1 -0
  28. package/src/services/sync/store/index.ts +34 -31
  29. package/src/services/sync/store/push-coalescer.ts +3 -3
  30. package/src/services/sync/store-configs.ts +10 -8
  31. package/src/services/sync/telemetry/flood-prevention.ts +27 -0
  32. package/src/services/sync/telemetry/index.ts +71 -9
  33. package/src/services/sync/telemetry/sampling.ts +2 -6
  34. package/src/services/user/types.d.ts +0 -7
  35. package/test/sync/adapter.ts +2 -34
  36. package/test/sync/initialize-sync-manager.js +8 -25
  37. package/.claude/settings.local.json +0 -9
  38. package/src/services/sync/concurrency-safety.ts +0 -4
@@ -2,4 +2,5 @@ import BaseContextProvider from "./base";
2
2
 
3
3
  export default abstract class BaseDurabilityProvider extends BaseContextProvider {
4
4
  abstract getValue(): boolean
5
+ abstract failed(): void
5
6
  }
@@ -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 () => new Database({
7
- adapter: adapter(),
8
- modelClasses: Object.values(modelClasses)
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,6 @@
1
+ import type SyncContext from "../context"
2
+ import type SyncStore from "../store"
3
+
4
+ export type SyncEffect = (context: SyncContext, stores: SyncStore[]) => () => void
5
+
6
+ export { default as createLogoutWarningEffect } from './logout-warning'
@@ -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
- const wrapped = err instanceof SyncError ? err : new SyncUnexpectedError((err as Error).message, context);
32
- SyncTelemetry.getInstance()?.capture(wrapped)
31
+ SyncTelemetry.getInstance()?.capture(err as Error, context)
33
32
 
34
- throw wrapped;
33
+ throw err;
35
34
  });
36
35
  }
37
36
 
38
37
  return result;
39
38
  } catch (err: unknown) {
40
- const wrapped = err instanceof SyncError ? err : new SyncUnexpectedError((err as Error).message, context);
41
- SyncTelemetry.getInstance()?.capture(wrapped);
39
+ SyncTelemetry.getInstance()?.capture(err as Error, context);
42
40
 
43
- throw wrapped;
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
- export type SyncPushFailureResponse = SyncResponseBase & {
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 { SyncConcurrencySafetyMechanism } from './concurrency-safety'
12
+ import { SyncEffect } from './effects'
12
13
  import { SyncTelemetry } from './telemetry/index'
13
- import { inBoundary } from './errors/boundary'
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
- return async () => {
28
+
29
+ return (force = false) => {
28
30
  SyncManager.instance = null
29
- await teardown()
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: { stores: SyncStore<any>[]; strategies: SyncStrategy[] }[]
52
- private safetyMap: { stores: SyncStore<any>[]; mechanisms: (() => void)[] }[]
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.database = this.telemetry.trace({ name: 'db:init' }, () => inBoundary(initDatabase)) // todo - can cause undefined??
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.safetyMap = []
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<TModel extends BaseModel>(config: SyncStoreConfig<TModel>) {
79
- return new SyncStore<TModel>(
88
+ createStore(config: SyncStoreConfig, database: Database) {
89
+ return new SyncStore(
80
90
  config,
81
91
  this.context,
82
- this.database,
92
+ database,
83
93
  this.retry,
84
94
  this.runScope,
85
95
  this.telemetry
86
96
  )
87
97
  }
88
98
 
89
- registerStores<TModel extends BaseModel>(stores: SyncStore<TModel>[]) {
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, SyncStore<TModel>>
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
- syncStoresWithStrategies(stores: SyncStore<any>[], strategies: SyncStrategy[]) {
109
- this.strategyMap.push({ stores, strategies })
114
+ registerStrategies(models: ModelClass[], strategies: SyncStrategy[]) {
115
+ this.strategyMap.push({ models, strategies })
110
116
  }
111
117
 
112
- protectStores(stores: SyncStore<any>[], mechanisms: SyncConcurrencySafetyMechanism[]) {
113
- const teardowns = mechanisms.map((mechanism) => mechanism(this.context, stores))
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(({ stores, strategies }) => {
136
+ this.strategyMap.forEach(({ models, strategies }) => {
124
137
  strategies.forEach((strategy) => {
125
- stores.forEach((store) => {
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
- contentProgressObserver.start(this.database).catch((error) => {
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
- this.runScope.abort()
141
- this.strategyMap.forEach(({ strategies }) =>
142
- strategies.forEach((strategy) => strategy.stop())
143
- )
144
- this.safetyMap.forEach(({ mechanisms }) => mechanisms.forEach((mechanism) => mechanism()))
145
- contentProgressObserver.stop()
146
- this.retry.stop()
147
- this.context.stop()
148
- await this.database.write(() => this.database.unsafeResetDatabase())
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?.ok ? 'success' : 'failure',
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 | void>) {
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, SyncPushFailureResponse, PushPayload } from '../fetch'
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 this.executePush(recordsToPush, span)
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 SyncPushFailureResponse
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.filter((result) => result.type === 'success')
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<void | SyncPushResponse>
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<void | SyncPushResponse>) {
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<void | SyncPushResponse>, records: BaseModel[]) {
31
+ private add(promise: Promise<SyncPushResponse>, records: BaseModel[]) {
32
32
  const intent = {
33
33
  promise,
34
34
  records: records.map(record => ({