sync.push и sync.pull в боевом API: контракт, доступ, старые клиенты

синхронизация≈ 18 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Сервер главы собран целиком: номер правке выдаёт строка-счётчик пользователя, повтор пачки узнаёт память вердиктов, чужое имя строки отвечает как несуществующее, а с курсора уезжает итог строк вместо журнала. Осталось поставить обе процедуры в транспорт главы 8 — и там выясняется, что боевой API спрашивает ещё о четырёх вещах, которых у миниатюры не было: как выглядит контракт, кого пускать, что ограничивает запрос и как быть с телефонами, до которых обновление не доехало. От ответов зависит не то, правильно ли сервер сливает правки, а то, доедут ли они до него вообще.

Разделы темы:·

Имена процедур и формы их входа придуманы не здесь. Они приехали вместе с телефоном: sync.push принимает { batch } и отвечает { verdicts }, sync.pull принимает { cursor, limit } и отвечает { changes, cursor, hasMore }. Это тот же файл contracts/sync.ts, что лежал у сервера-партнёра, и менять в нём нечего: правка контракта — это правка приложения, которое установлено у людей и обновится когда-нибудь.

Часть VI добавила ровно два поля, и оба необязательные. У операции — schema, номер формы её полей; у вердикта — row, строка целиком после отказа, ради которой в прошлой статье менялся reject(). Нет поля schema — значит, схема первая: клиенты части V его не шлют. Нет поля row — значит, сервер старый, и клиент пересобирает строку по-прежнему. Ни device, ни reset, ни rowIds, ни отдельной процедуры снимка в контракте нет.

Схема входа живёт отдельным пакетом, общим с клиентом. Вот весь файл — добавленное помечено комментариями:

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

То же правило пишется и своим валидатором главы 6. Обёртку для поля, которого может не быть, читатель писал там задачей; для этой формы не хватает ещё двух сборщиков — списка и словаря с заранее неизвестными именами. Прогноз: сколько путей назовёт отказ у пачки, в единственной операции которой сломаны сразу четыре поля?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Обе формы операции проходят одной схемой, и schema ?? 1 — единственное место, где разница между ними видна. Отказ называет путь до каждого сломанного поля, и номер операции в пачке — часть пути: без него сообщение «ожидалось целое число» в пачке на тысячу операций не значит ничего. Заметьте и то, чего в схеме нет: schema объявлена целым числом, а не перечислением известных серверу версий. Операция из будущего схему пройдёт — что делать с ней, решает не валидатор.

Соблазн дописать в контракт ещё три поля велик, и каждое из них выглядит полезным. device — имя устройства в запросе — позволил бы серверу знать, кто и как давно заходил, и чистить надгробия по самому отставшему. Но клиенты части V его не шлют, а значит, «самый отставший» неизвестен всегда, и чистка либо не начнётся, либо выбросит записи у того, кого не посчитали. Поэтому надгробия не чистятся вовсе — решение прошлой статьи.

reset — ответ «начни сначала» вместо страницы — понадобился бы отставшему за горизонт. Горизонта нет, поэтому и ответ не нужен; вдобавок клиент части V читает у ответа только changes, cursor и hasMore, и новое поле просто не заметил бы. rowIds — просьба «отдай вот эти строки целиком» — не нужен, потому что строка после отказа приезжает в самом вердикте.

Общее у всех трёх одно: каждое поле требует правки клиента, а правка клиента — это выкат в магазин и полгода ожидания, пока обновятся все. Контракт, который расширяется только необязательным, стоит дешевле любого из этих улучшений.

Необязательное поле — это не «поле, которое можно не заполнять». Это обещание: обе стороны продолжат работать, если его нет. Всё, чего нельзя обещать, обязано быть обязательным, и тогда это новый контракт, а не расширение старого.

Личность обе процедуры спрашивают одинаково: Authorization: Bearer и ничего кроме. Телефон держит токен в защищённом хранилище, веб-версия «Подхода» — в localStorage, и обе прикладывают его заголовком. Браузерный клиент с сессией в куке у учебника есть, но ходит он не сюда: как одна проверка обслуживает два носителя, разобрано в разделе про мост.

Второе задано транспортом. sync.pull — запрос, sync.push — мутация, а вид процедуры решает метод: выдача уезжает через GET, и вход у неё в адресе. Для чистого чтения это правильно — и это же вторая причина, по которой кука сюда не годится: GET с кукой уходит и с чужого сайта.

Прогноз: телефон зовёт sync.pull без токена. Что бросит @trpc/client — и найдётся ли у этой ошибки поле code, по которому цикл синхронизации отличает «нужен вход» от «сети нет»?

прогон node 26.5.0 · 29 сентября 2026
Вызов без токена глазами клиента: поля ошибки, обёртка и метод по виду процедуры
import 'reflect-metadata';
import { createHmac, timingSafeEqual } from 'node:crypto';
import { Module } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import { initTRPC, TRPCError } from '@trpc/server';
import { fastifyTRPCPlugin, type FastifyTRPCPluginOptions } from '@trpc/server/adapters/fastify';
import { createTRPCClient, httpBatchLink, TRPCClientError } from '@trpc/client';
import { z } from 'zod';

const SECRET = 'учебный-секрет-сервера-не-для-прода';
const sign = (userId: string): string => {
  const body = Buffer.from(JSON.stringify({ sub: userId })).toString('base64url');
  return `${body}.${createHmac('sha256', SECRET).update(body).digest('base64url')}`;
};
// проверка подписи — до всякого похода в базу: см. главу про доступ
function subjectOf(token: string | undefined): string | null {
  const [body = '', signature = ''] = (token ?? '').split('.');
  const expected = Buffer.from(createHmac('sha256', SECRET).update(body).digest('base64url'));
  const received = Buffer.from(signature);
  if (received.length !== expected.length || !timingSafeEqual(received, expected)) return null;
  return JSON.parse(Buffer.from(body, 'base64url').toString()).sub as string;
}

type Ctx = { token: string | undefined };
const t = initTRPC.context<Ctx>().create();
const authed = t.procedure.use(({ ctx, next }) => {
  const userId = subjectOf(ctx.token);
  if (userId === null) throw new TRPCError({ code: 'UNAUTHORIZED' });
  return next({ ctx: { userId } });
});

const op = z.object({ opId: z.string(), rowId: z.string(), fields: z.record(z.string(), z.unknown()), at: z.number(), schema: z.number().optional() });
const appRouter = t.router({
  sync: t.router({
    push: authed.input(z.object({ batch: z.array(op) })).mutation(({ input, ctx }) => ({
      verdicts: input.batch.map((one) => ({ opId: one.opId, status: 'applied' as const, by: ctx.userId })),
    })),
    pull: authed.input(z.object({ cursor: z.number(), limit: z.number() })).query(({ input, ctx }) => ({
      changes: [], cursor: input.cursor, hasMore: false, by: ctx.userId,
    })),
  }),
});
type AppRouter = typeof appRouter;

