musora-content-services 2.159.0 → 2.160.0
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
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
|
|
4
4
|
|
|
5
|
+
## [2.160.0](https://github.com/railroadmedia/musora-content-services/compare/v2.159.0...v2.160.0) (2026-05-13)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* adds stale record cleanup ([#963](https://github.com/railroadmedia/musora-content-services/issues/963)) ([72d917f](https://github.com/railroadmedia/musora-content-services/commit/72d917ff0d64d8c27f1d4ec831233c17663b1c49))
|
|
11
|
+
* use client-side filtering for live events to leverage sanity cache ([#930](https://github.com/railroadmedia/musora-content-services/issues/930)) ([cbfe024](https://github.com/railroadmedia/musora-content-services/commit/cbfe02476b654a240bb1ba35093a98a03535227c))
|
|
12
|
+
|
|
5
13
|
## [2.159.0](https://github.com/railroadmedia/musora-content-services/compare/v2.158.3...v2.159.0) (2026-05-12)
|
|
6
14
|
|
|
7
15
|
|
package/package.json
CHANGED
|
@@ -13,6 +13,7 @@ import { SyncEffect } from './effects'
|
|
|
13
13
|
import { SyncTelemetry } from './telemetry/index'
|
|
14
14
|
import createStoreConfigs from './store-configs'
|
|
15
15
|
import { contentProgressObserver } from '../awards/internal/content-progress-observer'
|
|
16
|
+
import { repairStaleSyncedRecords } from './stale-record-cleanup'
|
|
16
17
|
|
|
17
18
|
export type SyncTeardownMode = 'reset' | 'destroyOrReset' | 'abortWrites'
|
|
18
19
|
|
|
@@ -181,6 +182,10 @@ export default class SyncManager {
|
|
|
181
182
|
this.telemetry.error('[SyncManager] Failed to start contentProgressObserver', error)
|
|
182
183
|
})
|
|
183
184
|
|
|
185
|
+
repairStaleSyncedRecords(this.storesRegistry).catch((error) => {
|
|
186
|
+
this.telemetry.error('[SyncManager] Failed stale record cleanup', error)
|
|
187
|
+
})
|
|
188
|
+
|
|
184
189
|
const teardown = async (mode: SyncTeardownMode = 'reset') => {
|
|
185
190
|
if (this.teardownPromise) {
|
|
186
191
|
this.telemetry.debug(
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Q } from '@nozbe/watermelondb'
|
|
2
|
+
import { POST } from '../../infrastructure/http/HttpClient'
|
|
3
|
+
import BaseModel from './models/Base'
|
|
4
|
+
import type { default as SyncStore } from './store'
|
|
5
|
+
import type { EpochMs } from './index'
|
|
6
|
+
import { SyncTelemetry } from './telemetry/index'
|
|
7
|
+
|
|
8
|
+
const CLEANUP_FLAG_KEY = 'stale_synced_cleanup_v1'
|
|
9
|
+
const STALE_CUTOFF_MS = 10_000
|
|
10
|
+
const SYNC_TABLES = ['progress', 'content_likes', 'practices', 'practice_day_notes', 'user_award_progress']
|
|
11
|
+
|
|
12
|
+
export async function repairStaleSyncedRecords(storesRegistry: Record<string, SyncStore<any>>) {
|
|
13
|
+
const db = Object.values(storesRegistry)[0]!.db
|
|
14
|
+
// if (await db.localStorage.get<string>(CLEANUP_FLAG_KEY)) return // todo
|
|
15
|
+
|
|
16
|
+
const cutoff = Date.now() - STALE_CUTOFF_MS
|
|
17
|
+
const payload: Record<string, [id: string, updated_at: EpochMs][]> = {}
|
|
18
|
+
const recordsByTable: Record<string, BaseModel[]> = {}
|
|
19
|
+
|
|
20
|
+
for (const table of SYNC_TABLES) {
|
|
21
|
+
const store = storesRegistry[table]
|
|
22
|
+
if (!store) continue
|
|
23
|
+
|
|
24
|
+
const records = (await store.db.read(() =>
|
|
25
|
+
store.db.get(table).query(
|
|
26
|
+
Q.where('_status', 'synced'),
|
|
27
|
+
Q.where('updated_at', Q.lt(cutoff))
|
|
28
|
+
).fetch()
|
|
29
|
+
)) as BaseModel[]
|
|
30
|
+
|
|
31
|
+
if (records.length === 0) continue
|
|
32
|
+
|
|
33
|
+
payload[table] = records.map((r) => [r._raw.id, r._raw.updated_at as EpochMs])
|
|
34
|
+
recordsByTable[table] = records
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (Object.keys(payload).length === 0) return
|
|
38
|
+
|
|
39
|
+
const staleEntries: Record<string, [id: string, serverUpdatedAt: number][]> = await POST('/api/sync/v1/stale-record-check', payload)
|
|
40
|
+
|
|
41
|
+
const repairedByTable: Record<string, [id: string, localUpdatedAt: EpochMs, serverUpdatedAt: number][]> = {}
|
|
42
|
+
const preparedUpdates = Object.entries(staleEntries).flatMap(([table, entries]) => {
|
|
43
|
+
const records = recordsByTable[table] ?? []
|
|
44
|
+
const serverTimestampById = new Map(entries.map(([id, serverTs]) => [id, serverTs]))
|
|
45
|
+
return records
|
|
46
|
+
.filter((r) => serverTimestampById.has(r._raw.id))
|
|
47
|
+
.map((record) => {
|
|
48
|
+
const savedUpdatedAt = record._raw.updated_at as EpochMs
|
|
49
|
+
repairedByTable[table] ??= []
|
|
50
|
+
repairedByTable[table].push([record._raw.id, savedUpdatedAt, serverTimestampById.get(record._raw.id)!])
|
|
51
|
+
return record.prepareUpdate((r) => {
|
|
52
|
+
r._raw._status = 'updated'
|
|
53
|
+
r._raw.updated_at = savedUpdatedAt
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
if (preparedUpdates.length === 0) return
|
|
59
|
+
|
|
60
|
+
await db.write(async () => {
|
|
61
|
+
await db.batch(...preparedUpdates)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
SyncTelemetry.getInstance()?.log('[SyncManager] repaired stale synced records', { records: repairedByTable })
|
|
65
|
+
await db.write(() => db.localStorage.set(CLEANUP_FLAG_KEY, '1'))
|
|
66
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { Database } from '@nozbe/watermelondb'
|
|
2
|
+
import LokiJSAdapter from '@nozbe/watermelondb/adapters/lokijs'
|
|
3
|
+
import schema, { SYNC_TABLES } from '@/services/sync/schema/index'
|
|
4
|
+
import * as modelClasses from '@/services/sync/models/index'
|
|
5
|
+
import { makeStore } from './helpers/index'
|
|
6
|
+
import { repairStaleSyncedRecords } from '@/services/sync/stale-record-cleanup'
|
|
7
|
+
import * as HttpClient from '@/infrastructure/http/HttpClient'
|
|
8
|
+
|
|
9
|
+
function makeDatabase() {
|
|
10
|
+
const adapter = new LokiJSAdapter({
|
|
11
|
+
schema,
|
|
12
|
+
useWebWorker: false,
|
|
13
|
+
useIncrementalIndexedDB: false,
|
|
14
|
+
dbName: `test_stale_${Date.now()}_${Math.random()}`,
|
|
15
|
+
extraLokiOptions: { autosave: false },
|
|
16
|
+
})
|
|
17
|
+
return new Database({ adapter, modelClasses: Object.values(modelClasses) })
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let db: Database
|
|
21
|
+
let postMock: jest.SpyInstance
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
db = makeDatabase()
|
|
25
|
+
postMock = jest.spyOn(HttpClient, 'POST').mockResolvedValue({})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
afterEach(async () => {
|
|
29
|
+
await db.write(() => db.unsafeResetDatabase())
|
|
30
|
+
jest.restoreAllMocks()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('marks stale synced records as updated when server has older updated_at', async () => {
|
|
34
|
+
const { store } = makeStore(modelClasses.ContentProgress, db)
|
|
35
|
+
|
|
36
|
+
const localUpdatedAt = Date.now() - 60_000
|
|
37
|
+
const serverUpdatedAt = localUpdatedAt - 5_000 // server is behind
|
|
38
|
+
|
|
39
|
+
await store.importUpsert([
|
|
40
|
+
{
|
|
41
|
+
id: 'stale-1',
|
|
42
|
+
server_record_id: 0,
|
|
43
|
+
content_id: 123,
|
|
44
|
+
content_brand: 'drumeo',
|
|
45
|
+
content_type: 'lesson',
|
|
46
|
+
content_parent_id: 0,
|
|
47
|
+
state: 'started',
|
|
48
|
+
progress_percent: 50,
|
|
49
|
+
collection_type: 'self',
|
|
50
|
+
collection_id: 0,
|
|
51
|
+
resume_time_seconds: null,
|
|
52
|
+
last_interacted_a_la_carte: 0,
|
|
53
|
+
created_at: localUpdatedAt,
|
|
54
|
+
updated_at: localUpdatedAt,
|
|
55
|
+
_status: 'synced',
|
|
56
|
+
_changed: '',
|
|
57
|
+
} as any,
|
|
58
|
+
])
|
|
59
|
+
|
|
60
|
+
postMock.mockResolvedValue({
|
|
61
|
+
[SYNC_TABLES.CONTENT_PROGRESS]: [['stale-1', serverUpdatedAt]],
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const storesRegistry = { [SYNC_TABLES.CONTENT_PROGRESS]: store }
|
|
65
|
+
await repairStaleSyncedRecords(storesRegistry)
|
|
66
|
+
|
|
67
|
+
const record = await db.read(() => db.get(SYNC_TABLES.CONTENT_PROGRESS).find('stale-1')) as any
|
|
68
|
+
store.destroy()
|
|
69
|
+
|
|
70
|
+
expect(record._raw._status).toBe('updated')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('leaves records unchanged when server finds no stale entries', async () => {
|
|
74
|
+
const { store } = makeStore(modelClasses.ContentProgress, db)
|
|
75
|
+
|
|
76
|
+
const updatedAt = Date.now() - 60_000
|
|
77
|
+
await store.importUpsert([
|
|
78
|
+
{
|
|
79
|
+
id: 'fine-1',
|
|
80
|
+
server_record_id: 0,
|
|
81
|
+
content_id: 456,
|
|
82
|
+
content_brand: 'drumeo',
|
|
83
|
+
content_type: 'lesson',
|
|
84
|
+
content_parent_id: 0,
|
|
85
|
+
state: 'started',
|
|
86
|
+
progress_percent: 10,
|
|
87
|
+
collection_type: 'self',
|
|
88
|
+
collection_id: 0,
|
|
89
|
+
resume_time_seconds: null,
|
|
90
|
+
last_interacted_a_la_carte: 0,
|
|
91
|
+
created_at: updatedAt,
|
|
92
|
+
updated_at: updatedAt,
|
|
93
|
+
_status: 'synced',
|
|
94
|
+
_changed: '',
|
|
95
|
+
} as any,
|
|
96
|
+
])
|
|
97
|
+
|
|
98
|
+
postMock.mockResolvedValue({})
|
|
99
|
+
|
|
100
|
+
const storesRegistry = { [SYNC_TABLES.CONTENT_PROGRESS]: store }
|
|
101
|
+
await repairStaleSyncedRecords(storesRegistry)
|
|
102
|
+
|
|
103
|
+
const record = await db.read(() => db.get(SYNC_TABLES.CONTENT_PROGRESS).find('fine-1')) as any
|
|
104
|
+
store.destroy()
|
|
105
|
+
|
|
106
|
+
expect(record._raw._status).toBe('synced')
|
|
107
|
+
})
|