chuvsu-js 5.1.1 → 5.1.2

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/dist/tt/client.js CHANGED
@@ -123,6 +123,8 @@ export class TimetableClient {
123
123
  for (let attempt = 0; attempt < 4; attempt++) {
124
124
  const expectedRevision = this._repository.revision;
125
125
  const result = mutate(this._repository);
126
+ if (this._repository.revision === expectedRevision)
127
+ return result;
126
128
  if (!this.repositoryAdapter)
127
129
  return result;
128
130
  if (await this.repositoryAdapter.compareAndSet(expectedRevision, this._repository.export())) {
@@ -363,17 +365,18 @@ export class TimetableClient {
363
365
  period,
364
366
  days: await this.fetchOwnerSchedule(resolvedOwner, period, context.academicYearStartYear),
365
367
  })));
366
- for (const result of results) {
367
- const currentOwner = this.resolveOwner(resolvedOwner);
368
- const snapshot = createScheduleSourceSnapshot({
369
- sourceKey: this.sourceKey(currentOwner, result.period, context.academicYearStartYear),
370
- owner: currentOwner,
371
- academicYearStartYear: context.academicYearStartYear,
372
- period: result.period,
373
- days: result.days,
374
- });
375
- await this.mutateRepository((repository) => repository.ingest(snapshot));
376
- }
368
+ await this.mutateRepository((repository) => {
369
+ for (const result of results) {
370
+ const currentOwner = this.resolveOwner(resolvedOwner);
371
+ repository.ingest(createScheduleSourceSnapshot({
372
+ sourceKey: this.sourceKey(currentOwner, result.period, context.academicYearStartYear),
373
+ owner: currentOwner,
374
+ academicYearStartYear: context.academicYearStartYear,
375
+ period: result.period,
376
+ days: result.days,
377
+ }));
378
+ }
379
+ });
377
380
  return new Schedule(this._repository, this.resolveOwner(resolvedOwner), context.academicYearStartYear, { period: periods.length === 1 ? periods[0] : context.period });
378
381
  }
