Галоўная / Артыкулы / Практычныя прытамулі: Мая настройка monorepo для розповсюджэння фул-стак, канец-канец з абэрантнаю пераказоўкай

Практычныя прытамулі: Мая настройка monorepo для розповсюджэння фул-стак, канец-канец з абэрантнаю пераказоўкай

Практычныя прытамулкі: мая настройка monorepo для стварэння фул-стаковых, канец-до-канца тыпавахінных рашэнняў — кантракты, пераказы і месцы для додавання коду для команд, якія выкарыстоўваюць гэты патэрн.

2920 слоў

Існавайце гэта як перапрацоўку ідэй з артыкула «Мая наладка monorepo для стварэння аплікацыяў з усім функцыоналам, якія ўтримваюць типавую безпеку, з аднойчынным кодам на TypeScript, частка 2» для працавальнікаў: чыстыя этапы, аранжаваныя блакі для коду і прыметкі з восстанавлення, якія застаюцца пасля перадачы задання. Этап «Агульны відзор» найэфектывней працюе, калі яго розглядаць як вимерную плошчу. Запісаўце адну ідеальную транскрыпцыю, адзін прыклад неудачы і прыметку з вярненням да пачатковага стану прычаму расшырэння масштаба. Валіце маленькія, тэставаныя елементы замест большых скрыптов. Калі якісьць крок не выходзіце, прычына неудачы павінна вказываць на адную адпаведальнасць, а не на заплутаны процес.

name: monorepo-setup

services:
  db:
    image: postgres:17-alpine
    container_name: monorepo-setup-database-container
    ports:
      - 127.0.0.1:5432:5432
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -d ${POSTGRES_DB} -U ${POSTGRES_USER}"]
      interval: 10s
      timeout: 5s
      retries: 5
    volumes:
      - monorepo-setup:/var/lib/postgresql/data
    env_file:
      - apps/api/.env.database

volumes:
  monorepo-setup:
    name: monorepo-setup
# apps/api/.env.database
POSTGRES_DB=monorepo-setup
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
docker compose up -d --remove-orphans
docker ps
docker exec -it monorepo-setup-database-container psql -U postgres -d monorepo-setup
# monorepo-setup-database-container is the name of the container, as specified in the compose.yaml file
# -U postgres (POSTGRES_USER specified in the .env.database file)
# same for -d monorepo-setup (POSTGRES_DB)
# From the root of the monorepo
cd apps/api
npm i drizzle-orm@rc pg dotenv
npm i -D drizzle-kit@rc tsx @types/pg
DATABASE_URL=postgres://<user>:<password>@<host>:<port>/<database>
DATABASE_URL=postgres://postgres:postgres@localhost:5432/monorepo-setup
nest g res drizzle
// apps/api/src/drizzle/constants.ts
export const DRIZZLE_ASYNC_PROVIDER = "DRIZZLE_ASYNC_PROVIDER";
// apps/api/drizzle/providers/drizzle.provider.ts

import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";

import { EnvService } from "@/env/env.service";

import { DRIZZLE_ASYNC_PROVIDER } from "../constants";

export const drizzleProvider = {
  provide: DRIZZLE_ASYNC_PROVIDER,
  inject: [EnvService],
  useFactory: async (envService: EnvService) => {
    const connectionString = envService.get("DATABASE_URL");
    const pool = new Pool({
      connectionString,
    });

    return drizzle({ client: pool });
  },
};
// apps/api/src/drizzle/schema/users.ts

import { pgTable, text, varchar } from "drizzle-orm/pg-core";
import { timestamp } from "drizzle-orm/pg-core";
import { ulid } from "ulid";

export const users = pgTable("users", {
  id: varchar("id", { length: 26 })
    .primaryKey()
    .notNull()
    .$defaultFn(() => ulid().toLowerCase()),

  username: text("username").notNull().unique(),
  email: text("email").notNull().unique(),
  hashedPassword: text("hashed_password").notNull(),

  createdAt: timestamp("created_at").defaultNow().notNull(),
  updatedAt: timestamp("updated_at")
    .defaultNow()
    .$onUpdate(() => new Date())
    .notNull(),
});
// apps/api/src/drizzle/schema/posts.ts