@Module({})
class AppModule {}

const app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter(), { logger: false });
const fastify = app.getHttpAdapter().getInstance();
const seen: string[] = [];
fastify.addHook('onRequest', (request, _reply, done) => { seen.push(`${request.method} ${request.url}`); done(); });
const options: FastifyTRPCPluginOptions<AppRouter> = {
  prefix: '/trpc',
  trpcOptions: { router: appRouter, createContext: ({ req }) => ({ token: (req.headers.authorization ?? '').replace(/^Bearer /, '') || undefined }) },
};
await fastify.register(fastifyTRPCPlugin, options);
await app.listen(0, '127.0.0.1');
const url = `${await app.getUrl()}/trpc`;

const client = (token?: string) => createTRPCClient<AppRouter>({
  links: [httpBatchLink({ url, headers: () => (token === undefined ? {} : { authorization: `Bearer ${token}` }) })],
});

// обёртка вызова: неудачи телефон различает по коду, а не по тексту
function fail(text: string, code: number): Error & { code: number } {
  return Object.assign(new Error(text), { code });
}
async function call<T>(work: () => Promise<T>): Promise<T> {
  try {
    return await work();
  } catch (error) {
    if (error instanceof TRPCClientError && error.data?.httpStatus === 401) throw fail('нужен вход', 401);
    throw fail('ответа нет', 0);
  }
}

const guest = client();
try {
  await guest.sync.pull.query({ cursor: 0, limit: 100 });
} catch (error) {
  const trpc = error as TRPCClientError<AppRouter>;
  console.log('без токена:', trpc.constructor.name, '| свои поля:', JSON.stringify(Object.keys(trpc)));
  console.log('  error.code:', JSON.stringify((trpc as unknown as { code?: number }).code),
    '| error.data.httpStatus:', JSON.stringify(trpc.data?.httpStatus),
    '| error.data.code:', JSON.stringify(trpc.data?.code));
}
try {
  await call(() => guest.sync.pull.query({ cursor: 0, limit: 100 }));
} catch (error) {
  const wrapped = error as Error & { code: number };
  console.log('через обёртку:', wrapped.message, '| code === 401:', wrapped.code === 401);
}

const phone = client(sign('u1'));
console.log('с токеном, pull:', JSON.stringify(await phone.sync.pull.query({ cursor: 0, limit: 100 })));
console.log('с токеном, push:', JSON.stringify(await phone.sync.push.mutate({ batch: [{ opId: 'A:1', rowId: 'A-1', fields: { reps: 5 }, at: 1 }] })));

const path = (line: string): string => line.split('?')[0] ?? line;
console.log('как ехали вызовы:', seen.map(path).join(' | '));
console.log('вход выдачи — в адресе:', decodeURIComponent(seen[0] ?? ''));
await app.close();
nest start

Своих полей у TRPCClientError пять: cause, shape, data, meta, name. Поля code среди них нет — оно undefined. Число 401 лежит в data.httpStatus, имя отказа UNAUTHORIZED — в data.code. Отсюда и обёртка вызова: цикл части V проверяет (error as { code?: number }).code === 401 и по этому признаку ставит паузу, а не отсрочку. Без перевода ошибки транспорта в ошибку с кодом проверка ложна всегда, и телефон с протухшим токеном будет вечно откладывать отправку вместо того, чтобы попросить войти. Обёртка — три строки, и они решают, увидит ли человек экран входа.

Порядок внутри проверки тоже не произволен. Подпись токена сверяется первой и без единого запроса в базу — именно оттуда берётся имя человека для ключа ограничителя, и перебор чужих токенов не должен оплачиваться чтением таблицы. Существование пользователя и его роль перепроверяются уже в базе, той же функцией, что и у остальных процедур: токен живёт неделями, а учётку за это время могли удалить. Синхронизация не делает здесь ничего своего — и это главное, что о ней стоит сказать.

Кука ездит сама — здесь это единственное её достоинство. Приём пачки — мутация, то есть POST, а простая форма с чужого сайта умеет отправлять POST, и кука уехала бы вместе с ним; свой заголовок форме не поставить, зато вид содержимого у неё бывает трёх видов. Что из этих трёх доезжает до процедуры, глава про доступ уже замерила запуском: text/plain и форму в теле tRPC не разбирает вовсе — 415 ещё до процедуры, — а multipart/form-data доезжает, и останавливает его только схема входа.

То есть у процедуры с кукой последним рубежом от подделки межсайтового запроса оказывается валидатор границы — роль, на которую его никто не нанимал, и держится она на том, что схему не забыли. Токен в заголовке снимает вопрос целиком: заголовок сам не поедет. Поэтому куки у синхронизации нет, а браузерная сессия остаётся у процедур, которые открывает браузер.

Носителей личности может быть два, проверка обязана остаться одна. Как только у второго носителя заводится своя ветка проверки, у приложения появляется вторая дверь — и рано или поздно одну из них забудут запереть.

Ограничитель частоты у API уже есть — гостевой: окно в минуту, пауза в минуту, десять вызовов на пару «процедура и адрес». Он написан для входа, регистрации и «забыл пароль», и на процедуры синхронизации не годится ни одной своей половиной. Ключ по адресу делит лимит на всех, кто сидит за одним NAT, — а в зале это все, кто подключён к общему вайфаю. Потолок в десять вызовов упирается в одиннадцатую страницу первой синхронизации, которых у нового телефона бывают сотни.

Форма остаётся прежней: окно, счётчик, пауза. Меняются ключ и числа. Ключ — «процедура и пользователь», причём пользователь берётся из проверенной подписи токена, до всякого похода в базу: иначе перебор оплачивался бы запросами к ней. Числа раздельные, потому что цена вызовов разная: выдаче шестьсот в минуту, приёму сто двадцать. Прогноз: сколько единиц потратит пачка tRPC из трёх вызовов, приехавшая одним HTTP-запросом?

прогон node 26.5.0 · 29 сентября 2026
Пачка вызовов в одном запросе, потолки процедур и зал за одним адресом
import 'reflect-metadata';
import { createHmac } from 'node:crypto';
import { Module } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import { initTRPC, TRPCError } from '@trpc/server';
import { fastifyTRPCPlugin, type FastifyTRPCPluginOptions } from '@trpc/server/adapters/fastify';
import { createTRPCClient, httpBatchLink, TRPCClientError } from '@trpc/client';
import { z } from 'zod';

