@recordtimelabel/core
v0.6.20
Published
Shared RecordTimeLabel data model, merge logic, operations, and sync engine.
Downloads
2,189
Readme
@recordtimelabel/core
Shared RecordTimeLabel data model, normalization, merge logic, operation reducer, and sync engine.
Scope
This package is intentionally platform-neutral. It must not import React, Firebase, Chrome extension APIs, Electron APIs, or app-specific UI code.
Current consumers:
D:\Programming\RecordTimeLabelD:\Programming\YouTwitchDownloader
During local development an app can consume a sibling checkout with:
"@recordtimelabel/core": "file:../recordtimelabel-core"For release builds, consume a fixed npm package, git tag, or private registry version so builds do not depend on a sibling folder path. The release target is 0.6.20; verify that its registry tarball and lockfile integrity are available before updating consumers:
"@recordtimelabel/core": "0.6.20"If this checkout's package.json is ahead of the published version, publish the new package before updating consumers to that version.
Ordinary cold sync uses bounded operations and per-operation acknowledgements; it never promotes pending work to a snapshot replacement.
Public API
normalizeState(input)normalizeRecords(records, options)mergeLocalRemote({ localState, remoteState, pendingOps, clientId, now })applyOperation(state, operation)applyRecordTimeLabelOperation(state, operation)createRecordTimeLabelSyncEngine({ storage, cloud, session, client, clock, logger })RTL_MAX_SYNC_DRAIN_ROUNDSRTL_SYNC_DRAIN_RETRY_DELAY_MSRECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIESRECORD_TIMELABEL_CAPABILITY_CLOUD_FAILURE_STATERECORD_TIMELABEL_CLOUD_FAILURE_CLASSESRECORD_TIMELABEL_CLOUD_RECOVERY_ACTIONSnormalizeRecordTimeLabelCloudFailure(input)classifyRecordTimeLabelCloudRecovery(input)toRecordTimeLabelCloudFailureError(input)RECORD_TIMELABEL_PROTOCOL_CAPABILITIEStoRecordTimeLabelWireOperation(operation)buildRecordTimeLabelRequestId(namespace, operations)createRecordTimeLabelTransportFailureResults(operations, failure, options)normalizeRecordTimeLabelOperationResults(operations, results, options)normalizeRecordTimeLabelEnvelopeResponse(operations, response, options)createSyncEngine({ storageAdapter, cloudAdapter, clientId, clock, logger })createRecordTimeLabelController({ storageAdapter, cloudAdapter, clientId, settingKeys, clock, logger })buildRecordTimeLabelSyncPayload({ data, previousCloudData, pendingOps, syncMode, clientId, now, groupOrderNormalizer })buildSyncPayloadWithOperations({ data, previousCloudData, pendingOps, syncMode, clientId, now, groupOrderNormalizer })flushPendingOperations({ operations, storageAdapter, cloudAdapter, clientId, now })mergeRemoteStateIntoLocal({ localData, remoteData, pendingOps, settingKeys, clientId, now })buildLocalStateFromStorage(data, options)buildStoragePatchFromState(state, options)buildFirestoreV1UserPatch(state, options)buildFirestoreV2LogicalPaths(userId)buildFirestoreV2DocumentsFromState(state, options)buildFirestoreV2DocumentChangeSet(previousDocuments, nextDocuments, options)buildStateFromFirestoreV2Documents(documents, options)validateRecordTimeLabelOperationBatch(body)buildFirestoreV2OperationReadPlan(operations)extendFirestoreV2OperationReadPlanWithRecords(readPlan, records)extendFirestoreV2OperationReadPlanWithTrash(readPlan, trashEntries)planFirestoreV2OperationChanges({ documents, operations, localState, now, requireLifecycleGeneration })estimateFirestoreV2WriteUnits(changes, overhead)buildOperationsFromSnapshotDiff({ previousState, nextState, now, operationIdPrefix, batchSize })(一般操作在batches,folder.delete在bulkOperations)RTL_SYNC_PROTOCOL_VERSIONhasMeaningfulRecordTimeLabelCloudState(data, options)buildRecordTimeLabelContentFingerprint(data)normalizeRecordTimeLabelDomainState(input)migrateRecordTimeLabelExpandedGroups({ pendingOperations, currentView })buildMigratedRecordTimeLabelV2State(legacyState, options)RECORD_TIMELABEL_CLOUD_SCHEMASRECORD_TIMELABEL_SYNC_MODEScreateOperation(type, payload, options)OPERATION_TYPESgetActiveTrashEntries(entries, now)RTL_TRASH_RETENTION_MSbuildFirestoreV2SnapshotRoot({ currentRoot, incomingRoot, mode })applyRecordTimeLabelSnapshot(currentState, incomingState, mode)composeRecordTimeLabelHydratedState({ remote, pendingOps, importJobs, localNavigation })applyFirestoreV2ResolvedChangeBatch({ cache, changes, resolvedDocuments, targetRevision })FIRESTORE_V2_BOOTSTRAP_REASONS
The package exports five intentional entrypoints. The root (@recordtimelabel/core)
keeps the complete backwards-compatible surface, /protocol contains only shared
acknowledgement protocol helpers, /firestore-v2 contains platform-neutral document
and operation planners, /domain contains group identity, Twitch VOD matching,
legacy import, and channel-folder planning, and /compat contains the legacy
createSyncEngine and createRecordTimeLabelController APIs.
RECORD_TIMELABEL_DOMAIN_CAPABILITIES is a local release contract. It is not a
gateway protocol capability.
Durable sync engine
createRecordTimeLabelSyncEngine is the platform-neutral durable workspace API. Its storage port
has load() and save(workspace) methods (adapters may consume the optional second
save(workspace, fenceContext) argument to enforce the session/epoch atomically), and its cloud port has bootstrap(context, attemptOptions),
applyOperations(envelope, context), and subscribe(listener, context) methods. The session port
provides current(), subscribe(listener), and isCurrent(sessionToken, uid, workspaceEpoch).
sessionToken is an opaque fence for equality only. Core never aliases current.id or
the whole session object as an authorization token, and bootstrap/catchUp/apply
context no longer carries token. Platform adapters obtain Firebase credentials from
their own credential provider.
normalizeRecordTimeLabelCloudFailure preserves status, reason, code,
message, retryable, and retryAfterMs, and assigns exactly one class:
transient, bootstrap-required, auth-transition-required, terminal, or
stale-session. Classification uses only an explicit class, HTTP status,
retryable, bootstrapRequired, and other platform-neutral fields. Adapters
may keep a Firebase reason string, but Core does not interpret Firebase or JWT
taxonomies. {error: "reason"} keeps that string as reason, code, and
message. Capability cloud-failure-state-v1 is part of
RECORD_TIMELABEL_PROTOCOL_CAPABILITIES; clients must not invent that string.
An auth-transition-required bootstrap or catch-up failure latches the captured
session identity (sessionToken + UID + workspace epoch) so repeated init()
calls do not invoke cloud.bootstrap again. A changed SessionPort identity,
UID, or workspace epoch clears the latch. An ID token refresh that keeps the
same sessionToken must not change session identity or current work.
recoverBaseline falls back from catch-up only for explicit
bootstrap-required / cursor-gap outcomes; auth and transient failures keep
their class and must not start a second full walk.
The persisted workspace is versioned and contains only durable data:
{
schemaVersion: 1,
ownerUid: 'user-id',
workspaceEpoch: 3,
remoteBaseline: { state, revision: 12, changeCursor: 'cursor-12' },
pendingOperations: [],
rejectedOperations: {},
syncMeta: {
// Core-owned resume fence; the cursor itself remains opaque adapter data.
changeCursorBinding: {
ownerUid: 'user-id', uid: 'user-id', workspaceEpoch: 3,
authSessionBinding: 'auth-session-3'
}
}
}remoteBaseline.changeCursor is an opaque string owned by the cloud adapter and
must remain byte-for-byte stable across persistence. Core stores the owner,
workspace-epoch, and non-credential auth binding separately in
syncMeta.changeCursorBinding so a committed workspace can be resumed without
embedding session credentials in the cursor.
Every normalized pending operation also carries ownerUid and a non-negative
workspaceEpoch. Dispatch stamps these fields from the captured session; legacy
operations missing them inherit the migrated workspace identity. An operation whose
explicit identity does not match the current workspace is moved to
rejectedOperations with a stable identity-mismatch reason before any cloud apply.
Pending operations also carry an internal syncBatchId. Operations dispatched in
one call share a batch boundary, while legacy pending operations are assigned one
stable boundary during migration. The engine sends only the first FIFO batch boundary,
capped at 20 operations, and derives a stable request ID from that boundary and the
ordered canonical wire operations. Durable retry metadata does not affect request identity,
while reusing an operation ID with different wire content is quarantined. A bounded history
of completed operation fingerprints is kept in syncMeta; syncBatchId, identity fences,
and retry scheduling fields stay outside canonical wire operations.
Bootstrap attempts use initial-hydration, rejection-rebase, or gap-recovery
modes with a unique attemptId; all require a fresh logical attempt. Structured
failures, missing state, and missing/invalid revisions fail closed. UID or workspace
epoch changes clear the visible workspace and require init() before another dispatch
or sync; token refresh for the same UID/epoch only refreshes the subscription fence. An
operation acknowledgement records its revision diagnostically and advances
remoteBaseline.revision with a complete state, or with an all-applied no-state ACK at
exactly the next revision; larger revision gaps trigger a fresh bootstrap. Re-dispatching
the same operation ID and wire content is reported as deduplicated, while different wire
content is quarantined as an ID conflict. Rejected entries are terminal diagnostics in this
API; an intentional retry currently requires a new operation ID.
The engine exposes init(), dispatch(operations), sync(reason),
syncUntilIdle(reason, options), waitForIdle(), waitForRemoteReady(), getSnapshot(),
subscribe(listener), and destroy(). A cloud subscribe(listener, context) disposer may
carry a .ready promise. After init() settles, hosts can await waitForRemoteReady() to
prove that the current subscription's initial root observation and any bounded catch-up have
settled; this wait rejects when the listener fails before readiness and fails closed across a
session generation change or destroy. init() deliberately does not await this promise because
the initial listener callback may itself enter the engine queue. Anonymous/no-subscription use
is immediately ready.
sync() preserves the one-FIFO-batch boundary. syncUntilIdle() drains
subsequent batches up to RTL_MAX_SYNC_DRAIN_ROUNDS; it stops on retry/deferred/protocol
conditions and reports syncDrainLimitReached as a retryable failure instead of silently
reporting success. Hosts may provide beforeRound({round, pendingCount, snapshot}) to block
gateway writes while a host-specific hydration/import gate is active, and runRound({round,
pendingCount, run}) to serialize that gate check and the gateway call with host mutations.
getSnapshot().state is derived by replaying pending operations over remoteBaseline.state;
rejected operations are kept in diagnostics and are not replayed. Session tokens are used for
fencing but are never persisted. The legacy
createSyncEngine and app adapters remain available and are not implicitly migrated by this API.
expandedGroups is a local view projection, not durable cloud domain state. Durable workspaces,
content fingerprints, and Firestore v2 root documents exclude it. The migration helper consumes
legacy expandedGroups.update operations in order, returns the resulting view projection, and is
idempotent; legacy root fields are preserved verbatim when a compatibility document is rewritten
so older clients can continue reading the view until they migrate.
Operation-result protocol
The root package and @recordtimelabel/core/protocol export the shared acknowledgement
normalizers. normalizeRecordTimeLabelOperationResults(operations, results) verifies count,
request membership, unique IDs, and completeness before returning results in request order.
The only normalized statuses are applied, noop,
rejected, and retryable; retryable is derived from that status, while the legacy id and
applied fields remain available alongside operationId, reason, and retryAfterMs.
normalizeRecordTimeLabelEnvelopeResponse(operations, response) requires an explicit
operationResults array by default. Malformed, duplicate, missing, unknown, or ambiguous IDs
throw stable protocol errors so callers can reject the entire acknowledgement before changing
durable state.
Gateway-only compatibility code may opt into successful legacy envelopes with
{allowLegacySuccessWithoutResults: true}. New durable clients never enable that fallback.
Capabilities operation-conflict-quarantine, strict-operation-results, and
lifecycle-generation-fence gate the corresponding 0.4.4 behavior. Single
record.restore, folder.restore, and trash.purge operations require a positive
expectedGeneration; batch lifecycle operations retain their existing contract. The gateway
validator derives enforcement from client.capabilities. The planner is strict by default;
the time-limited legacy gateway path must explicitly pass requireLifecycleGeneration: false
for clients that did not declare the capability. A supplied but stale generation is always rejected.
Firestore v1 Compatibility
Phase 1 keeps the existing users/{uid} document shape:
foldersrecordsgroupOrderfolderOrderexpandedGroupssettings
The core also normalizes and preserves these compatibility metadata fields:
rtlSyncMetadeletedRecordTombstonesdeletedFolderTombstonestrashEntries
Deletion must be represented by tombstones or operations. Do not reintroduce the old heuristic that treats "local exists but cloud missing for more than five minutes" as deletion.
mergeLocalRemote resolves deletions by lifecycleGeneration, in both directions: a tombstone or
trash entry is dropped when either side carries that entity as active and newer, and restore
operations are what bump the generation. When generations tie — an active copy written outside the
delete/restore path — the active copy's own timestamp breaks the tie against deletedAt. A stale
snapshot on either side therefore cannot re-delete an entity that was already restored.
Consumers must not reimplement this reconciliation in an app-level wrapper. In particular, never clear a local tombstone just because the remote snapshot still carries the entity as active: that is presence-based inference, the same anti-pattern as the five-minute heuristic above, and it is not order-independent, so two clients using different rules will not converge on shared cloud data.
Firestore v2 Gateway Contract
The package exposes platform-neutral v2 document helpers so app adapters can share the same record/folder/settings/ops shape before wiring Firebase SDK or REST calls:
- root document
users/{uid}/recordTimeLabel/main: settings, order arrays, tombstones, and sync metadata users/{uid}/recordTimeLabel/main/records/{recordId}: flattened per-record documents withfolderIdusers/{uid}/recordTimeLabel/main/folders/{folderId}: per-folder documentsusers/{uid}/recordTimeLabel/main/trash/{trashEntryId}: 30-day record/folder snapshotsusers/{uid}/recordTimeLabel/main/lifecycleTombstones/{id}: content-free anti-revival markersusers/{uid}/recordTimeLabel/main/ops/{opId}: pending operation documents
The v2 validator, bounded read planner, pure mutation planner, cost estimator, and snapshot-diff batch builder are also platform-neutral. They do not import Firebase and do not perform network writes. Functions and clients must use this package instead of maintaining app-specific planner copies.
Test
npm test