import { pgTable, text, varchar } from "drizzle-orm/pg-core";
import { timestamp } from "drizzle-orm/pg-core";
import { ulid } from "ulid";

import { users } from "./users";

export const posts = pgTable("posts", {
  id: varchar("id", { length: 26 })
    .primaryKey()
    .notNull()
    .$defaultFn(() => ulid().toLowerCase()),
  authorId: varchar("author_id", { length: 26 })
    .notNull()
    .references(() => users.id, { onDelete: "cascade" }),

  title: text("title").notNull(),
  slug: text("slug").notNull(),
  content: text("content").notNull(),

  publishedAt: timestamp("published_at"),
  createdAt: timestamp("created_at").defaultNow().notNull(),
  updatedAt: timestamp("updated_at")
    .defaultNow()
    .$onUpdate(() => new Date())
    .notNull(),
});
// apps/api/src/drizzle/schema/index.ts

export * from "./posts";
export * from "./users";
npm i ulid
// apps/api/src/env.ts

import { z } from "zod";

export const envSchema = z.object({
  NODE_ENV: z.enum(["production", "development"]),

  JWT_SECRET: z.string().trim().min(1),

  DASHBOARD_URL: z.url(),
  WEB_APP_URL: z.url(),

  DATABASE_URL: z.string().trim().min(1),
});

export type Env = z.infer<typeof envSchema>;
// apps/api/drizzle.config.ts

import { defineConfig } from "drizzle-kit";

import "dotenv/config";

export default defineConfig({
  out: "./src/drizzle/migrations",
  schema: "./src/drizzle/schema/index.ts",
  dialect: "postgresql",
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
});
// apps/api/package.json

{
  "scripts": {
    ...
    "generate": "npx drizzle-kit generate",
    "migrate": "npx drizzle-kit migrate",
    ...
  }
}
npm run generate
npm run generate
docker exec -it monorepo-setup-database-container psql -U postgres -d monorepo-setup
\dt
// apps/api/package.json

{
  "scripts": {
    ...
   "studio": "npx drizzle-kit studio"
    ...
  }
}
npm run studio
import { EnvModule } from "@/env/env.module";
import { Module } from "@nestjs/common";

import { DRIZZLE_ASYNC_PROVIDER } from "./constants";
import { drizzleProvider } from "./providers/drizzle.provider";

@Module({
  imports: [EnvModule],
  providers: [drizzleProvider],
  exports: [DRIZZLE_ASYNC_PROVIDER],
})
export class DrizzleModule {}
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";

import { EnvService } from "./env.service";

@Module({
  imports: [ConfigModule],
  providers: [EnvService],
  exports: [EnvService],
})
export class EnvModule {}
nest g res users
// apps/api/src/users/users.service.ts

import * as bcrypt from "bcrypt";
import { eq, or } from "drizzle-orm";
import { NodePgDatabase } from "drizzle-orm/node-postgres";

import { DRIZZLE_ASYNC_PROVIDER } from "@/drizzle/constants";
import { users } from "@/drizzle/schema";
import { Inject, Injectable } from "@nestjs/common";
import { TRPCError } from "@trpc/server";

import { CreateUserDtoType, FindByEmailDtoType } from "./users.dto";

@Injectable()
export class UsersService {
  private readonly saltRounds = 10;

  constructor(
    @Inject(DRIZZLE_ASYNC_PROVIDER)
    private readonly db: NodePgDatabase,
  ) {}

  async create(createUserDto: CreateUserDtoType) {
    const { email, password, username } = createUserDto;

    const [existingUserWithSameEmailOrUsername] = await this.db
      .select({ email: users.email, username: users.username })
      .from(users)
      .where(or(eq(users.email, email), eq(users.username, username)))
      .limit(1);

    if (existingUserWithSameEmailOrUsername) {
      if (existingUserWithSameEmailOrUsername.email === email) {
        throw new TRPCError({
          code: "CONFLICT",
          message: "This email is already used",
        });
      } else {
        throw new TRPCError({
          code: "CONFLICT",
          message: "This username already exists",
        });
      }
    }

    const hashedPassword = await bcrypt.hash(password, this.saltRounds);

    const [createdUser] = await this.db
      .insert(users)
      .values({
        username,
        email,
        hashedPassword,
      })
      .returning({
        id: users.id,
        email: users.email,
        username: users.username,
      });

    return createdUser;
  }