const SECRET = 'учебный-секрет-сервера-не-для-прода';
const sign = (userId: string): string => {
  const body = Buffer.from(JSON.stringify({ sub: userId })).toString('base64url');
  return `${body}.${createHmac('sha256', SECRET).update(body).digest('base64url')}`;
};
const subjectOf = (token: string | undefined): string | null => {
  const [body = '', signature = ''] = (token ?? '').split('.');
  if (createHmac('sha256', SECRET).update(body).digest('base64url') !== signature) return null;
  return JSON.parse(Buffer.from(body, 'base64url').toString()).sub as string;
};

/** Потолки на окно: у выдачи он выше, потому что первая синхронизация идёт страницами. */
const SYNC: Record<string, number> = { 'sync.pull': 600, 'sync.push': 120 };
/** Гостевой потолок автора — один на все гостевые процедуры. */
const GUEST: Record<string, number> = { 'sync.pull': 10, 'sync.push': 10 };
const WINDOW = 60_000;
const BLOCK = 60_000;
type Counter = { hits: number; expiresAt: number; blockedUntil: number };

/** Окно и пауза — той же формы, что у гостевого ограничителя; меняется только ключ. */
function createLimiter(keyOf: (path: string, subject: Subject) => string, ceiling: Record<string, number>) {
  const counters = new Map<string, Counter>();
  return (path: string, subject: Subject, now: number): boolean => {
    const key = keyOf(path, subject);
    const was = counters.get(key);
    const counter = was === undefined || now >= was.expiresAt
      ? { hits: 0, expiresAt: now + WINDOW, blockedUntil: 0 }
      : was;
    counters.set(key, counter);
    if (now < counter.blockedUntil) return false;
    counter.hits += 1;
    if (counter.hits <= (ceiling[path] ?? 10)) return true;
    counter.blockedUntil = now + BLOCK;
    return false;
  };
}

type Ctx = { token: string | undefined; ip: string };
type Subject = { userId: string | null; ip: string };
const t = initTRPC.context<Ctx>().create();

/** Сервер собирается дважды: одна и та же форма с ключом по пользователю и по адресу. */
function buildRouter(limiter: (path: string, subject: Subject, now: number) => boolean, counted: string[]) {
  const limited = t.procedure.use(({ ctx, path, next }) => {
    // личность — из подписи токена: она нужна ключу, а в базу за ней не ходим
    const userId = subjectOf(ctx.token);
    counted.push(path);
    if (!limiter(path, { userId, ip: ctx.ip }, Date.now())) throw new TRPCError({ code: 'TOO_MANY_REQUESTS' });
    if (userId === null) throw new TRPCError({ code: 'UNAUTHORIZED' });
    return next({ ctx: { userId } });
  });
  const op = z.object({ opId: z.string(), rowId: z.string(), fields: z.record(z.string(), z.unknown()), at: z.number() });
  return t.router({
    sync: t.router({
      push: limited.input(z.object({ batch: z.array(op) })).mutation(() => ({ verdicts: [] })),
      pull: limited.input(z.object({ cursor: z.number(), limit: z.number() })).query(({ input }) => ({
        changes: [], cursor: input.cursor, hasMore: false,
      })),
    }),
  });
}
type AppRouter = ReturnType<typeof buildRouter>;

@Module({})
class AppModule {}

async function start(keyOf: (path: string, subject: Subject) => string, ceiling: Record<string, number> = SYNC) {
  const counted: string[] = [];
  const requests: string[] = [];
  const router = buildRouter(createLimiter(keyOf, ceiling), counted);
  const app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter(), { logger: false });
  const fastify = app.getHttpAdapter().getInstance();
  fastify.addHook('onRequest', (request, _reply, done) => { requests.push(`${request.method} ${request.url}`); done(); });
  const options: FastifyTRPCPluginOptions<AppRouter> = {
    prefix: '/trpc',
    trpcOptions: { router, createContext: ({ req }) => ({ token: (req.headers.authorization ?? '').replace(/^Bearer /, '') || undefined, ip: req.ip }) },
  };
  await fastify.register(fastifyTRPCPlugin, options);
  await app.listen(0, '127.0.0.1');
  const url = `${await app.getUrl()}/trpc`;
  const client = (userId: string) => createTRPCClient<AppRouter>({
    links: [httpBatchLink({ url, headers: () => ({ authorization: `Bearer ${sign(userId)}` }) })],
  });
  return { app, client, counted, requests };
}

/** Два ключа: авторизованных считаем по человеку, гостей — по адресу, другого признака нет. */
const byUserKey = (path: string, subject: Subject): string =>
  subject.userId === null ? `${path}:ip:${subject.ip}` : `${path}:u:${subject.userId}`;
const byIpKey = (path: string, subject: Subject): string => `${path}:ip:${subject.ip}`;

const refused = (error: unknown): string =>
  error instanceof TRPCClientError ? String(error.data?.code) : 'не отказ ограничителя';
const pull = (who: ReturnType<Awaited<ReturnType<typeof start>>['client']>) => who.sync.pull.query({ cursor: 0, limit: 100 });
const push = (who: ReturnType<Awaited<ReturnType<typeof start>>['client']>) => who.sync.push.mutate({ batch: [] });
async function tryMany(work: () => Promise<unknown>, times: number) {
  let passed = 0;
  let last = 'отказов не было';
  for (let i = 0; i < times; i += 1) {
    try {
      await work();
      passed += 1;
    } catch (error) {
      last = refused(error);
    }
  }
  return { passed, last };
}

// Сцена 1. Пачка tRPC: один HTTP-запрос — и сколько вызовов в счёте ограничителя
{
  const { app, client, counted, requests } = await start(byUserKey);
  const one = client('u1');
  await Promise.all([pull(one), pull(one), pull(one)]);
  console.log('пачка из трёх выдач: запросов', requests.length, '| в счёте ограничителя', counted.length);
  console.log('  адрес пачки:', decodeURIComponent((requests[0] ?? '').split('&input=')[0] ?? ''));
  await app.close();
}

// Сцена 2. Потолки процедур и второй пользователь за тем же ключом-адресом
{
  const { app, client } = await start(byUserKey);
  const one = client('u1');
  const two = client('u2');
  const pulls = await tryMany(() => pull(one), 600);
  console.log('\nвыдача, потолок 600: прошло', pulls.passed, '| последний отказ:', pulls.last);
  console.log('  601-я выдача:', await pull(one).then(() => 'прошла').catch(refused));
  const pushes = await tryMany(() => push(one), 120);
  console.log('приём, потолок 120: прошло', pushes.passed, '| последний отказ:', pushes.last);
  console.log('  121-й приём:', await push(one).then(() => 'прошёл').catch(refused));
  console.log('  первый приём второго человека:', await push(two).then(() => 'прошёл').catch(refused));
  await app.close();
}

