group events

This commit is contained in:
Bastian Wagner 2025-12-19 13:14:04 +01:00
parent 5a08e2319f
commit e05ab13d0d
16 changed files with 442 additions and 101 deletions

View File

@ -1,98 +1,194 @@
<p align="center"> # Costly Architektur & Entscheidungsgrundlagen
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 ## Ziel des Projekts
[circleci-url]: https://circleci.com/gh/nestjs/nest Costly ist eine **Offline-fähige PWA zur Aufteilung von Gruppenausgaben**.
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p> Kernanforderungen:
<p align="center"> - Gruppen ohne Accounts (Join per Invite)
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a> - Offline Änderungen möglich
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a> - Späterer Sync ohne Datenverlust
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a> - Erweiterbar für Konflikte, Undo, Mehrgeräte-Nutzung
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description ---
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. ## Architektur Überblick
## Project setup Costly nutzt eine **Hybrid Event-Driven Architektur**:
```bash - **Events sind die Quelle der Wahrheit**
$ npm install - **Relationale Tabellen sind Read-Models**
- Offline-Sync basiert auf **append-only Events**
### Überblick
Client
├─ Offline Outbox (Events)
├─ UI arbeitet mit aktuellem State
└─ Sync: sendet Events + holt neue Events
Server
├─ Event Store (group_events, expense_events, ...)
├─ Read Models (groups, expenses, ...)
└─ Projektion: Events → State
yaml
Code kopieren
---
## Warum Events?
### Nicht: „Wie sieht der Zustand aus?“
### Sondern: „Was ist passiert?“
Beispiel:
- ❌ `Group.name = "Trip"`
- ✅ `GROUP_RENAMED { from: "Urlaub", to: "Trip" }`
### Vorteile
- Offline-Änderungen lassen sich sicher synchronisieren
- Events sind idempotent (keine Doppeländerungen)
- Konflikte sind sichtbar und erklärbar
- Undo / Replay später möglich
- Debugging & Audit inklusive
---
## Event-Log vs. History
- `group_events` **ersetzt** klassische `group_history`
- Keine doppelte Speicherung
- Events *sind* die History
Merksatz:
> **History sagt, was ist.
> Events sagen, was passiert ist.**
---
## Hybrid-Ansatz (bewusst gewählt)
Costly ist **kein dogmatisches Event Sourcing**.
Wir nutzen:
- ✅ Events als Quelle der Wahrheit
- ✅ Materialisierte Tabellen (`groups`, `expenses`) für schnelle Queries
➡️ Best of both worlds.
---
## Aktueller Konflikt-Ansatz
**Last-write-wins**, basierend auf Server-Reihenfolge der Events.
- Client, der später synchronisiert, kann frühere Änderungen überschreiben
- Das ist **bewusst akzeptiert** fürs MVP
Spätere Erweiterung möglich (siehe unten).
---
## Event-Grundstruktur
```ts
GroupEvent {
id: string; // vom Client generiert (UUID/ULID)
groupId: string;
type: string; // z.B. GROUP_CREATED, GROUP_RENAMED
payload: object; // fachliche Daten
actorId?: string; // deviceId / memberId
createdAt: Date; // Server-Zeit
}
``` ```
## Warum Client-generierte IDs?
### Ermöglicht Idempotenz
## Compile and run the project - Events können gefahrlos erneut gesendet werden
- Wichtig für Offline-Retry
### Warum es weiterhin REST-Endpunkte gibt
Es gibt z. B.:
- PATCH /groups/:id/name
Diese Endpunkte:
- validieren Input
- prüfen Regeln
- erzeugen intern Events
```bash Der Client muss Events nicht kennen, solange er online ist.
# development
$ npm run start
# watch mode ➡️ Später können Offline-Events direkt an /events/batch geschickt werden.
$ npm run start:dev
# production mode ## Transaktionen aktueller Stand
$ npm run start:prod Jede fachliche Aktion läuft atomar:
- Event wird gespeichert
- Read Model wird aktualisiert
- Beides in einer DB-Transaktion
Beispiel:
```sql
Code kopieren
renameGroup()
├─ UPDATE groups
└─ INSERT group_events
``` ```
### Erweiterung: Optimistic Concurrency (später)
Problem:
- Zwei Geräte ändern offline denselben Wert
## Run tests Lösung:
- Client sendet zusätzlich expectedLastEventId
- Server prüft:
- passt → akzeptieren
- passt nicht → 409 Conflict
```bash Das ist ohne Architekturwechsel möglich, weil Events genutzt werden.
# unit tests
$ npm run test
# e2e tests ## Erweiterung: Transaktionen als Event-Gruppe
$ npm run test:e2e Wenn später komplexe Änderungen kommen (z. B. mehrere Expenses auf einmal):
# test coverage Idee
$ npm run test:cov Mehrere Events gehören logisch zusammen.
```ts
{
transactionId: "tx-123",
type: "EXPENSE_CREATED"
}
{
transactionId: "tx-123",
type: "EXPENSE_SPLIT_UPDATED"
}
``` ```
### Möglichkeiten:
## Deployment Server behandelt Events mit gleicher transactionId atomar
- Undo / Rollback pro Transaktion möglich
- UI kann „eine Aktion“ anzeigen statt viele Events
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information. ### Erweiterung: Undo / Replay
Da Events append-only sind:
- Undo = Gegen-Event
- Replay = Events neu anwenden → State neu aufbauen
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps: Beispiel:
- GROUP_RENAMED (A → B)
- Undo → GROUP_RENAMED (B → A)
```bash Wichtige Designregeln
$ npm install -g @nestjs/mau - Repositories kennen keine Events
$ mau deploy - Services orchestrieren State + Event
``` - Events sind append-only
- Read Models dürfen neu aufgebaut werden
- Keine Logik im Controller
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure. Warum das alles?
Weil Costly:
- offline funktionieren soll
- ohne Accounts auskommen soll
- später wachsen können soll
- aber jetzt schon stabil sein muss
## Resources Diese Architektur ist der kleinste sinnvolle Schritt, um das zu erreichen.
Check out a few resources that may come in handy when working with NestJS: ## Status
- [x]Groups als Events
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework. - [x] Rename als Event
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy). - [ ] Expenses als Events
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/). - [ ] Client-Outbox (IndexedDB)
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks. - [ ] Sync Pull (GET /events?since=...)
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com). - [ ] Conflict Handling (optional)
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).