  async findByEmail(findByEmailDto: FindByEmailDtoType) {
    const { email } = findByEmailDto;

    const [user] = await this.db
      .select({
        id: users.id,
        email: users.email,
        username: users.username,
        hashedPassword: users.hashedPassword,
      })
      .from(users)
      .where(eq(users.email, email))
      .limit(1);

    if (!user) {
      return null;
    }

    return user;
  }
}
// apps/api/src/users/users.dto.ts

import z from "zod";

export const CreateUserDto = z.object({
  email: z.email(),
  username: z.string().min(1).max(25),
  password: z.string().min(1).max(72),
});

export const FindByEmailDto = z.object({
  email: z.email(),
});

export type CreateUserDtoType = z.infer<typeof CreateUserDto>;
export type FindByEmailDtoType = z.infer<typeof FindByEmailDto>;
# apps/api

npm i bcrypt
npm i -D @types/bcrypt
// apps/api/src/users/users.module.ts

import { DrizzleModule } from "@/drizzle/drizzle.module";
import { Module } from "@nestjs/common";

import { UsersService } from "./users.service";

@Module({
  imports: [DrizzleModule],
  providers: [UsersService],
  exports: [UsersService],
})
export class UsersModule {}
nest g res posts
import { and, eq } from "drizzle-orm";
import { NodePgDatabase } from "drizzle-orm/node-postgres";

import { DRIZZLE_ASYNC_PROVIDER } from "@/drizzle/constants";
import { posts } from "@/drizzle/schema";
import { Inject, Injectable } from "@nestjs/common";
import { TRPCError } from "@trpc/server";

import {
  CreatePostDtoType,
  FindAllPostsDtoType,
  FindOnePostDtoType,
} from "./posts.dto";

@Injectable()
export class PostsService {
  constructor(
    @Inject(DRIZZLE_ASYNC_PROVIDER)
    private readonly db: NodePgDatabase,
  ) {}
  async create(createPostDto: CreatePostDtoType) {
    const { title, slug, content, authorId } = createPostDto;

    const [existingPostWithSameSlug] = await this.db
      .select({ slug: posts.slug })
      .from(posts)
      .where(and(eq(posts.authorId, authorId), eq(posts.slug, slug)))
      .limit(1);

    if (existingPostWithSameSlug) {
      throw new TRPCError({
        code: "CONFLICT",
        message: "A post with that slug already exists",
      });
    }

    const [createdPost] = await this.db
      .insert(posts)
      .values({ title, slug, content, authorId, publishedAt: new Date() })
      .returning({
        id: posts.id,
        title: posts.title,
        slug: posts.slug,
        content: posts.content,
        authorId: posts.authorId,
      });

    return createdPost;
  }

  async findAll(findAllPostsDto: FindAllPostsDtoType) {
    const { authorId } = findAllPostsDto;

    const postsFetched = await this.db
      .select({ title: posts.title, slug: posts.slug, content: posts.content })
      .from(posts)
      .where(eq(posts.authorId, authorId));

    return postsFetched;
  }

  async findOne(findOnePostDto: FindOnePostDtoType) {
    const { authorId, slug } = findOnePostDto;

    const [post] = await this.db
      .select({
        title: posts.title,
        slug: posts.slug,
        content: posts.content,
      })
      .from(posts)
      .where(and(eq(posts.authorId, authorId), eq(posts.slug, slug)))
      .limit(1);

    if (!post) {
      return null;
    }

    return post;
  }
}
// apps/api/src/posts/posts.dto.ts

import z from "zod";

export const CreatePostDto = z.object({
  title: z.string().min(1).max(150),
  slug: z.string().min(1).max(150),
  content: z.string().min(1),
});

export const FindOnePostDto = z.object({
  slug: z.string().min(1).max(150),
});

type AuthorId = { authorId: string };
export type CreatePostDtoType = z.infer<typeof CreatePostDto> & AuthorId;
export type FindAllPostsDtoType = AuthorId;
export type FindOnePostDtoType = z.infer<typeof FindOnePostDto> & AuthorId;
// apps/api/src/posts/posts.module.ts

