@recordtimelabel/core 0.6.7 → 0.6.9

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 release target is `0.6.7`; verify that its registry tarball and lockfile integrity are available before updating consumers:
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.9`; verify that its registry tarball and lockfile integrity are available before updating consumers:
21
21
 
22
22
  ```json
23
- "@recordtimelabel/core": "0.6.7"
23
+ "@recordtimelabel/core": "0.6.9"
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@recordtimelabel/core",
3
- "version": "0.6.7",
3
+ "version": "0.6.9",
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
@@ -145,6 +145,33 @@ export const applyFirestoreV2ResolvedChangeBatch = ({
145
145
  };
146
146
  let rootChanged = false;
147
147
 
148
+ // Resolved documents represent the final target revision, not every
149
+ // intermediate receipt. If an entity is changed and then deleted within
150
+ // this contiguous batch, its final document correctly does not exist.
151
+ // Record the final mutation so that specific case can be reduced without
152
+ // weakening the fail-closed rule for genuinely missing changed documents.
153
+ const finalMutations = {
154
+ records: new Map(),
155
+ folders: new Map(),
156
+ trash: new Map()
157
+ };
158
+ batch.forEach((change, index) => {
159
+ [
160
+ ['records', change?.changedRecordIds, 'changed'],
161
+ ['records', change?.deletedRecordIds, 'deleted'],
162
+ ['folders', change?.changedFolderIds, 'changed'],
163
+ ['folders', change?.deletedFolderIds, 'deleted'],
164
+ ['trash', change?.changedTrashEntryIds, 'changed'],
165
+ ['trash', change?.deletedTrashEntryIds, 'deleted']
166
+ ].forEach(([kind, ids, mutation]) => {
167
+ toIdList(ids).forEach((id) => finalMutations[kind].set(id, {index, mutation}));
168
+ });
169
+ });
170
+ const isShadowedByLaterDelete = (kind, id, index) => {
171
+ const finalMutation = finalMutations[kind].get(id);
172
+ return finalMutation?.mutation === 'deleted' && finalMutation.index > index;
173
+ };
174
+
148
175
  for (let index = 0; index < batch.length; index += 1) {
149
176
  const change = batch[index] || {};
150
177
  const revision = Number(change.revision || 0);
@@ -193,7 +220,7 @@ export const applyFirestoreV2ResolvedChangeBatch = ({
193
220
  ...changedTrash.map((id) => ['trash', id])
194
221
  ].some(([kind, id]) => {
195
222
  const document = resolvedDocument(resolvedDocuments, kind, id);
196
- return document == null;
223
+ return document == null && !isShadowedByLaterDelete(kind, id, index);
197
224
  });
198
225
  if (missingChanged) {
199
226
  return fail(FIRESTORE_V2_BOOTSTRAP_REASONS.CHANGED_DOCUMENT_MISSING, inputCache);
@@ -211,14 +238,17 @@ export const applyFirestoreV2ResolvedChangeBatch = ({
211
238
 
212
239
  changedRecords.forEach((id) => {
213
240
  const document = resolvedDocument(resolvedDocuments, 'records', id);
241
+ if (document == null && isShadowedByLaterDelete('records', id, index)) return;
214
242
  candidate.documents.records[id] = {id, ...document};
215
243
  });
216
244
  changedFolders.forEach((id) => {
217
245
  const document = resolvedDocument(resolvedDocuments, 'folders', id);
246
+ if (document == null && isShadowedByLaterDelete('folders', id, index)) return;
218
247
  candidate.documents.folders[id] = {id, ...document};
219
248
  });
220
249
  changedTrash.forEach((id) => {
221
250
  const document = resolvedDocument(resolvedDocuments, 'trash', id);
251
+ if (document == null && isShadowedByLaterDelete('trash', id, index)) return;
222
252
  candidate.documents.trash[id] = {id, ...document};
223
253
  });
224
254
  deletedRecords.forEach((id) => delete candidate.documents.records[id]);
package/src/index.js CHANGED
@@ -145,7 +145,7 @@ export {
145
145
  selectBestMatchingTwitchVod
146
146
  };
147
147
 
148
- export const RECORD_TIMELABEL_CORE_VERSION = '0.6.7';
148
+ export const RECORD_TIMELABEL_CORE_VERSION = '0.6.9';
149
149
  export const RTL_SYNC_PROTOCOL_VERSION = 2;
150
150
  export const RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES = Object.freeze([
151
151
  'fifo-retry-fence',
@@ -4048,6 +4048,7 @@ export const createRecordTimeLabelSyncEngine = ({
4048
4048
  })
4049
4049
  };
4050
4050
  let bootstrapAttemptSequence = 0;
4051
+ const activeBootstrapAttempts = new Map();
4051
4052
  let authTransitionLatch = null;
4052
4053
  const listeners = new Set();
4053
4054
  let queue = Promise.resolve();
@@ -4168,7 +4169,7 @@ export const createRecordTimeLabelSyncEngine = ({
4168
4169
  return {changed, stale: false};
4169
4170
  };
4170
4171
 
4171
- const bootstrapAttemptOptions = (mode) => ({
4172
+ const createBootstrapAttemptOptions = (mode) => ({
4172
4173
  mode,
4173
4174
  attemptId: `bootstrap:${mode}:${now()}:${++bootstrapAttemptSequence}:${
4174
4175
  globalThis.crypto?.randomUUID?.() || Math.random().toString(36).slice(2, 14)
@@ -4176,6 +4177,27 @@ export const createRecordTimeLabelSyncEngine = ({
4176
4177
  requireFresh: true
4177
4178
  });
4178
4179
 
4180
+ const bootstrapAttemptIdentity = (context = {}) => JSON.stringify([
4181
+ context.uid ?? null,
4182
+ context.sessionToken ?? null,
4183
+ Number(context.workspaceEpoch || 0)
4184
+ ]);
4185
+
4186
+ const bootstrapAttemptOptions = (mode, context) => {
4187
+ const identity = bootstrapAttemptIdentity(context);
4188
+ const active = activeBootstrapAttempts.get(mode);
4189
+ if (active?.identity === identity) return active.options;
4190
+ const options = createBootstrapAttemptOptions(mode);
4191
+ activeBootstrapAttempts.set(mode, {identity, options});
4192
+ return options;
4193
+ };
4194
+
4195
+ const clearBootstrapAttempt = (mode, options) => {
4196
+ if (activeBootstrapAttempts.get(mode)?.options === options) {
4197
+ activeBootstrapAttempts.delete(mode);
4198
+ }
4199
+ };
4200
+
4179
4201
  const normalizeBootstrapResponse = (response) => {
4180
4202
  if (!rtlEnvelopeSuccess(response)) {
4181
4203
  const source = response && typeof response === 'object' ? response : {
@@ -4200,6 +4222,24 @@ export const createRecordTimeLabelSyncEngine = ({
4200
4222
  return {state, revision, changeCursor: remote.changeCursor ?? null};
4201
4223
  };
4202
4224
 
4225
+ // A transient failure belongs to the same logical page walk. Keep its
4226
+ // attempt ID so the platform bootstrap owner can resume the signed cursor;
4227
+ // successful and non-transient outcomes retire the attempt.
4228
+ const runBootstrapAttempt = async (context, mode) => {
4229
+ const options = bootstrapAttemptOptions(mode, context);
4230
+ try {
4231
+ const baseline = normalizeBootstrapResponse(await cloud.bootstrap(context, options));
4232
+ clearBootstrapAttempt(mode, options);
4233
+ return baseline;
4234
+ } catch (error) {
4235
+ const failure = normalizeRecordTimeLabelCloudFailure(error);
4236
+ if (failure.class !== RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TRANSIENT) {
4237
+ clearBootstrapAttempt(mode, options);
4238
+ }
4239
+ throw error;
4240
+ }
4241
+ };
4242
+
4203
4243
  const clearAuthTransitionLatchIfSessionChanged = (captured) => {
4204
4244
  if (!authTransitionLatch) return;
4205
4245
  if (!rtlSameSessionIdentity(authTransitionLatch, captured)) {
@@ -4283,7 +4323,7 @@ export const createRecordTimeLabelSyncEngine = ({
4283
4323
  const caught = await cloud.catchUp(context, {
4284
4324
  fromRevision: workspace.remoteBaseline.revision,
4285
4325
  targetRevision,
4286
- ...bootstrapAttemptOptions(`${mode}-catchup`)
4326
+ ...createBootstrapAttemptOptions(`${mode}-catchup`)
4287
4327
  });
4288
4328
  if (rtlEnvelopeSuccess(caught)) {
4289
4329
  return requireBootstrapRevision(normalizeBootstrapResponse(caught), targetRevision);
@@ -4308,10 +4348,7 @@ export const createRecordTimeLabelSyncEngine = ({
4308
4348
  throwIfAuthTransitionLatched(captured);
4309
4349
  try {
4310
4350
  return requireBootstrapRevision(
4311
- normalizeBootstrapResponse(await cloud.bootstrap(
4312
- context,
4313
- bootstrapAttemptOptions(mode)
4314
- )),
4351
+ await runBootstrapAttempt(context, mode),
4315
4352
  targetRevision
4316
4353
  );
4317
4354
  } catch (error) {
@@ -4585,10 +4622,7 @@ export const createRecordTimeLabelSyncEngine = ({
4585
4622
  }
4586
4623
  let baseline;
4587
4624
  try {
4588
- baseline = normalizeBootstrapResponse(await cloud.bootstrap(
4589
- context,
4590
- bootstrapAttemptOptions('apply-failure-rebase')
4591
- ));
4625
+ baseline = await runBootstrapAttempt(context, 'apply-failure-rebase');
4592
4626
  } catch (bootstrapError) {
4593
4627
  if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
4594
4628
  rethrowClassifiedCloudFailure(captured, bootstrapError);
@@ -4755,10 +4789,7 @@ export const createRecordTimeLabelSyncEngine = ({
4755
4789
  if (typeof cloud?.bootstrap === 'function') {
4756
4790
  try {
4757
4791
  freshBaseline = requireBootstrapRevision(
4758
- normalizeBootstrapResponse(await cloud.bootstrap(
4759
- context,
4760
- bootstrapAttemptOptions('rejection-rebase')
4761
- )),
4792
+ await runBootstrapAttempt(context, 'rejection-rebase'),
4762
4793
  requiredRevision
4763
4794
  );
4764
4795
  } catch (bootstrapError) {
@@ -5253,10 +5284,7 @@ export const createRecordTimeLabelSyncEngine = ({
5253
5284
  });
5254
5285
  let bootstrap;
5255
5286
  try {
5256
- bootstrap = normalizeBootstrapResponse(await cloud.bootstrap(
5257
- context,
5258
- bootstrapAttemptOptions('initial-hydration')
5259
- ));
5287
+ bootstrap = await runBootstrapAttempt(context, 'initial-hydration');
5260
5288
  } catch (error) {
5261
5289
  if (!(await isCurrent(captured))) return getSnapshot();
5262
5290
  if (persistedHydrationRequired) {
@@ -5783,10 +5811,7 @@ export const createRecordTimeLabelSyncEngine = ({
5783
5811
  let freshBaseline;
5784
5812
  try {
5785
5813
  freshBaseline = requireBootstrapRevision(
5786
- normalizeBootstrapResponse(await cloud.bootstrap(
5787
- context,
5788
- bootstrapAttemptOptions('rejection-rebase')
5789
- )),
5814
+ await runBootstrapAttempt(context, 'rejection-rebase'),
5790
5815
  responseRevision
5791
5816
  );
5792
5817
  } catch (error) {
@@ -5934,6 +5959,12 @@ export const createRecordTimeLabelSyncEngine = ({
5934
5959
  const captured = capture();
5935
5960
  clearAuthTransitionLatchIfSessionChanged(captured);
5936
5961
  if (initialized && !hydrationRequired && workspaceMatchesSession(captured)) {
5962
+ // A transient listener failure stops only the provider subscription.
5963
+ // Re-entering init for the same owner must reattach that subscription
5964
+ // without discarding the committed workspace or bootstrapping again.
5965
+ if (captured?.uid && !subscriptionContext && typeof cloud?.subscribe === 'function') {
5966
+ startCloudSubscription(captured, workspace);
5967
+ }
5937
5968
  return getSnapshot();
5938
5969
  }
5939
5970
  if (initialized && !hydrationRequired && !workspaceMatchesSession(captured)) {