View File

@ -19,7 +19,8 @@
"mysql2": "^3.16.0", "mysql2": "^3.16.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"typeorm": "^0.3.28" "typeorm": "^0.3.28",
"ulid": "^3.0.2"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.2.0", "@eslint/eslintrc": "^3.2.0",
@ -11467,6 +11468,15 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/ulid": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/ulid/-/ulid-3.0.2.tgz",
"integrity": "sha512-yu26mwteFYzBAot7KVMqFGCVpsF6g8wXfJzQUHvu1no3+rRRSFcSV2nKeYvNPLD2J4b08jYBDhHUjeH0ygIl9w==",
"license": "MIT",
"bin": {
"ulid": "dist/cli.js"
}
},
"node_modules/unbzip2-stream": { "node_modules/unbzip2-stream": {
"version": "1.4.3", "version": "1.4.3",
"resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz", "resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz",

View File

@ -30,7 +30,8 @@
"mysql2": "^3.16.0", "mysql2": "^3.16.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"typeorm": "^0.3.28" "typeorm": "^0.3.28",
"ulid": "^3.0.2"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.2.0", "@eslint/eslintrc": "^3.2.0",

View File

@ -2,8 +2,9 @@ import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { GroupEntity } from '../groups/persistence/group.entity'; import { GroupEntity } from '../groups/persistence/group.entity';
import { GroupEventEntity } from 'src/groups/persistence/group-event.entity';
const ENTITY = [GroupEntity] const ENTITY = [GroupEntity, GroupEventEntity]
@Module({ @Module({
imports: [ imports: [
@ -19,7 +20,7 @@ const ENTITY = [GroupEntity]
password: configService.get('DATABASE_PASSWORD'), password: configService.get('DATABASE_PASSWORD'),
database: configService.get('DATABASE_NAME'), database: configService.get('DATABASE_NAME'),
entities: ENTITY, entities: ENTITY,
synchronize: true, synchronize: (configService.get('DATABASE_SYNC')||'').toLowerCase() == 'true',
}) })
}) })
], ],