import { DrizzleModule } from "@/drizzle/drizzle.module";
import { Module } from "@nestjs/common";

import { PostsService } from "./posts.service";

@Module({
  imports: [DrizzleModule],
  providers: [PostsService],
  exports: [PostsService],
})
export class PostsModule {}
nest g res auth
// apps/api/src/auth/auth.service.ts

import * as bcrypt from "bcrypt";

import { JWTDtoType } from "@/common/dto";
import { UsersService } from "@/users/users.service";
import { Injectable } from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
import { TRPCError } from "@trpc/server";

import { LoginDtoType, RegisterDtoType } from "./auth.dto";

@Injectable()
export class AuthService {
  constructor(
    private readonly jwtService: JwtService,
    private readonly usersService: UsersService,
  ) {}

  async register(registerDto: RegisterDtoType) {
    const { username, email, password } = registerDto;

    const createdUser = await this.usersService.create({
      email,
      username,
      password,
    });

    const payload: Pick<JWTDtoType, "sub"> = { sub: createdUser.id };
    const token = await this.jwtService.signAsync(payload);

    return { accessToken: token };
  }

  async login(loginDto: LoginDtoType) {
    const { email, password } = loginDto;

    const user = await this.usersService.findByEmail({ email });

    if (!user) {
      throw new TRPCError({
        code: "NOT_FOUND",
        message: "User not found",
      });
    }

    const isPasswordCorrect = await bcrypt.compare(
      password,
      user.hashedPassword,
    );
    if (!isPasswordCorrect) {
      throw new TRPCError({
        code: "UNAUTHORIZED",
        message: "Incorrect password",
      });
    }

    const payload: Pick<JWTDtoType, "sub"> = { sub: user.id };
    const token = await this.jwtService.signAsync(payload);

    return {
      accessToken: token,
    };
  }
}
// apps/api/src/auth/auth.dto.ts

import z from "zod";

export const RegisterDto = z.object({
  username: z.string().min(1).max(25),
  email: z.email(),
  password: z.string().min(1).max(72),
});

export const LoginDto = z.object({
  email: z.email(),
  password: z.string().min(1).max(72),
});

export type RegisterDtoType = z.infer<typeof RegisterDto>;
export type LoginDtoType = z.infer<typeof LoginDto>;
// apps/api/src/auth/auth.router.ts

import { TrpcService } from "@/trpc/trpc.service";
import { Injectable } from "@nestjs/common";

import { LoginDto, RegisterDto } from "./auth.dto";
import { AuthService } from "./auth.service";

@Injectable()
export class AuthRouter {
  constructor(
    private readonly trpcService: TrpcService,
    private readonly authService: AuthService,
  ) {}

  private readonly AUTH_COOKIE_NAME = "auth_token";
  private readonly COOKIE_MAX_AGE = 2 * 24 * 60 * 60 * 1000; // 2 days

  procedures() {
    return {
      auth: this.trpcService.trpc.router({
        register: this.trpcService
          .publicProcedure()
          .input(RegisterDto)
          .mutation(async ({ ctx, input }) => {
            const { accessToken } = await this.authService.register(input);

            // Set the HTTP-only cookie
            ctx.res.cookie(this.AUTH_COOKIE_NAME, accessToken, {
              httpOnly: true,
              secure: true,
              sameSite: "lax",
              maxAge: this.COOKIE_MAX_AGE,
            });
          }),

        login: this.trpcService
          .publicProcedure()
          .input(LoginDto)
          .mutation(async ({ ctx, input }) => {
            const { accessToken } = await this.authService.login(input);

            // Set the HTTP-only cookie
            ctx.res.cookie(this.AUTH_COOKIE_NAME, accessToken, {
              httpOnly: true,
              secure: true,
              sameSite: "lax",
              maxAge: this.COOKIE_MAX_AGE,
            });
          }),
      }),
    };
  }
}
// apps/api/src/auth/auth.module.ts

import { EnvModule } from "@/env/env.module";
import { EnvService } from "@/env/env.service";
import { TrpcService } from "@/trpc/trpc.service";
import { UsersModule } from "@/users/users.module";
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { JwtModule } from "@nestjs/jwt";