// Сцена 3. Тот же ограничитель с гостевым ключом: зал за одним адресом
{
  const byIp = await start(byIpKey, GUEST);
  const byUser = await start(byUserKey);
  const hall = Array.from({ length: 30 }, (_, i) => `u${i + 1}`);
  const count = async (stand: Awaited<ReturnType<typeof start>>) => {
    let no = 0;
    for (const person of hall) {
      const who = stand.client(person);
      for (const work of [() => pull(who), () => push(who)]) {
        try {
          await work();
        } catch {
          no += 1;
        }
      }
    }
    return `отказов ${no} из ${hall.length * 2}`;
  };
  console.log('\nзал: 30 человек × (приём + выдача) за одним адресом');
  console.log('  гостевой ключ «процедура + адрес», потолок 10:', await count(byIp));
  console.log('  ключ «процедура + пользователь», потолки 600 и 120:', await count(byUser));
  await byIp.app.close();
  await byUser.app.close();
}
nest start

Три вызова в одном запросе — три единицы. Это и есть главная причина, по которой ограничитель живёт звеном tRPC, а не грубым рубежом по HTTP-запросам: пачка приезжает одним запросом, и рубеж по запросам сосчитает в ней одну единицу вместо трёх. Звено же видит путь каждого вызова отдельно — адрес пачки называет процедуру столько раз, сколько в ней вызовов. Потолки держатся ровно: шестьсот выдач проходят, шестьсот первая получает TOO_MANY_REQUESTS; сто двадцать приёмов проходят, сто двадцать первый — нет. Первый приём второго человека проходит, пока первый стоит в паузе: счёт у каждого свой.

Последняя сцена — тот же зал. Тридцать человек, у каждого приём и выдача, все за одним адресом. Гостевой ключ отвергает сорок вызовов из шестидесяти, ключ по пользователю — ни одного. Отказ, кстати, не катастрофа: 429 клиент части V принимает за «другую неудачу» и уходит в отсрочку, курсор и очередь целы. Катастрофа в другом — что в отсрочку уйдут все сразу и по причине, которой у них нет.

Из той же породы решений — предел страницы, и он в главе уже принят: limit сервер урезает кодом до пятисот, а не отвергает схемой. Здесь к нему стоит добавить одно. В боевом проекте автора тот же предел стоит прямо в схеме, и это тоже верно: там клиента пишут те же люди, что и сервер, и договориться можно. Оффлайн-клиенту, выпущенному в магазин полгода назад, договариваться не с кем — отсюда и разница.

Сам отказ при этом транспортный, а не доменный: «слишком часто» не рассказывает ничего о заметках, подходах и правах, и через отображение доменных кодов его вести незачем. Это тот редкий случай, когда TRPCError бросается напрямую, — и в боевом коде рядом с ним стоит комментарий, объясняющий почему: иначе первый же читатель попробует «привести к общему виду».

И последняя оговорка, без которой числа выше — украшение. Счётчик здесь живёт в памяти процесса, и это честно ровно до тех пор, пока экземпляр API один. Второй экземпляр удваивает разрешённое: у каждого свой счётчик, а человек один. Лечится это общим хранилищем счёта — и тем же ключом, — но выбор между памятью и общим хранилищем должен быть сделан заранее и вслух, потому что задним числом он выглядит как «ограничитель почему-то пропускает вдвое больше».

Ключ «процедура и пользователь» придуман не здесь: ниже выдержка из боевого ограничителя, где такой ключ уже работает. Числа и корзины там свои, а причина в комментарии та же, которую прогон показал числами: HTTP-запрос один, работы в нём три, и увидеть её можно только по пути процедуры.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Первый вопрос к любому ограничителю — чей это счёт. Второй — сколько единиц тратит один HTTP-запрос. Если ответ на второй «всегда одну», ограничитель считает запросы, а не работу, и пачка обходит его тремя строчками в клиенте.

Ограничитель считает вызовы, и от его отказа ничего не теряется: клиент подождёт и позовёт снова. У размера пачки свойство хуже. Цикл части V отправляет исходящую очередь целиком, а на повтор отправляет ту же самую — значит, очередь, которая однажды не прошла по размеру, не пройдёт никогда. Это не отсрочка, а остановка: дневник человека остаётся на одном телефоне навсегда.

Предел на /trpc один, и он не там, где его ищут. Плагин tRPC регистрирует один маршрут — fastify.all на /:path — и опций маршрута ему не передаёт; собственный предел адаптера maxBodySize выставлен в null, то есть снят. Остаётся общий bodyLimit самого Fastify, мегабайт по умолчанию, и ставится он там же, где создаётся адаптер. Прогноз: пройдёт ли предел по умолчанию очередь за год без сети — и что увидит клиент, если не пройдёт?

прогон node 26.5.0 · 29 сентября 2026
Очередь за год против предела тела: где проходит граница и что видно клиенту
import 'reflect-metadata';
import { Module } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import { initTRPC } from '@trpc/server';
import { fastifyTRPCPlugin, type FastifyTRPCPluginOptions } from '@trpc/server/adapters/fastify';
import { createTRPCClient, httpLink, TRPCClientError } from '@trpc/client';
import { z } from 'zod';

type Op = { opId: string; rowId: string; fields: Record<string, string | number>; at: number };

const t = initTRPC.create();
const op = z.object({ opId: z.string(), rowId: z.string(), fields: z.record(z.string(), z.unknown()), at: z.number() });
const appRouter = t.router({
  sync: t.router({
    // за один запрос разбирается не больше тысячи операций: остаток телефон досылает сам
    push: t.procedure.input(z.object({ batch: z.array(op) })).mutation(({ input }) => ({
      got: input.batch.length,
      verdicts: input.batch.slice(0, 1000).map((one) => ({ opId: one.opId, status: 'applied' as const })),
    })),
  }),
});
type AppRouter = typeof appRouter;

@Module({})
class AppModule {}

async function start(bodyLimit?: number) {
  const adapter = bodyLimit === undefined ? new FastifyAdapter() : new FastifyAdapter({ bodyLimit });
  const app = await NestFactory.create<NestFastifyApplication>(AppModule, adapter, { logger: false });
  const fastify = app.getHttpAdapter().getInstance();
  const options: FastifyTRPCPluginOptions<AppRouter> = { prefix: '/trpc', trpcOptions: { router: appRouter } };
  await fastify.register(fastifyTRPCPlugin, options);
  await app.listen(0, '127.0.0.1');
  const url = `${await app.getUrl()}/trpc`;
  const client = createTRPCClient<AppRouter>({ links: [httpLink({ url })] });
  return { app, client, url, limit: fastify.initialConfig.bodyLimit };
}

/*
 * Очередь дневника без сети. Имя устройства — UUID («Журнал: имена и номера
 * рождаются на телефоне»), поэтому имена операции и строки длинные: они и весят.
 */