View File

@ -1,15 +1,43 @@
import { Body, Controller, Post } from "@nestjs/common"; import { Body, Controller, Get, Param, Patch, Post } from "@nestjs/common";
import { CreateGroupDto } from "../dto/create-group.dto"; import { CreateGroupDto } from "../dto/create-group.dto";
import { GroupsService } from "../application/groups.service"; import { GroupsService } from "../application/groups.service";
import { RenameGroupDto } from "../dto/rename-group.dto";
import { IncomingEventDto } from "src/model/dto/incoming-event.dto";
import { ulid } from 'ulid';
@Controller({path: 'groups'}) @Controller({path: 'groups'})
export class GroupsController { export class GroupsController {
constructor(private groupsService: GroupsService) {} constructor(private groupsService: GroupsService) {}
@Get()
getGroups() {
return this.groupsService.getAll();
}
@Post() @Post()
createGroup(@Body() dto: CreateGroupDto) { createGroup(@Body() dto: CreateGroupDto) {
return this.groupsService.create(dto); return this.groupsService.create(dto);
} }
@Patch(':id/name')
rename(@Param('id') id: string, @Body() dto: RenameGroupDto) {
// actorId später z.B. aus Header/Cookie ziehen
const event: IncomingEventDto = {
id: ulid(),
payload: { to: dto.name },
type: 'GROUP_RENAMED',
actorId: undefined
}
return this.ingest(id, [event])
}
@Post(':groupId/events/batch')
ingest(
@Param('groupId') groupId: string,
@Body() events: IncomingEventDto[],
) {
return this.groupsService.ingestEvents(groupId, events);
}
} }

View File