import { AuthRouter } from "./auth.router";
import { AuthService } from "./auth.service";

@Module({
  imports: [
    ConfigModule,
    UsersModule,
    JwtModule.registerAsync({
      imports: [EnvModule],
      useFactory: async (envService: EnvService) => ({
        global: true,
        secret: envService.get("JWT_SECRET"),
        signOptions: { expiresIn: "2d" },
      }),
      inject: [EnvService],
    }),
  ],

  providers: [AuthService, AuthRouter, TrpcService, EnvService],
  exports: [AuthRouter, AuthService],
})
export class AuthModule {}
// apps/api/src/trpc/trpc.router.ts

  appRouter = this.trpcService.trpc.router({
    getHello: this.trpcService
      .publicProcedure()
      .input(
        z.object({
          name: z.string().min(1),
        }),
      )
      .query(({ input }) => `Hello, ${input.name} from trpc server`),

    ...this.authRouter.procedures(),
  });
// apps/api/src/trpc/trpc.module.ts

@Module({
  imports: [AuthModule],
  providers: [TrpcService, TrpcRouter, JwtService, EnvService, AuthRouter],
  exports: [TrpcService],
})
// apps/api/src/posts/posts.router.ts

import { TrpcService } from "@/trpc/trpc.service";
import { Injectable } from "@nestjs/common";

import { CreatePostDto, FindOnePostDto } from "./posts.dto";
import { PostsService } from "./posts.service";

@Injectable()
export class PostsRouter {
  constructor(
    private readonly trpcService: TrpcService,
    private readonly postsService: PostsService,
  ) {}

  procedures() {
    return {
      posts: this.trpcService.trpc.router({
        create: this.trpcService
          .protectedProcedure()
          .input(CreatePostDto)
          .mutation(async ({ ctx, input }) =>
            this.postsService.create({ ...input, authorId: ctx.user.sub }),
          ),

        findAll: this.trpcService
          .protectedProcedure()
          .query(async ({ ctx }) =>
            this.postsService.findAll({ authorId: ctx.user.sub }),
          ),

        findOne: this.trpcService
          .protectedProcedure()
          .input(FindOnePostDto)
          .query(async ({ ctx, input }) =>
            this.postsService.findOne({ ...input, authorId: ctx.user.sub }),
          ),
      }),
    };
  }
}
// apps/api/src/trpc/trpc.router.ts

  appRouter = this.trpcService.trpc.router({
    getHello: this.trpcService
      .publicProcedure()
      .input(
        z.object({
          name: z.string().min(1),
        }),
      )
      .query(({ input }) => `Hello, ${input.name} from trpc server`),

    ...this.authRouter.procedures(),
    ...this.postsRouter.procedures(),
  });
// apps/api/src/trpc/trpc.module.ts

@Module({
  imports: [AuthModule, PostsModule],
  providers: [
    TrpcService,
    TrpcRouter,
    JwtService,
    EnvService,
    AuthRouter,
    PostsRouter,
  ],
  exports: [TrpcService],
})
import { Controller, useForm } from "react-hook-form";
import { Link, useNavigate } from "react-router";
import { toast } from "sonner";

import { useTRPC } from "@/utils/trpc";
import { zodResolver } from "@hookform/resolvers/zod";
import { LoginSchema, type LoginSchemaType } from "@repo/common/types-schemas";
import { Button } from "@repo/ui/components/ui/button";
import { Field, FieldError, FieldLabel } from "@repo/ui/components/ui/field";
import { Input } from "@repo/ui/components/ui/input";
import { useMutation } from "@tanstack/react-query";

import { useTogglePassword } from "../hooks/use-toggle-password";