const device = '7f3c2a9e-5b1d-4e8f-a6c0-2d9b8e1f4a37';
const YEAR = 6015;
const queue: Op[] = [];
for (let set = 1; queue.length < YEAR * 5; set += 1) {
  const rowId = `${device}-${set}`;
  const put = (fields: Op['fields'], at: number) =>
    queue.push({ opId: `${device}:${queue.length + 1}`, rowId, fields, at });
  put({ exercise: 'жим лежа', weightKg: 82.5, reps: 5 }, set * 2);
  put({ reps: 6 }, set * 2 + 1);
}
const bytes = (count: number): number => Buffer.byteLength(JSON.stringify({ batch: queue.slice(0, count) }));

const answer = async (client: Awaited<ReturnType<typeof start>>['client'], count: number) => {
  try {
    const out = await client.sync.push.mutate({ batch: queue.slice(0, count) });
    return `200, принято операций ${out.got}, вердиктов ${out.verdicts.length}`;
  } catch (error) {
    // печатаем только устойчивую часть: текст этой ошибки от захода к заходу разный
    const trpc = error instanceof TRPCClientError;
    return `отказ | ошибка tRPC: ${trpc} | data: ${trpc ? JSON.stringify((error as TRPCClientError<AppRouter>).data) : '—'}`;
  }
};

console.log('очередь за год без сети:', YEAR, 'операций, тело', bytes(YEAR), 'Б');

{
  const stand = await start();
  const limit = stand.limit ?? 0;
  console.log('\nпредел тела по умолчанию: у Fastify', limit, 'Б — запас', limit - bytes(YEAR), 'Б');
  console.log('  очередь за год:', await answer(stand.client, YEAR));
  console.log('  она же на повтор:', await answer(stand.client, YEAR));
  // где проходит граница: делим пополам, пока принятое и отвергнутое не сойдутся
  let ok = YEAR;
  let no = YEAR * 5;
  while (no - ok > 1) {
    const middle = Math.floor((ok + no) / 2);
    if ((await answer(stand.client, middle)).startsWith('200')) ok = middle;
    else no = middle;
  }
  console.log('  последняя принятая пачка:', ok, 'операций, тело', bytes(ok), 'Б');
  console.log('  первая непринятая:', no, 'операций, тело', bytes(no), 'Б');
  console.log('  что видит клиент:', await answer(stand.client, no));
  /*
   * Тот же запрос руками: код ответа и тело — чтобы видеть, кто отказал и почему.
   * Повтор здесь не украшение: тело больше предела, Fastify отвечает, не дочитав
   * его до конца, и сброс соединения успевает дойти до клиента раньше ответа —
   * тогда fetch бросает ECONNRESET или EPIPE (22 броска на 60 попытках). Сам
   * ответ, когда он доходит, от этого не меняется
   */
  const byHand = async () => {
    const body = JSON.stringify({ batch: queue.slice(0, no) });
    for (let attempt = 1; ; attempt += 1) {
      try {
        return await fetch(`${stand.url}/sync.push`, { method: 'POST', headers: { 'content-type': 'application/json' }, body });
      } catch (error) {
        if (attempt === 20) throw error;
      }
    }
  };
  const raw = await byHand();
  console.log('  тот же POST руками:', raw.status, await raw.text());
  await stand.app.close();
}
{
  const stand = await start(16 * 1024 * 1024);
  console.log('\nпредел поднят переменной окружения: у Fastify', stand.limit, 'Б');
  console.log('  очередь за пять лет:', YEAR * 5, 'операций, тело', bytes(YEAR * 5), 'Б');
  console.log('  ответ:', await answer(stand.client, YEAR * 5));
  await stand.app.close();
}
nest start

Шесть тысяч операций — примерно год дневника без сети — весят 942 971 байт: предел по умолчанию они проходят, но запас сто килобайт, по той же мерке — сорок дней. Граница нашлась ровно: 6685 операций проходят, 6686 нет, то есть год очередь переживает, а чуть больше тринадцати месяцев — уже нет. Имена тут весят больше данных — имя устройства это UUID, и оно стоит в каждом opId и в каждом rowId.

Страшнее размера то, что видит клиент. Сервер честно отвечает 413 и называет причину — FST_ERR_CTP_BODY_TOO_LARGE, «Request body is too large», — но до клиента это доезжает как ошибка без data вовсе: ни кода tRPC, ни httpStatus. Для цикла синхронизации это «другая неудача», то есть отсрочка, то есть тот же запрос через минуту, через две, через час. Ни один экран не покажет «пачка слишком велика», потому что никто об этом не узнал.

Предел тела читатель уже писал сам — в своём сервере главы 4, и там он вёл себя понятнее: обрыв соединения был виден клиенту как обрыв. Здесь между сервером и клиентом стоит библиотека, которая ждёт ответа своей формы, и ответ Fastify в эту форму не попадает. Урок общий: предел, поставленный ниже той библиотеки, через которую ходит клиент, приезжает к нему без смысла.

Лечение простое и с ценой: предел поднимается переменной окружения — шестнадцать мегабайт пропускают очередь за пять лет. Цена в том, что предел на сервере один: большее тело после этого принимают все процедуры, включая те, которым мегабайта было много. Второй предел — свой, и он не про байты: тысячу операций за запрос глава приняла раньше, и вердикты уходят только им. Прогноз: что вернёт пачка из тысячи двухсот операций и сколько записей придёт на limit: 5000?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Оба предела стоят здесь рядом не ради повторения: в одном модуле они складываются — и складываются без единой правки клиента. Остаток пачки цикл досылает сам, страницу добирает по hasMore. Важно другое, и это уже про приём: вердиктов не бывает ноль при непустой пачке. 200 с пустым списком — это ответ, после которого клиент зовёт снова немедленно и получает то же самое, то есть цикл без отсрочек.

Предел, о котором клиент не может узнать из ответа, — это не предел, а обрыв. Если отказ не переводится в понятный клиенту код, единственное, что остаётся приложению, — повторять, и оно будет.

Мобильное приложение живёт годами, обновление по воздуху достаётся не всем, и поля в нём со временем переименовываются. Поэтому операция несёт номер своей формы, и ставится он в момент записи, а не отправки: операция может пролежать в очереди неделю и уехать уже после обновления. Поднимает операцию до своей схемы принимающая сторона — отправитель о новой форме знать не может.

Ступенька для этого берётся не любая. Та, которой часть V поднимала операции, — сужающая: старое поле она убирает, новое ставит на его место, и телефону, выпустившему лестницу, этого довольно. Прогноз: что увидит лента телефона 1.0, если ту же ступеньку поставить на сервер, пока такие телефоны ещё в строю?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Сужающая ступенька не солгала ни в одном поле — она сделала ровно то, что умеет. Соврала лента: телефон 1.0 читает reps, а его не осталось, и собственный подход человека выглядит пустым. Расширяющая ступенька добавляет repsDone рядом, reps оставляет, и оба поля получают отметку той же операции — значит, оба уезжают в выдачу. Телефон 1.0 читает reps, телефон 1.1 — repsDone ?? reps, и обоим есть что показать. Операцию 1.1, которая пишет оба поля сразу, такая ступенька не трогает вовсе: она идемпотентна, и в модуле стоит на пути каждой операции, а не только старой.

