@taskless-app/shared 0.1.34 → 0.1.36

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.
@@ -0,0 +1,657 @@
1
+ import { z } from "zod";
2
+ // MOVE-7: ПАКЕТ ИМПОРТА — нейтральный формат, в который коннектор перекладывает
3
+ // чужой сервис (Confluence, Notion, что угодно ещё). Ядро импорта читает ТОЛЬКО
4
+ // его и про исходный сервис не знает ничего.
5
+ //
6
+ // ФОРМА ВЗЯТА ИЗ СПЕКИ: docs/superpowers/specs/2026-08-25-external-import-design.md,
7
+ // раздел «Пакет импорта». Имена полей повторяют её пример один в один
8
+ // (people/name, spaceId/parentId/authorId/assigneeId, on, file/mime/name) —
9
+ // по спеке будут писаться MOVE-9 и все следующие коннекторы, и расхождение в
10
+ // фундаменте размножилось бы на каждый из них.
11
+ //
12
+ // Почему формат живёт в shared, а не в api: потребителей у него минимум трое —
13
+ // коннектор (пишет пакет), ядро импорта в api (читает и раскладывает по нашим
14
+ // таблицам) и web (показывает человеку, что приехало и что попало в карантин).
15
+ // Отсюда ограничение: shared публикуется в npm, поэтому здесь НЕТ ни Prisma,
16
+ // ни путей на диске, ни импортов из api — только форма данных и чистые функции.
17
+ //
18
+ // ТЕЛО ЕДЕТ HTML, А НЕ ProseMirror. Иначе КАЖДЫЙ коннектор обязан знать схему
19
+ // нашего редактора, а она живёт во фронтовом пакете и меняется по нашим
20
+ // причинам. Перевод HTML → наш документ делает ядро, один раз на всех.
21
+ //
22
+ // ЧТО НАМЕРЕННО НЕ СДЕЛАНО. Сущности лежат в манифесте ЦЕЛИКОМ, а не по файлу
23
+ // на страницу: пакет читается разом, и один JSON проще и коннектору, и ядру.
24
+ // Когда появится экспорт, который в память не влезает, это будет formatVersion 2
25
+ // — ради чего версия и заведена. Отдельно лежат только файлы вложений.
26
+ /**
27
+ * Версия формата, которую пишет сегодняшний коннектор и понимает сегодняшнее
28
+ * ядро. Растёт на ЛОМАЮЩЕМ изменении формы; добавление необязательного поля
29
+ * версию не двигает — старое ядро прочитает такой пакет, не заметив нового поля.
30
+ *
31
+ * В примере спеки версии нет, но задача требует её прямо: «схема обязана быть
32
+ * версионированной, иначе завтрашний коннектор сломает вчерашний пакет».
33
+ */
34
+ export const IMPORT_PACKAGE_FORMAT_VERSION = 1;
35
+ /**
36
+ * Версии, которые ядро умеет читать. Отдельно от текущей, потому что читать мы
37
+ * обязаны шире, чем писать: пакет мог быть собран неделю назад и пролежать в
38
+ * очереди, пока ядро обновилось.
39
+ */
40
+ export const SUPPORTED_IMPORT_PACKAGE_VERSIONS = [1];
41
+ // Потолки — защита от «коннектор выгрузил сюда весь сервис одной строкой».
42
+ export const IMPORT_EXTERNAL_ID_MAX = 400;
43
+ export const IMPORT_TITLE_MAX = 1000;
44
+ export const IMPORT_HTML_MAX = 5_000_000;
45
+ export const IMPORT_FILE_PATH_MAX = 600;
46
+ export const IMPORT_LABEL_MAX = 200;
47
+ export const IMPORT_LABELS_MAX = 100;
48
+ export const IMPORT_URL_MAX = 2000;
49
+ export const IMPORT_SOURCE_MAX = 100;
50
+ export const IMPORT_ATTACHMENT_REFS_MAX = 500;
51
+ /**
52
+ * Машинное имя исходного сервиса. Без `:` и пробелов сознательно: на этом стоит
53
+ * однозначность ключа идемпотентности (`importEntityKey`).
54
+ */
55
+ export const IMPORT_SOURCE_RE = /^[a-z0-9][a-z0-9_-]*$/;
56
+ /**
57
+ * НЕВИДИМЫЕ СИМВОЛЫ — всё, чего человек в строке не увидит, а машина учтёт.
58
+ *
59
+ * Используется у `ImportExternalId` и `ImportAttachmentName` — оба алфавит не
60
+ * сужают (в отличие от `ImportFilePath`, который 2026-08-26 перешёл на белый
61
+ * список `IMPORT_FILE_PATH_RE` и вообще не пропускает ничего вне
62
+ * `[A-Za-z0-9._-]`, так что для него эта проверка стала бы мёртвым кодом —
63
+ * невидимые символы и суррогаты закрыты для пути уже тем, что их нет в
64
+ * алфавите). `externalId` и `name` сознательно не сужают алфавит (см. их
65
+ * комментарии): оба — либо чужой id, либо человекочитаемое имя файла, где
66
+ * скобки/пробелы/кириллица законны, поэтому для них нужен именно перечень
67
+ * запрещённых классов, а не белый список.
68
+ *
69
+ * Классами Unicode, а не перечнем кодов, потому что перечень тут всегда неполон:
70
+ * • `Cc` — управляющие C0 (включая NUL), DEL и C1. NUL Postgres не хранит
71
+ * в `text`;
72
+ * • `Cf` — форматирующие: zero-width space, BOM, мягкий перенос,
73
+ * bidi-переопределения. Ключ идемпотентности показывается человеку, а
74
+ * такой символ делает две разные строки неразличимыми на глаз;
75
+ * • `Zl`/`Zp` — разделители строки и абзаца: разрыв строки ломает однострочную
76
+ * запись в лог прогона и в отчёт о карантине;
77
+ * • `Default_Ignorable_Code_Point` — свойство, а не категория, и без него в
78
+ * проверке оставалась дыра: селекторы вариаций (U+FE0F), монгольские FVS
79
+ * (U+180B–U+180D) и combining grapheme joiner (U+034F) лежат в категории
80
+ * `Mn`, то есть ни под один класс выше не подходят, — а невидимы ровно так
81
+ * же. `p42` и `p42\u{FE0F}` давали ДВА РАЗНЫХ ключа идемпотентности,
82
+ * неразличимых на экране карантина. Нашли верификаторы TERMES и SCARAB на
83
+ * ревью MOVE-7;
84
+ * • `U+2800` BRAILLE PATTERN BLANK — единственный код здесь перечнем, а не
85
+ * классом, и это не лень. Свойства «рисуется пустым местом» в Unicode нет:
86
+ * у него категория `So`, `Default_Ignorable` он не имеет, — а выглядит как
87
+ * пустая ячейка в любом шрифте с Брайлем (тем же приёмом делают невидимые
88
+ * ники в мессенджерах). Остальные известные пустые глифы — филлеры хангыля
89
+ * (U+115F, U+1160, U+3164, U+FFA0), кхмерские U+17B4/U+17B5, монгольский
90
+ * U+180E — все `Default_Ignorable` и ловятся строкой выше; проверено
91
+ * перебором, U+2800 из них единственный выпадающий. Нашёл верификатор
92
+ * MYRMEX третьим кругом на том же ревью.
93
+ *
94
+ * Пробелы категории `Zs` (в т.ч. неразрывный) сюда НЕ входят намеренно: они видны
95
+ * как пробел, а по краям строки их и так снимает отдельная проверка.
96
+ *
97
+ * Побочно под запрет попадают ZWJ и теговые символы, то есть составные эмодзи в
98
+ * качестве `externalId`. Машинного id такой формы у сервисов не бывает;
99
+ * появится — вырежется один класс, а не вся проверка.
100
+ */
101
+ export const IMPORT_INVISIBLE_CHARS_RE = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\p{Default_Ignorable_Code_Point}\u{2800}]/u;
102
+ /**
103
+ * ОДИНОЧНЫЙ (НЕПАРНЫЙ) СУРРОГАТ — не «невидимый символ», а вообще не символ, и
104
+ * поэтому его не ловит НИ ОДНО свойство Unicode: для `\p{...}`-классов такого
105
+ * кода просто не существует. Проверка отдельная именно по этой причине, а не
106
+ * ради красоты.
107
+ *
108
+ * Чем это хуже всего, что чинили выше. Невидимые символы дают два ключа,
109
+ * НЕРАЗЛИЧИМЫХ НА ГЛАЗ. Одиночные суррогаты дают два ключа, РАЗНЫХ по схеме и
110
+ * СОВПАДАЮЩИХ БАЙТ В БАЙТ после кодирования в UTF-8: все 2048 значений
111
+ * `U+D800`…`U+DFFF` схлопываются в один и тот же `U+FFFD`. То есть на границе
112
+ * с Postgres (колонка `text`, куда ключ и едет) две разные сущности становятся
113
+ * одной — прямая коллизия идемпотентности, а не косметика.
114
+ *
115
+ * И это не только про злой умысел: `JSON.parse` такую строку принимает молча,
116
+ * а получается она обычным наивным `slice(0, N)` где-нибудь выше по цепочке —
117
+ * если срез пришёлся на середину суррогатной пары (эмодзи, редкий CJK,
118
+ * математические знаки). Классические грабли обрезки, а не атака.
119
+ *
120
+ * Регулярка без флага `u` намеренно: она работает с UTF-16 code units, а
121
+ * искать надо ровно непарную половину пары. Нашёл верификатор HORNET
122
+ * четвёртым кругом на ревью MOVE-7.
123
+ *
124
+ * Используется у `ImportExternalId` и `ImportAttachmentName` — как и
125
+ * `IMPORT_INVISIBLE_CHARS_RE`, у `ImportFilePath` эта проверка стала
126
+ * избыточной: белый список `IMPORT_FILE_PATH_RE` не пропускает суррогат по
127
+ * построению.
128
+ */
129
+ export const IMPORT_LONE_SURROGATE_RE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
130
+ /**
131
+ * Идентификатор сущности В ИСХОДНОМ СЕРВИСЕ — основа идемпотентности: по нему
132
+ * повторный прогон находит уже перенесённое и обновляет, а не плодит копии.
133
+ * Обязан быть СТАБИЛЬНЫМ между выгрузками (id страницы — да, её заголовок — нет).
134
+ *
135
+ * Строка, а не число: у Confluence это число, у Notion — uuid, у следующего
136
+ * сервиса что-то третье. Алфавит поэтому НЕ сужаем до латиницы с цифрами: чужой
137
+ * сервис вправе выдавать что угодно, а коннектор, вынужденный перекодировать id,
138
+ * потерял бы ровно ту стабильность, на которой всё стоит.
139
+ *
140
+ * Но два класса символов запрещены, и оба — про то, что с этой строкой будет
141
+ * ДАЛЬШЕ:
142
+ *
143
+ * • НЕВИДИМЫЕ (`IMPORT_INVISIBLE_CHARS_RE`). `externalId` уезжает в ключ
144
+ * идемпотентности (`importEntityKey`), а тот — в колонку БД, в лог прогона и
145
+ * в текст ошибок. Postgres не хранит NUL в `text`, так что пакет с таким id
146
+ * падал бы уже на записи — в ядре импорта, далеко от места, где мусор
147
+ * приехал. Остальные невидимые не роняют запись, но делают два разных ключа
148
+ * неразличимыми для человека, который смотрит на карантин и решает, одна это
149
+ * сущность или две. Печатность ключа — заявленный контракт, и держать его
150
+ * обязана схема: сам `importEntityKey` чистая склейка и вход проверять не
151
+ * может.
152
+ *
153
+ * • КРАЙНИЕ ПРОБЕЛЫ. `"p42"` и `"p42 "` — разные ключи и потому разные
154
+ * сущности, хотя различие невидимо глазом; такой пакет молча импортируется
155
+ * дважды. Пробелы ВНУТРИ оставлены: они видны, ключ не ломают (свободная
156
+ * часть в нём последняя) и у сервисов, где id это путь или заголовок, законны.
157
+ */
158
+ export const ImportExternalId = z
159
+ .string()
160
+ .min(1)
161
+ .max(IMPORT_EXTERNAL_ID_MAX)
162
+ .refine((id) => !IMPORT_INVISIBLE_CHARS_RE.test(id), {
163
+ message: "externalId не должен содержать невидимых символов (управляющие, zero-width, bidi, разрывы строк) — он едет в ключ идемпотентности, в БД и в лог",
164
+ })
165
+ .refine((id) => !IMPORT_LONE_SURROGATE_RE.test(id), {
166
+ message: "externalId не должен содержать непарных суррогатов: в UTF-8 они все схлопываются в один и тот же символ, и два разных id становятся одним ключом",
167
+ })
168
+ .refine((id) => id === id.trim(), {
169
+ message: "externalId не должен начинаться или заканчиваться пробелом — невидимое различие даёт два разных ключа",
170
+ });
171
+ /**
172
+ * Момент времени из исходного сервиса — ISO 8601 СО СМЕЩЕНИЕМ. Смещение
173
+ * обязательно: у экспортов сплошь и рядом локальное время сервера, и без него
174
+ * даты уезжают на часовой пояс, а «кто раньше» перестаёт работать. Строка, а не
175
+ * Date, потому что пакет — это JSON на диске.
176
+ */
177
+ export const ImportDate = z.string().datetime({ offset: true });
178
+ /**
179
+ * ПУТЬ К ФАЙЛУ ВЛОЖЕНИЯ ВНУТРИ ПАКЕТА — относительный, от манифеста
180
+ * (`files/a3-scheme.png`). Спека говорит «манифест JSON плюс папка файлов», и
181
+ * подкаталог тут не украшение: в выгрузке Confluence вложений много, и два
182
+ * разных документа спокойно принесут по своему `image.png` — плоские имена их
183
+ * столкнут, папка нет.
184
+ *
185
+ * БЕЛЫЙ СПИСОК, А НЕ ПЕРЕЧЕНЬ ЗАПРЕЩЁННОГО. Четыре круга ревью на этом же
186
+ * пакете (MOVE-7) чинили контракт «путь печатный» отказом от всё новых и новых
187
+ * находок — управляющие ASCII, невидимый Unicode классами `Cf`/`Zl`/`Zp`,
188
+ * категория `Mn` через `Default_Ignorable_Code_Point`, отдельно Брайль
189
+ * `U+2800`, отдельно `%` из-за percent-декодирования при резолве. Каждый раунд
190
+ * закрывал настоящую дыру и открывал место для следующей: гонка за
191
+ * запрещённым принципиально неполна. Путь внутри пакета при этом выбирает
192
+ * сам коннектор — это не то поле, где живёт человекочитаемое имя (оно в
193
+ * `name` у `ImportAttachment`), поэтому сузить алфавит для него можно без
194
+ * потери: скобки, пробелы, кириллица никуда не делись, просто переехали в
195
+ * `name`.
196
+ *
197
+ * `^[A-Za-z0-9._-]+(?:/[A-Za-z0-9._-]+)*$` закрывает весь класс разом, по
198
+ * построению, а не перечислением:
199
+ * • пробел, скобки, `%`, `\` — вне алфавита, схема их не пропустит;
200
+ * • НЕВИДИМЫЙ Unicode и одиночные суррогаты — тоже вне алфавита: строка,
201
+ * целиком состоящая из `A-Za-z0-9._-`, ни того, ни другого не содержит
202
+ * физически. `IMPORT_INVISIBLE_CHARS_RE`/`IMPORT_LONE_SURROGATE_RE` здесь
203
+ * больше не нужны — они остаются только у `ImportExternalId`, чей алфавит
204
+ * сознательно не сужен (см. его комментарий);
205
+ * • ведущий `/`, пустой сегмент (`files//a.png`), обратный слэш —
206
+ * исключены формой регулярки (сегмент — непустая последовательность
207
+ * разрешённых символов, разделитель ровно один `/`).
208
+ *
209
+ * Регулярка НЕ ловит сегменты `.` и `..` — они состоят из разрешённых
210
+ * символов и под неё подходят, поэтому запрет на них — отдельная проверка
211
+ * ниже, как и раньше.
212
+ */
213
+ export const IMPORT_FILE_PATH_RE = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/;
214
+ export const ImportFilePath = z
215
+ .string()
216
+ .min(1)
217
+ .max(IMPORT_FILE_PATH_MAX)
218
+ .regex(IMPORT_FILE_PATH_RE, {
219
+ message: "путь к файлу вложения — белый список [A-Za-z0-9._-], сегменты через «/»; человекочитаемое имя едет отдельным полем name",
220
+ })
221
+ .refine((path) => path.split("/").every((segment) => segment !== "." && segment !== ".."), { message: "путь к файлу вложения не должен содержать сегментов «.» и «..»" });
222
+ const ImportLabels = z.array(z.string().min(1).max(IMPORT_LABEL_MAX)).max(IMPORT_LABELS_MAX);
223
+ const ImportAttachmentRefs = z.array(ImportExternalId).max(IMPORT_ATTACHMENT_REFS_MAX);
224
+ /**
225
+ * Человек из исходного сервиса. Здесь он ПРИЗРАК: коннектор знает только то, что
226
+ * было в экспорте, а сопоставление с нашими пользователями — работа ядра
227
+ * (MOVE-10). Поэтому обязателен ровно `externalId`, а `name` — то, что показать
228
+ * человеку, пока сопоставления нет.
229
+ *
230
+ * `email` тут ПОДСКАЗКА ДЛЯ СОПОСТАВЛЕНИЯ, а не адрес входа: спека прямо
231
+ * запрещает класть чужую почту в наше уникальное поле `User.email`, иначе импорт
232
+ * становится способом занять чужой адрес.
233
+ */
234
+ export const ImportPerson = z.object({
235
+ externalId: ImportExternalId,
236
+ name: z.string().min(1).max(IMPORT_TITLE_MAX),
237
+ email: z.string().email().max(IMPORT_TITLE_MAX).optional(),
238
+ /** Логин в исходном сервисе — вторая зацепка, когда почты в экспорте нет. */
239
+ username: z.string().min(1).max(IMPORT_TITLE_MAX).optional(),
240
+ avatarUrl: z.string().max(IMPORT_URL_MAX).optional(),
241
+ });
242
+ /**
243
+ * Пространство — верхний контейнер исходного сервиса (space в Confluence,
244
+ * teamspace в Notion). Во что оно превратится у нас, решает ядро.
245
+ */
246
+ export const ImportSpace = z.object({
247
+ externalId: ImportExternalId,
248
+ title: z.string().min(1).max(IMPORT_TITLE_MAX),
249
+ descriptionHtml: z.string().max(IMPORT_HTML_MAX).optional(),
250
+ /** Ссылка на пространство в исходном сервисе — чтобы после переезда было куда вернуться. */
251
+ sourceUrl: z.string().max(IMPORT_URL_MAX).optional(),
252
+ });
253
+ /**
254
+ * Состояние спринта. ПЕРЕЧИСЛЕНИЕМ, в отличие от `status` у задачи, который
255
+ * едет строкой исходного сервиса, — и это не непоследовательность. Статусов у
256
+ * чужого трекера сколько угодно и своих, раскладывать их по нашим умеет только
257
+ * ядро с картой воркфлоу; состояний же спринта ровно три, и они одни и те же у
258
+ * Jira, YouTrack и у нас (`Sprint.state` в схеме: `planned | active |
259
+ * completed`). Перечисление здесь ловит опечатку коннектора на входе, а не
260
+ * через неделю в отчёте.
261
+ */
262
+ export const IMPORT_SPRINT_STATES = ["planned", "active", "completed"];
263
+ /**
264
+ * MOVE-28: СПРИНТ — итерация исходного трекера (sprint в Jira, sprint/веха в
265
+ * YouTrack). Первый шаг к переносу спринтов спринтами, а не меткой на задаче
266
+ * (MOVE-29): здесь только форма, ни выкачки, ни раскладки по нашим таблицам.
267
+ *
268
+ * `spaceId` ОБЯЗАТЕЛЕН — спринт принадлежит пространству, как страница (у
269
+ * задачи `spaceId` необязательный, потому что у плоских экспортов задача
270
+ * приезжает без проекта и уезжает в запасной; у спринта такого случая нет:
271
+ * итерация без доски бессмысленна, и коннектор, который не знает её
272
+ * пространства, обязан споткнуться здесь, а не создать бесхозный спринт).
273
+ *
274
+ * Как и везде в пакете, `externalId` — id ИЗ ЧУЖОГО СЕРВИСА, а не наш cuid.
275
+ * Даты необязательные: у планируемого спринта их сплошь и рядом нет.
276
+ */
277
+ export const ImportSprint = z.object({
278
+ externalId: ImportExternalId,
279
+ spaceId: ImportExternalId,
280
+ name: z.string().min(1).max(IMPORT_TITLE_MAX),
281
+ /** Цель итерации — короткий текст, который человек писал в трекере. */
282
+ goal: z.string().max(IMPORT_TITLE_MAX).optional(),
283
+ startsAt: ImportDate.optional(),
284
+ endsAt: ImportDate.optional(),
285
+ state: z.enum(IMPORT_SPRINT_STATES),
286
+ });
287
+ /**
288
+ * К чему прицеплен комментарий (`on` из спеки). `id` здесь — `externalId` цели
289
+ * в исходном сервисе, не наш cuid.
290
+ */
291
+ export const ImportCommentTarget = z.object({
292
+ kind: z.enum(["page", "task"]),
293
+ id: ImportExternalId,
294
+ });
295
+ /**
296
+ * Страница.
297
+ *
298
+ * ВНИМАНИЕ К ИМЕНАМ: `spaceId`, `parentId`, `authorId` — это `externalId` ИЗ
299
+ * ЧУЖОГО СЕРВИСА, а не ссылки на наши таблицы. Имена взяты из спеки, и по имени
300
+ * они читаются как внешние ключи на нашу базу — по сути это не так, наши id
301
+ * появятся только в ядре, когда сущность уже создана.
302
+ *
303
+ * Дерево едет ССЫЛКАМИ (`parentId`), а не вложенностью: вложенность заставила бы
304
+ * коннектор собирать дерево целиком в памяти и ломалась бы на циклах, которые в
305
+ * чужих экспортах встречаются. `null`/отсутствие — корень пространства.
306
+ */
307
+ export const ImportPage = z.object({
308
+ externalId: ImportExternalId,
309
+ spaceId: ImportExternalId,
310
+ parentId: ImportExternalId.nullish(),
311
+ title: z.string().min(1).max(IMPORT_TITLE_MAX),
312
+ /** Тело в HTML — см. про ProseMirror в шапке файла. */
313
+ bodyHtml: z.string().max(IMPORT_HTML_MAX),
314
+ authorId: ImportExternalId.optional(),
315
+ /** Кто правил последним, если сервис это отдаёт, — иначе автор и правщик сливаются. */
316
+ lastEditorId: ImportExternalId.optional(),
317
+ createdAt: ImportDate,
318
+ updatedAt: ImportDate.optional(),
319
+ /** `externalId` вложений этой страницы — сами файлы в `attachments` манифеста. */
320
+ attachments: ImportAttachmentRefs.optional(),
321
+ labels: ImportLabels.optional(),
322
+ sourceUrl: z.string().max(IMPORT_URL_MAX).optional(),
323
+ });
324
+ /**
325
+ * Задача. Статус едет СТРОКОЙ ИСХОДНОГО СЕРВИСА, а не нашим enum'ом: у чужого
326
+ * трекера свои состояния, и раскладывать их по нашим — работа ядра, где для
327
+ * этого есть карта воркфлоу. Коннектор, который угадывает наш статус, обманет
328
+ * ровно один раз и незаметно.
329
+ *
330
+ * `spaceId`, `parentId`, `authorId`, `assigneeId` — тоже внешние id, см. `ImportPage`.
331
+ */
332
+ export const ImportTask = z.object({
333
+ externalId: ImportExternalId,
334
+ spaceId: ImportExternalId.optional(),
335
+ parentId: ImportExternalId.nullish(),
336
+ title: z.string().min(1).max(IMPORT_TITLE_MAX),
337
+ descriptionHtml: z.string().max(IMPORT_HTML_MAX).optional(),
338
+ status: z.string().min(1).max(IMPORT_TITLE_MAX).optional(),
339
+ priority: z.string().min(1).max(IMPORT_TITLE_MAX).optional(),
340
+ authorId: ImportExternalId.optional(),
341
+ assigneeId: ImportExternalId.optional(),
342
+ /**
343
+ * MOVE-28: в каком спринте задача — `externalId` из `sprints`. Необязателен
344
+ * дважды: у источника без спринтов их нет вовсе, и внутри источника со
345
+ * спринтами задача может лежать в бэклоге.
346
+ */
347
+ sprintId: ImportExternalId.optional(),
348
+ createdAt: ImportDate,
349
+ updatedAt: ImportDate.optional(),
350
+ dueAt: ImportDate.optional(),
351
+ completedAt: ImportDate.optional(),
352
+ attachments: ImportAttachmentRefs.optional(),
353
+ labels: ImportLabels.optional(),
354
+ sourceUrl: z.string().max(IMPORT_URL_MAX).optional(),
355
+ });
356
+ /**
357
+ * Комментарий к странице или задаче. `parentId` — ответ на другой комментарий
358
+ * (ветка обсуждения), не владелец: владелец всегда в `on`.
359
+ */
360
+ export const ImportComment = z.object({
361
+ externalId: ImportExternalId,
362
+ on: ImportCommentTarget,
363
+ parentId: ImportExternalId.nullish(),
364
+ authorId: ImportExternalId.optional(),
365
+ bodyHtml: z.string().max(IMPORT_HTML_MAX),
366
+ createdAt: ImportDate,
367
+ updatedAt: ImportDate.optional(),
368
+ });
369
+ /**
370
+ * Вложение. Метаданные здесь, БАЙТЫ — файлом внутри пакета (`file`). Владельца у
371
+ * вложения нет: на него ссылаются страница и задача через свой `attachments`,
372
+ * как и показано в спеке — один и тот же файл так может принадлежать нескольким
373
+ * сущностям, не размножаясь.
374
+ */
375
+ /**
376
+ * Имя файла, каким его видит ЧЕЛОВЕК (в отличие от `file` — служебного пути,
377
+ * белый список для которого сузил алфавит до ASCII). `name` алфавит не сужает
378
+ * — скобки, пробелы, кириллица здесь как раз законны, это и есть то поле,
379
+ * куда они переехали из `ImportFilePath` (см. её комментарий). Но именно
380
+ * поэтому `name` — единственное поле в файле, которое ПОКАЗЫВАЕТСЯ человеку
381
+ * буквально как имя файла, и оставлять его вовсе без защиты от невидимых
382
+ * символов значило бы закрыть RTL-override-подмену расширения у `file` и
383
+ * `externalId` и оставить открытой ровно у того поля, где она опаснее всего:
384
+ * `"invoice‮fdp.exe"` пройдёт схему и отрисуется человеку как
385
+ * «invoice.pdf» при реальном `.exe`. Найдено ревью SPHINX на MOVE-7.
386
+ */
387
+ export const ImportAttachmentName = z
388
+ .string()
389
+ .min(1)
390
+ .max(IMPORT_TITLE_MAX)
391
+ .refine((name) => !IMPORT_INVISIBLE_CHARS_RE.test(name), {
392
+ message: "имя файла вложения не должно содержать невидимых символов — RTL override подменяет видимое расширение",
393
+ })
394
+ .refine((name) => !IMPORT_LONE_SURROGATE_RE.test(name), {
395
+ message: "имя файла вложения не должно содержать непарных суррогатов",
396
+ });
397
+ export const ImportAttachment = z.object({
398
+ externalId: ImportExternalId,
399
+ /** Относительный путь внутри пакета: `files/a3-scheme.png`. */
400
+ file: ImportFilePath,
401
+ /** Как файл назывался у человека — `file` в пакете может быть обеззаражен коннектором. */
402
+ name: ImportAttachmentName.optional(),
403
+ mime: z.string().min(1).max(IMPORT_TITLE_MAX).optional(),
404
+ byteSize: z.number().int().nonnegative().optional(),
405
+ authorId: ImportExternalId.optional(),
406
+ createdAt: ImportDate.optional(),
407
+ });
408
+ /**
409
+ * МАНИФЕСТ — то, что коннектор кладёт в `manifest.json`, а ядро читает первым и
410
+ * единственным входом. Все сущности здесь; рядом лежит только папка файлов.
411
+ */
412
+ export const ImportPackageManifest = z.object({
413
+ /**
414
+ * Версия формата. Стоит ПЕРВЫМ полем и проверяется ДО остальной схемы (см.
415
+ * `parseImportPackageManifest`): пакет завтрашней версии обязан получить
416
+ * внятный отказ, а не ворох ошибок валидации, из которых не видно, что дело
417
+ * в версии.
418
+ */
419
+ formatVersion: z.number().int().positive(),
420
+ /**
421
+ * Машинное имя исходного сервиса (`confluence`). Входит в ключ
422
+ * идемпотентности: одинаковые `externalId` из разных сервисов — разные
423
+ * сущности, и без имени сервиса второй импорт «узнал» бы чужое.
424
+ */
425
+ source: z.string().min(1).max(IMPORT_SOURCE_MAX).regex(IMPORT_SOURCE_RE, {
426
+ message: "имя сервиса — машинное: строчные латинские буквы, цифры, дефис и подчёркивание",
427
+ }),
428
+ /** Адрес инсталляции — на self-hosted сервисах одного имени сервиса мало. */
429
+ sourceUrl: z.string().max(IMPORT_URL_MAX).optional(),
430
+ /** Когда снят экспорт (не когда собран пакет) — по нему видно, насколько данные протухли. */
431
+ exportedAt: ImportDate.optional(),
432
+ /** Чем собран пакет — чтобы по кривому импорту было видно, какой коннектор чинить. */
433
+ generator: z
434
+ .object({
435
+ name: z.string().min(1).max(IMPORT_SOURCE_MAX),
436
+ version: z.string().min(1).max(IMPORT_SOURCE_MAX).optional(),
437
+ })
438
+ .optional(),
439
+ people: z.array(ImportPerson).default([]),
440
+ spaces: z.array(ImportSpace).default([]),
441
+ /**
442
+ * MOVE-28. Секция НЕОБЯЗАТЕЛЬНАЯ, и `.default([])` здесь — вся обратная
443
+ * совместимость: пакет Confluence и уже выкачанные пакеты Jira/YouTrack
444
+ * секции не знают, разбираются как раньше и получают пустой список.
445
+ * `formatVersion` по этой же причине не двигается — см. её комментарий:
446
+ * версия растёт на ЛОМАЮЩЕМ изменении формы.
447
+ */
448
+ sprints: z.array(ImportSprint).default([]),
449
+ pages: z.array(ImportPage).default([]),
450
+ tasks: z.array(ImportTask).default([]),
451
+ comments: z.array(ImportComment).default([]),
452
+ attachments: z.array(ImportAttachment).default([]),
453
+ });
454
+ /** Виды сущностей пакета — по ним же строится ключ идемпотентности. */
455
+ export const IMPORT_ENTITY_KINDS = [
456
+ "person",
457
+ "space",
458
+ "sprint",
459
+ "page",
460
+ "task",
461
+ "comment",
462
+ "attachment",
463
+ ];
464
+ /**
465
+ * Вид сущности → список в манифесте. Таблицей, а не склейкой `kind + "s"`:
466
+ * у `person` список называется `people`, и правило «плюс s» тут врёт.
467
+ */
468
+ export const IMPORT_ENTITY_FIELDS = {
469
+ person: "people",
470
+ space: "spaces",
471
+ sprint: "sprints",
472
+ page: "pages",
473
+ task: "tasks",
474
+ comment: "comments",
475
+ attachment: "attachments",
476
+ };
477
+ /**
478
+ * КЛЮЧ ИДЕМПОТЕНТНОСТИ. Собирается ЗДЕСЬ и только здесь: ядро, коннектор и отчёт
479
+ * о прогоне обязаны считать его одинаково, иначе повторный прогон не узнает уже
480
+ * перенесённое и создаст второй экземпляр всего. Это тот же тройной ключ, что у
481
+ * `ImportedEntity` в спеке (`source`, `kind`, `externalId`).
482
+ *
483
+ * Разделитель `:` однозначен, потому что свободная часть ровно одна и она
484
+ * ПОСЛЕДНЯЯ: `source` ограничен `IMPORT_SOURCE_RE`, `kind` — фиксированный
485
+ * перечень, двоеточий в них не бывает. `externalId` может содержать что угодно
486
+ * (в Notion как раз двоеточия) — на хвосте это разбор не ломает.
487
+ *
488
+ * Ключ ПЕЧАТНЫЙ — это КОНТРАКТ, и держится он не здесь: сама функция всего лишь
489
+ * склейка и вход не проверяет. Печатность обеспечивают `IMPORT_SOURCE_RE` и
490
+ * запрет невидимых символов в `ImportExternalId` (`IMPORT_INVISIBLE_CHARS_RE` —
491
+ * управляющие, zero-width, bidi, разрывы строк); звать ключ на строках, не
492
+ * прошедших схему, значит остаться без этой гарантии. Нужна она затем, что ключ
493
+ * поедет в колонку БД, в лог прогона и на экран карантина: Postgres не хранит
494
+ * NUL в `text`, а два ключа, различающиеся невидимым символом, человек не
495
+ * различит и в глаза.
496
+ */
497
+ export function importEntityKey(source, kind, externalId) {
498
+ return `${source}:${kind}:${externalId}`;
499
+ }
500
+ export function isSupportedImportPackageVersion(version) {
501
+ return SUPPORTED_IMPORT_PACKAGE_VERSIONS.includes(version);
502
+ }
503
+ /**
504
+ * Сырой JSON манифеста → типизированный манифест или ВНЯТНЫЙ ОТКАЗ.
505
+ *
506
+ * Версия смотрится ДО схемы намеренно. Если бы `formatVersion` был просто
507
+ * `z.literal(1)`, пакет завтрашнего коннектора отвалился бы наравне с битым
508
+ * JSON — и человек, глядя на список ошибок полей, чинил бы не то.
509
+ *
510
+ * Не бросает: пакет приходит извне, и ядру нужен отказ, который можно положить в
511
+ * карантин и показать человеку, а не исключение.
512
+ */
513
+ export function parseImportPackageManifest(raw) {
514
+ if (raw == null || typeof raw !== "object" || Array.isArray(raw)) {
515
+ return {
516
+ ok: false,
517
+ error: { reason: "missing_version", message: "манифест должен быть объектом с полем formatVersion" },
518
+ };
519
+ }
520
+ const version = raw.formatVersion;
521
+ if (typeof version !== "number" || !Number.isInteger(version) || version <= 0) {
522
+ return {
523
+ ok: false,
524
+ error: {
525
+ reason: "missing_version",
526
+ message: "в манифесте нет версии формата (formatVersion) — это не пакет импорта",
527
+ },
528
+ };
529
+ }
530
+ if (!isSupportedImportPackageVersion(version)) {
531
+ return {
532
+ ok: false,
533
+ error: {
534
+ reason: "unsupported_version",
535
+ version,
536
+ supported: SUPPORTED_IMPORT_PACKAGE_VERSIONS,
537
+ message: `пакет формата версии ${version}, ядро понимает ` +
538
+ `${SUPPORTED_IMPORT_PACKAGE_VERSIONS.join(", ")} — обновите Taskless или пересоберите пакет`,
539
+ },
540
+ };
541
+ }
542
+ const parsed = ImportPackageManifest.safeParse(raw);
543
+ if (!parsed.success) {
544
+ return {
545
+ ok: false,
546
+ error: {
547
+ reason: "invalid",
548
+ issues: parsed.error.issues,
549
+ message: `манифест не проходит валидацию: ${parsed.error.issues.length} ошибок в полях`,
550
+ },
551
+ };
552
+ }
553
+ return { ok: true, manifest: parsed.data };
554
+ }
555
+ /**
556
+ * Проверка СВЯЗНОСТИ — то, что Zod не ловит по устройству: он проверяет каждую
557
+ * сущность отдельно, а пакет ломается на связях между ними.
558
+ *
559
+ * Ловит две вещи, и обе про идемпотентность:
560
+ * • повторный `externalId` внутри вида — ключ перестаёт быть ключом, и
561
+ * повторный прогон будет находить то одну сущность, то другую;
562
+ * • ссылку в никуда (родитель, автор, пространство, владелец, вложение) — ядру
563
+ * нечего с ней делать, а молча уронить связь значит потерять кусок дерева и
564
+ * узнать об этом от человека через неделю.
565
+ *
566
+ * Отдаёт СПИСОК, а не бросает: пакет с десятком висячих ссылок должен уехать в
567
+ * карантин целиком и показаться человеку одним экраном. Что делать с проблемами
568
+ * — решает ядро (MOVE-11).
569
+ */
570
+ export function checkImportPackageRefs(manifest) {
571
+ const problems = [];
572
+ // Один список «вид → его сущности» на обе фазы. Перечислять виды отдельно для
573
+ // множеств и отдельно для обхода нельзя: `Record<ImportEntityKind, …>` не даст
574
+ // забыть вид ЗДЕСЬ, а забытый вызов обхода не поймал бы никто — проверка
575
+ // дублей для нового вида молча перестала бы работать.
576
+ const entities = {
577
+ person: manifest.people,
578
+ space: manifest.spaces,
579
+ sprint: manifest.sprints,
580
+ page: manifest.pages,
581
+ task: manifest.tasks,
582
+ comment: manifest.comments,
583
+ attachment: manifest.attachments,
584
+ };
585
+ const ids = {};
586
+ for (const kind of IMPORT_ENTITY_KINDS) {
587
+ const seen = new Set();
588
+ ids[kind] = seen;
589
+ for (const entity of entities[kind]) {
590
+ if (seen.has(entity.externalId)) {
591
+ problems.push({
592
+ kind,
593
+ externalId: entity.externalId,
594
+ problem: "duplicate_external_id",
595
+ message: `${kind}: externalId «${entity.externalId}» встречается больше одного раза`,
596
+ });
597
+ continue;
598
+ }
599
+ seen.add(entity.externalId);
600
+ }
601
+ }
602
+ const ref = (kind, externalId, field, target, value) => {
603
+ if (value == null || ids[target].has(value))
604
+ return;
605
+ problems.push({
606
+ kind,
607
+ externalId,
608
+ problem: "dangling_ref",
609
+ field,
610
+ message: `${kind} «${externalId}»: ${field} ссылается на ${target} «${value}», которого нет в пакете`,
611
+ });
612
+ };
613
+ const attachmentRefs = (kind, externalId, refs) => {
614
+ for (const value of refs ?? [])
615
+ ref(kind, externalId, "attachments", "attachment", value);
616
+ };
617
+ // У пространства исходящих ссылок нет — оно верх дерева, обходить нечего.
618
+ for (const sprint of manifest.sprints) {
619
+ ref("sprint", sprint.externalId, "spaceId", "space", sprint.spaceId);
620
+ }
621
+ for (const page of manifest.pages) {
622
+ ref("page", page.externalId, "spaceId", "space", page.spaceId);
623
+ ref("page", page.externalId, "parentId", "page", page.parentId);
624
+ ref("page", page.externalId, "authorId", "person", page.authorId);
625
+ ref("page", page.externalId, "lastEditorId", "person", page.lastEditorId);
626
+ attachmentRefs("page", page.externalId, page.attachments);
627
+ }
628
+ for (const task of manifest.tasks) {
629
+ ref("task", task.externalId, "spaceId", "space", task.spaceId);
630
+ ref("task", task.externalId, "parentId", "task", task.parentId);
631
+ ref("task", task.externalId, "authorId", "person", task.authorId);
632
+ ref("task", task.externalId, "assigneeId", "person", task.assigneeId);
633
+ ref("task", task.externalId, "sprintId", "sprint", task.sprintId);
634
+ attachmentRefs("task", task.externalId, task.attachments);
635
+ }
636
+ for (const comment of manifest.comments) {
637
+ ref("comment", comment.externalId, "on.id", comment.on.kind === "page" ? "page" : "task", comment.on.id);
638
+ ref("comment", comment.externalId, "parentId", "comment", comment.parentId);
639
+ ref("comment", comment.externalId, "authorId", "person", comment.authorId);
640
+ }
641
+ for (const attachment of manifest.attachments) {
642
+ ref("attachment", attachment.externalId, "authorId", "person", attachment.authorId);
643
+ }
644
+ return problems;
645
+ }
646
+ /**
647
+ * Пути файлов, которые обязаны лежать в пакете. Ядру нужно ДО раскладки понять,
648
+ * полон ли пакет: недостающий файл на середине прогона — это половина импорта и
649
+ * ручная уборка.
650
+ */
651
+ export function importPackageFilePaths(manifest) {
652
+ const paths = new Set();
653
+ for (const attachment of manifest.attachments)
654
+ paths.add(attachment.file);
655
+ return [...paths];
656
+ }
657
+ //# sourceMappingURL=importPackage.js.map