379
382
  async getGroupSchedule(groupId, options) {
@@ -8,6 +8,8 @@ export declare class TimetableRepository {
8
8
  private readonly links;
9
9
  private readonly seriesRecords;
10
10
  private readonly occurrenceRecords;
11
+ private readonly recordIdsByBucket;
12
+ private readonly recordIdsByOwner;
11
13
  private _revision;
12
14
  constructor(opts?: {
13
15
  idGenerator?: LessonIdGenerator;
@@ -39,6 +41,14 @@ export declare class TimetableRepository {
39
41
  private addClaim;
40
42
  private findPriorTarget;
41
43
  private findCanonicalTarget;
44
+ private addRecordToBucket;
45
+ private ownerReferenceKeys;
46
+ private claimOwnerKeys;
47
+ private addClaimOwnerKeys;
48
+ private removeRecordOwnerIndex;
49
+ private rebuildRecordOwnerIndex;
50
+ private removeRecordIndexes;
51
+ private recordsForOwner;
42
52
  private aggregateSubstitutions;
43
53
  private aggregateSeries;
44
54
  private aggregateOccurrence;
@@ -8,6 +8,12 @@ function claimKey(sourceKey, observationKey) {
8
8
  function sourceObservationKey(sourceKey, observationKey) {
9
9
  return claimKey(sourceKey, observationKey);
10
10
  }
11
+ function canonicalBucketKey(observation, source) {
12
+ const prefix = `${source.academicYearStartYear}:${source.period}:${observation.kind}:${normalizeScheduleText(observation.subject)}`;
13
+ return observation.kind === "series"
14
+ ? `${prefix}:${observation.recurrence.weekday}:${recurrenceWeeks(observation)}`
15
+ : `${prefix}:${observation.date}`;
16
+ }
11
17
  function sameDate(a, b) {
12
18
  return a === b;
13
19
  }
@@ -144,6 +150,13 @@ function deserializeSource(source) {
144
150
  observations: source.observations.map(deserializeObservation),
145
151
  };
146
152
  }
153
+ function sameSourceContent(left, right) {
154
+ return left.sourceKey === right.sourceKey &&
155
+ left.academicYearStartYear === right.academicYearStartYear &&
156
+ left.period === right.period &&
157
+ JSON.stringify(left.owner) === JSON.stringify(right.owner) &&
158
+ JSON.stringify(left.observations) === JSON.stringify(right.observations);
159
+ }
147
160
  function sourceRef(claim) {
148
161
  return {
149
162
  sourceKey: claim.source.sourceKey,
@@ -267,6 +280,8 @@ export class TimetableRepository {
267
280
  links = new Map();
268
281
  seriesRecords = new Map();
269
282
  occurrenceRecords = new Map();
283
+ recordIdsByBucket = new Map();
284
+ recordIdsByOwner = new Map();
270
285
  _revision = 0;
271
286
  constructor(opts) {
272
287
  this.idGenerator = opts?.idGenerator ?? new RandomLessonIdGenerator();
@@ -297,6 +312,17 @@ export class TimetableRepository {
297
312
  this.validateSource(incoming);
298
313
  this.rememberSourceEntities(incoming);
299
314
  const prior = this.sources.get(incoming.sourceKey);
315
+ if (prior && sameSourceContent(prior, incoming)) {
316
+ const links = incoming.observations.map((observation) => this.links.get(sourceObservationKey(incoming.sourceKey, observation.key)));
317
+ return {
318
+ revision: this._revision,
319
+ seriesIds: links.flatMap((link) => link?.kind === "series" ? [link.id] : []),
320
+ lessonIds: links.flatMap((link) => link?.kind === "occurrence" ? [link.id] : []),
321
+ created: 0,
322
+ updated: incoming.observations.length,
323
+ removedObservations: 0,
324
+ };
325
+ }
300
326
  const priorClaims = new Map();
301
327
  if (prior) {
302
328
  for (const observation of prior.observations) {
@@ -365,7 +391,7 @@ export class TimetableRepository {
365
391
  }
366
392
  getSeries(opts) {
367
393
  const values = [];
368
- for (const record of this.seriesRecords.values()) {
394
+ for (const record of this.recordsForOwner(this.seriesRecords, opts?.owner)) {
369
395
  if (record.claims.size === 0)
370
396
  continue;
371
397
  const value = this.aggregateSeries(record);
@@ -387,7 +413,7 @@ export class TimetableRepository {
387
413
  }
388
414
  getDirectOccurrences(opts) {
389
415
  const values = [];
390
- for (const record of this.occurrenceRecords.values()) {
416
+ for (const record of this.recordsForOwner(this.occurrenceRecords, opts?.owner)) {
391
417
  if (record.claims.size === 0)
392
418
  continue;
393
419
  const value = this.aggregateOccurrence(record);
@@ -450,6 +476,8 @@ export class TimetableRepository {
450
476
  this.links.clear();
451
477
  this.seriesRecords.clear();
452
478
  this.occurrenceRecords.clear();
479
+ this.recordIdsByBucket.clear();
480
+ this.recordIdsByOwner.clear();
453
481
  this._directory = new TimetableDirectory(snapshot.directory);
454
482
  this._revision = snapshot.revision;
455
483
  for (const source of sources) {
@@ -601,12 +629,16 @@ export class TimetableRepository {
601
629
  }
602
630
  pruneEmptyRecords() {
603
631
  for (const [id, record] of this.seriesRecords) {
604
- if (record.claims.size === 0)
632
+ if (record.claims.size === 0) {
633
+ this.removeRecordIndexes(record);
605
634
  this.seriesRecords.delete(id);
635
+ }
606
636
  }
607
637
  for (const [id, record] of this.occurrenceRecords) {
608
- if (record.claims.size === 0)
638
+ if (record.claims.size === 0) {
639
+ this.removeRecordIndexes(record);
609
640
  this.occurrenceRecords.delete(id);
641
+ }
610
642
  }
611
643
  }
612
644
  removeClaim(sourceKey, observationKey) {
@@ -616,25 +648,39 @@ export class TimetableRepository {
616
648
  const record = link.kind === "series"
617
649
  ? this.seriesRecords.get(link.id)
618
650
  : this.occurrenceRecords.get(link.id);
619
- record?.claims.delete(claimKey(sourceKey, observationKey));
651
+ if (!record)
652
+ return;
653
+ record.claims.delete(claimKey(sourceKey, observationKey));
654
+ this.rebuildRecordOwnerIndex(record);
620
655
  }
621
656
  addClaim(link, source, observation) {
622
657
  const key = claimKey(source.sourceKey, observation.key);
658
+ const bucketKey = canonicalBucketKey(observation, source);
623
659
  if (link.kind === "series" && observation.kind === "series") {
624
660
  let record = this.seriesRecords.get(link.id);
625
661
  if (!record) {
626
- record = { id: link.id, claims: new Map() };
662
+ record = { id: link.id, claims: new Map(), bucketKey, ownerKeys: new Set() };
627
663
  this.seriesRecords.set(link.id, record);
664
+ this.addRecordToBucket(record);
665
+ }
666
+ if (record.bucketKey !== bucketKey) {
667
+ throw new Error(`Incompatible timetable series link: ${link.id}`);
628
668
  }
629
669
  record.claims.set(key, { source, observation });
670
+ this.addClaimOwnerKeys(record, source, observation);
630
671
  }
631
672
  if (link.kind === "occurrence" && observation.kind === "occurrence") {
632
673
  let record = this.occurrenceRecords.get(link.id);
633
674
  if (!record) {
634
- record = { id: link.id, claims: new Map() };
675
+ record = { id: link.id, claims: new Map(), bucketKey, ownerKeys: new Set() };
635
676
  this.occurrenceRecords.set(link.id, record);
677
+ this.addRecordToBucket(record);
678
+ }
679
+ if (record.bucketKey !== bucketKey) {
680
+ throw new Error(`Incompatible timetable occurrence link: ${link.id}`);
636
681
  }
637
682
  record.claims.set(key, { source, observation });
683
+ this.addClaimOwnerKeys(record, source, observation);
638
684
  }
639
685
  }
640
686
  findPriorTarget(observation, prior, usedTargets) {
@@ -657,12 +703,18 @@ export class TimetableRepository {
657
703
  return best && best.score >= 55 && !tied ? best.link : undefined;
658
704
  }
659
705
  findCanonicalTarget(observation, source, usedTargets) {
660
- const records = observation.kind === "series"
661
- ? this.seriesRecords.values()
662
- : this.occurrenceRecords.values();
706
+ const recordMap = observation.kind === "series"
707
+ ? this.seriesRecords
708
+ : this.occurrenceRecords;
709
+ const candidateIds = this.recordIdsByBucket.get(canonicalBucketKey(observation, source));
710
+ if (!candidateIds)
711
+ return undefined;
663
712
  let best;
664
713
  let second = Number.NEGATIVE_INFINITY;
665
- for (const record of records) {
714
+ for (const id of candidateIds) {
715
+ const record = recordMap.get(id);
716
+ if (!record)
717
+ continue;
666
718
  if (usedTargets.has(record.id) || record.claims.size === 0)
667
719
  continue;
668
720
  let recordScore = Number.NEGATIVE_INFINITY;
@@ -686,6 +738,102 @@ export class TimetableRepository {
686
738
  return undefined;
687
739
  return { kind: observation.kind, id: best.id };
688
740
  }
741
+ addRecordToBucket(record) {
742
+ let ids = this.recordIdsByBucket.get(record.bucketKey);
743
+ if (!ids) {
744
+ ids = new Set();
745
+ this.recordIdsByBucket.set(record.bucketKey, ids);
746
+ }
747
+ ids.add(record.id);
748
+ }
749
+ ownerReferenceKeys(kind, value) {
750
+ const resolved = kind === "group"
751
+ ? this.directory.resolveGroup(value)
752
+ : kind === "teacher"
753
+ ? this.directory.resolveTeacher(value)
754
+ : this.directory.resolveRoom(value);
755
+ const keys = new Set();
756
+ for (const reference of [value, resolved]) {
757
+ if (reference.id != null)
758
+ keys.add(`${kind}:id:${reference.id}`);
759
+ const name = normalizeScheduleText(reference.name);
760
+ if (name)
761
+ keys.add(`${kind}:name:${name}`);
762
+ }
763
+ return [...keys];
764
+ }
765
+ claimOwnerKeys(source, observation) {
766
+ const keys = new Set();
767
+ const add = (kind, value) => {
768
+ for (const key of this.ownerReferenceKeys(kind, value))
769
+ keys.add(key);
770
+ };
771
+ if (source.owner.type === "group")
772
+ add("group", source.owner.group);
773
+ if (source.owner.type === "teacher")
774
+ add("teacher", source.owner.teacher);
775
+ if (source.owner.type === "room")
776
+ add("room", source.owner.room);
777
+ for (const value of observation.groups.values)
778
+ add("group", value.group);
779
+ for (const value of observation.teachers.values)
780
+ add("teacher", value);
781
+ for (const value of observation.rooms.values)
782
+ add("room", value);
783
+ return keys;
784
+ }
785
+ addClaimOwnerKeys(record, source, observation) {
786
+ for (const key of this.claimOwnerKeys(source, observation)) {
787
+ record.ownerKeys.add(key);
788
+ let ids = this.recordIdsByOwner.get(key);
789
+ if (!ids) {
790
+ ids = new Set();
791
+ this.recordIdsByOwner.set(key, ids);
792
+ }
793
+ ids.add(record.id);
794
+ }
795
+ }
796
+ removeRecordOwnerIndex(record) {
797
+ for (const key of record.ownerKeys) {
798
+ const ids = this.recordIdsByOwner.get(key);
799
+ ids?.delete(record.id);
800
+ if (ids?.size === 0)
801
+ this.recordIdsByOwner.delete(key);
802
+ }
803
+ record.ownerKeys.clear();
804
+ }
805
+ rebuildRecordOwnerIndex(record) {
806
+ this.removeRecordOwnerIndex(record);
807
+ for (const claim of record.claims.values()) {
808
+ this.addClaimOwnerKeys(record, claim.source, claim.observation);
809
+ }
810
+ }
811
+ removeRecordIndexes(record) {
812
+ const bucketIds = this.recordIdsByBucket.get(record.bucketKey);
813
+ bucketIds?.delete(record.id);
814
+ if (bucketIds?.size === 0)
815
+ this.recordIdsByBucket.delete(record.bucketKey);
816
+ this.removeRecordOwnerIndex(record);
817
+ }
818
+ recordsForOwner(records, owner) {
819
+ if (!owner)
820
+ return records.values();
821
+ const kind = owner.type;
822
+ const reference = owner.type === "group"
823
+ ? owner.group
824
+ : owner.type === "teacher"
825
+ ? owner.teacher
826
+ : owner.room;
827
+ const ids = new Set();
828
+ for (const key of this.ownerReferenceKeys(kind, reference)) {
829
+ for (const id of this.recordIdsByOwner.get(key) ?? [])
830
+ ids.add(id);
831
+ }
832
+ return [...ids].flatMap((id) => {
833
+ const record = records.get(id);
834
+ return record ? [record] : [];
835
+ });
836
+ }
689
837
  aggregateSubstitutions(claims) {
690
838
  const byDate = new Map();
691
839
  for (const substitution of claims.flatMap((value) => value.observation.substitutions ?? [])) {
@@ -281,6 +281,12 @@ const snapshot = await client.exportRepository();
281
281
  вместе со всеми когда-либо загруженными расписаниями, а восстановление требует
282
282
  повторной агрегации связей.
283
283
 
284
+ Начиная с 5.1.2 поиск совпадающих занятий и выборка расписания владельца используют
285
+ внутренние индексы, одинаковые повторные снимки не меняют ревизию, а периоды одного
286
+ запроса сохраняются одной транзакцией адаптера. Это исключает полный обход
287
+ репозитория из обычного пути запроса; `exportRepository()` по-прежнему имеет цену,
288
+ пропорциональную размеру полного снимка, и не должен вызываться на клиентском пути.
289
+
284
290
  Для API, SSR и офлайн-кеша материализуйте только готовое расписание владельца:
285
291
 
286
292
  ```ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chuvsu-js",
3
- "version": "5.1.1",
3
+ "version": "5.1.2",
4
4
  "description": "Библиотека для расписания и личного кабинета ЧГУ им. И. Н. Ульянова",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",