@recordtimelabel/core 0.6.2 → 0.6.5

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/README.md CHANGED
@@ -17,10 +17,10 @@ During local development an app can consume a sibling checkout with:
17
17
  "@recordtimelabel/core": "file:../recordtimelabel-core"
18
18
  ```
19
19
 
20
- 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 current published artifact is `0.6.2`:
20
+ 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.5`; verify that its registry tarball and lockfile integrity are available before updating consumers:
21
21
 
22
22
  ```json
23
- "@recordtimelabel/core": "0.6.2"
23
+ "@recordtimelabel/core": "0.6.5"
24
24
  ```
25
25
 
26
26
  If this checkout's `package.json` is ahead of the published version, publish the new package before updating consumers to that version.
@@ -106,14 +106,22 @@ the whole session object as an authorization token, and `bootstrap`/`catchUp`/`a
106
106
  context no longer carries `token`. Platform adapters obtain Firebase credentials from
107
107
  their own credential provider.
108
108
 
109
- `normalizeRecordTimeLabelCloudFailure` preserves `status`, `reason`, `retryable`, and
110
- `retryAfterMs`, and assigns exactly one class: `transient`, `bootstrap-required`,
111
- `auth-transition-required`, `terminal`, or `stale-session`. Capability
112
- `cloud-failure-state-v1` advertises this contract. An `auth-transition-required`
113
- bootstrap or catch-up failure latches that session identity so repeated `init()`
114
- calls do not invoke `cloud.bootstrap` again. A changed SessionPort capture, UID, or
115
- workspace epoch clears the latch. `recoverBaseline` falls back from catch-up only for
116
- explicit `bootstrap-required` / cursor-gap outcomes; auth and transient failures keep
109
+ `normalizeRecordTimeLabelCloudFailure` preserves `status`, `reason`, `code`,
110
+ `message`, `retryable`, and `retryAfterMs`, and assigns exactly one class:
111
+ `transient`, `bootstrap-required`, `auth-transition-required`, `terminal`, or
112
+ `stale-session`. Classification uses only an explicit class, HTTP status,
113
+ `retryable`, `bootstrapRequired`, and other platform-neutral fields. Adapters
114
+ may keep a Firebase reason string, but Core does not interpret Firebase or JWT
115
+ taxonomies. `{error: "reason"}` keeps that string as `reason`, `code`, and
116
+ `message`. Capability `cloud-failure-state-v1` is part of
117
+ `RECORD_TIMELABEL_PROTOCOL_CAPABILITIES`; clients must not invent that string.
118
+ An `auth-transition-required` bootstrap or catch-up failure latches the captured
119
+ session identity (`sessionToken` + UID + workspace epoch) so repeated `init()`
120
+ calls do not invoke `cloud.bootstrap` again. A changed SessionPort identity,
121
+ UID, or workspace epoch clears the latch. An ID token refresh that keeps the
122
+ same `sessionToken` must not change session identity or current work.
123
+ `recoverBaseline` falls back from catch-up only for explicit
124
+ `bootstrap-required` / cursor-gap outcomes; auth and transient failures keep
117
125
  their class and must not start a second full walk.
118
126
 
119
127
  The persisted workspace is versioned and contains only durable data:
@@ -126,10 +134,22 @@ The persisted workspace is versioned and contains only durable data:
126
134
  remoteBaseline: { state, revision: 12, changeCursor: 'cursor-12' },
127
135
  pendingOperations: [],
128
136
  rejectedOperations: {},
129
- syncMeta: {}
137
+ syncMeta: {
138
+ // Core-owned resume fence; the cursor itself remains opaque adapter data.
139
+ changeCursorBinding: {
140
+ ownerUid: 'user-id', uid: 'user-id', workspaceEpoch: 3,
141
+ authSessionBinding: 'auth-session-3'
142
+ }
143
+ }
130
144
  }
131
145
  ```
132
146
 