@ -1,10 +1,16 @@
import { Injectable, NotFoundException } from "@nestjs/common"; import { Injectable, NotFoundException } from "@nestjs/common";
import { GroupsRepository } from "../persistence/group.repository"; import { GroupsRepository } from "../persistence/group.repository";
import { CreateGroupDto } from "../dto/create-group.dto"; import { CreateGroupDto } from "../dto/create-group.dto";
import { UpdateGroupDto } from "../dto/update-group.dto";
import { DataSource } from "typeorm";
import { GroupEntity } from "../persistence/group.entity";
import { GroupEventEntity } from "../persistence/group-event.entity";
import { ulid } from 'ulid';
import { IncomingEventDto } from "src/model/dto/incoming-event.dto";
@Injectable() @Injectable()
export class GroupsService { export class GroupsService {
constructor(private readonly groups: GroupsRepository) {} constructor(private readonly dataSource: DataSource, private readonly groups: GroupsRepository) {}
async join(id: string) { async join(id: string) {
const group = await this.groups.findById(id); const group = await this.groups.findById(id);
@ -12,7 +18,93 @@ export class GroupsService {
return group; return group;
} }
async create(dto: CreateGroupDto) { async create(dto: CreateGroupDto, actorId?: string) {
return this.groups.create(dto); return this.dataSource.transaction(async (manager) => {
const created = await this.groups.createGroup(dto, manager);
await manager.getRepository(GroupEventEntity).save({
id: ulid(),
groupId: created.id,
type: 'GROUP_CREATED',
actorId,
payload: {
name: created.name,
},
});
return created;
});
}
async getAll() {
return this.groups.all();
}
// async rename(groupId: string, newName: string, actorId?: string) {
// return this.dataSource.transaction(async (manager) => {
// const group = await this.groups.findByIdOrFail(groupId, manager);
// const from = group.name;
// const to = newName;
// // optional: wenn gleich, nix tun (oder trotzdem eventen, wie du willst)
// if (from === to) return group;
// group.name = to;
// const saved = await this.groups.save(group, manager);
// await manager.getRepository(GroupEventEntity).save({
// id: ulid(),
// groupId: saved.id,
// type: 'GROUP_RENAMED',
// actorId,
// payload: { from, to },
// });
// return saved;
// });
// }
async ingestEvents(groupId: string, events: IncomingEventDto[]) {
return this.dataSource.transaction(async (manager) => {
const eventsRepo = manager.getRepository(GroupEventEntity);
const groupsRepo = manager.getRepository(GroupEntity);
// 1) Events persistieren (idempotent)
// -> INSERT IGNORE: wenn id schon da, wird es ignoriert
await manager
.createQueryBuilder()
.insert()
.into(GroupEventEntity)
.values(events.map(e => ({
id: e.id,
groupId,
type: e.type as any,
payload: e.payload,
actorId: e.actorId,
})))
.orIgnore() // MySQL: INSERT IGNORE
.execute();
// 2) Jetzt nur die neu eingefügten Events anwenden
// Trick: hole Events anhand IDs aus DB und wende in stabiler Reihenfolge an
const persisted = await eventsRepo.find({
where: { id: events.map(e => e.id) as any },
order: { createdAt: 'ASC' },
});
// optional: nur diejenigen anwenden, die "gerade neu" sind
// (Wenn du ganz sauber sein willst: execute() liefert affected, aber nicht IDs.
// Pragmatik: apply ist idempotent, dann ist doppelt anwenden ok, aber nur wenn Operationen idempotent sind!)
for (const ev of persisted) {
if (ev.type === 'GROUP_RENAMED') {
const group = await groupsRepo.findOneByOrFail({ id: groupId });
group.name = ev.payload.to;
await groupsRepo.save(group);
}
}
return { accepted: events.length };
});
} }
} }

View File

@ -0,0 +1,8 @@
import { IsString, IsNotEmpty, Length } from 'class-validator';
export class RenameGroupDto {
@IsString()
@IsNotEmpty()
@Length(1, 100)
name: string;
}

View File

@ -0,0 +1,6 @@
import { IsOptional, IsString, Length } from "class-validator";
export class UpdateGroupDto {
@IsOptional() @IsString() @Length(1, 100)
name?: string;
}

View File

@ -4,9 +4,10 @@ import { GroupEntity } from './persistence/group.entity';
import { GroupsController } from './api/groups.controller'; import { GroupsController } from './api/groups.controller';
import { GroupsService } from './application/groups.service'; import { GroupsService } from './application/groups.service';
import { GroupsRepository } from './persistence/group.repository'; import { GroupsRepository } from './persistence/group.repository';
import { GroupEventEntity } from './persistence/group-event.entity';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([GroupEntity])], imports: [TypeOrmModule.forFeature([GroupEntity, GroupEventEntity])],
controllers: [GroupsController], controllers: [GroupsController],
providers: [GroupsService, GroupsRepository] providers: [GroupsService, GroupsRepository]
}) })

View File

@ -0,0 +1,29 @@
import { Entity, PrimaryColumn, Column, CreateDateColumn, Index } from 'typeorm';
export type GroupEventType =
| 'GROUP_CREATED'
| 'GROUP_RENAMED'
| 'GROUP_INVITE_ROTATED'
| 'GROUP_DELETED';
@Entity('group_events')
@Index(['groupId', 'createdAt'])
export class GroupEventEntity {
@PrimaryColumn()
id: string; // ULID oder UUID, kommt vom Server oder Client
@Column()
groupId: string;
@Column({ type: 'varchar', length: 64 })
type: GroupEventType;
@Column({ type: 'json' })
payload: any;
@Column({ nullable: true })
actorId?: string;
@CreateDateColumn()
createdAt: Date;
}

View File