Сужение от этого не отменяется — оно откладывается. Это отдельный выкат сервера и не по календарю, а по данным: пока приходят операции старой формы, старая форма жива. Порядок внутри самого сужения тот же, что у телефонной ступеньки 1.2: сперва долить repsDone строкам, у которых его нет, и только потом убрать reps. Для серверной базы это то же расширение и сужение, которым выкатывают миграции, — там один шаг в схеме и вовсе разбит на три выпуска.

Обратите внимание, где номера схемы нет. В PullInput его нет: клиент не сообщает, какую форму полей он понимает, и сервер не опускает строки до неё. Так сделано не из лени — опускание не всегда обратимо, а главное, оно требует хранить операции «как пришли», то есть журнал. Сервер этой главы хранит итог, и поднимать ему нечего: то, что уже легло в строку, лежит там в единственной, самой свежей форме. Всю работу со схемами он делает на входе, один раз за операцию.

Обратный случай — операция со схемой, которой сервер не знает. Это нарушение порядка выпуска: первым выкатывается тот, кто принимает, и если телефон уехал вперёд сервера, виноват выкат, а не телефон. Сервер разбирает пачку до такой операции и отдаёт вердикты началу; остаток клиент досылает следующим заходом.

А если она первая и разбирать нечего — ответом идёт ошибка запроса, не 401 и не 200. Отказ по операции здесь не годится: получив rejected, клиент выбросит честную запись человека. Пустые вердикты тоже: цикл при непустой очереди позовёт снова немедленно и будет звать без остановки. Ошибка запроса отправляет клиента в отсрочку, очередь остаётся целой и ждёт выката сервера — это единственное место в главе, где отказ относится ко всей пачке, и цена ему названа: честные операции за такой стоят, пока сервер не обновят.

Расширяющая ступенька выглядит грязной: в строке живут два поля с одним смыслом, и это видно всем. Сужающая выглядит чистой ровно до того дня, когда в базу заглянет телефон, о котором забыли.

Всё, что глава собирала по частям, теперь стоит одним модулем: Nest с адаптером Fastify, плагин tRPC на нём, две процедуры за проверкой токена, настоящий Postgres под ними. А проверяет их не тест, написанный под этот же модуль, а приёмка из части V — один вечер из жизни дневника: зал без сети, дорога домой, правка с веба и с телефона одновременно, удаление лишнего. Клиенты в ней — те самые, из части V, без единой правки, кроме той, что глава внесла в reject().

Прогноз: сойдётся ли итог с эталоном приёмки — и одинаковы ли дневники двух устройств, если сравнить их так же, как сравнивала приёмка, строкой JSON?

прогон под базой PostgreSQL 18.4 · node 26.5.0 · 29 сентября 2026
Приёмка на двух устройствах, протухший токен и порядок выпуска на живом модуле
import { createHmac, timingSafeEqual } from 'node:crypto';
import { Inject, Injectable, Module } from '@nestjs/common';
import { initTRPC, TRPCError } from '@trpc/server';
import { z } from 'zod';
import pg from 'pg';
import type { Entry, Fields, Op, Verdict } from '../../_shared/p6-sync/client-v5.mjs';

/**
 * Выпуск сервера: какую схему он понимает и как поднимает до неё операции.
 * Нет поля schema — схема 1. Первый выпуск знает только её и ступенек не имеет.
 */
export const RELEASE: { latest: number; lift: (one: Op) => Op } = { latest: 1, lift: (one) => one };

/*
 * Контракт: файл части V плюс два необязательных поля — schema у операции и row
 * у вердикта. Ни device, ни reset, ни rowIds в нём нет.
 */
const fieldValue = z.union([z.string(), z.number(), z.boolean()]);
const op = z.object({
  opId: z.string().min(1),
  rowId: z.string().min(1),
  fields: z.record(z.string(), fieldValue),
  at: z.number().int(),
  schema: z.number().int().positive().optional(),
});
export const pushInput = z.object({ batch: z.array(op).min(1) });
export const pullInput = z.object({ cursor: z.number().int().min(0), limit: z.number().int().positive() });

const SECRET = 'учебный-секрет-сервера-не-для-прода';
/** Токен главы про доступ, урезанный до двух полей: кто и до какого часа. */
export const sign = (userId: string, secondsLeft: number): string => {
  const body = Buffer.from(JSON.stringify({ sub: userId, exp: Math.floor(Date.now() / 1000) + secondsLeft })).toString('base64url');
  return `${body}.${createHmac('sha256', SECRET).update(body).digest('base64url')}`;
};
/** Подпись и срок — и ни одного запроса в базу: личность отсюда идёт в ключ ограничителя. */
function subjectOf(token: string | undefined): string | null {
  const [body = '', signature = ''] = (token ?? '').split('.');
  const expected = Buffer.from(createHmac('sha256', SECRET).update(body).digest('base64url'));
  const received = Buffer.from(signature);
  if (received.length !== expected.length || !timingSafeEqual(received, expected)) return null;
  const claims = JSON.parse(Buffer.from(body, 'base64url').toString()) as { sub: string; exp: number };
  return Date.now() / 1000 >= claims.exp ? null : claims.sub;
}

/* ---- слияние по полям: то же правило, что у телефона, слово в слово ---- */
type Mark = { at: number; opId: string };
const newer = (a: Mark, b: Mark): boolean => (a.at !== b.at ? a.at > b.at : a.opId > b.opId);
function merge(fields: Fields | undefined, one: Op, marks: Record<string, Mark>): Fields {
  const next: Fields = { ...fields };
  for (const field of Object.keys(one.fields)) {
    const was = marks[field];
    if (was && !newer(one, was)) continue;
    next[field] = one.fields[field] as Fields[string];
    marks[field] = { at: one.at, opId: one.opId };
  }
  return next;
}
const checkRow = (fields: Fields): string | null => {
  const missing = ['exercise', 'weightKg', 'reps'].filter((field) => !(field in fields));
  if (missing.length > 0) return 'нет полей: ' + missing.join(', ');
  return Number(fields.reps) > 100 ? 'повторов больше сотни' : null;
};

/*
 * Ступенька схемы 1 → 2. Пока живы телефоны 1.0, она РАСШИРЯЮЩАЯ: старое поле
 * остаётся, новое появляется рядом. Идемпотентна — операция, уже несущая оба
 * поля, проходит через неё без изменений.
 */