147
+ `remoteBaseline.changeCursor` is an opaque string owned by the cloud adapter and
148
+ must remain byte-for-byte stable across persistence. Core stores the owner,
149
+ workspace-epoch, and non-credential auth binding separately in
150
+ `syncMeta.changeCursorBinding` so a committed workspace can be resumed without
151
+ embedding session credentials in the cursor.
152
+
133
153
  Every normalized pending operation also carries `ownerUid` and a non-negative
134
154
  `workspaceEpoch`. Dispatch stamps these fields from the captured session; legacy
135
155
  operations missing them inherit the migrated workspace identity. An operation whose
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@recordtimelabel/core",
3
- "version": "0.6.2",
3
+ "version": "0.6.5",
4
4
  "type": "module",
5
5
  "description": "Shared RecordTimeLabel data model, merge logic, operations, and sync engine.",
6
6
  "main": "./src/index.js",
package/src/changefeed.js CHANGED
@@ -72,15 +72,38 @@ export const applyFirestoreV2ResolvedChangeBatch = ({
72
72
  const inputRevision = Number(cache?.revision || 0);
73
73
  const inputDocuments = cache?.documents || {};
74
74
  const inputCache = {revision: inputRevision, documents: inputDocuments};
75
- const expectedTarget = targetRevision == null ? null : Number(targetRevision);
76
- if (!Number.isFinite(inputRevision) || inputRevision < 0) {
75
+ const hasExpectedTarget = targetRevision != null;
76
+ const expectedTarget = hasExpectedTarget ? Number(targetRevision) : null;
77
+ if (!Number.isSafeInteger(inputRevision) || inputRevision < 0 ||
78
+ (hasExpectedTarget && (!Number.isSafeInteger(expectedTarget) || expectedTarget < 0))) {
77
79
  return fail(FIRESTORE_V2_BOOTSTRAP_REASONS.REVISION_GAP, inputCache);
78
80
  }
79
81
 
80
82
  const batch = Array.isArray(changes) ? changes : [];
81
83
  if (batch.length === 0) {
82
- if (expectedTarget != null && Number.isFinite(expectedTarget) && expectedTarget < inputRevision) {
83
- return fail(FIRESTORE_V2_BOOTSTRAP_REASONS.CACHE_AHEAD, inputCache);
84
+ if (expectedTarget != null && Number.isFinite(expectedTarget)) {
85
+ if (expectedTarget < inputRevision) {
86
+ return fail(FIRESTORE_V2_BOOTSTRAP_REASONS.CACHE_AHEAD, inputCache);
87
+ }
88
+ }
89
+ const resolvedRoot = resolvedDocuments?.root;
90
+ if (hasExpectedTarget || resolvedRoot !== undefined) {
91
+ const rootRevision = Number(resolvedRoot?.revision);
92
+ const expectedRootRevision = expectedTarget != null && Number.isFinite(expectedTarget)
93
+ ? expectedTarget
94
+ : inputRevision;
95
+ if (!resolvedRoot || !Number.isSafeInteger(rootRevision) || rootRevision < 0) {
96
+ return fail(FIRESTORE_V2_BOOTSTRAP_REASONS.ROOT_DOCUMENT_MISSING, inputCache);
97
+ }
98
+ if (rootRevision < expectedRootRevision) {
99
+ return fail(FIRESTORE_V2_BOOTSTRAP_REASONS.ROOT_DOCUMENT_MISSING, inputCache);
100
+ }
101
+ if (rootRevision > expectedRootRevision) {
102
+ return fail(FIRESTORE_V2_BOOTSTRAP_REASONS.REVISION_OVERSHOOT, inputCache);
103
+ }
104
+ }
105
+ if (expectedTarget != null && Number.isFinite(expectedTarget) && expectedTarget > inputRevision) {
106
+ return fail(FIRESTORE_V2_BOOTSTRAP_REASONS.REVISION_GAP, inputCache);
84
107
  }
85
108
  return {
86
109
  ok: true,
@@ -92,19 +115,30 @@ export const applyFirestoreV2ResolvedChangeBatch = ({
92
115
  }
93
116
 
94
117
  const firstRevision = Number(batch[0]?.revision || 0);
95
- if (expectedTarget != null && Number.isFinite(expectedTarget) && inputRevision > expectedTarget) {
118
+ if (!Number.isSafeInteger(firstRevision) || firstRevision < 0) {
119
+ return fail(FIRESTORE_V2_BOOTSTRAP_REASONS.REVISION_GAP, inputCache);
120
+ }
121
+ if (expectedTarget != null && inputRevision > expectedTarget) {
96
122
  return fail(FIRESTORE_V2_BOOTSTRAP_REASONS.CACHE_AHEAD, inputCache);
97
123
  }
98
124
  if (firstRevision <= inputRevision) {
99
125
  return fail(FIRESTORE_V2_BOOTSTRAP_REASONS.REVISION_DUPLICATE, inputCache);
100
126
  }
101
- if (expectedTarget != null && Number.isFinite(expectedTarget) && firstRevision > expectedTarget) {
127
+ if (expectedTarget != null && firstRevision > expectedTarget) {
102
128
  return fail(FIRESTORE_V2_BOOTSTRAP_REASONS.REVISION_OVERSHOOT, inputCache);
103
129
  }
104
130
  if (firstRevision > inputRevision + 1) {
105
131
  return fail(FIRESTORE_V2_BOOTSTRAP_REASONS.REVISION_GAP, inputCache);
106
132
  }
107
133
 
134
+ // Keep the last applied revision available for root exactness when the
135
+ // caller did not provide a target. A malformed or stale final entry is
136
+ // rejected before candidate mutation.
137
+ const finalRevision = Number(batch.at(-1)?.revision || 0);
138
+ if (!Number.isSafeInteger(finalRevision) || finalRevision < inputRevision) {
139
+ return fail(FIRESTORE_V2_BOOTSTRAP_REASONS.REVISION_DUPLICATE, inputCache);
140
+ }
141
+
108
142
  const candidate = {
109
143
  revision: inputRevision,
110
144
  documents: cloneDocuments(inputDocuments)
@@ -114,13 +148,16 @@ export const applyFirestoreV2ResolvedChangeBatch = ({
114
148
  for (let index = 0; index < batch.length; index += 1) {
115
149
  const change = batch[index] || {};
116
150
  const revision = Number(change.revision || 0);
151
+ if (!Number.isSafeInteger(revision) || revision < 0) {
152
+ return fail(FIRESTORE_V2_BOOTSTRAP_REASONS.REVISION_GAP, inputCache);
153
+ }
117
154
  if (revision !== candidate.revision + 1) {
118
155
  const reason = revision <= candidate.revision
119
156
  ? FIRESTORE_V2_BOOTSTRAP_REASONS.REVISION_DUPLICATE
120
157
  : FIRESTORE_V2_BOOTSTRAP_REASONS.REVISION_GAP;
121
158
  return fail(reason, inputCache);
122
159
  }
123
- if (expectedTarget != null && Number.isFinite(expectedTarget) && revision > expectedTarget) {
160
+ if (expectedTarget != null && revision > expectedTarget) {
124
161
  return fail(FIRESTORE_V2_BOOTSTRAP_REASONS.REVISION_OVERSHOOT, inputCache);
125
162
  }
126
163
 
@@ -165,7 +202,8 @@ export const applyFirestoreV2ResolvedChangeBatch = ({
165
202
  if (change.rootChanged === true) {
166
203
  const rootDocument = resolvedDocuments.root;
167
204
  const rootRevision = Number(rootDocument?.revision || 0);
168
- if (!rootDocument || !Number.isFinite(rootRevision) || rootRevision < revision) {
205
+ if (!rootDocument || !Number.isSafeInteger(rootRevision) || rootRevision < 0 ||
206
+ rootRevision < revision) {
169
207
  return fail(FIRESTORE_V2_BOOTSTRAP_REASONS.ROOT_DOCUMENT_MISSING, inputCache);
170
208
  }
171
209
  rootChanged = true;
@@ -206,6 +244,27 @@ export const applyFirestoreV2ResolvedChangeBatch = ({
206
244
  candidate.revision = revision;
207
245
  }
208
246
 
247
+ // When a change resolves the root (or a caller supplied one alongside the
248
+ // batch), its revision is the proof that all document reads belong to the
249
+ // same authoritative point. A behind, missing, or overshooting root is a
250
+ // bootstrap condition; the candidate is discarded atomically.
251
+ const resolvedRoot = resolvedDocuments?.root;
252
+ if (hasExpectedTarget || rootChanged || resolvedRoot !== undefined) {
253
+ const rootRevision = Number(resolvedRoot?.revision);
254
+ const expectedRootRevision = expectedTarget != null && Number.isFinite(expectedTarget)
255
+ ? expectedTarget
256
+ : finalRevision;
257
+ if (!resolvedRoot || !Number.isSafeInteger(rootRevision) || rootRevision < 0) {
258
+ return fail(FIRESTORE_V2_BOOTSTRAP_REASONS.ROOT_DOCUMENT_MISSING, inputCache);
259
+ }
260
+ if (rootRevision < expectedRootRevision) {
261
+ return fail(FIRESTORE_V2_BOOTSTRAP_REASONS.ROOT_DOCUMENT_MISSING, inputCache);
262
+ }
263
+ if (rootRevision > expectedRootRevision) {
264
+ return fail(FIRESTORE_V2_BOOTSTRAP_REASONS.REVISION_OVERSHOOT, inputCache);
265
+ }
266
+ }
267
+
209
268
  if (rootChanged) {
210
269
  const rootDocument = resolvedDocuments.root;
211
270
  candidate.documents.root = {id: 'main', ...rootDocument};
@@ -7,12 +7,21 @@ export {
7
7
  RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE,
8
8
  RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,
9
9
  RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
10
+ RECORD_TIMELABEL_CAPABILITY_DETERMINISTIC_PLANNER,
11
+ RECORD_TIMELABEL_CAPABILITY_STRICT_READINESS,
12
+ RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE_V1,
13
+ RECORD_TIMELABEL_CLOUD_FAILURE_SCHEMA_VERSION,
10
14
  RECORD_TIMELABEL_CLOUD_SCHEMAS,
11
15
  RECORD_TIMELABEL_OPERATION_RESULT_STATUSES,
12
16
  RECORD_TIMELABEL_PROTOCOL_CAPABILITIES,
13
17
  RTL_MAX_OPERATIONS_PER_REQUEST,
14
18
  RTL_MAX_REQUEST_BYTES,
15
19
  RTL_MAX_TARGET_WRITES,
20
+ RECORD_TIMELABEL_PLANNER_MODES,
21
+ RECORD_TIMELABEL_SAFE_ID_MAX_BYTES,
22
+ isRecordTimeLabelSafeDocumentId,
23
+ normalizeRecordTimeLabelImmutableId,
24
+ normalizeRecordTimeLabelPlannerId,
16
25
  RTL_SYNC_PROTOCOL_VERSION,
17
26
  buildFirestoreV2DocumentChangeSet,
18
27
  buildFirestoreV2DocumentsFromState,
@@ -33,6 +42,8 @@ export {
33
42
  normalizeRecordTimeLabelEnvelopeResponse,
34
43
  normalizeRecordTimeLabelOperationResults,
35
44
  planFirestoreV2OperationChanges,
45
+ planRecordTimeLabelDeterministicOperationChanges,
46
+ planFirestoreV2DeterministicOperationChanges,
36
47
  toRecordTimeLabelWireOperation,
37
48
  normalizeRecordTimeLabelDomainState,
38
49
  validateRecordTimeLabelOperationBatch