219 lines
11 KiB
JavaScript
219 lines
11 KiB
JavaScript
const { sha256Hex } = require('./identityHash')
|
|
const { CONTENT_VERSION, emptyProgress, projectProgress } = require('./progressContract')
|
|
const copy = value => JSON.parse(JSON.stringify(value))
|
|
const KEY = 'tang-xuetang-v1:'
|
|
|
|
// Dependency-injected for offline tests. Tokens remain in the existing host key
|
|
// and request header only; local slots use a SHA-256 fingerprint, not the token.
|
|
function createPlatformBridge(platform, config) {
|
|
let active = null
|
|
const listeners = new Set()
|
|
function token() { try { return String(platform.getStorageSync('token') || '') } catch (_) { return '' } }
|
|
function read(key, fallback) { try { return platform.getStorageSync(key) || fallback } catch (_) { return fallback } }
|
|
function write(key, value) { try { platform.setStorageSync(key, copy(value)); return true } catch (_) { return false } }
|
|
function context() {
|
|
const current = token()
|
|
if (active && active.token === current) return active
|
|
if (active && active.timer) clearTimeout(active.timer)
|
|
const scope = KEY + (current ? sha256Hex(current) : 'guest')
|
|
const mapped = current ? read(scope + ':user', null) : null
|
|
const key = Number.isSafeInteger(mapped) && mapped > 0 ? KEY + 'user:' + mapped : scope
|
|
const cached = read(key, {})
|
|
active = { token: current, scope, key, verified: false, opening: null, flushing: null, timer: null,
|
|
status: current ? 'offline' : 'guest', remote: null,
|
|
state: { progress: emptyProgress(), revision: 0, story_generation: 0, user_id: null,
|
|
dirty: false, resetPending: false, serial: 0, pending: null, ...cached } }
|
|
return active
|
|
}
|
|
const current = c => active === c && token() === c.token
|
|
function notify(c, status) {
|
|
if (!current(c)) return
|
|
c.status = status
|
|
listeners.forEach(listener => { try { listener(status) } catch (_) {} })
|
|
}
|
|
function persist(c) {
|
|
const ok = write(c.key, c.state)
|
|
if (!ok) notify(c, 'storage-error')
|
|
return ok
|
|
}
|
|
async function request(c, path, method = 'GET', data) {
|
|
if (!current(c) || !c.token) throw new Error('SESSION_CHANGED')
|
|
return new Promise((resolve, reject) => platform.request({
|
|
url: String(config.apiBaseUrl).replace(/\/+$/, '') + '/api/tang/' + path,
|
|
method, data, timeout: 8000,
|
|
header: { token: c.token, 'content-type': 'application/json' },
|
|
success(result) {
|
|
if (!current(c)) return reject(new Error('SESSION_CHANGED'))
|
|
const body = result.data
|
|
if (result.statusCode !== 200 || !body || typeof body !== 'object') return reject(new Error('API_UNAVAILABLE'))
|
|
if (body.code === -1) { c.verified = false; notify(c, 'auth-expired'); return reject(new Error('AUTH_EXPIRED')) }
|
|
if (body.code !== 1) {
|
|
const error = new Error(body.data && body.data.error_code || 'API_UNAVAILABLE')
|
|
return reject(error)
|
|
}
|
|
resolve(body.data)
|
|
}, fail() { reject(new Error('NETWORK_UNAVAILABLE')) },
|
|
}))
|
|
}
|
|
function validateRemote(value) {
|
|
if (!value || value.schema_version !== 1 || value.content_version !== CONTENT_VERSION
|
|
|| !Number.isSafeInteger(value.user_id) || value.user_id <= 0
|
|
|| !Number.isSafeInteger(value.revision) || value.revision < 0
|
|
|| !Number.isSafeInteger(value.story_generation) || value.story_generation < 0
|
|
|| !value.progress || typeof value.progress !== 'object') throw new Error('CONTENT_MISMATCH')
|
|
return { ...value, progress: projectProgress(value.progress) }
|
|
}
|
|
function hydrate(c, remote) {
|
|
c.state.progress = { ...c.state.progress, ...remote.progress }
|
|
c.state.revision = remote.revision
|
|
c.state.story_generation = remote.story_generation
|
|
c.state.user_id = remote.user_id
|
|
c.state.pending = null
|
|
c.state.resetPending = false
|
|
c.state.dirty = false
|
|
const saved = persist(c)
|
|
if (saved) notify(c, 'synced')
|
|
return saved
|
|
}
|
|
function failure(c, error) {
|
|
if (!current(c) || error.message === 'SESSION_CHANGED') return
|
|
if (['AUTH_EXPIRED', 'AUTH_REQUIRED'].includes(error.message)) c.verified = false
|
|
const permanent = ['INVALID_REQUEST', 'PAYLOAD_TOO_LARGE', 'IDEMPOTENCY_CONFLICT', 'UNSUPPORTED_MEDIA_TYPE', 'METHOD_NOT_ALLOWED']
|
|
if (c.status !== 'storage-error') notify(c, ['AUTH_EXPIRED', 'AUTH_REQUIRED'].includes(error.message) ? 'auth-expired'
|
|
: ['CONTENT_MISMATCH', 'UNSUPPORTED_CONTENT_VERSION'].includes(error.message) ? 'version-error'
|
|
: permanent.includes(error.message) ? 'sync-error' : 'offline')
|
|
}
|
|
async function open(force = false) {
|
|
const c = context()
|
|
if (!c.token) return c.status
|
|
if (c.opening) return c.opening
|
|
if (c.verified && !force) return c.status
|
|
c.opening = (async () => {
|
|
c.verified = false
|
|
notify(c, 'connecting')
|
|
try {
|
|
const [catalog, raw] = await Promise.all([request(c, 'catalog'), request(c, 'progress')])
|
|
if (!current(c)) return 'session-changed'
|
|
if (!catalog || catalog.schema_version !== 1 || catalog.content_version !== CONTENT_VERSION) throw new Error('CONTENT_MISMATCH')
|
|
const remote = validateRemote(raw)
|
|
if (c.state.user_id && c.state.user_id !== remote.user_id) throw new Error('CONTENT_MISMATCH')
|
|
// Only the authenticated server identity may select a shared user slot.
|
|
// A renewed token can recover the same user's unsynced local queue.
|
|
const userKey = KEY + 'user:' + remote.user_id
|
|
if (c.key !== userKey) {
|
|
const candidate = read(userKey, null)
|
|
const saved = candidate && typeof candidate === 'object' && !Array.isArray(candidate) ? candidate : null
|
|
if (saved && !c.state.dirty) c.state = saved
|
|
else if (saved && c.state.dirty && !write(c.scope + ':previous-user-backup', saved)) throw new Error('STORAGE_UNAVAILABLE')
|
|
c.key = userKey
|
|
if (!write(c.scope + ':user', remote.user_id)) { notify(c, 'storage-error'); return c.status }
|
|
}
|
|
c.verified = true
|
|
c.state.user_id = remote.user_id
|
|
if (!c.state.dirty) hydrate(c, remote)
|
|
else if (c.state.pending) await flushContext(c) // retry the exact idempotent request first
|
|
else if (c.state.revision !== remote.revision || c.state.story_generation !== remote.story_generation) {
|
|
c.remote = remote; notify(c, 'conflict')
|
|
} else await flushContext(c)
|
|
} catch (error) { failure(c, error) }
|
|
finally { c.opening = null }
|
|
return c.status
|
|
})()
|
|
return c.opening
|
|
}
|
|
function schedule(c) {
|
|
if (c.timer) clearTimeout(c.timer)
|
|
c.timer = setTimeout(() => { c.timer = null; flushContext(c) }, 600)
|
|
}
|
|
async function flushContext(c) {
|
|
if (!current(c) || !c.verified || !c.state.dirty || ['conflict', 'storage-error', 'sync-error', 'version-error'].includes(c.status)) return
|
|
if (c.flushing) return c.flushing
|
|
// Defer preparation so the promise is assigned before any early exit.
|
|
// The outer finally also covers a failed durable queue write.
|
|
c.flushing = Promise.resolve().then(async () => {
|
|
try {
|
|
if (!current(c)) return
|
|
if (!c.state.pending) {
|
|
const operation = c.state.resetPending ? 'reset_story' : 'replace'
|
|
const progress = projectProgress(c.state.progress)
|
|
c.state.pending = { serial: operation === 'reset_story' ? c.state.resetAt : c.state.serial, body: {
|
|
schema_version: 1, content_version: CONTENT_VERSION,
|
|
base_revision: c.state.revision, story_generation: c.state.story_generation,
|
|
request_id: 'tang-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 14),
|
|
operation, progress: operation === 'reset_story'
|
|
? { ...emptyProgress(), collectedMemoryCards: progress.collectedMemoryCards } : progress,
|
|
} }
|
|
}
|
|
if (!persist(c)) return
|
|
const pending = copy(c.state.pending)
|
|
notify(c, 'syncing')
|
|
const remote = validateRemote(await request(c, 'saveProgress', 'POST', pending.body))
|
|
if (!current(c)) return
|
|
if (remote.user_id !== c.state.user_id) throw new Error('CONTENT_MISMATCH')
|
|
c.state.revision = remote.revision
|
|
c.state.story_generation = remote.story_generation
|
|
c.state.pending = null
|
|
// A reset created while another request was in flight must not be lost.
|
|
if (pending.body.operation === 'reset_story' && (c.state.resetAt || 0) <= pending.serial) c.state.resetPending = false
|
|
if (pending.serial === c.state.serial) hydrate(c, remote)
|
|
else if (persist(c)) { notify(c, 'pending'); schedule(c) }
|
|
} catch (error) {
|
|
if (error.message === 'PROGRESS_CONFLICT' && current(c)) {
|
|
try { c.remote = validateRemote(await request(c, 'progress')); notify(c, 'conflict') }
|
|
catch (readError) { failure(c, readError) }
|
|
} else failure(c, error)
|
|
}
|
|
}).finally(() => { c.flushing = null })
|
|
return c.flushing
|
|
}
|
|
function saveProgress(value, reset = false) {
|
|
const c = context()
|
|
// A delayed callback from an old page/account must not enter the new slot.
|
|
if (!value || value.__tangLocalScope !== c.scope) return false
|
|
c.state.progress = copy(value)
|
|
delete c.state.progress.__tangLocalScope
|
|
c.state.dirty = true
|
|
c.state.serial += 1
|
|
if (reset) { c.state.resetPending = true; c.state.resetAt = c.state.serial }
|
|
if (!persist(c)) return false
|
|
if (!['conflict', 'sync-error', 'version-error', 'auth-expired'].includes(c.status)) notify(c, c.token ? 'pending' : 'guest')
|
|
if (c.verified) schedule(c)
|
|
return true
|
|
}
|
|
function getConflictContext() {
|
|
const c = context()
|
|
return c.status === 'conflict' && c.remote ? JSON.stringify([
|
|
c.scope, c.remote.revision, c.remote.story_generation, c.state.serial,
|
|
]) : ''
|
|
}
|
|
async function resolveConflict(choice, expectedContext) {
|
|
const c = context()
|
|
if (!expectedContext || expectedContext !== getConflictContext() || !c.verified) return false
|
|
// Recoverable, account-scoped backup before either explicit resolution.
|
|
if (!write(c.key + ':conflict-backup', { local: c.state, remote: c.remote })) { notify(c, 'storage-error'); return false }
|
|
if (choice === 'cloud') { const saved = hydrate(c, c.remote); if (saved) c.remote = null; return saved }
|
|
if (choice !== 'local') return false
|
|
c.state.revision = c.remote.revision
|
|
c.state.story_generation = c.remote.story_generation
|
|
c.state.pending = null
|
|
c.remote = null
|
|
if (!persist(c)) return false
|
|
notify(c, 'pending')
|
|
await flushContext(c)
|
|
return c.status === 'synced'
|
|
}
|
|
return {
|
|
open, saveProgress, resolveConflict, getConflictContext,
|
|
getProgress: () => { const c = context(); return { ...copy(c.state.progress || {}), ...projectProgress(c.state.progress), __tangLocalScope: c.scope } },
|
|
getScope: () => context().scope,
|
|
getStatus: () => context().status,
|
|
flush: () => { const c = context(); if (c.timer) { clearTimeout(c.timer); c.timer = null } return flushContext(c) },
|
|
subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener) },
|
|
// Local preferences/audio are account-scoped and are never passed to request().
|
|
readLocal: (suffix, fallback) => copy(read(context().key + ':' + suffix, fallback)),
|
|
writeLocal: (suffix, value) => write(context().key + ':' + suffix, value),
|
|
dispose() { if (active && active.timer) clearTimeout(active.timer); listeners.clear(); active = null },
|
|
}
|
|
}
|
|
module.exports = { createPlatformBridge }
|