export const LoginForm = () => {
  const form = useForm<LoginSchemaType>({
    resolver: zodResolver(LoginSchema),
    defaultValues: {
      email: "",
      password: "",
    },
  });

  const { isPasswordVisible, EyeIconComponent } = useTogglePassword();

  const navigate = useNavigate();
  const trpc = useTRPC();
  const loginMutation = useMutation(trpc.auth.login.mutationOptions());

  const onSubmit = (values: LoginSchemaType) => {
    loginMutation.mutate(
      {
        email: values.email,
        password: values.password,
      },
      {
        onError: (error) => {
          const errorMessage = error.message;

          if (errorMessage.toLowerCase().includes("email")) {
            form.setError("email", { message: errorMessage });
          } else if (errorMessage.toLowerCase().includes("password")) {
            form.setError("password", { message: errorMessage });
          } else {
            form.setError("root", { message: errorMessage });
          }
        },

        onSuccess: () => {
          toast.success("Login complete");

          navigate("/posts");
        },
      },
    );
  };

  return (
    <form
      onSubmit={form.handleSubmit(onSubmit)}
      className="flex w-[clamp(40%,30rem,80%)] flex-col gap-4 p-2"
    >
      <h2 className="flex flex-col items-center justify-center gap-2 text-center text-3xl font-extrabold max-[42.5rem]:text-2xl">
        Login
      </h2>

      <Controller
        control={form.control}
        name="email"
        render={({ field, fieldState }) => (
          <Field data-invalid={fieldState.invalid}>
            <FieldLabel htmlFor={field.name}>Email</FieldLabel>
            <Input
              id={field.name}
              placeholder="example@email.com"
              type="email"
              aria-invalid={fieldState.invalid}
              className="border-border h-10"
              {...field}
            />
            {fieldState.invalid && <FieldError errors={[fieldState.error]} />}
          </Field>
        )}
      />

      <Controller
        control={form.control}
        name="password"
        render={({ field, fieldState }) => (
          <Field data-invalid={fieldState.invalid}>
            <FieldLabel htmlFor={field.name}>Password</FieldLabel>
            <div className="relative flex items-center justify-end">
              <Input
                id={field.name}
                placeholder="***********"
                type={isPasswordVisible ? "text" : "password"}
                aria-invalid={fieldState.invalid}
                className="border-border h-10"
                {...field}
              />
              <EyeIconComponent />
            </div>
            {fieldState.invalid && <FieldError errors={[fieldState.error]} />}
          </Field>
        )}
      />
      <p>
        Not registered yet?{" "}
        <Link to="/register" className="underline">
          Register
        </Link>
      </p>

      <Button
        variant="default"
        type="submit"
        disabled={form.formState.isSubmitting}
        className="h-10 w-1/2 self-center rounded-lg"
      >
        Submit
      </Button>

      <div className="h-4">
        {form.formState.errors.root && (
          <FieldError errors={[form.formState.errors.root]} />
        )}
      </div>
    </form>
  );
};

Перакладчык заданняў

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

Документавайце як шлях успеху, так і шлях вярнення. Практыкі перапрыбутку, людзкія контралі і обработка некоректных паведамленняў ёсць часткай продукту, а не пазнейшым дапрацоўкам.

Напісайце кароткі посібнік: як роцыяваць клучы, як апустошыць чергу, як вярнуць стан да пазнейшага запісу.

Зберагайце настройкі парадульна ад коду прыемніка. Файлы сераўнавання, храненні секрэтных данных і флагі функций должны знаходзіцца ў аднам месцы, куды аператары можаць адбавіць аудыт без неабяжлівага чытання всей структуры.

Калі бюджет дазволяе, дадзіце тэст на перакананне, які працюе з критычным шляхам у CI за дапамогою фікстураў, а не з рэальнымі платнымі API.

Запісвайце часы выканання а таксу калечака або запиту праз рэзультаты функцыйянаў. Відразліва візуалізацыя таксы запобегае неспакоўным рахункам, калі маршрут пераходзіць з дэмавайнага режыма ў спакульнаныя среды.

Перш чым пераводзіць стэк у продакшн, заморозьце версіі, зафіксавайце «золаты» транскрыпты для критычных маршрутаў і паказваце крокі для атрыбуцыі. У спакульнаных средах неабходны ліміты на колькасць запытоў, перакананні ў прыналежнасці тэрыторыі і чысткі власнік для ротацыі секрэтных даных. Лепш выбраць простую надзею на надзейнасць, чым хітрыя експерыментальныя дэманстрацыі.

Прыметка для 401e3ffdff5d: не кладзіце ключы прадастаўцаў у репазітарый, задаце верхнюю межу для токена на кожную сесію і зберагачыце транскрыпты праз рэзультаты адлікавання, каб пазнейшыя замены модэляў заставаліся пораўнанымі.