@ -1,4 +1,4 @@
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"; import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from "typeorm";
@Entity('groups') @Entity('groups')
export class GroupEntity { export class GroupEntity {
@ -6,7 +6,13 @@ export class GroupEntity {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
id: string; id: string;
@Column({nullable: false}) @Column({ nullable: false })
name: string; name: string;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
} }

View File

@ -1,8 +1,9 @@
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm"; import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm"; import { EntityManager, Repository } from "typeorm";
import { GroupEntity } from "./group.entity"; import { GroupEntity } from "./group.entity";
import { CreateGroupDto } from "../dto/create-group.dto"; import { CreateGroupDto } from "../dto/create-group.dto";
import { UpdateGroupDto } from "../dto/update-group.dto";
@Injectable() @Injectable()
export class GroupsRepository { export class GroupsRepository {
@ -11,15 +12,36 @@ export class GroupsRepository {
private readonly repo: Repository<GroupEntity>, private readonly repo: Repository<GroupEntity>,
) {} ) {}
save(group: GroupEntity) {
return this.repo.save(group);
}
findById(id: string) { findById(id: string) {
return this.repo.findOne({ where: { id } }); return this.repo.findOne({ where: { id } });
} }
create(dto: CreateGroupDto) { all() {
return this.repo.save(this.repo.create(dto)) return this.repo.find();
}
createGroup(
dto: CreateGroupDto,
manager?: EntityManager,
): Promise<GroupEntity> {
const r = manager ? manager.getRepository(GroupEntity) : this.repo;
const group = r.create({
name: dto.name,
});
return r.save(group);
}
private getRepo(manager?: EntityManager) {
return manager ? manager.getRepository(GroupEntity) : this.repo;
}
async findByIdOrFail(id: string, manager?: EntityManager) {
return this.getRepo(manager).findOneByOrFail({ id });
}
async save(group: GroupEntity, manager?: EntityManager) {
return this.getRepo(manager).save(group);
} }
} }

View File

@ -0,0 +1,15 @@
import { IsString, IsObject, IsOptional, IsISO8601 } from "class-validator";
export type GROUPEVENTTYPE = 'GROUP_RENAMED'
export class IncomingEventDto {
@IsString() id: string;
@IsString() type: GROUPEVENTTYPE;
@IsObject() payload: any;
@IsOptional() @IsString()
actorId?: string;
@IsOptional() @IsISO8601()
clientCreatedAt?: string;
}

View File

@ -1,26 +1,26 @@
services: services:
frontend: frontend:
build: build:
context: ./frontend context: ./costly-api
image: gitea.bastian-wagner.dev/bastian/valgard/frontend:latest image: gitea.forgecore.work/bastian/costly/costly-client:latest
networks: networks:
- internal - internal
backend: backend:
build: build:
context: ./backend context: ./backend
image: gitea.bastian-wagner.dev/bastian/valgard/backend:latest image: gitea.forgecore.work/bastian/costly/costly-api:latest
networks: networks:
- internal - internal
nginx: nginx:
image: gitea.bastian-wagner.dev/bastian/valgard/nginx:latest image: gitea.forgecore.work/bastian/costly/nginx:latest
build: build:
context: ./nginx context: ./nginx
ports: ports:
- "8081:80" - "8082:80"
volumes: volumes:
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf - ./nginx.conf:/etc/nginx/conf.d/default.conf
depends_on: depends_on:
- frontend - frontend
- backend - backend

21
package-lock.json generated Normal file
View File

@ -0,0 +1,21 @@
{
"name": "costly",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"ulid": "^3.0.2"
}
},
"node_modules/ulid": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/ulid/-/ulid-3.0.2.tgz",
"integrity": "sha512-yu26mwteFYzBAot7KVMqFGCVpsF6g8wXfJzQUHvu1no3+rRRSFcSV2nKeYvNPLD2J4b08jYBDhHUjeH0ygIl9w==",
"license": "MIT",
"bin": {
"ulid": "dist/cli.js"
}
}
}
}

5
package.json Normal file
View File

@ -0,0 +1,5 @@
{
"dependencies": {
"ulid": "^3.0.2"
}
}