musora-content-services 2.159.0 → 2.160.1

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,21 @@
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.1](https://github.com/railroadmedia/musora-content-services/compare/v2.160.0...v2.160.1) (2026-05-13)
6
+
7
+
8
+ ### Bug Fixes
9
+
10
+ * disable debug code ([#970](https://github.com/railroadmedia/musora-content-services/issues/970)) ([4046c4f](https://github.com/railroadmedia/musora-content-services/commit/4046c4fca4457b9d21eab5221876a991bdf22973))
11
+
12
+ ## [2.160.0](https://github.com/railroadmedia/musora-content-services/compare/v2.159.0...v2.160.0) (2026-05-13)
13
+
14
+
15
+ ### Features
16
+
17
+ * adds stale record cleanup ([#963](https://github.com/railroadmedia/musora-content-services/issues/963)) ([72d917f](https://github.com/railroadmedia/musora-content-services/commit/72d917ff0d64d8c27f1d4ec831233c17663b1c49))
18
+ * 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))
19
+
5
20
  ## [2.159.0](https://github.com/railroadmedia/musora-content-services/compare/v2.158.3...v2.159.0) (2026-05-12)
6
21
 
7
22
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "musora-content-services",
3
- "version": "2.159.0",
3
+ "version": "2.160.1",
4
4
  "description": "A package for Musoras content services ",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -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
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
+ })
@@ -1,12 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "Bash(rg:*)",
5
- "Bash(npm run lint:*)",
6
- "Bash(ls:*)",
7
- "Bash(npm test *)",
8
- "Bash(npx jest *)"
9
- ],
10
- "deny": []
11
- }
12
- }