Этап 0 пры практыкаванні заходаў з абароны працюе наякша, калі яго расследжваць як мерыемую паверхню. Запісаце адна «золатая» версія, адзін прыклад неудачы і запісы парадоксу перад расшырэнням масштаба. Запісвайце часы выканання, а таксу токенаў чы запытак праза рэзультаты функцыйнасці. Відчутнасць костоў з самага пачатку запобегае неспакойным рахункам, калі процес пераходзіць з дэмаверсіі ў спяльныя среды.

Дакладнасць пры практыкаванні заходаў 0/962: змерьце час выканання, класію памылак і выкарыстаныя токены для гэтага пункту, а потым выберце, чы застаўляць змены на адной пазнакованай базе пытанняў, а не на адной лічбе прыкладаў.

Для этапу 1 пры практыкаванні заходаў з абароны перад змянай коду неабходна праказаць вхідныя даны, адпаведальнага за крок і критэрыя завершэння. Аперацыяныя працавнікі павінны магчымае перадзеяць крок з вядомай точкі контролю, не спадзяючыся на схованы стан. Дакументавайце як успішны, так і варыянт вяснавання. Перапрыбуткі, людзкія контролі і обработка неканальных паведамленняў є часткай продукту, а не дадатковым дапрацоўкам.

Дзеянне паўжасткі 1/962: звярніце увагу на час выканання, клас памылакі і колькасць токенаў, якія былі выкарыстаны для гэтага зазначэння, а пасля, на аднойчыне з фіксаваным наборам пытанняў, а не на асобістых спазыраннях, выявіце, чы хачаце застаўіць змяну.

Калі працюеце над 2-й стадзіяю паўжасткі, спачатку запісайце контракт: неабходныя даны, сігнал успеху і тое, што выходзіць у разе частковай памылкі. Такі список контроля дапамагае заставаць пазнейшыя змяны ў кодзе чыстымі. Спрыймайце гэтую стадзію як контракт межаў між вхіднымі данымі і перакананымі выходнымі рэзультатамі. Дайце назвы артыфактам, задаць критэрыяі успеху і адмовіцеся ад беззвучнага частковага завершэння.

Дзеянне паўжасткі 2/962: звярніце увагу на час выканання, клас памылакі і колькасць токенаў, якія былі выкарыстаны для гэтага зазначэння, а пасля, на аднойчыне з фіксаваным наборам пытанняў, а не на асобістых спазыраннях, выявіце, чы хачаце застаўіць змяну.

Этап 3 прынцыпа зміцнення работае наяўна, калі яго розглядаць як вимерную паверхню. Зафіксавайце адна ідеальная версія, адзін прыклад неудачы і запіс пра вярненне да пачатковага стану перш чым расширваць сферу дзеяння. Зберагаюце настройкі пазырочна ад коду прыемліка. Файлы сераўнавання, хранільнікі секрэтных данных і флагі функций павінны знаходзіцца ў адном месцы, куда аператары можуць адбавляць контроль, не чытаючы весь граф.

Дакладнасць прынцыпа зміцнення 3/962: вимеравайце час выканання, класію памылак і витрату токенав для гэтага запісу, а пасля, на базе фіксаванага набору запытанняў, а не на адной прыватнай думцы, выявіце, чы рэшацься застаўляць змяну.

Для 4-й стадзіі практыкы забезпечэння надзеі неабяжна практычна апрэцыя: перад зменым коду трэба чытко ваказаць параметры, адпраўніка кожнага крока і крэтыяры завершэння. Аперацыйныя працавнікі должны магчымае перадзваначыць крок з вядомага пункта контролю, не прымушаныя здагадвацца пра схованы стан системы. Лепш выбіраць маленькія, тэставаныя елементы працы незамест велікія скрыпты. Калі крок не выйшаў, прычына неудачы должна вказываць на конкрэтную адпаведальнасць, а не на заплутаны ланцоўкі задач.

Дакладнасць практыкы забезпечэння надзеі 4/962: памеры часу выканання, класаў адказоў і витрачання ресурсаў для гэтай практыкі, пасля чаго трэба вырашыць, чы робіцца змена на адной пазначанай базе дакладнасцей, а не на адной лічбе прыкладоў.