chuvsu-js 5.1.1 → 5.2.0

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
@@ -181,7 +181,11 @@ const client = new TimetableClient({
181
181
 
182
182
  `TimetableRepositoryAdapter` можно реализовать поверх БД, KV-хранилища или
183
183
  файлового сервиса. Запись использует ревизии compare-and-set, чтобы параллельные
184
- процессы не перезаписывали установленные ID.
184
+ процессы не перезаписывали установленные ID. Для БД реализуйте
185
+ `compareAndSetPatch`: обычное обновление передает только измененные сущности и
186
+ полные замены затронутых источников. `compareAndSet` с полным снимком оставлен
187
+ как совместимый fallback; его не следует использовать на горячем пути большого
188
+ репозитория.
185
189
 
186
190
  ```ts
187
191
  const snapshot = await client.exportRepository();
package/dist/tt/client.js CHANGED
@@ -122,10 +122,23 @@ export class TimetableClient {
122
122
  await this.ensureRepository();
123
123
  for (let attempt = 0; attempt < 4; attempt++) {
124
124
  const expectedRevision = this._repository.revision;
125
- const result = mutate(this._repository);
125
+ const mutation = this._repository.capturePatch(() => mutate(this._repository));
126
+ const result = mutation.result;
127
+ if (!mutation.patch)
128
+ return result;
126
129
  if (!this.repositoryAdapter)
127
130
  return result;
128
- if (await this.repositoryAdapter.compareAndSet(expectedRevision, this._repository.export())) {
131
+ let saved;
132
+ if (this.repositoryAdapter.compareAndSetPatch) {
133
+ saved = await this.repositoryAdapter.compareAndSetPatch(expectedRevision, mutation.patch);
134
+ }
135
+ else if (this.repositoryAdapter.compareAndSet) {
136
+ saved = await this.repositoryAdapter.compareAndSet(expectedRevision, this._repository.export());
137
+ }
138
+ else {
139
+ throw new Error("Timetable repository adapter cannot persist changes");
140
+ }
141
+ if (saved) {
129
142
  return result;
130
143
  }
131
144
  const latest = await this.repositoryAdapter.load();
@@ -363,17 +376,18 @@ export class TimetableClient {
363
376
  period,
364
377
  days: await this.fetchOwnerSchedule(resolvedOwner, period, context.academicYearStartYear),
365
378
  })));
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
- }
379
+ await this.mutateRepository((repository) => {
380
+ for (const result of results) {
381
+ const currentOwner = this.resolveOwner(resolvedOwner);
382
+ repository.ingest(createScheduleSourceSnapshot({
383
+ sourceKey: this.sourceKey(currentOwner, result.period, context.academicYearStartYear),
384
+ owner: currentOwner,
385
+ academicYearStartYear: context.academicYearStartYear,
386
+ period: result.period,
387
+ days: result.days,
388
+ }));
389
+ }
390
+ });
377
391
  return new Schedule(this._repository, this.resolveOwner(resolvedOwner), context.academicYearStartYear, { period: periods.length === 1 ? periods[0] : context.period });
378
392
  }
379
393
  async getGroupSchedule(groupId, options) {
@@ -1,8 +1,8 @@
1
1
  import type { GroupRef, RoomRef, TeacherRef, TimetableDirectorySnapshot } from "./types.js";
2
2
  export declare class TimetableDirectory {
3
- private groups;
4
- private teachers;
5
- private rooms;
3
+ private readonly groups;
4
+ private readonly teachers;
5
+ private readonly rooms;
6
6
  constructor(snapshot?: TimetableDirectorySnapshot);
7
7
  rememberGroups(values: GroupRef[]): boolean;
8
8
  rememberTeachers(values: TeacherRef[]): boolean;
@@ -1,4 +1,4 @@
1
- import { mergeEntityRefs, normalizeScheduleText, } from "./normalize.js";
1
+ import { entityKey, mergeEntityRefs, normalizeScheduleText, } from "./normalize.js";
2
2
  function personKey(name) {
3
3
  const words = normalizeScheduleText(name)
4
4
  .replace(/[.,]/gu, " ")
@@ -11,106 +11,167 @@ function personKey(name) {
11
11
  .join("");
12
12
  return `${surname}|${initials}`;
13
13
  }
14
- function exactKey(name) {
15
- return normalizeScheduleText(name);
16
- }
17
14
  function validateRefs(values, label) {
18
15
  for (const value of values) {
19
16
  if (!value.name.trim())
20
17
  throw new TypeError(`${label} name is empty`);
21
- if (value.id != null &&
22
- (!Number.isInteger(value.id) || value.id < 1)) {
18
+ if (value.id != null && (!Number.isInteger(value.id) || value.id < 1)) {
23
19
  throw new RangeError(`${label} ID must be a positive integer`);
24
20
  }
25
21
  }
26
22
  }
27
- function mergeByIdAndName(current, incoming) {
28
- const values = mergeEntityRefs([...current, ...incoming]);
29
- const before = JSON.stringify(current);
30
- const after = JSON.stringify(values);
31
- return { values, changed: before !== after };
32
- }
33
- function mergeTeachersByIdentity(current, incoming) {
34
- const merged = mergeEntityRefs([...current, ...incoming]);
35
- const identified = merged.filter((value) => value.id != null);
36
- const unresolved = [];
37
- for (const alias of merged.filter((value) => value.id == null)) {
38
- const matches = identified
39
- .map((value, index) => ({ value, index }))
40
- .filter(({ value }) => personKey(value.name) === personKey(alias.name));
41
- const ids = new Set(matches.map(({ value }) => value.id));
42
- if (ids.size === 1) {
43
- const match = matches[0];
44
- identified[match.index] = { ...alias, ...match.value };
23
+ class EntityIndex {
24
+ teacherIdentity;
25
+ records = new Set();
26
+ byId = new Map();
27
+ byName = new Map();
28
+ byPerson = new Map();
29
+ constructor(values, teacherIdentity = false) {
30
+ this.teacherIdentity = teacherIdentity;
31
+ for (const value of values)
32
+ this.rememberOne(structuredClone(value));
33
+ }
34
+ remember(values) {
35
+ let changed = false;
36
+ for (const value of values) {
37
+ if (this.rememberOne(structuredClone(value)))
38
+ changed = true;
39
+ }
40
+ return changed;
41
+ }
42
+ resolve(value) {
43
+ const exact = value.id == null
44
+ ? this.byName.get(normalizeScheduleText(value.name))
45
+ : this.byId.get(value.id);
46
+ if (exact)
47
+ return mergeEntityRefs([value, exact.value])[0];
48
+ if (!this.teacherIdentity)
49
+ return structuredClone(value);
50
+ const candidates = this.byPerson.get(personKey(value.name)) ?? new Set();
51
+ const identified = [...candidates].filter((candidate) => candidate.value.id != null);
52
+ const ids = new Set(identified.map((candidate) => candidate.value.id));
53
+ return ids.size === 1
54
+ ? mergeEntityRefs([value, identified[0].value])[0]
55
+ : structuredClone(value);
56
+ }
57
+ export() {
58
+ return [...this.records]
59
+ .map((record) => structuredClone(record.value))
60
+ .sort((a, b) => entityKey(a).localeCompare(entityKey(b)));
61
+ }
62
+ rememberOne(value) {
63
+ const name = normalizeScheduleText(value.name);
64
+ const person = this.teacherIdentity ? personKey(value.name) : "";
65
+ let record = value.id == null ? undefined : this.byId.get(value.id);
66
+ record ??= this.byName.get(name);
67
+ if (!record && this.teacherIdentity) {
68
+ const identified = [...(this.byPerson.get(person) ?? [])]
69
+ .filter((candidate) => candidate.value.id != null);
70
+ const ids = new Set(identified.map((candidate) => candidate.value.id));
71
+ if (ids.size === 1)
72
+ record = identified[0];
45
73
  }
46
- else {
47
- unresolved.push(alias);
74
+ if (!record) {
75
+ record = { value, ids: new Set(), names: new Set(), people: new Set() };
76
+ this.records.add(record);
77
+ this.index(record, value);
78
+ if (this.teacherIdentity && value.id != null)
79
+ this.mergeTeacherAliases(record, person);
80
+ return true;
48
81
  }
82
+ const before = JSON.stringify(record.value);
83
+ record.value = mergeEntityRefs([record.value, value])[0];
84
+ this.index(record, value);
85
+ this.index(record, record.value);
86
+ if (this.teacherIdentity && record.value.id != null)
87
+ this.mergeTeacherAliases(record, person);
88
+ return before !== JSON.stringify(record.value);
89
+ }
90
+ index(record, value) {
91
+ if (value.id != null) {
92
+ record.ids.add(value.id);
93
+ this.byId.set(value.id, record);
94
+ }
95
+ const name = normalizeScheduleText(value.name);
96
+ if (name) {
97
+ record.names.add(name);
98
+ this.byName.set(name, record);
99
+ }
100
+ if (this.teacherIdentity) {
101
+ const person = personKey(value.name);
102
+ record.people.add(person);
103
+ let records = this.byPerson.get(person);
104
+ if (!records) {
105
+ records = new Set();
106
+ this.byPerson.set(person, records);
107
+ }
108
+ records.add(record);
109
+ }
110
+ }
111
+ mergeTeacherAliases(target, person) {
112
+ const aliases = [...(this.byPerson.get(person) ?? [])]
113
+ .filter((candidate) => candidate !== target && candidate.value.id == null);
114
+ for (const alias of aliases)
115
+ this.mergeRecords(target, alias);
116
+ }
117
+ mergeRecords(target, source) {
118
+ target.value = mergeEntityRefs([source.value, target.value])[0];
119
+ for (const id of source.ids) {
120
+ target.ids.add(id);
121
+ this.byId.set(id, target);
122
+ }
123
+ for (const name of source.names) {
124
+ target.names.add(name);
125
+ this.byName.set(name, target);
126
+ }
127
+ for (const person of source.people) {
128
+ target.people.add(person);
129
+ const records = this.byPerson.get(person);
130
+ records?.delete(source);
131
+ records?.add(target);
132
+ }
133
+ this.records.delete(source);
49
134
  }
50
- const values = mergeEntityRefs([...identified, ...unresolved]);
51
- return {
52
- values,
53
- changed: JSON.stringify(current) !== JSON.stringify(values),
54
- };
55
- }
56
- function resolveUnique(value, known, key) {
57
- if (value.id != null) {
58
- const byId = known.find((candidate) => candidate.id === value.id);
59
- return byId ? mergeEntityRefs([value, byId])[0] : value;
60
- }
61
- const lookup = key(value.name);
62
- const candidates = known.filter((candidate) => candidate.id != null && key(candidate.name) === lookup);
63
- const ids = new Set(candidates.map((candidate) => candidate.id));
64
- if (ids.size !== 1)
65
- return value;
66
- return mergeEntityRefs([value, candidates[0]])[0];
67
135
  }
68
136
  export class TimetableDirectory {
69
- groups = [];
70
- teachers = [];
71
- rooms = [];
137
+ groups;
138
+ teachers;
139
+ rooms;
72
140
  constructor(snapshot) {
73
- if (snapshot) {
74
- validateRefs(snapshot.groups, "Group");
75
- validateRefs(snapshot.teachers, "Teacher");
76
- validateRefs(snapshot.rooms, "Room");
77
- this.groups = mergeEntityRefs(structuredClone(snapshot.groups));
78
- this.teachers = mergeTeachersByIdentity([], structuredClone(snapshot.teachers)).values;
79
- this.rooms = mergeEntityRefs(structuredClone(snapshot.rooms));
80
- }
141
+ const initial = snapshot ?? { groups: [], teachers: [], rooms: [] };
142
+ validateRefs(initial.groups, "Group");
143
+ validateRefs(initial.teachers, "Teacher");
144
+ validateRefs(initial.rooms, "Room");
145
+ this.groups = new EntityIndex(initial.groups);
146
+ this.teachers = new EntityIndex(initial.teachers, true);
147
+ this.rooms = new EntityIndex(initial.rooms);
81
148
  }
82
149
  rememberGroups(values) {
83
150
  validateRefs(values, "Group");
84
- const result = mergeByIdAndName(this.groups, values);
85
- this.groups = result.values;
86
- return result.changed;
151
+ return this.groups.remember(values);
87
152
  }
88
153
  rememberTeachers(values) {
89
154
  validateRefs(values, "Teacher");
90
- const result = mergeTeachersByIdentity(this.teachers, values);
91
- this.teachers = result.values;
92
- return result.changed;
155
+ return this.teachers.remember(values);
93
156
  }
94
157
  rememberRooms(values) {
95
158
  validateRefs(values, "Room");
96
- const result = mergeByIdAndName(this.rooms, values);
97
- this.rooms = result.values;
98
- return result.changed;
159
+ return this.rooms.remember(values);
99
160
  }
100
161
  resolveGroup(value) {
101
- return resolveUnique(value, this.groups, exactKey);
162
+ return this.groups.resolve(value);
102
163
  }
103
164
  resolveTeacher(value) {
104
- return resolveUnique(value, this.teachers, personKey);
165
+ return this.teachers.resolve(value);
105
166
  }
106
167
  resolveRoom(value) {
107
- return resolveUnique(value, this.rooms, exactKey);
168
+ return this.rooms.resolve(value);
108
169
  }
109
170
  export() {
110
171
  return {
111
- groups: structuredClone(this.groups),
112
- teachers: structuredClone(this.teachers),
113
- rooms: structuredClone(this.rooms),
172
+ groups: this.groups.export(),
173
+ teachers: this.teachers.export(),
174
+ rooms: this.rooms.export(),
114
175
  };
115
176
  }
116
177
  }
@@ -6,4 +6,4 @@ export { Schedule } from "./schedule.js";
6
6
  export { MaterializedSchedule, materializeSchedule, materializeScheduleSnapshot, } from "./materialized-schedule.js";
7
7
  export type { ScheduleQueryOptions, ScheduleOptions, ScheduleWeekdayOptions, } from "./schedule.js";
8
8
  export type { MaterializeScheduleOptions, MaterializedScheduleSnapshot, SerializedLessonOccurrence, SerializedLessonSourceRef, } from "./materialized-schedule.js";
9
- export type { GroupAttendance, GroupRef, IngestResult, LessonId, LessonIdGenerator, LessonOccurrence, LessonRecurrence, LessonSeries, LessonSeriesId, RelationCompleteness, RelationSet, LessonSourceRef, LessonStatus, LessonSubstitution, LessonTransfer, NamedEntityRef, OccurrenceObservation, RoomRef, ScheduleObservation, ScheduleOwner, ScheduleSourceSnapshot, SeriesObservation, TeacherRef, TimetableDirectorySnapshot, TimetableRepositoryAdapter, TimetableRepositorySnapshot, } from "./types.js";
9
+ export type { GroupAttendance, GroupRef, IngestResult, LessonId, LessonIdGenerator, LessonOccurrence, LessonRecurrence, LessonSeries, LessonSeriesId, RelationCompleteness, RelationSet, LessonSourceRef, LessonStatus, LessonSubstitution, LessonTransfer, NamedEntityRef, OccurrenceObservation, RoomRef, ScheduleObservation, ScheduleOwner, ScheduleSourceSnapshot, SeriesObservation, TeacherRef, TimetableDirectorySnapshot, TimetableRepositoryAdapter, TimetableRepositoryPatch, TimetableRepositorySnapshot, } from "./types.js";
@@ -1,6 +1,6 @@
1
1
  import type { AcademicPeriod } from "../../common/types.js";
2
2
  import { TimetableDirectory } from "./directory.js";
3
- import type { IngestResult, LessonIdGenerator, LessonOccurrence, LessonSeries, ScheduleOwner, ScheduleSourceSnapshot, TimetableRepositoryAdapter, TimetableRepositorySnapshot } from "./types.js";
3
+ import type { IngestResult, LessonIdGenerator, LessonOccurrence, LessonSeries, ScheduleOwner, ScheduleSourceSnapshot, TimetableRepositoryAdapter, TimetableRepositoryPatch, TimetableRepositorySnapshot } from "./types.js";
4
4
  export declare class TimetableRepository {
5
5
  private readonly idGenerator;
6
6
  private _directory;
@@ -8,6 +8,9 @@ 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;
13
+ private patchTracker;
11
14
  private _revision;
12
15
  constructor(opts?: {
13
16
  idGenerator?: LessonIdGenerator;
@@ -18,6 +21,10 @@ export declare class TimetableRepository {
18
21
  rememberGroups(values: Parameters<TimetableDirectory["rememberGroups"]>[0]): void;
19
22
  rememberTeachers(values: Parameters<TimetableDirectory["rememberTeachers"]>[0]): void;
20
23
  rememberRooms(values: Parameters<TimetableDirectory["rememberRooms"]>[0]): void;
24
+ capturePatch<T>(mutate: () => T): {
25
+ result: T;
26
+ patch: TimetableRepositoryPatch | null;
27
+ };
21
28
  ingest(snapshot: ScheduleSourceSnapshot): IngestResult;
22
29
  getSeries(opts?: {
23
30
  owner?: ScheduleOwner;
@@ -30,15 +37,23 @@ export declare class TimetableRepository {
30
37
  periods?: readonly AcademicPeriod[];
31
38
  }): LessonOccurrence[];
32
39
  export(): TimetableRepositorySnapshot;
40
+ private buildPatch;
33
41
  replaceSnapshot(snapshot: TimetableRepositorySnapshot): void;
34
42
  private restore;
35
43
  private validateSource;
36
44
  private rememberSourceEntities;
37
- private pruneEmptyRecords;
38
45
  private removeClaim;
39
46
  private addClaim;
40
47
  private findPriorTarget;
41
48
  private findCanonicalTarget;
49
+ private addRecordToBucket;
50
+ private ownerReferenceKeys;
51
+ private claimOwnerKeys;
52
+ private addClaimOwnerKeys;
53
+ private removeRecordOwnerIndex;
54
+ private rebuildRecordOwnerIndex;
55
+ private removeRecordIndexes;
56
+ private recordsForOwner;
42
57
  private aggregateSubstitutions;
43
58
  private aggregateSeries;
44
59
  private aggregateOccurrence;
@@ -1,13 +1,19 @@
1
1
  import { isLocalDate } from "../utils/date.js";
2
2
  import { TimetableDirectory } from "./directory.js";
3
3
  import { RandomLessonIdGenerator } from "./ids.js";
4
- import { entityKey, mergeGroups, mergeRooms, mergeTeachers, normalizeScheduleText, } from "./normalize.js";
4
+ import { entityKey, mergeEntityRefs, mergeGroups, mergeRooms, mergeTeachers, normalizeScheduleText, } from "./normalize.js";
5
5
  function claimKey(sourceKey, observationKey) {
6
6
  return `${sourceKey}\u0000${observationKey}`;
7
7
  }
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,9 @@ 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();
285
+ patchTracker = null;
270
286
  _revision = 0;
271
287
  constructor(opts) {
272
288
  this.idGenerator = opts?.idGenerator ?? new RandomLessonIdGenerator();
@@ -281,22 +297,63 @@ export class TimetableRepository {
281
297
  return this._revision;
282
298
  }
283
299
  rememberGroups(values) {
284
- if (this.directory.rememberGroups(values))
300
+ if (this.directory.rememberGroups(values)) {
301
+ this.patchTracker?.groups.push(...structuredClone(values));
285
302
  this._revision++;
303
+ }
286
304
  }
287
305
  rememberTeachers(values) {
288
- if (this.directory.rememberTeachers(values))
306
+ if (this.directory.rememberTeachers(values)) {
307
+ this.patchTracker?.teachers.push(...structuredClone(values));
289
308
  this._revision++;
309
+ }
290
310
  }
291
311
  rememberRooms(values) {
292
- if (this.directory.rememberRooms(values))
312
+ if (this.directory.rememberRooms(values)) {
313
+ this.patchTracker?.rooms.push(...structuredClone(values));
293
314
  this._revision++;
315
+ }
316
+ }
317
+ capturePatch(mutate) {
318
+ if (this.patchTracker)
319
+ throw new Error("Nested timetable repository mutation");
320
+ const expectedRevision = this._revision;
321
+ const tracker = {
322
+ sourceKeys: new Set(),
323
+ groups: [],
324
+ teachers: [],
325
+ rooms: [],
326
+ };
327
+ this.patchTracker = tracker;
328
+ try {
329
+ const result = mutate();
330
+ return {
331
+ result,
332
+ patch: this._revision === expectedRevision ? null : this.buildPatch(tracker),
333
+ };
334
+ }
335
+ finally {
336
+ this.patchTracker = null;
337
+ }
294
338
  }
295
339
  ingest(snapshot) {
296
340
  const incoming = structuredClone(snapshot);
297
341
  this.validateSource(incoming);
298
- this.rememberSourceEntities(incoming);
342
+ const directoryChanged = this.rememberSourceEntities(incoming);
299
343
  const prior = this.sources.get(incoming.sourceKey);
344
+ if (prior && sameSourceContent(prior, incoming)) {
345
+ const links = incoming.observations.map((observation) => this.links.get(sourceObservationKey(incoming.sourceKey, observation.key)));
346
+ if (directoryChanged)
347
+ this._revision++;
348
+ return {
349
+ revision: this._revision,
350
+ seriesIds: links.flatMap((link) => link?.kind === "series" ? [link.id] : []),
351
+ lessonIds: links.flatMap((link) => link?.kind === "occurrence" ? [link.id] : []),
352
+ created: 0,
353
+ updated: incoming.observations.length,
354
+ removedObservations: 0,
355
+ };
356
+ }
300
357
  const priorClaims = new Map();
301
358
  if (prior) {
302
359
  for (const observation of prior.observations) {
@@ -305,6 +362,7 @@ export class TimetableRepository {
305
362
  }
306
363
  }
307
364
  this.sources.set(incoming.sourceKey, incoming);
365
+ this.patchTracker?.sourceKeys.add(incoming.sourceKey);
308
366
  const usedTargets = new Set();
309
367
  const seriesIds = [];
310
368
  const lessonIds = [];
@@ -352,7 +410,6 @@ export class TimetableRepository {
352
410
  }
353
411
  }
354
412
  }
355
- this.pruneEmptyRecords();
356
413
  this._revision++;
357
414
  return {
358
415
  revision: this._revision,
@@ -365,7 +422,7 @@ export class TimetableRepository {
365
422
  }
366
423
  getSeries(opts) {
367
424
  const values = [];
368
- for (const record of this.seriesRecords.values()) {
425
+ for (const record of this.recordsForOwner(this.seriesRecords, opts?.owner)) {
369
426
  if (record.claims.size === 0)
370
427
  continue;
371
428
  const value = this.aggregateSeries(record);
@@ -387,7 +444,7 @@ export class TimetableRepository {
387
444
  }
388
445
  getDirectOccurrences(opts) {
389
446
  const values = [];
390
- for (const record of this.occurrenceRecords.values()) {
447
+ for (const record of this.recordsForOwner(this.occurrenceRecords, opts?.owner)) {
391
448
  if (record.claims.size === 0)
392
449
  continue;
393
450
  const value = this.aggregateOccurrence(record);
@@ -427,6 +484,32 @@ export class TimetableRepository {
427
484
  a.observationKey.localeCompare(b.observationKey)),
428
485
  };
429
486
  }
487
+ buildPatch(tracker) {
488
+ return {
489
+ schemaVersion: 5,
490
+ revision: this._revision,
491
+ directoryUpserts: {
492
+ groups: mergeEntityRefs(tracker.groups),
493
+ teachers: mergeTeachers(tracker.teachers),
494
+ rooms: mergeRooms(tracker.rooms),
495
+ },
496
+ sourceReplacements: [...tracker.sourceKeys].sort().flatMap((sourceKey) => {
497
+ const source = this.sources.get(sourceKey);
498
+ if (!source)
499
+ return [];
500
+ return [{
501
+ source: serializeSource(source),
502
+ links: source.observations.map((observation) => {
503
+ const link = this.links.get(sourceObservationKey(sourceKey, observation.key));
504
+ if (!link) {
505
+ throw new Error(`Missing timetable link: ${sourceKey}/${observation.key}`);
506
+ }
507
+ return { observationKey: observation.key, ...link };
508
+ }),
509
+ }];
510
+ }),
511
+ };
512
+ }
430
513
  replaceSnapshot(snapshot) {
431
514
  this.restore(snapshot);
432
515
  }
@@ -450,6 +533,8 @@ export class TimetableRepository {
450
533
  this.links.clear();
451
534
  this.seriesRecords.clear();
452
535
  this.occurrenceRecords.clear();
536
+ this.recordIdsByBucket.clear();
537
+ this.recordIdsByOwner.clear();
453
538
  this._directory = new TimetableDirectory(snapshot.directory);
454
539
  this._revision = snapshot.revision;
455
540
  for (const source of sources) {
@@ -595,19 +680,16 @@ export class TimetableRepository {
595
680
  rooms.push(...(substitution.rooms ?? []));
596
681
  }
597
682
  }
598
- this.directory.rememberGroups(groups);
599
- this.directory.rememberTeachers(teachers);
600
- this.directory.rememberRooms(rooms);
601
- }
602
- pruneEmptyRecords() {
603
- for (const [id, record] of this.seriesRecords) {
604
- if (record.claims.size === 0)
605
- this.seriesRecords.delete(id);
606
- }
607
- for (const [id, record] of this.occurrenceRecords) {
608
- if (record.claims.size === 0)
609
- this.occurrenceRecords.delete(id);
610
- }
683
+ const groupsChanged = this.directory.rememberGroups(groups);
684
+ const teachersChanged = this.directory.rememberTeachers(teachers);
685
+ const roomsChanged = this.directory.rememberRooms(rooms);
686
+ if (groupsChanged)
687
+ this.patchTracker?.groups.push(...structuredClone(groups));
688
+ if (teachersChanged)
689
+ this.patchTracker?.teachers.push(...structuredClone(teachers));
690
+ if (roomsChanged)
691
+ this.patchTracker?.rooms.push(...structuredClone(rooms));
692
+ return groupsChanged || teachersChanged || roomsChanged;
611
693
  }
612
694
  removeClaim(sourceKey, observationKey) {
613
695
  const link = this.links.get(sourceObservationKey(sourceKey, observationKey));
@@ -616,25 +698,47 @@ export class TimetableRepository {
616
698
  const record = link.kind === "series"
617
699
  ? this.seriesRecords.get(link.id)
618
700
  : this.occurrenceRecords.get(link.id);
619
- record?.claims.delete(claimKey(sourceKey, observationKey));
701
+ if (!record)
702
+ return;
703
+ record.claims.delete(claimKey(sourceKey, observationKey));
704
+ if (record.claims.size === 0) {
705
+ this.removeRecordIndexes(record);
706
+ if (link.kind === "series")
707
+ this.seriesRecords.delete(link.id);
708
+ else
709
+ this.occurrenceRecords.delete(link.id);
710
+ return;
711
+ }
712
+ this.rebuildRecordOwnerIndex(record);
620
713
  }
621
714
  addClaim(link, source, observation) {
622
715
  const key = claimKey(source.sourceKey, observation.key);
716
+ const bucketKey = canonicalBucketKey(observation, source);
623
717
  if (link.kind === "series" && observation.kind === "series") {
624
718
  let record = this.seriesRecords.get(link.id);
625
719
  if (!record) {
626
- record = { id: link.id, claims: new Map() };
720
+ record = { id: link.id, claims: new Map(), bucketKey, ownerKeys: new Set() };
627
721
  this.seriesRecords.set(link.id, record);
722
+ this.addRecordToBucket(record);
723
+ }
724
+ if (record.bucketKey !== bucketKey) {
725
+ throw new Error(`Incompatible timetable series link: ${link.id}`);
628
726
  }
629
727
  record.claims.set(key, { source, observation });
728
+ this.addClaimOwnerKeys(record, source, observation);
630
729
  }
631
730
  if (link.kind === "occurrence" && observation.kind === "occurrence") {
632
731
  let record = this.occurrenceRecords.get(link.id);
633
732
  if (!record) {
634
- record = { id: link.id, claims: new Map() };
733
+ record = { id: link.id, claims: new Map(), bucketKey, ownerKeys: new Set() };
635
734
  this.occurrenceRecords.set(link.id, record);
735
+ this.addRecordToBucket(record);
736
+ }
737
+ if (record.bucketKey !== bucketKey) {
738
+ throw new Error(`Incompatible timetable occurrence link: ${link.id}`);
636
739
  }
637
740
  record.claims.set(key, { source, observation });
741
+ this.addClaimOwnerKeys(record, source, observation);
638
742
  }
639
743
  }
640
744
  findPriorTarget(observation, prior, usedTargets) {
@@ -657,12 +761,18 @@ export class TimetableRepository {
657
761
  return best && best.score >= 55 && !tied ? best.link : undefined;
658
762
  }
659
763
  findCanonicalTarget(observation, source, usedTargets) {
660
- const records = observation.kind === "series"
661
- ? this.seriesRecords.values()
662
- : this.occurrenceRecords.values();
764
+ const recordMap = observation.kind === "series"
765
+ ? this.seriesRecords
766
+ : this.occurrenceRecords;
767
+ const candidateIds = this.recordIdsByBucket.get(canonicalBucketKey(observation, source));
768
+ if (!candidateIds)
769
+ return undefined;
663
770
  let best;
664
771
  let second = Number.NEGATIVE_INFINITY;
665
- for (const record of records) {
772
+ for (const id of candidateIds) {
773
+ const record = recordMap.get(id);
774
+ if (!record)
775
+ continue;
666
776
  if (usedTargets.has(record.id) || record.claims.size === 0)
667
777
  continue;
668
778
  let recordScore = Number.NEGATIVE_INFINITY;
@@ -686,6 +796,102 @@ export class TimetableRepository {
686
796
  return undefined;
687
797
  return { kind: observation.kind, id: best.id };
688
798
  }
799
+ addRecordToBucket(record) {
800
+ let ids = this.recordIdsByBucket.get(record.bucketKey);
801
+ if (!ids) {
802
+ ids = new Set();
803
+ this.recordIdsByBucket.set(record.bucketKey, ids);
804
+ }
805
+ ids.add(record.id);
806
+ }
807
+ ownerReferenceKeys(kind, value) {
808
+ const resolved = kind === "group"
809
+ ? this.directory.resolveGroup(value)
810
+ : kind === "teacher"
811
+ ? this.directory.resolveTeacher(value)
812
+ : this.directory.resolveRoom(value);
813
+ const keys = new Set();
814
+ for (const reference of [value, resolved]) {
815
+ if (reference.id != null)
816
+ keys.add(`${kind}:id:${reference.id}`);
817
+ const name = normalizeScheduleText(reference.name);
818
+ if (name)
819
+ keys.add(`${kind}:name:${name}`);
820
+ }
821
+ return [...keys];
822
+ }
823
+ claimOwnerKeys(source, observation) {
824
+ const keys = new Set();
825
+ const add = (kind, value) => {
826
+ for (const key of this.ownerReferenceKeys(kind, value))
827
+ keys.add(key);
828
+ };
829
+ if (source.owner.type === "group")
830
+ add("group", source.owner.group);
831
+ if (source.owner.type === "teacher")
832
+ add("teacher", source.owner.teacher);
833
+ if (source.owner.type === "room")
834
+ add("room", source.owner.room);
835
+ for (const value of observation.groups.values)
836
+ add("group", value.group);
837
+ for (const value of observation.teachers.values)
838
+ add("teacher", value);
839
+ for (const value of observation.rooms.values)
840
+ add("room", value);
841
+ return keys;
842
+ }
843
+ addClaimOwnerKeys(record, source, observation) {
844
+ for (const key of this.claimOwnerKeys(source, observation)) {
845
+ record.ownerKeys.add(key);
846
+ let ids = this.recordIdsByOwner.get(key);
847
+ if (!ids) {
848
+ ids = new Set();
849
+ this.recordIdsByOwner.set(key, ids);
850
+ }
851
+ ids.add(record.id);
852
+ }
853
+ }
854
+ removeRecordOwnerIndex(record) {
855
+ for (const key of record.ownerKeys) {
856
+ const ids = this.recordIdsByOwner.get(key);
857
+ ids?.delete(record.id);
858
+ if (ids?.size === 0)
859
+ this.recordIdsByOwner.delete(key);
860
+ }
861
+ record.ownerKeys.clear();
862
+ }
863
+ rebuildRecordOwnerIndex(record) {
864
+ this.removeRecordOwnerIndex(record);
865
+ for (const claim of record.claims.values()) {
866
+ this.addClaimOwnerKeys(record, claim.source, claim.observation);
867
+ }
868
+ }
869
+ removeRecordIndexes(record) {
870
+ const bucketIds = this.recordIdsByBucket.get(record.bucketKey);
871
+ bucketIds?.delete(record.id);
872
+ if (bucketIds?.size === 0)
873
+ this.recordIdsByBucket.delete(record.bucketKey);
874
+ this.removeRecordOwnerIndex(record);
875
+ }
876
+ recordsForOwner(records, owner) {
877
+ if (!owner)
878
+ return records.values();
879
+ const kind = owner.type;
880
+ const reference = owner.type === "group"
881
+ ? owner.group
882
+ : owner.type === "teacher"
883
+ ? owner.teacher
884
+ : owner.room;
885
+ const ids = new Set();
886
+ for (const key of this.ownerReferenceKeys(kind, reference)) {
887
+ for (const id of this.recordIdsByOwner.get(key) ?? [])
888
+ ids.add(id);
889
+ }
890
+ return [...ids].flatMap((id) => {
891
+ const record = records.get(id);
892
+ return record ? [record] : [];
893
+ });
894
+ }
689
895
  aggregateSubstitutions(claims) {
690
896
  const byDate = new Map();
691
897
  for (const substitution of claims.flatMap((value) => value.observation.substitutions ?? [])) {
@@ -158,6 +158,21 @@ export interface TimetableRepositorySnapshot {
158
158
  id: string;
159
159
  }>;
160
160
  }
161
+ export interface TimetableRepositoryPatch {
162
+ schemaVersion: 5;
163
+ revision: number;
164
+ /** Entity rows changed by this mutation. Merge them into persistent directory storage. */
165
+ directoryUpserts: TimetableDirectorySnapshot;
166
+ /** Complete replacements for touched sources. Untouched source rows stay unchanged. */
167
+ sourceReplacements: Array<{
168
+ source: SerializedScheduleSourceSnapshot;
169
+ links: Array<{
170
+ observationKey: string;
171
+ kind: "series" | "occurrence";
172
+ id: string;
173
+ }>;
174
+ }>;
175
+ }
161
176
  export interface TimetableDirectorySnapshot {
162
177
  groups: GroupRef[];
163
178
  teachers: TeacherRef[];
@@ -165,7 +180,10 @@ export interface TimetableDirectorySnapshot {
165
180
  }
166
181
  export interface TimetableRepositoryAdapter {
167
182
  load(): Promise<TimetableRepositorySnapshot | null>;
168
- compareAndSet(expectedRevision: number, snapshot: TimetableRepositorySnapshot): Promise<boolean>;
183
+ /** Legacy full-snapshot CAS fallback. Prefer compareAndSetPatch for persistent storage. */
184
+ compareAndSet?(expectedRevision: number, snapshot: TimetableRepositorySnapshot): Promise<boolean>;
185
+ /** Incremental CAS: persist only supplied entity upserts and source replacements. */
186
+ compareAndSetPatch?(expectedRevision: number, patch: TimetableRepositoryPatch): Promise<boolean>;
169
187
  clear?(): Promise<void>;
170
188
  }
171
189
  export interface LessonIdGenerator {
@@ -117,7 +117,11 @@ ID серии создается генератором и хранится в
117
117
  3. Производные представления запросов, инвалидируемые ревизией.
118
118
 
119
119
  Снимок репозитория имеет `schemaVersion: 5`. Адаптер постоянного хранилища
120
- использует compare-and-set по ревизии.
120
+ использует compare-and-set по ревизии. Предпочтительный метод
121
+ `compareAndSetPatch` атомарно применяет только измененные строки справочника и
122
+ полностью заменяет затронутые источники со связями. Обычная мутация не экспортирует
123
+ и не обходит весь репозиторий; полный снимок остается форматом загрузки, переноса и
124
+ резервного копирования.
121
125
 
122
126
  ## Запросы
123
127
 
@@ -281,6 +281,17 @@ const snapshot = await client.exportRepository();
281
281
  вместе со всеми когда-либо загруженными расписаниями, а восстановление требует
282
282
  повторной агрегации связей.
283
283
 
284
+ Начиная с 5.2.0 поиск совпадающих занятий, справочник сущностей и удаление пустых
285
+ канонических записей используют локальные индексы. Одинаковые повторные снимки не
286
+ меняют ревизию. `compareAndSetPatch` получает только `directoryUpserts` и
287
+ `sourceReplacements`, затронутые одной мутацией. Поэтому размер записи и обычное
288
+ время обновления не растут вместе со всем репозиторием. Старый `compareAndSet`
289
+ поддерживается как fallback, но сериализует полный снимок.
290
+
291
+ `exportRepository()` по-прежнему имеет цену, пропорциональную размеру полного
292
+ снимка. Используйте его для экспорта, резервной копии или миграции, не на пути
293
+ каждого запроса.
294
+
284
295
  Для API, SSR и офлайн-кеша материализуйте только готовое расписание владельца:
285
296
 
286
297
  ```ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chuvsu-js",
3
- "version": "5.1.1",
3
+ "version": "5.2.0",
4
4
  "description": "Библиотека для расписания и личного кабинета ЧГУ им. И. Н. Ульянова",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",