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 +5 -1
- package/dist/tt/client.js +27 -13
- package/dist/tt/domain/directory.d.ts +3 -3
- package/dist/tt/domain/directory.js +130 -69
- package/dist/tt/domain/index.d.ts +1 -1
- package/dist/tt/domain/repository.d.ts +17 -2
- package/dist/tt/domain/repository.js +234 -28
- package/dist/tt/domain/types.d.ts +19 -1
- package/docs/v5-architecture.md +5 -1
- package/docs/v5-migration.md +11 -0
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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
|
-
|
|
367
|
-
const
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
this.rooms = result.values;
|
|
98
|
-
return result.changed;
|
|
159
|
+
return this.rooms.remember(values);
|
|
99
160
|
}
|
|
100
161
|
resolveGroup(value) {
|
|
101
|
-
return
|
|
162
|
+
return this.groups.resolve(value);
|
|
102
163
|
}
|
|
103
164
|
resolveTeacher(value) {
|
|
104
|
-
return
|
|
165
|
+
return this.teachers.resolve(value);
|
|
105
166
|
}
|
|
106
167
|
resolveRoom(value) {
|
|
107
|
-
return
|
|
168
|
+
return this.rooms.resolve(value);
|
|
108
169
|
}
|
|
109
170
|
export() {
|
|
110
171
|
return {
|
|
111
|
-
groups:
|
|
112
|
-
teachers:
|
|
113
|
-
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
|
|
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
|
|
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
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
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
|
|
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
|
|
661
|
-
? this.seriesRecords
|
|
662
|
-
: this.occurrenceRecords
|
|
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
|
|
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
|
-
|
|
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 {
|
package/docs/v5-architecture.md
CHANGED
|
@@ -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
|
|
package/docs/v5-migration.md
CHANGED
|
@@ -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
|