export function expand(one: Op): Op {
  const fields = { ...one.fields };
  if ('reps' in fields && !('repsDone' in fields)) fields.repsDone = fields.reps as Fields[string];
  return { ...one, fields, schema: 2 };
}

type Stored = { rowId: string; fields: Fields; marks: Record<string, Mark>; seq: number };
/** Строка уходит записями по отметкам: поля с общей отметкой — одной записью. */
function entriesOf(stored: Stored): Entry[] {
  const byMark = new Map<string, Entry>();
  for (const field of Object.keys(stored.fields).sort()) {
    const mark = stored.marks[field] as Mark;
    const name = `${mark.at}|${mark.opId}`;
    const entry = byMark.get(name) ?? { opId: mark.opId, rowId: stored.rowId, fields: {}, at: mark.at, seq: stored.seq };
    entry.fields[field] = stored.fields[field] as Fields[string];
    byMark.set(name, entry);
  }
  return [...byMark.values()].sort((a, b) => a.at - b.at || (a.opId < b.opId ? -1 : 1));
}

export const POOL = Symbol('POOL');
/** За запрос разбирается не больше тысячи операций: остаток телефон досылает сам. */
const PER_REQUEST = 1000;
/** Больше пятисот строк за страницу сервер не отдаёт, о чём бы ни просил клиент. */
const PER_PAGE = 500;

@Injectable()
export class SyncService {
  constructor(@Inject(POOL) private readonly pool: pg.Pool) {}

  async push(userId: string, batch: Op[]): Promise<{ verdicts: Verdict[] }> {
    const client = await this.pool.connect();
    try {
      await client.query('BEGIN');
      await client.query('INSERT INTO sync_heads (user_id) VALUES ($1) ON CONFLICT DO NOTHING', [userId]);
      await client.query('SELECT seq FROM sync_heads WHERE user_id = $1 FOR UPDATE', [userId]);
      const verdicts: Verdict[] = [];
      const taken: Op[] = [];
      for (const raw of batch.slice(0, PER_REQUEST)) {
        // операция новее сервера: разбор пачки останавливается на ней
        if ((raw.schema ?? 1) > RELEASE.latest) break;
        const one = RELEASE.lift(raw);
        taken.push(one);
        const { rows: [stored] } = await client.query<Stored>(
          'SELECT fields, marks FROM sync_rows WHERE user_id = $1 AND row_id = $2', [userId, one.rowId]);
        const before = stored?.marks ?? {};
        const marks = { ...before };
        const fields = merge(stored?.fields, one, marks);
        const moved = JSON.stringify(marks) !== JSON.stringify(before);
        const reason = stored && !moved ? null : checkRow(fields);
        const computed: Verdict = reason
          ? { opId: one.opId, status: 'rejected', reason }
          : { opId: one.opId, status: 'applied' };
        const put = await client.query<{ status: string }>(
          `INSERT INTO sync_verdicts (user_id, op_id, status, reason) VALUES ($1, $2, $3, $4)
           ON CONFLICT DO NOTHING RETURNING status`,
          [userId, one.opId, computed.status, computed.reason ?? null]);
        if (put.rowCount === 0) {
          const { rows: [saved] } = await client.query<{ status: string; reason: string | null }>(
            'SELECT status, reason FROM sync_verdicts WHERE user_id = $1 AND op_id = $2', [userId, one.opId]);
          verdicts.push(saved.status === 'applied'
            ? { opId: one.opId, status: 'duplicate' }
            : { opId: one.opId, status: 'rejected', reason: saved.reason ?? undefined });
          continue;
        }
        if (computed.status === 'applied' && moved) {
          const { rows: [head] } = await client.query<{ seq: string }>(
            'UPDATE sync_heads SET seq = seq + 1 WHERE user_id = $1 RETURNING seq', [userId]);
          await client.query(
            `INSERT INTO sync_rows (user_id, row_id, fields, marks, seq) VALUES ($1, $2, $3, $4, $5)
             ON CONFLICT (user_id, row_id) DO UPDATE SET fields = excluded.fields, marks = excluded.marks, seq = excluded.seq`,
            [userId, one.rowId, fields, marks, Number(head.seq)]);
        }
        verdicts.push(computed);
      }
      // разобрать нечего — ошибка запроса, а не 200 с пустыми вердиктами:
      // на пустые вердикты при непустой очереди цикл телефона зовёт себя снова
      if (verdicts.length === 0) throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'операция новее сервера' });
      // строка целиком — после всей пачки: к этому времени она окончательная
      for (let i = 0; i < verdicts.length; i += 1) {
        if (verdicts[i].status !== 'rejected') continue;
        const { rows: [stored] } = await client.query<Stored>(
          'SELECT row_id, fields, marks, seq FROM sync_rows WHERE user_id = $1 AND row_id = $2',
          [userId, taken[i].rowId]);
        verdicts[i].row = stored === undefined ? [] : entriesOf({ ...stored, seq: Number(stored.seq) });
      }
      await client.query('COMMIT');
      return { verdicts };
    } catch (error) {
      await client.query('ROLLBACK').catch(() => {});
      throw error;
    } finally {
      client.release();
    }
  }

  async pull(userId: string, cursor: number, limit: number): Promise<{ changes: Entry[]; cursor: number; hasMore: boolean }> {
    // limit урезается кодом, а не отказом схемы: отдать меньше просимого законно
    const take = Math.min(limit, PER_PAGE);
    const { rows } = await this.pool.query<Stored>(
      `SELECT row_id AS "rowId", fields, marks, seq FROM sync_rows
       WHERE user_id = $1 AND seq > $2 ORDER BY seq LIMIT $3`,
      [userId, cursor, take + 1]);
    const page = rows.slice(0, take).map((row) => ({ ...row, seq: Number(row.seq) }));
    const last = page.length > 0 ? (page[page.length - 1] as Stored).seq : cursor;
    return { changes: page.flatMap(entriesOf), cursor: last, hasMore: rows.length > page.length };
  }
}

type Ctx = { token: string | undefined };
const t = initTRPC.context<Ctx>().create();
/** Синхронизация принимает только Bearer: куки-клиента у неё нет. */
const authed = t.procedure.use(({ ctx, next }) => {
  const userId = subjectOf(ctx.token);
  if (userId === null) throw new TRPCError({ code: 'UNAUTHORIZED' });
  return next({ ctx: { userId } });
});

export function buildRouter(sync: SyncService) {
  return t.router({
    sync: t.router({
      push: authed.input(pushInput).mutation(({ input, ctx }) => sync.push(ctx.userId, input.batch)),
      // выдача остаётся чистым чтением, поэтому Query: в @trpc/client это GET
      pull: authed.input(pullInput).query(({ input, ctx }) => sync.pull(ctx.userId, input.cursor, input.limit)),
    }),
  });
}
export type AppRouter = ReturnType<typeof buildRouter>;
export const contextOf = (headers: Record<string, string | string[] | undefined>): Ctx => ({
  token: String(headers.authorization ?? '').replace(/^Bearer /, '') || undefined,
});

