musora-content-services 2.122.3 → 2.122.4
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 +7 -0
- package/package.json +1 -1
- package/src/services/contentProgress.js +7 -7
- package/src/services/sync/adapters/factory.ts +25 -10
- package/src/services/sync/adapters/lokijs.ts +14 -8
- package/src/services/sync/database/factory.ts +5 -4
- package/src/services/sync/fetch.ts +19 -11
- package/src/services/sync/index.ts +2 -0
- package/src/services/sync/manager.ts +114 -41
- package/src/services/sync/repositories/base.ts +2 -2
- package/src/services/sync/store/index.ts +54 -25
- package/src/services/sync/telemetry/index.ts +26 -9
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
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.122.4](https://github.com/railroadmedia/musora-content-services/compare/v2.122.3...v2.122.4) (2026-01-26)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* melon data user isolation ([#717](https://github.com/railroadmedia/musora-content-services/issues/717)) ([6893c3c](https://github.com/railroadmedia/musora-content-services/commit/6893c3c644e1eefcfeeb6439b460a46d853616d6))
|
|
11
|
+
|
|
5
12
|
### [2.122.3](https://github.com/railroadmedia/musora-content-services/compare/v2.122.2...v2.122.3) (2026-01-23)
|
|
6
13
|
|
|
7
14
|
### [2.122.2](https://github.com/railroadmedia/musora-content-services/compare/v2.122.1...v2.122.2) (2026-01-23)
|
package/package.json
CHANGED
|
@@ -444,8 +444,8 @@ export async function flushWatchSession(sessionToFlush = null, shouldClearInterv
|
|
|
444
444
|
sessionToFlush.pushInterval = null
|
|
445
445
|
}
|
|
446
446
|
|
|
447
|
-
db.contentProgress.requestPushUnsynced()
|
|
448
|
-
db.practices.requestPushUnsynced()
|
|
447
|
+
db.contentProgress.requestPushUnsynced('flush-watch-session')
|
|
448
|
+
db.practices.requestPushUnsynced('flush-watch-session')
|
|
449
449
|
}
|
|
450
450
|
|
|
451
451
|
async function trackPractice(contentId, secondsPlayed, practiceSession, details = {}) {
|
|
@@ -515,7 +515,7 @@ async function saveContentProgress(contentId, collection, progress, currentSecon
|
|
|
515
515
|
|
|
516
516
|
// skip bubbling if progress hasnt changed
|
|
517
517
|
if (progress === currentProgress) {
|
|
518
|
-
if (!skipPush) db.contentProgress.requestPushUnsynced()
|
|
518
|
+
if (!skipPush) db.contentProgress.requestPushUnsynced('save-content-progress')
|
|
519
519
|
return
|
|
520
520
|
}
|
|
521
521
|
|
|
@@ -549,7 +549,7 @@ async function saveContentProgress(contentId, collection, progress, currentSecon
|
|
|
549
549
|
}
|
|
550
550
|
}
|
|
551
551
|
|
|
552
|
-
if (!skipPush) db.contentProgress.requestPushUnsynced()
|
|
552
|
+
if (!skipPush) db.contentProgress.requestPushUnsynced('save-content-progress')
|
|
553
553
|
|
|
554
554
|
return response
|
|
555
555
|
}
|
|
@@ -582,7 +582,7 @@ async function setStartedOrCompletedStatus(contentId, collection, isCompleted, {
|
|
|
582
582
|
}
|
|
583
583
|
}
|
|
584
584
|
|
|
585
|
-
if (!skipPush) db.contentProgress.requestPushUnsynced()
|
|
585
|
+
if (!skipPush) db.contentProgress.requestPushUnsynced('set-started-or-completed-status')
|
|
586
586
|
|
|
587
587
|
return response
|
|
588
588
|
}
|
|
@@ -627,7 +627,7 @@ async function setStartedOrCompletedStatusMany(contentIds, collection, isComplet
|
|
|
627
627
|
}
|
|
628
628
|
}
|
|
629
629
|
|
|
630
|
-
if (!skipPush) db.contentProgress.requestPushUnsynced()
|
|
630
|
+
if (!skipPush) db.contentProgress.requestPushUnsynced('set-started-or-completed-status-many')
|
|
631
631
|
|
|
632
632
|
return response
|
|
633
633
|
}
|
|
@@ -665,7 +665,7 @@ async function resetStatus(contentId, collection = null, {skipPush = false} = {}
|
|
|
665
665
|
await duplicateLearningPathProgressToExternalContents(progresses, collection, hierarchy, {skipPush: true})
|
|
666
666
|
}
|
|
667
667
|
|
|
668
|
-
if (!skipPush) db.contentProgress.requestPushUnsynced()
|
|
668
|
+
if (!skipPush) db.contentProgress.requestPushUnsynced('reset-status')
|
|
669
669
|
|
|
670
670
|
return response
|
|
671
671
|
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import schema from '../schema'
|
|
2
|
+
import type { SyncUserScope } from '../index'
|
|
3
|
+
import { SyncError } from '../errors'
|
|
2
4
|
|
|
3
5
|
import type SQLiteAdapter from '@nozbe/watermelondb/adapters/sqlite'
|
|
4
6
|
import type LokiJSAdapter from '@nozbe/watermelondb/adapters/lokijs'
|
|
5
7
|
|
|
6
|
-
import { SyncTelemetry } from '../telemetry'
|
|
7
|
-
|
|
8
8
|
export type DatabaseAdapter = SQLiteAdapter | LokiJSAdapter
|
|
9
9
|
|
|
10
10
|
type SQLiteAdapterOptions = ConstructorParameters<typeof SQLiteAdapter>[0]
|
|
@@ -14,13 +14,28 @@ type DatabaseAdapterOptions = SQLiteAdapterOptions & LokiJSAdapterOptions
|
|
|
14
14
|
|
|
15
15
|
export default function syncAdapterFactory<T extends DatabaseAdapter>(
|
|
16
16
|
AdapterClass: new (options: DatabaseAdapterOptions) => T,
|
|
17
|
-
_namespace: string,
|
|
18
17
|
opts: Omit<DatabaseAdapterOptions, 'schema' | 'migrations'>
|
|
19
|
-
): () => T {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
18
|
+
): (userScope: SyncUserScope) => T {
|
|
19
|
+
// IMPORTANT: we rely on namespaced databases to prevent data clobbering
|
|
20
|
+
// when localStorage.userId somehow changes outside of an explicit, app-managed logout
|
|
21
|
+
// the system always checks on writes, pushes, and pulls that the localstorage.userId matches the userScope.initialId
|
|
22
|
+
// but without namespaced dbs, that would not be sufficient to prevent a database with data for user A
|
|
23
|
+
// from later masquerading as a database for user B
|
|
24
|
+
|
|
25
|
+
// This also allows us to keep the entire setup flow synchronous and lazy
|
|
26
|
+
// i.e., no checks necessary on database initialization comparing the previous/stored userId to the new/intended/current userId
|
|
27
|
+
// (and a panicked resetting if those checks fail)
|
|
28
|
+
|
|
29
|
+
return (userScope: SyncUserScope) => {
|
|
30
|
+
if (!userScope.initialId) {
|
|
31
|
+
throw new SyncError('User ID is required to construct database adapter')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return new AdapterClass({
|
|
35
|
+
...opts,
|
|
36
|
+
dbName: `sync:${userScope.initialId}`,
|
|
37
|
+
schema,
|
|
38
|
+
migrations: undefined
|
|
39
|
+
})
|
|
40
|
+
}
|
|
26
41
|
}
|
|
@@ -3,7 +3,7 @@ import { SyncTelemetry } from '../telemetry'
|
|
|
3
3
|
import LokiJSAdapter from '@nozbe/watermelondb/adapters/lokijs'
|
|
4
4
|
export { LokiJSAdapter as default }
|
|
5
5
|
|
|
6
|
-
import { deleteDatabase } from '@nozbe/watermelondb/adapters/lokijs/worker/lokiExtensions'
|
|
6
|
+
import { deleteDatabase, lokiFatalError } from '@nozbe/watermelondb/adapters/lokijs/worker/lokiExtensions'
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Mute impending driver errors that are expected after sync adapter failure
|
|
@@ -75,6 +75,12 @@ export function simulateIndexedDBQuotaExceeded() {
|
|
|
75
75
|
})
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
export function abortWritesToDatabase(adapter: LokiJSAdapter) {
|
|
79
|
+
// acts as handy helper to disable loki's save methods entirely
|
|
80
|
+
lokiFatalError(adapter._driver.loki)
|
|
81
|
+
return Promise.resolve()
|
|
82
|
+
}
|
|
83
|
+
|
|
78
84
|
/**
|
|
79
85
|
* Completely destroy database, as opposed to watermelon's reset
|
|
80
86
|
* (which merely clears all records but re-initializes the database schema)
|
|
@@ -84,18 +90,18 @@ export function simulateIndexedDBQuotaExceeded() {
|
|
|
84
90
|
export function destroyDatabase(dbName: string, adapter: LokiJSAdapter): Promise<void> {
|
|
85
91
|
return new Promise(async (resolve, reject) => {
|
|
86
92
|
if (adapter._driver) {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
+
try {
|
|
94
|
+
// good manners to clear the cache, even though this adapter will likely be discarded
|
|
95
|
+
adapter._clearCachedRecords();
|
|
96
|
+
} catch (err: unknown) {
|
|
97
|
+
SyncTelemetry.getInstance()?.capture(err)
|
|
98
|
+
}
|
|
93
99
|
|
|
94
100
|
try {
|
|
95
101
|
await deleteDatabase(adapter._driver.loki)
|
|
96
102
|
return resolve();
|
|
97
103
|
} catch (err: unknown) {
|
|
98
|
-
SyncTelemetry.getInstance()?.capture(err
|
|
104
|
+
SyncTelemetry.getInstance()?.capture(err)
|
|
99
105
|
return reject(err);
|
|
100
106
|
}
|
|
101
107
|
}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import type { DatabaseAdapter } from '../adapters/factory'
|
|
2
|
-
import { Database
|
|
2
|
+
import { Database } from '@nozbe/watermelondb'
|
|
3
3
|
import * as modelClasses from '../models'
|
|
4
|
+
import type { SyncUserScope } from '../index'
|
|
4
5
|
|
|
5
|
-
export default function syncDatabaseFactory(adapter: () => DatabaseAdapter, { onInitError }: { onInitError?: (error: Error) => void } = {}) {
|
|
6
|
-
return () => {
|
|
6
|
+
export default function syncDatabaseFactory(adapter: (userScope: SyncUserScope) => DatabaseAdapter, { onInitError }: { onInitError?: (error: Error) => void } = {}) {
|
|
7
|
+
return (userScope: SyncUserScope) => {
|
|
7
8
|
try {
|
|
8
9
|
return new Database({
|
|
9
|
-
adapter: adapter(),
|
|
10
|
+
adapter: adapter(userScope),
|
|
10
11
|
modelClasses: Object.values(modelClasses)
|
|
11
12
|
})
|
|
12
13
|
} catch (error) {
|
|
@@ -69,7 +69,8 @@ type SyncPullSuccessResponse = SyncResponseBase & {
|
|
|
69
69
|
ok: true
|
|
70
70
|
entries: SyncEntry[]
|
|
71
71
|
token: SyncToken
|
|
72
|
-
previousToken: SyncToken | null
|
|
72
|
+
previousToken: SyncToken | null,
|
|
73
|
+
intendedUserId: number
|
|
73
74
|
}
|
|
74
75
|
export type SyncPullFetchFailureResponse = SyncResponseBase & {
|
|
75
76
|
ok: false,
|
|
@@ -117,8 +118,8 @@ interface ServerPushPayload {
|
|
|
117
118
|
}[]
|
|
118
119
|
}
|
|
119
120
|
|
|
120
|
-
export function makeFetchRequest(input: RequestInfo, init?: RequestInit): (session: BaseSessionProvider) => Request {
|
|
121
|
-
return (session) => new Request(globalConfig.baseUrl + input, {
|
|
121
|
+
export function makeFetchRequest(input: RequestInfo, init?: RequestInit): (userId: number, session: BaseSessionProvider) => Request {
|
|
122
|
+
return (userId, session) => new Request(globalConfig.baseUrl + input, {
|
|
122
123
|
...init,
|
|
123
124
|
headers: {
|
|
124
125
|
...init?.headers,
|
|
@@ -129,14 +130,15 @@ export function makeFetchRequest(input: RequestInfo, init?: RequestInit): (sessi
|
|
|
129
130
|
'X-Sync-Client-Id': session.getClientId(),
|
|
130
131
|
...(session.getSessionId() ? {
|
|
131
132
|
'X-Sync-Client-Session-Id': session.getSessionId()!
|
|
132
|
-
} : {})
|
|
133
|
+
} : {}),
|
|
134
|
+
'X-Sync-Intended-User-Id': userId.toString()
|
|
133
135
|
}
|
|
134
136
|
})
|
|
135
137
|
}
|
|
136
138
|
|
|
137
|
-
export function handlePull(callback: (session: BaseSessionProvider) => Request) {
|
|
138
|
-
return async function(session: BaseSessionProvider, lastFetchToken: SyncToken | null, signal?: AbortSignal): Promise<SyncPullResponse> {
|
|
139
|
-
const generatedRequest = callback(session)
|
|
139
|
+
export function handlePull(callback: (userId: number, session: BaseSessionProvider) => Request) {
|
|
140
|
+
return async function(userId: number, session: BaseSessionProvider, lastFetchToken: SyncToken | null, signal?: AbortSignal): Promise<SyncPullResponse> {
|
|
141
|
+
const generatedRequest = callback(userId, session)
|
|
140
142
|
const url = serializePullUrlQuery(generatedRequest.url, lastFetchToken)
|
|
141
143
|
const request = new Request(url, {
|
|
142
144
|
credentials: 'include',
|
|
@@ -163,6 +165,11 @@ export function handlePull(callback: (session: BaseSessionProvider) => Request)
|
|
|
163
165
|
}
|
|
164
166
|
}
|
|
165
167
|
|
|
168
|
+
if (!response.headers.get('X-Sync-Intended-User-Id')) {
|
|
169
|
+
throw new Error('Missing X-Sync-Intended-User-Id header')
|
|
170
|
+
}
|
|
171
|
+
const intendedUserId = +response.headers.get('X-Sync-Intended-User-Id')!
|
|
172
|
+
|
|
166
173
|
const json = await response.json() as RawPullResponse
|
|
167
174
|
const data = deserializePullResponse(json)
|
|
168
175
|
|
|
@@ -177,14 +184,15 @@ export function handlePull(callback: (session: BaseSessionProvider) => Request)
|
|
|
177
184
|
ok: true,
|
|
178
185
|
entries,
|
|
179
186
|
token,
|
|
180
|
-
previousToken
|
|
187
|
+
previousToken,
|
|
188
|
+
intendedUserId
|
|
181
189
|
}
|
|
182
190
|
}
|
|
183
191
|
}
|
|
184
192
|
|
|
185
|
-
export function handlePush(callback: (session: BaseSessionProvider) => Request) {
|
|
186
|
-
return async function(session: BaseSessionProvider, payload: PushPayload, signal?: AbortSignal): Promise<SyncPushResponse> {
|
|
187
|
-
const generatedRequest = callback(session)
|
|
193
|
+
export function handlePush(callback: (userId: number, session: BaseSessionProvider) => Request) {
|
|
194
|
+
return async function(userId: number, session: BaseSessionProvider, payload: PushPayload, signal?: AbortSignal): Promise<SyncPushResponse> {
|
|
195
|
+
const generatedRequest = callback(userId, session)
|
|
188
196
|
const serverPayload = serializePushPayload(payload)
|
|
189
197
|
const request = new Request(generatedRequest, {
|
|
190
198
|
credentials: 'include',
|
|
@@ -4,6 +4,8 @@ import { Q, RecordId } from "@nozbe/watermelondb"
|
|
|
4
4
|
import { type ModelSerialized, type RawSerialized } from "./serializers"
|
|
5
5
|
import BaseModel from "./models/Base"
|
|
6
6
|
|
|
7
|
+
export type SyncUserScope = { initialId: number, getCurrentId: () => number }
|
|
8
|
+
|
|
7
9
|
export { default as db } from './repository-proxy'
|
|
8
10
|
export { Q }
|
|
9
11
|
|
|
@@ -5,7 +5,7 @@ import SyncRunScope from './run-scope'
|
|
|
5
5
|
import { SyncStrategy } from './strategies'
|
|
6
6
|
import { default as SyncStore, SyncStoreConfig } from './store'
|
|
7
7
|
|
|
8
|
-
import { ModelClass } from './index'
|
|
8
|
+
import { ModelClass, SyncUserScope } from './index'
|
|
9
9
|
import SyncRetry from './retry'
|
|
10
10
|
import SyncContext from './context'
|
|
11
11
|
import { SyncError } from './errors'
|
|
@@ -14,6 +14,8 @@ import { SyncTelemetry } from './telemetry/index'
|
|
|
14
14
|
import createStoreConfigs from './store-configs'
|
|
15
15
|
import { contentProgressObserver } from '../awards/internal/content-progress-observer'
|
|
16
16
|
|
|
17
|
+
export type SyncTeardownMode = 'reset' | 'destroyOrReset' | 'abortWrites'
|
|
18
|
+
|
|
17
19
|
export default class SyncManager {
|
|
18
20
|
private static counter = 0
|
|
19
21
|
private static instance: SyncManager | null = null
|
|
@@ -22,13 +24,13 @@ export default class SyncManager {
|
|
|
22
24
|
if (SyncManager.instance) {
|
|
23
25
|
throw new SyncError('SyncManager already initialized')
|
|
24
26
|
}
|
|
25
|
-
|
|
26
27
|
SyncManager.instance = instance
|
|
28
|
+
|
|
27
29
|
const teardown = instance.setup()
|
|
28
30
|
|
|
29
|
-
return (
|
|
31
|
+
return async (mode: SyncTeardownMode = 'reset') => {
|
|
30
32
|
SyncManager.instance = null
|
|
31
|
-
return teardown(
|
|
33
|
+
return teardown(mode).catch((error) => {
|
|
32
34
|
SyncManager.instance = instance // restore instance on teardown failure
|
|
33
35
|
throw error
|
|
34
36
|
})
|
|
@@ -49,6 +51,8 @@ export default class SyncManager {
|
|
|
49
51
|
private id: string
|
|
50
52
|
public telemetry: SyncTelemetry
|
|
51
53
|
private context: SyncContext
|
|
54
|
+
private userScope: SyncUserScope
|
|
55
|
+
|
|
52
56
|
private storeConfigsRegistry: Record<string, SyncStoreConfig<any>>
|
|
53
57
|
private storesRegistry: Record<string, SyncStore<any>>
|
|
54
58
|
private runScope: SyncRunScope
|
|
@@ -56,17 +60,30 @@ export default class SyncManager {
|
|
|
56
60
|
private strategyMap: { models: ModelClass[]; strategies: SyncStrategy[] }[]
|
|
57
61
|
private effectMap: { models: ModelClass[]; effects: SyncEffect[] }[]
|
|
58
62
|
|
|
59
|
-
private initDatabase: () => Database
|
|
63
|
+
private initDatabase: (userScope: SyncUserScope) => Database
|
|
60
64
|
private destroyDatabase?: (dbName: string, adapter: DatabaseAdapter) => Promise<void>
|
|
61
|
-
|
|
62
|
-
|
|
65
|
+
private abortWritesToDatabase?: (adapter: DatabaseAdapter) => Promise<void>
|
|
66
|
+
|
|
67
|
+
private teardownPromise: Promise<void> | null = null
|
|
68
|
+
|
|
69
|
+
constructor(
|
|
70
|
+
userScope: SyncUserScope,
|
|
71
|
+
context: SyncContext,
|
|
72
|
+
initDatabase: (userScope: SyncUserScope) => Database,
|
|
73
|
+
teardownDatabase: {
|
|
74
|
+
destroy?: (dbName: string, adapter: DatabaseAdapter) => Promise<void>
|
|
75
|
+
abort?: (adapter: DatabaseAdapter) => Promise<void>
|
|
76
|
+
} = {}
|
|
77
|
+
) {
|
|
63
78
|
this.id = (SyncManager.counter++).toString()
|
|
64
79
|
|
|
65
80
|
this.telemetry = SyncTelemetry.getInstance()!
|
|
66
81
|
this.context = context
|
|
82
|
+
this.userScope = userScope
|
|
67
83
|
|
|
68
84
|
this.initDatabase = initDatabase
|
|
69
|
-
this.destroyDatabase =
|
|
85
|
+
this.destroyDatabase = teardownDatabase.destroy
|
|
86
|
+
this.abortWritesToDatabase = teardownDatabase.abort
|
|
70
87
|
|
|
71
88
|
this.storeConfigsRegistry = this.registerStoreConfigs(createStoreConfigs())
|
|
72
89
|
this.storesRegistry = {}
|
|
@@ -88,6 +105,7 @@ export default class SyncManager {
|
|
|
88
105
|
createStore(config: SyncStoreConfig, database: Database) {
|
|
89
106
|
return new SyncStore(
|
|
90
107
|
config,
|
|
108
|
+
this.userScope,
|
|
91
109
|
this.context,
|
|
92
110
|
database,
|
|
93
111
|
this.retry,
|
|
@@ -124,7 +142,10 @@ export default class SyncManager {
|
|
|
124
142
|
|
|
125
143
|
// can fail synchronously immediately (e.g., schema/migration validation errors)
|
|
126
144
|
// or asynchronously (e.g., indexedDB errors synchronously OR asynchronously (!))
|
|
127
|
-
const database = this.telemetry.trace(
|
|
145
|
+
const database = this.telemetry.trace(
|
|
146
|
+
{ name: 'db:init', attributes: { ...this.context.session.toJSON() } },
|
|
147
|
+
() => this.initDatabase(this.userScope)
|
|
148
|
+
)
|
|
128
149
|
|
|
129
150
|
Object.entries(this.storeConfigsRegistry).forEach(([table, storeConfig]) => {
|
|
130
151
|
this.storesRegistry[table] = this.createStore(storeConfig, database)
|
|
@@ -146,49 +167,101 @@ export default class SyncManager {
|
|
|
146
167
|
})
|
|
147
168
|
|
|
148
169
|
const effectTeardowns = this.effectMap.flatMap(({ models, effects }) => {
|
|
149
|
-
return effects.map((effect) =>
|
|
150
|
-
|
|
170
|
+
return effects.map((effect) =>
|
|
171
|
+
effect(
|
|
172
|
+
this.context,
|
|
173
|
+
models.map((model) => this.storesRegistry[model.table])
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
})
|
|
151
177
|
|
|
152
178
|
contentProgressObserver.start(database).catch((error) => {
|
|
153
179
|
this.telemetry.error('[SyncManager] Failed to start contentProgressObserver', error)
|
|
154
180
|
})
|
|
155
181
|
|
|
156
|
-
const teardown = async (
|
|
157
|
-
this.
|
|
182
|
+
const teardown = async (mode: SyncTeardownMode = 'reset') => {
|
|
183
|
+
if (this.teardownPromise) {
|
|
184
|
+
this.telemetry.debug(
|
|
185
|
+
'[SyncManager] Teardown already in progress, returning existing promise'
|
|
186
|
+
)
|
|
187
|
+
return this.teardownPromise
|
|
188
|
+
}
|
|
158
189
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
190
|
+
this.teardownPromise = (async () => {
|
|
191
|
+
this.telemetry.debug('[SyncManager] Tearing down')
|
|
192
|
+
|
|
193
|
+
const clear = (mode: SyncTeardownMode) => {
|
|
194
|
+
if (mode === 'abortWrites') {
|
|
195
|
+
if (!this.abortWritesToDatabase) {
|
|
196
|
+
throw new SyncError('Cannot abort writes to database - implementation not provided')
|
|
197
|
+
}
|
|
198
|
+
if (!database.adapter.underlyingAdapter) {
|
|
199
|
+
throw new SyncError('Cannot abort writes to database - adapter not available')
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return this.abortWritesToDatabase(database.adapter.underlyingAdapter)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (mode === 'destroyOrReset') {
|
|
206
|
+
try {
|
|
207
|
+
if (!this.destroyDatabase) {
|
|
208
|
+
throw new SyncError(
|
|
209
|
+
'Cannot destroy or reset database - destroy implementation not provided'
|
|
210
|
+
)
|
|
211
|
+
}
|
|
212
|
+
if (!database.adapter.underlyingAdapter) {
|
|
213
|
+
throw new SyncError('Cannot destroy database - adapter not available')
|
|
214
|
+
}
|
|
215
|
+
if (!database.adapter.dbName) {
|
|
216
|
+
throw new SyncError('Cannot destroy database - dbName not available')
|
|
217
|
+
}
|
|
218
|
+
return this.destroyDatabase(
|
|
219
|
+
database.adapter.dbName,
|
|
220
|
+
database.adapter.underlyingAdapter
|
|
221
|
+
)
|
|
222
|
+
} catch (err: unknown) {
|
|
223
|
+
this.telemetry.capture(err)
|
|
224
|
+
return database.write(() => database.unsafeResetDatabase())
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return database.write(() => database.unsafeResetDatabase()).then(() =>{
|
|
229
|
+
// destroy the db anyways
|
|
230
|
+
// useful if we're using user-namespaced dbs
|
|
231
|
+
if (this.destroyDatabase && database.adapter.dbName && database.adapter.underlyingAdapter) {
|
|
232
|
+
return this.destroyDatabase(
|
|
233
|
+
database.adapter.dbName,
|
|
234
|
+
database.adapter.underlyingAdapter
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
})
|
|
164
238
|
}
|
|
165
|
-
}
|
|
166
239
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
240
|
+
try {
|
|
241
|
+
Object.values(this.storesRegistry).forEach((store) => {
|
|
242
|
+
store.destroy()
|
|
243
|
+
})
|
|
171
244
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
245
|
+
this.runScope.abort()
|
|
246
|
+
this.strategyMap.forEach(({ strategies }) =>
|
|
247
|
+
strategies.forEach((strategy) => strategy.stop())
|
|
248
|
+
)
|
|
249
|
+
effectTeardowns.forEach((teardown) => teardown())
|
|
250
|
+
this.retry.stop()
|
|
251
|
+
this.context.stop()
|
|
252
|
+
|
|
253
|
+
contentProgressObserver.stop()
|
|
254
|
+
} catch (error) {
|
|
255
|
+
// capture, but don't rethrow
|
|
256
|
+
this.telemetry.capture(error)
|
|
257
|
+
}
|
|
177
258
|
|
|
178
|
-
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
}
|
|
259
|
+
return clear(mode)
|
|
260
|
+
})().finally(() => {
|
|
261
|
+
this.teardownPromise = null
|
|
262
|
+
})
|
|
183
263
|
|
|
184
|
-
|
|
185
|
-
return clear(force);
|
|
186
|
-
} catch (error) {
|
|
187
|
-
if (!force) {
|
|
188
|
-
return clear(true);
|
|
189
|
-
}
|
|
190
|
-
throw error
|
|
191
|
-
}
|
|
264
|
+
return this.teardownPromise
|
|
192
265
|
}
|
|
193
266
|
|
|
194
267
|
return teardown
|
|
@@ -242,7 +242,7 @@ export default class SyncRepository<TModel extends BaseModel> {
|
|
|
242
242
|
return result
|
|
243
243
|
}
|
|
244
244
|
|
|
245
|
-
requestPushUnsynced() {
|
|
246
|
-
this.store.pushUnsyncedWithRetry()
|
|
245
|
+
requestPushUnsynced(cause?: string) {
|
|
246
|
+
this.store.pushUnsyncedWithRetry(undefined, { type: 'repo-push-request', cause })
|
|
247
247
|
}
|
|
248
248
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Database, Q, Query, type Collection, type RecordId } from '@nozbe/watermelondb'
|
|
2
2
|
import { RawSerializer, ModelSerializer } from '../serializers'
|
|
3
|
-
import { ModelClass, SyncToken, SyncEntry,
|
|
3
|
+
import { ModelClass, SyncToken, SyncEntry, SyncUserScope, SyncContext, EpochMs } from '..'
|
|
4
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'
|
|
@@ -18,11 +18,13 @@ import type LokiJSAdapter from '@nozbe/watermelondb/adapters/lokijs'
|
|
|
18
18
|
import { SyncError } from '../errors'
|
|
19
19
|
|
|
20
20
|
type SyncPull = (
|
|
21
|
+
intendedUserId: number,
|
|
21
22
|
session: BaseSessionProvider,
|
|
22
23
|
previousFetchToken: SyncToken | null,
|
|
23
24
|
signal: AbortSignal
|
|
24
25
|
) => Promise<SyncPullResponse>
|
|
25
26
|
type SyncPush = (
|
|
27
|
+
intendedUserId: number,
|
|
26
28
|
session: BaseSessionProvider,
|
|
27
29
|
payload: PushPayload,
|
|
28
30
|
signal: AbortSignal
|
|
@@ -42,6 +44,7 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
42
44
|
static readonly CLEANUP_INTERVAL = 60_000 * 60 // 1hr
|
|
43
45
|
|
|
44
46
|
readonly telemetry: SyncTelemetry
|
|
47
|
+
readonly userScope: SyncUserScope
|
|
45
48
|
readonly context: SyncContext
|
|
46
49
|
readonly retry: SyncRetry
|
|
47
50
|
readonly runScope: SyncRunScope
|
|
@@ -67,12 +70,14 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
67
70
|
|
|
68
71
|
constructor(
|
|
69
72
|
{ model, comparator, pull, push }: SyncStoreConfig<TModel>,
|
|
73
|
+
userScope: SyncUserScope,
|
|
70
74
|
context: SyncContext,
|
|
71
75
|
db: Database,
|
|
72
76
|
retry: SyncRetry,
|
|
73
77
|
runScope: SyncRunScope,
|
|
74
78
|
telemetry: SyncTelemetry
|
|
75
79
|
) {
|
|
80
|
+
this.userScope = userScope
|
|
76
81
|
this.context = context
|
|
77
82
|
this.retry = retry
|
|
78
83
|
this.runScope = runScope
|
|
@@ -113,7 +118,7 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
113
118
|
let pushError: any = null
|
|
114
119
|
|
|
115
120
|
try {
|
|
116
|
-
await this.pushUnsyncedWithRetry(span)
|
|
121
|
+
await this.pushUnsyncedWithRetry(span, { type: 'sync-request', reason })
|
|
117
122
|
} catch (err) {
|
|
118
123
|
pushError = err
|
|
119
124
|
}
|
|
@@ -135,7 +140,7 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
135
140
|
this.telemetry.trace(
|
|
136
141
|
{ name: `sync:${this.model.table}`, op: 'push', attributes: { ...ctx, ...this.context.session.toJSON() } },
|
|
137
142
|
async span => {
|
|
138
|
-
await this.pushUnsyncedWithRetry(span)
|
|
143
|
+
await this.pushUnsyncedWithRetry(span, { type: 'push-request', reason })
|
|
139
144
|
}
|
|
140
145
|
)
|
|
141
146
|
}, { table: this.model.table, reason })
|
|
@@ -199,14 +204,14 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
199
204
|
|
|
200
205
|
async insertOne(builder: (record: TModel) => void, span?: Span) {
|
|
201
206
|
return await this.runScope.abortable(async () => {
|
|
202
|
-
const record = await this.
|
|
207
|
+
const record = await this.paranoidWrite(span, async () => {
|
|
203
208
|
return this.collection.create(rec => {
|
|
204
209
|
builder(rec)
|
|
205
210
|
})
|
|
206
211
|
})
|
|
207
212
|
this.emit('upserted', [record])
|
|
208
213
|
|
|
209
|
-
this.pushUnsyncedWithRetry(span)
|
|
214
|
+
this.pushUnsyncedWithRetry(span, { type: 'insertOne', recordId: record.id })
|
|
210
215
|
await this.ensurePersistence()
|
|
211
216
|
|
|
212
217
|
return this.modelSerializer.toPlainObject(record)
|
|
@@ -221,12 +226,12 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
221
226
|
throw new SyncError('Record not found', { id })
|
|
222
227
|
}
|
|
223
228
|
|
|
224
|
-
const record = await this.
|
|
229
|
+
const record = await this.paranoidWrite(span, async () => {
|
|
225
230
|
return found.update(builder)
|
|
226
231
|
})
|
|
227
232
|
this.emit('upserted', [record])
|
|
228
233
|
|
|
229
|
-
this.pushUnsyncedWithRetry(span)
|
|
234
|
+
this.pushUnsyncedWithRetry(span, { type: 'updateOneId', recordId: record.id })
|
|
230
235
|
await this.ensurePersistence()
|
|
231
236
|
|
|
232
237
|
return this.modelSerializer.toPlainObject(record)
|
|
@@ -239,7 +244,7 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
239
244
|
return await this.runScope.abortable(async () => {
|
|
240
245
|
const ids = Object.keys(builders)
|
|
241
246
|
|
|
242
|
-
const records = await this.
|
|
247
|
+
const records = await this.paranoidWrite(span, async writer => {
|
|
243
248
|
const existing = await writer.callReader(() => this.queryMaybeDeletedRecords(Q.where('id', Q.oneOf(ids))))
|
|
244
249
|
const existingMap = existing.reduce((map, record) => map.set(record.id, record), new Map<RecordId, TModel>())
|
|
245
250
|
|
|
@@ -297,7 +302,7 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
297
302
|
this.emit('upserted', records)
|
|
298
303
|
|
|
299
304
|
if (!skipPush) {
|
|
300
|
-
this.pushUnsyncedWithRetry(span)
|
|
305
|
+
this.pushUnsyncedWithRetry(span, { type: 'upsertSome', recordIds: records.map(r => r.id).join(',') })
|
|
301
306
|
}
|
|
302
307
|
await this.ensurePersistence()
|
|
303
308
|
|
|
@@ -324,7 +329,7 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
324
329
|
return await this.runScope.abortable(async () => {
|
|
325
330
|
let record: TModel | null = null
|
|
326
331
|
|
|
327
|
-
await this.
|
|
332
|
+
await this.paranoidWrite(span, async writer => {
|
|
328
333
|
const existing = await writer.callReader(() => this.queryMaybeDeletedRecords(Q.where('id', id))).then(
|
|
329
334
|
(records) => records[0] || null
|
|
330
335
|
)
|
|
@@ -346,7 +351,7 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
346
351
|
this.emit('deleted', [id])
|
|
347
352
|
|
|
348
353
|
if (!skipPush) {
|
|
349
|
-
this.pushUnsyncedWithRetry(span)
|
|
354
|
+
this.pushUnsyncedWithRetry(span, { type: 'deleteOne', recordId: id })
|
|
350
355
|
}
|
|
351
356
|
await this.ensurePersistence()
|
|
352
357
|
|
|
@@ -356,7 +361,7 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
356
361
|
|
|
357
362
|
async deleteSome(ids: RecordId[], span?: Span, { skipPush = false } = {}) {
|
|
358
363
|
return this.runScope.abortable(async () => {
|
|
359
|
-
await this.
|
|
364
|
+
await this.paranoidWrite(span, async writer => {
|
|
360
365
|
const existing = await this.queryRecords(Q.where('id', Q.oneOf(ids)))
|
|
361
366
|
|
|
362
367
|
await writer.batch(...existing.map(record => record.prepareMarkAsDeleted()))
|
|
@@ -365,7 +370,7 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
365
370
|
this.emit('deleted', ids)
|
|
366
371
|
|
|
367
372
|
if (!skipPush) {
|
|
368
|
-
this.pushUnsyncedWithRetry(span)
|
|
373
|
+
this.pushUnsyncedWithRetry(span, { type: 'deleteSome', recordIds: ids.join(',') })
|
|
369
374
|
}
|
|
370
375
|
await this.ensurePersistence()
|
|
371
376
|
|
|
@@ -379,7 +384,7 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
379
384
|
|
|
380
385
|
async restoreSome(ids: RecordId[], span?: Span) {
|
|
381
386
|
return this.runScope.abortable(async () => {
|
|
382
|
-
const records = await this.
|
|
387
|
+
const records = await this.paranoidWrite(span, async writer => {
|
|
383
388
|
const records = await writer.callReader(() => this.queryMaybeDeletedRecords(
|
|
384
389
|
Q.where('id', Q.oneOf(ids)),
|
|
385
390
|
Q.where('_status', 'deleted')
|
|
@@ -401,7 +406,7 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
401
406
|
|
|
402
407
|
this.emit('upserted', records)
|
|
403
408
|
|
|
404
|
-
this.pushUnsyncedWithRetry(span)
|
|
409
|
+
this.pushUnsyncedWithRetry(span, { type: 'restoreSome', recordIds: ids.join(',') })
|
|
405
410
|
await this.ensurePersistence()
|
|
406
411
|
|
|
407
412
|
return records.map((record) => this.modelSerializer.toPlainObject(record))
|
|
@@ -410,7 +415,7 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
410
415
|
|
|
411
416
|
async importUpsert(recordRaws: TModel['_raw'][]) {
|
|
412
417
|
await this.runScope.abortable(async () => {
|
|
413
|
-
await this.
|
|
418
|
+
await this.paranoidWrite(undefined, async writer => {
|
|
414
419
|
const ids = recordRaws.map(r => r.id)
|
|
415
420
|
const existingMap = await writer.callReader(() => this.queryMaybeDeletedRecords(Q.where('id', Q.oneOf(ids)))).then(records => {
|
|
416
421
|
return records.reduce((map, record) => map.set(record.id, record), new Map<RecordId, TModel>())
|
|
@@ -469,7 +474,7 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
469
474
|
}
|
|
470
475
|
async importDeletion(ids: RecordId[]) {
|
|
471
476
|
await this.runScope.abortable(async () => {
|
|
472
|
-
await this.
|
|
477
|
+
await this.paranoidWrite(undefined, async writer => {
|
|
473
478
|
const existingMap = await writer.callReader(() => this.queryMaybeDeletedRecords(Q.where('id', Q.oneOf(ids)))).then(records => {
|
|
474
479
|
return records.reduce((map, record) => map.set(record.id, record), new Map<RecordId, TModel>())
|
|
475
480
|
})
|
|
@@ -516,7 +521,7 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
516
521
|
)()
|
|
517
522
|
}
|
|
518
523
|
|
|
519
|
-
public async pushUnsyncedWithRetry(span?: Span) {
|
|
524
|
+
public async pushUnsyncedWithRetry(span?: Span, cause?: string | Record<string, string>) {
|
|
520
525
|
const records = await this.queryMaybeDeletedRecords(Q.where('_status', Q.notEq('synced')))
|
|
521
526
|
|
|
522
527
|
if (records.length) {
|
|
@@ -529,8 +534,9 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
529
534
|
this.pushCoalescer.push(
|
|
530
535
|
records,
|
|
531
536
|
queueThrottle({ state: this.pushThrottleState }, () => {
|
|
537
|
+
const causeAttrs = typeof cause === 'string' ? { type: cause } : cause ?? {}
|
|
532
538
|
return this.retry.request<SyncPushResponse>(
|
|
533
|
-
{ name: `push:${this.model.table}`, op: 'push', parentSpan: span },
|
|
539
|
+
{ name: `push:${this.model.table}`, op: 'push', parentSpan: span, attributes: { ...causeAttrs } },
|
|
534
540
|
async (span) => {
|
|
535
541
|
// re-query records since this fn may be deferred due to throttling/retries
|
|
536
542
|
const currentRecords = await this.queryMaybeDeletedRecords(Q.where('id', Q.oneOf(recordIds)))
|
|
@@ -571,10 +577,21 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
571
577
|
attributes: { lastFetchToken: lastFetchToken ?? undefined, ...this.context.session.toJSON() },
|
|
572
578
|
parentSpan: pullSpan,
|
|
573
579
|
},
|
|
574
|
-
() => this.puller(this.context.session, lastFetchToken, this.runScope.signal)
|
|
580
|
+
() => this.puller(this.userScope.initialId, this.context.session, lastFetchToken, this.runScope.signal)
|
|
575
581
|
)
|
|
576
582
|
|
|
577
583
|
if (response.ok) {
|
|
584
|
+
const initialId = this.userScope.initialId
|
|
585
|
+
const currentId = this.userScope.getCurrentId()
|
|
586
|
+
|
|
587
|
+
if (response.intendedUserId !== initialId || response.intendedUserId !== currentId) {
|
|
588
|
+
throw new SyncError('Intended user ID does not match', {
|
|
589
|
+
intendedUserId: response.intendedUserId,
|
|
590
|
+
initialUserId: initialId,
|
|
591
|
+
currentUserId: currentId
|
|
592
|
+
})
|
|
593
|
+
}
|
|
594
|
+
|
|
578
595
|
await this.writeEntries(response.entries, !response.previousToken, pullSpan)
|
|
579
596
|
await this.setLastFetchToken(response.token)
|
|
580
597
|
|
|
@@ -604,7 +621,7 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
604
621
|
attributes: { ...this.context.session.toJSON() },
|
|
605
622
|
parentSpan: pushSpan,
|
|
606
623
|
},
|
|
607
|
-
() => this.pusher(this.context.session, payload, this.runScope.signal)
|
|
624
|
+
() => this.pusher(this.userScope.initialId, this.context.session, payload, this.runScope.signal)
|
|
608
625
|
)
|
|
609
626
|
|
|
610
627
|
if (response.ok) {
|
|
@@ -620,7 +637,7 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
620
637
|
if (failedResults.length) {
|
|
621
638
|
this.telemetry.warn(
|
|
622
639
|
`[store:${this.model.table}] Push completed with failed records`,
|
|
623
|
-
{ results: failedResults }
|
|
640
|
+
{ results: failedResults, resultsJSON: JSON.stringify(failedResults) }
|
|
624
641
|
)
|
|
625
642
|
}
|
|
626
643
|
|
|
@@ -775,10 +792,22 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
775
792
|
})
|
|
776
793
|
}
|
|
777
794
|
|
|
778
|
-
|
|
795
|
+
/**
|
|
796
|
+
* Performs telemetry-wrapped write, crucially checking if the user has changed
|
|
797
|
+
*/
|
|
798
|
+
private paranoidWrite<T>(
|
|
779
799
|
parentSpan: Span | undefined,
|
|
780
800
|
work: (writer: WriterInterface) => Promise<T>
|
|
781
801
|
): Promise<T> {
|
|
802
|
+
const initialId = this.userScope.initialId
|
|
803
|
+
const currentId = this.userScope.getCurrentId()
|
|
804
|
+
if (initialId !== currentId) {
|
|
805
|
+
throw new SyncError('Aborted cross-user write operation', {
|
|
806
|
+
initialId,
|
|
807
|
+
currentId,
|
|
808
|
+
})
|
|
809
|
+
}
|
|
810
|
+
|
|
782
811
|
return this.telemetry.trace(
|
|
783
812
|
{ name: `write:${this.model.table}`, op: 'write', parentSpan, attributes: { ...this.context.session.toJSON() } },
|
|
784
813
|
(writeSpan) => {
|
|
@@ -799,7 +828,7 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
799
828
|
|
|
800
829
|
private async writeEntries(entries: SyncEntry[], freshSync: boolean = false, parentSpan?: Span) {
|
|
801
830
|
await this.runScope.abortable(async () => {
|
|
802
|
-
return this.
|
|
831
|
+
return this.paranoidWrite(parentSpan, async (writer) => {
|
|
803
832
|
const batches = await this.buildWriteBatchesFromEntries(writer, entries, freshSync)
|
|
804
833
|
|
|
805
834
|
for (const batch of batches) {
|
|
@@ -945,7 +974,7 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
945
974
|
private startCleanupTimer() {
|
|
946
975
|
this.cleanupTimer = setInterval(() => {
|
|
947
976
|
this.runScope.abortable(async () => {
|
|
948
|
-
this.
|
|
977
|
+
this.paranoidWrite(undefined, async (writer) => {
|
|
949
978
|
await this.cleanupOldDeletedRecords(writer)
|
|
950
979
|
})
|
|
951
980
|
})
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { SyncUserScope } from '../index'
|
|
1
2
|
import watermelonLogger from '@nozbe/watermelondb/utils/common/logger'
|
|
2
3
|
import { SyncError, SyncUnexpectedError } from '../errors'
|
|
3
4
|
|
|
@@ -6,6 +7,7 @@ export type SentryBrowserOptions = NonNullable<Parameters<typeof InjectedSentry.
|
|
|
6
7
|
|
|
7
8
|
export type SentryLike = {
|
|
8
9
|
captureException: typeof InjectedSentry.captureException
|
|
10
|
+
captureMessage: typeof InjectedSentry.captureMessage
|
|
9
11
|
addBreadcrumb: typeof InjectedSentry.addBreadcrumb
|
|
10
12
|
startSpan: typeof InjectedSentry.startSpan
|
|
11
13
|
}
|
|
@@ -24,6 +26,15 @@ export enum SeverityLevel {
|
|
|
24
26
|
FATAL = 5,
|
|
25
27
|
}
|
|
26
28
|
|
|
29
|
+
const severityLevelToSentryLevel: Record<SeverityLevel, 'fatal' | 'error' | 'warning' | 'log' | 'info' | 'debug'> = {
|
|
30
|
+
[SeverityLevel.DEBUG]: 'debug',
|
|
31
|
+
[SeverityLevel.INFO]: 'info',
|
|
32
|
+
[SeverityLevel.LOG]: 'log',
|
|
33
|
+
[SeverityLevel.WARNING]: 'warning',
|
|
34
|
+
[SeverityLevel.ERROR]: 'error',
|
|
35
|
+
[SeverityLevel.FATAL]: 'fatal',
|
|
36
|
+
}
|
|
37
|
+
|
|
27
38
|
export class SyncTelemetry {
|
|
28
39
|
private static instance: SyncTelemetry | null = null
|
|
29
40
|
|
|
@@ -40,7 +51,7 @@ export class SyncTelemetry {
|
|
|
40
51
|
SyncTelemetry.instance = null
|
|
41
52
|
}
|
|
42
53
|
|
|
43
|
-
private
|
|
54
|
+
private userScope: SyncUserScope
|
|
44
55
|
private Sentry: SentryLike
|
|
45
56
|
private level: SeverityLevel
|
|
46
57
|
private pretty: boolean
|
|
@@ -51,14 +62,14 @@ export class SyncTelemetry {
|
|
|
51
62
|
private _ignoreConsole = false
|
|
52
63
|
|
|
53
64
|
constructor(
|
|
54
|
-
|
|
65
|
+
userScope: SyncUserScope,
|
|
55
66
|
{
|
|
56
67
|
Sentry,
|
|
57
68
|
level,
|
|
58
69
|
pretty,
|
|
59
70
|
}: { Sentry: SentryLike; level?: keyof typeof SeverityLevel; pretty?: boolean }
|
|
60
71
|
) {
|
|
61
|
-
this.
|
|
72
|
+
this.userScope = userScope
|
|
62
73
|
this.Sentry = Sentry
|
|
63
74
|
this.level =
|
|
64
75
|
typeof level !== 'undefined' && level in SeverityLevel
|
|
@@ -78,7 +89,8 @@ export class SyncTelemetry {
|
|
|
78
89
|
op: `${SYNC_TELEMETRY_TRACE_PREFIX}${opts.op}`,
|
|
79
90
|
attributes: {
|
|
80
91
|
...opts.attributes,
|
|
81
|
-
|
|
92
|
+
'user.initialId': this.userScope.initialId,
|
|
93
|
+
'user.currentId': this.userScope.getCurrentId(),
|
|
82
94
|
},
|
|
83
95
|
}
|
|
84
96
|
return this.Sentry.startSpan<T>(options, (span) => {
|
|
@@ -93,7 +105,7 @@ export class SyncTelemetry {
|
|
|
93
105
|
})
|
|
94
106
|
}
|
|
95
107
|
|
|
96
|
-
capture(err:
|
|
108
|
+
capture(err: unknown, context = {}) {
|
|
97
109
|
const wrapped =
|
|
98
110
|
err instanceof SyncError ? err : new SyncUnexpectedError((err as Error).message, context)
|
|
99
111
|
|
|
@@ -110,7 +122,7 @@ export class SyncTelemetry {
|
|
|
110
122
|
)
|
|
111
123
|
|
|
112
124
|
this._ignoreConsole = true
|
|
113
|
-
this.error(err.message)
|
|
125
|
+
this.error(err instanceof Error ? err.message : String(err))
|
|
114
126
|
this._ignoreConsole = false
|
|
115
127
|
}
|
|
116
128
|
|
|
@@ -155,11 +167,11 @@ export class SyncTelemetry {
|
|
|
155
167
|
this._log(SeverityLevel.WARNING, 'warn', message, extra)
|
|
156
168
|
}
|
|
157
169
|
|
|
158
|
-
error(message: unknown
|
|
170
|
+
error(message: unknown, extra?: any) {
|
|
159
171
|
this._log(SeverityLevel.ERROR, 'error', message, extra)
|
|
160
172
|
}
|
|
161
173
|
|
|
162
|
-
fatal(message: unknown
|
|
174
|
+
fatal(message: unknown, extra?: any) {
|
|
163
175
|
this._log(SeverityLevel.FATAL, 'error', message, extra)
|
|
164
176
|
}
|
|
165
177
|
|
|
@@ -169,7 +181,12 @@ export class SyncTelemetry {
|
|
|
169
181
|
this._ignoreConsole = true
|
|
170
182
|
console[consoleMethod](...this.formattedConsoleMessage(message, extra))
|
|
171
183
|
this._ignoreConsole = false
|
|
172
|
-
|
|
184
|
+
|
|
185
|
+
if (level >= SeverityLevel.WARNING) {
|
|
186
|
+
this.Sentry.captureMessage(message instanceof Error ? message.message : String(message), severityLevelToSentryLevel[level])
|
|
187
|
+
} else {
|
|
188
|
+
this.Sentry.addBreadcrumb({ message: message instanceof Error ? message.message : String(message), level: severityLevelToSentryLevel[level] })
|
|
189
|
+
}
|
|
173
190
|
}
|
|
174
191
|
|
|
175
192
|
private formattedConsoleMessage(message: unknown, extra: any) {
|