@Module({
  providers: [
    SyncService,
    { provide: POOL, useFactory: () => new pg.Pool({ connectionString: process.env.DATABASE_URL }) },
  ],
  exports: [SyncService],
})
export class SyncModule {}
nest start
рядом лежит общий код, без него команда не выполнится: ../_shared/p6-sync/client-v5.mjs, ../_shared/p6-sync/client-v5.d.mts

Эталон сошёлся: на обоих устройствах остался один подход, вес с телефона, повторы с веба, надгробие удалённого приседа у сервера лежит, а счётчик дошёл до пяти. А вот сравнение строкой JSON говорит «не одинаковы» — и это не ошибка синхронизации. У сервера-итога страница собирается из отметок, а отметки перебираются по алфавиту полей, поэтому в строке, приехавшей с сервера, поля стоят в другом порядке, чем в строке, собранной на своём телефоне. Значения совпадают до последнего. Приёмку, доставшуюся от части V, приходится поправить: дневники сравниваются по значениям, а не строкой.

Остальные две сцены — то, о чём шла речь выше, на живом проводе. Протухший токен доезжает до клиента кодом 401, синхронизация встаёт на паузу, очередь из двух подходов цела, и после входа оба уезжают. Операция 1.1, отправленная до выката сервера, получает PRECONDITION_FAILED и 412 — тот самый порядок выпуска; после выката проходят обе формы, и лента телефона 1.0 показывает числа в обеих строках.

Чего этот смоук не проверяет — знать так же важно. В нём нет одновременности: обе копии пачки, гонка за строкой-счётчиком и взаимная блокировка разбирались прогонами прошлых статей, и повторять их на живом модуле незачем. Нет в нём и сети: «ответа нет» здесь просто флаг в обёртке, а настоящий обрыв посреди пачки ведёт себя иначе — это как раз тот класс, который ловится только приёмкой на двух настоящих устройствах.

У боевого проекта автора синхронизация устроена скромнее — и это стоит увидеть, чтобы не принять здешний объём за обязательный. Там она односторонняя: процедура одна, sync.pull, потому что сообщения создаёт другая процедура, а не пачка операций. Декораторы в листинге — от nestjs-trpc, того самого пакета, который часть VI не взяла; зато видно то же самое: звено личности на процедуре, курсор в схеме и предел страницы у самого контракта.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Глава на этом закрыта. Дальше API обрастает тем, что к синхронизации не сводится: загрузкой файлов, тестами против настоящей базы и журналом с мягкой остановкой — тем, без чего боевой сервер живёт ровно до первой ночной поломки.

Смоук, написанный под свой же сервер, проверяет, что сервер делает то, что задумал его автор. Смоук на чужом клиенте проверяет то единственное, что важно: что сервер делает то, чего ждёт приложение, которое уже установлено у людей.

  • Контракт части VI — файл части V плюс два необязательных поля: schema у операции и row у вердикта. Нет поля schema — схема первая; ни device, ни reset, ни процедуры снимка в контракте нет.
  • Обе процедуры принимают только Bearer. У TRPCClientError нет поля code: 401 лежит в data.httpStatus, и обёртка вызова обязана перевести его в код — иначе пауза при протухшем токене не сработает.
  • Ограничитель — той же формы, что гостевой, но с ключом «процедура и пользователь»: выдаче шестьсот в минуту, приёму сто двадцать. Звено tRPC считает каждый вызов внутри пачки, а ключ по адресу отверг бы сорок вызовов из шестидесяти у зала за одним NAT.
  • Предел тела на /trpc один — bodyLimit Fastify: собственный предел адаптера tRPC снят, а опций маршрута плагин не ставит. Мегабайт по умолчанию режет очередь примерно через тринадцать месяцев молчания, и до клиента отказ доезжает без кода.
  • За запрос разбирается не больше тысячи операций, страница отдаёт не больше пятисот строк, а limit урезается кодом, а не отвергается схемой: успешный ответ с пустыми вердиктами на непустую пачку крутит цикл без отсрочек.
  • Ступенька схемы на сервере расширяющая: новое поле рядом со старым, оба с отметкой операции, оба в выдаче. Сужение — отдельный выкат по данным, а операция новее сервера останавливает разбор и при пустом разборе отвечает ошибкой запроса.

1/4Клиент части V не шлёт поле schema. Как сервер части VI понимает такую операцию?

2/4Пачка tRPC привезла три вызова sync.pull одним HTTP-запросом. Сколько единиц спишет ограничитель со звена?

3/4Очередь телефона выросла настолько, что не проходит bodyLimit. Что произойдёт дальше без правок на сервере?

4/4Почему sync.pull оставлен запросом, а не сделан мутацией?

Ступенька, из-за которой в ленте прочерк

На сервер поставили ступеньку, приехавшую с телефона, — сужающую. Пока живы телефоны 1.0, их собственные подходы выглядят в ленте пустыми. Перепишите step так, чтобы лента 1.0 показывала число в обеих строках, а телефону 1.1 по-прежнему хватало repsDone. Операцию, которая уже несёт оба поля, ступенька менять не должна.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Ступенька должна добавлять, а не заменять: новое поле появляется рядом, старое остаётся на месте. И смотреть она обязана на оба поля — иначе операция, несущая оба, получила бы repsDone, переписанный из reps: сегодня это одно и то же число, а завтра смысл полей разойдётся.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Лента 1.0 читает reps и показывает «жим 5, присед 3», а поля строк у обеих — exercise+reps+repsDone+weightKg. Убрать reps можно будет отдельным выкатом, когда операции первой схемы перестанут приходить, — и в том выкате сперва долить repsDone строкам, у которых его нет.

Пачка, на которую нечего ответить

Приём пачки останавливается на операции, схему которой сервер не знает, и отдаёт вердикты началу. Но если такая операция стоит первой, разбирать нечего — и сейчас клиент получает успешный ответ с пустым списком вердиктов, после чего зовёт снова немедленно и получает то же самое. Почините receive: пустых вердиктов на непустую пачку быть не должно.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Пустой разбор — это ошибка запроса, а не успешный ответ. Бросать её надо до того, как собран ответ: вердиктов не бывает ноль, но и меньше одной операции разобрать нельзя.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Код здесь не 401: у клиента части V 401 означает «нужен вход» и ставит паузу до входа, а очередь должна ждать не человека, а выката сервера. И не rejected по операции — такой вердикт клиент понял бы как «запись негодная» и выбросил бы её из журнала.