@arcaelas/dynamite 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.eslintrc.js ADDED
File without changes
package/.prettierrc ADDED
File without changes
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 arcaela
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,259 @@
1
+ ![Arcaelas Insiders](https://raw.githubusercontent.com/arcaelas/dist/main/banner/svg/dark.svg#gh-dark-mode-only)
2
+ ![Arcaelas Insiders](https://raw.githubusercontent.com/arcaelas/dist/main/banner/svg/light.svg#gh-light-mode-only)
3
+
4
+ # Dinamite ORM
5
+
6
+ > A **decorator‑first**, zero‑boilerplate ORM for DynamoDB (AWS SDK v3).
7
+ >
8
+ > _Auto‑provisions tables · Runs anywhere Node.js runs · Written in TypeScript only_
9
+
10
+ <p align="center">
11
+ <a href="https://www.npmjs.com/package/@arcaelas/dinamite"><img src="https://img.shields.io/npm/v/@arcaelas/dinamite?color=cb3837" alt="npm"></a>
12
+ <img src="https://img.shields.io/bundlephobia/minzip/@arcaelas/dinamite?label=gzip" alt="size">
13
+ <img src="https://img.shields.io/github/license/arcaelas/dinamite" alt="MIT">
14
+ </p>
15
+
16
+ ---
17
+
18
+ ## Contents
19
+
20
+ - [Install](#install)
21
+ - [Hello World](#hello-world)
22
+ - [Decorators Reference](#decorators-reference)
23
+ - [Model API](#model-api)
24
+
25
+ - [Static CRUD](#static-crud)
26
+ - [Instance CRUD](#instance-crud)
27
+ - [Serialization](#serialization)
28
+
29
+ - [Configuration](#configuration)
30
+
31
+ - [Connection](#connection)
32
+ - [Naming rules & pluralisation](#naming-rules--pluralisation)
33
+ - [Running on DynamoDB Local](#running-on-dynamodb-local)
34
+
35
+ - [Type Reference](#type-reference)
36
+ - [Recipes](#recipes)
37
+ - [Troubleshooting](#troubleshooting)
38
+ - [Contributing](#contributing)
39
+
40
+ ---
41
+
42
+ ## Install
43
+
44
+ ```bash
45
+ npm i @arcaelas/dinamite
46
+ # peer deps (unless already installed)
47
+ npm i @aws-sdk/client-dynamodb @aws-sdk/util-dynamodb pluralize
48
+ ```
49
+
50
+ ---
51
+
52
+ ## Hello World
53
+
54
+ ```ts
55
+ import {
56
+ connect,
57
+ Table,
58
+ Index, // PK
59
+ CreatedAt,
60
+ UpdatedAt,
61
+ Default,
62
+ } from "@arcaelas/dinamite";
63
+
64
+ connect({
65
+ region: "us-east-1",
66
+ // DynamoDB Local example
67
+ endpoint: "http://localhost:7007",
68
+ credentials: { accessKeyId: "x", secretAccessKey: "x" },
69
+ });
70
+
71
+ class User extends Table {
72
+ @Index() // Partition Key
73
+ declare id: string;
74
+
75
+ @Default(() => "")
76
+ declare name: string;
77
+
78
+ @CreatedAt() // ISO‑string timestamp
79
+ declare created: string;
80
+
81
+ @UpdatedAt()
82
+ declare updated: string;
83
+ }
84
+
85
+ const bob = await User.create({ id: "u1", name: "Bob" });
86
+
87
+ bob.name = "Robert";
88
+ await bob.save(); // upsert
89
+
90
+ console.log(await User.where());
91
+ await bob.destroy();
92
+ ```
93
+
94
+ First call auto‑creates a table `users` (`user` → **snake + plural**).
95
+
96
+ ---
97
+
98
+ ## Decorators Reference
99
+
100
+ | Decorator | Purpose | Extras |
101
+ | ------------------ | ----------------------------------------------------- | ------ |
102
+ | `@Index()` | **Partition key**. Exactly one «PK» per model. | |
103
+ | `@IndexSort()` | **Sort key**. Requires previous `@Index()`. | |
104
+ | `@PrimaryKey()` | Shortcut: _PK + SK on same property_. | |
105
+ | `@Default(fn)` | Lazy default value, evaluated once per instance. | |
106
+ | `@Mutate(fn)` | Sequential value transformer. Runs before validators. | |
107
+ | `@Validate(fn\[])` | Sync validator(s); return `true` or error `string`. | |
108
+ | `@NotNull()` | Built‑in not‑null / not‑empty validation. | |
109
+ | `@CreatedAt()` | Timestamp (ISO) on first assignment. | |
110
+ | `@UpdatedAt()` | Timestamp (ISO) **every** assignment. | |
111
+ | `@Name("alias")` | Override table _or_ column name. | |
112
+
113
+ Execution order: **Default → Mutate\[] → Validate\[]**
114
+
115
+ ---
116
+
117
+ ## Model API
118
+
119
+ ### Static CRUD
120
+
121
+ ```ts
122
+ User.create(data); // PutItem (auto‑table‑creation)
123
+ User.update(id, patch); // PutItem replacement
124
+ User.destroy(id); // DeleteItem
125
+ User.where(); // Scan → User[]
126
+ ```
127
+
128
+ ### Instance CRUD
129
+
130
+ ```ts
131
+ const u = new User({ id: "42", name: "Neo" });
132
+ await u.save(); // inserts
133
+
134
+ u.name = "The One";
135
+ await u.save(); // updates
136
+
137
+ await u.update({ name: "Thomas" });
138
+ await u.destroy();
139
+ ```
140
+
141
+ ### Serialization
142
+
143
+ `model.toJSON()` → **only fields declared via decorators** are included.
144
+ Undefined values are stripped before `marshall()` (`removeUndefinedValues`).
145
+
146
+ ---
147
+
148
+ ## Configuration
149
+
150
+ ### Connection
151
+
152
+ ```ts
153
+ connect({
154
+ region: "…",
155
+ endpoint: "https://…", // optional – for DynamoDB Local
156
+ credentials: {
157
+ accessKeyId: "…",
158
+ secretAccessKey: "…",
159
+ },
160
+ });
161
+ ```
162
+
163
+ ### Naming rules & pluralisation
164
+
165
+ - `PascalCase` / `camelCase` → `snake_case`
166
+ - Singular → **plural** using [`pluralize`](https://www.npmjs.com/package/pluralize)
167
+
168
+ Override with `@Name("my_table")`.
169
+
170
+ ### Running on DynamoDB Local
171
+
172
+ ```bash
173
+ docker run -p 7007:8000 amazon/dynamodb-local
174
+ ```
175
+
176
+ ---
177
+
178
+ ## Type Reference
179
+
180
+ ```ts
181
+ type Inmutable = string | number | boolean | null | object;
182
+
183
+ type Mutate = (value: any) => Inmutable;
184
+ type Default = Inmutable | (() => Inmutable);
185
+ type Validate = (value: any) => true | string;
186
+
187
+ interface Column {
188
+ name: string;
189
+ default?: Default;
190
+ mutate?: Mutate[];
191
+ validate?: Validate[];
192
+ index?: true; // PK
193
+ indexSort?: true; // SK
194
+ unique?: true; // not yet enforced
195
+ }
196
+
197
+ interface WrapperEntry {
198
+ name: string; // physical table
199
+ columns: Map<string | symbol, Column>; // property → Column
200
+ }
201
+ ```
202
+
203
+ Internal state lives in **`src/core/wrapper.ts`**.
204
+
205
+ ---
206
+
207
+ ## Recipes
208
+
209
+ ### Soft‑delete flag
210
+
211
+ ```ts
212
+ class Post extends Table {
213
+ @Index() declare id: string;
214
+ @Default(() => false) declare deleted: boolean;
215
+
216
+ async softDelete() {
217
+ this.deleted = true;
218
+ await this.save();
219
+ }
220
+ }
221
+ ```
222
+
223
+ ### Custom mutator – email normalisation
224
+
225
+ ```ts
226
+ import { Mutate } from "@arcaelas/dinamite";
227
+
228
+ const lower: Mutate = (v) => String(v).toLowerCase();
229
+
230
+ class Subscriber extends Table {
231
+ @Index() declare id: string;
232
+ @Mutate(lower) declare email: string;
233
+ }
234
+ ```
235
+
236
+ ### Using DynamoDB Streams + Lambda
237
+
238
+ Because tables are created at runtime you can safely deploy stacks without `resources`, then subscribe Lambdas to the **physical table names** emitted by Dinamite (`User` → `users`, unless overridden).
239
+
240
+ ---
241
+
242
+ ## Troubleshooting
243
+
244
+ | Error | Explanation & fix |
245
+ | ------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
246
+ | `Metadata no encontrada` | Model file imported before decorators executed – avoid circular imports; ensure `connect()` runs **first**. |
247
+ | `PartitionKey faltante` | No `@Index()` in the model. Add one. |
248
+ | `Two keys can not have the same name` | PK & SK attribute clash. Use `@PrimaryKey()` or distinct column names. |
249
+ | `UnrecognizedClientException` | Wrong credentials / DynamoDB Local not running. |
250
+
251
+ ---
252
+
253
+ ## Contributing
254
+
255
+ 1. Fork → feature → PR. Conventional commits (`feat:`, `fix:`…).
256
+ 2. `yarn test` must pass (Jest + ESLint).
257
+ 3. Document new features in this README.
258
+
259
+ _Made with ❤️ by [Miguel Alejandro](https://github.com/arcaelas)_ – MIT License.
@@ -0,0 +1,77 @@
1
+ /* __tests__/crud.spec.ts
2
+ * -----------------------------------------------------------
3
+ * CRUD end-to-end contra DynamoDB Local (puerto 7007)
4
+ */
5
+
6
+ import { DeleteTableCommand, DynamoDBClient } from "@aws-sdk/client-dynamodb";
7
+
8
+ import { connect, default as Table } from "../src/core/table";
9
+ import wrapper from "../src/core/wrapper";
10
+
11
+ import Default from "../src/decorators/default";
12
+ import Name from "../src/decorators/name";
13
+ import NotNull from "../src/decorators/not_null";
14
+ import PrimaryKey from "../src/decorators/primary_key";
15
+
16
+ const ddbCfg = {
17
+ region: "local",
18
+ endpoint: "http://localhost:7007",
19
+ credentials: { accessKeyId: "x", secretAccessKey: "x" },
20
+ };
21
+
22
+ beforeAll(() => connect(ddbCfg));
23
+
24
+ afterEach(async () => {
25
+ /* Eliminar todas las tablas creadas por los tests */
26
+ const ddb = new DynamoDBClient(ddbCfg);
27
+ for (const e of wrapper.values()) {
28
+ try {
29
+ await ddb.send(new DeleteTableCommand({ TableName: e.name }));
30
+ } catch (_) {
31
+ /* tabla puede no existir */
32
+ }
33
+ }
34
+ wrapper.clear();
35
+ });
36
+
37
+ describe("CRUD Dinamite ORM (DynamoDB Local)", () => {
38
+ jest.setTimeout(10_000);
39
+
40
+ it("create → update → where → destroy", async () => {
41
+ @Name("crud_users1")
42
+ class User extends Table {
43
+ @PrimaryKey()
44
+ declare id: string;
45
+
46
+ @NotNull()
47
+ declare email: string;
48
+
49
+ @Default(() => new Date().toISOString())
50
+ declare created_at: string;
51
+ }
52
+
53
+ /* ---------- create (auto-crea tabla) ---------- */
54
+ const u1 = await User.create({ id: "u1", email: "a@b.com" });
55
+ expect(u1.email).toBe("a@b.com");
56
+
57
+ /* ---------- update ---------- */
58
+ await User.update("u1", { email: "c@d.com" });
59
+ const [after] = await User.where();
60
+ expect(after.email).toBe("c@d.com");
61
+
62
+ /* ---------- destroy ---------- */
63
+ await User.destroy("u1");
64
+ expect(await User.where()).toHaveLength(0);
65
+ });
66
+
67
+ it("where() devuelve [] si la tabla no existe", async () => {
68
+ @Name("ghosts")
69
+ class Ghost extends Table {
70
+ @PrimaryKey()
71
+ declare id: string;
72
+ }
73
+
74
+ const rows = await Ghost.where();
75
+ expect(rows).toEqual([]);
76
+ });
77
+ });
@@ -0,0 +1,134 @@
1
+ /* __tests__/decorators.spec.ts
2
+ * -----------------------------------------------------------
3
+ * Suite para verificar todos los decoradores Dinamite ORM.
4
+ */
5
+
6
+ import wrapper, { STORE } from "../src/core/wrapper";
7
+
8
+ import CreatedAt from "../src/decorators/created_at";
9
+ import Default from "../src/decorators/default";
10
+ import Index from "../src/decorators/index";
11
+ import IndexSort from "../src/decorators/index_sort";
12
+ import Mutate from "../src/decorators/mutate";
13
+ import Name from "../src/decorators/name";
14
+ import NotNull from "../src/decorators/not_null";
15
+ import PrimaryKey from "../src/decorators/primary_key";
16
+ import UpdatedAt from "../src/decorators/updated_at";
17
+ import Validate from "../src/decorators/validate";
18
+
19
+ describe("Decoradores Dinamite ORM", () => {
20
+ beforeEach(() => wrapper.clear());
21
+
22
+ /* ---------------------------------------------------------- */
23
+ it("nombre de tabla por defecto (snake_plural) cuando no se usa @Name", () => {
24
+ class Book {
25
+ @NotNull()
26
+ declare title: string;
27
+ }
28
+ const meta = wrapper.get(Book)!;
29
+ expect(meta.name).toBe("books"); // Book → books
30
+ });
31
+
32
+ /* ---------------------------------------------------------- */
33
+ it("@Name (clase y propiedad)", () => {
34
+ @Name("usuarios")
35
+ class User {
36
+ @Name("correo")
37
+ declare email: string;
38
+ }
39
+
40
+ const meta = wrapper.get(User)!;
41
+ expect(meta.name).toBe("usuarios");
42
+ expect(meta.columns.get("email")!.name).toBe("correo");
43
+ });
44
+
45
+ /* ---------------------------------------------------------- */
46
+ it("@Index + @IndexSort (PK + SK)", () => {
47
+ class Post {
48
+ @Index() // Partition Key
49
+ declare slug: string;
50
+
51
+ @IndexSort() // Sort Key
52
+ declare created: string;
53
+ }
54
+
55
+ const meta = wrapper.get(Post)!;
56
+ const pkCol = meta.columns.get("slug")!;
57
+ const skCol = meta.columns.get("created")!;
58
+
59
+ expect(pkCol.index).toBe(true);
60
+ expect(skCol.indexSort).toBe(true);
61
+ });
62
+
63
+ /* ---------------------------------------------------------- */
64
+ it("@PrimaryKey (combinado)", () => {
65
+ class Comment {
66
+ @PrimaryKey()
67
+ declare id: string;
68
+ }
69
+
70
+ const meta = wrapper.get(Comment)!;
71
+ const col = meta.columns.get("id")!;
72
+
73
+ expect(col.index).toBe(true);
74
+ expect(col.indexSort).toBe(true);
75
+ });
76
+
77
+ /* ---------------------------------------------------------- */
78
+ it("@Default + @Mutate + @Validate pipeline", () => {
79
+ @Name("tests")
80
+ class Model {
81
+ @Default(() => 10)
82
+ @Mutate((v) => (v as number) * 2)
83
+ @Validate((v) => ((v as number) >= 20 ? true : "menor a 20"))
84
+ declare score: number;
85
+ }
86
+
87
+ const m = new Model();
88
+ (m as any).score = undefined; // dispara pipeline
89
+ expect(m.score).toBe(20);
90
+ expect((m as any)[STORE].score).toBe(20);
91
+
92
+ expect(() => {
93
+ m.score = 5 as any;
94
+ }).toThrow("menor a 20");
95
+ });
96
+
97
+ /* ---------------------------------------------------------- */
98
+ it("@NotNull rechaza null/undefined/cadena vacía", () => {
99
+ class File {
100
+ @NotNull()
101
+ declare path: string;
102
+ }
103
+
104
+ const f = new File();
105
+ expect(() => ((f as any).path = null)).toThrow();
106
+ expect(() => ((f as any).path = " ")).toThrow();
107
+ f.path = "/tmp/file";
108
+ expect(f.path).toBe("/tmp/file");
109
+ });
110
+
111
+ /* ---------------------------------------------------------- */
112
+ it("@CreatedAt y @UpdatedAt gestionan timestamps", () => {
113
+ class Audit {
114
+ @CreatedAt()
115
+ declare created: string;
116
+
117
+ @UpdatedAt()
118
+ declare updated: string;
119
+ }
120
+
121
+ const a = new Audit();
122
+ (a as any).created = undefined;
123
+ (a as any).updated = undefined;
124
+
125
+ const firstCreated = a.created;
126
+ const firstUpdated = a.updated;
127
+
128
+ return new Promise((r) => setTimeout(r, 10)).then(() => {
129
+ (a as any).updated = undefined;
130
+ expect(a.created).toBe(firstCreated);
131
+ expect(a.updated).not.toBe(firstUpdated);
132
+ });
133
+ });
134
+ });
@@ -0,0 +1,86 @@
1
+ /* __tests__/instance-crud.spec.ts
2
+ * ------------------------------------------------------------------
3
+ * CRUD usando métodos de **instancia** de Dinamite ORM con DynamoDB Local.
4
+ * Se asume que DynamoDB Local está corriendo en http://localhost:7007.
5
+ * ------------------------------------------------------------------ */
6
+ import {
7
+ connect,
8
+ CreatedAt,
9
+ Name,
10
+ NotNull,
11
+ PrimaryKey,
12
+ Table,
13
+ UpdatedAt,
14
+ } from "../src";
15
+
16
+ // ---------- conexión DynamoDB Local ----------
17
+ beforeAll(() =>
18
+ connect({
19
+ region: "local",
20
+ endpoint: "http://localhost:7007",
21
+ credentials: { accessKeyId: "x", secretAccessKey: "x" },
22
+ })
23
+ );
24
+
25
+ // ---------- Modelo de prueba ----------
26
+ @Name("users")
27
+ class User extends Table {
28
+ @PrimaryKey()
29
+ declare id: string;
30
+
31
+ @NotNull() // ← registra la columna en el wrapper
32
+ declare email: string;
33
+
34
+ @CreatedAt()
35
+ declare created: string;
36
+
37
+ @UpdatedAt()
38
+ declare updated: string;
39
+ }
40
+
41
+ describe("CRUD Dinamite ORM – instancia", () => {
42
+ it("where() antes de existir la tabla → []", async () => {
43
+ const rows = await User.where();
44
+ expect(rows).toEqual([]);
45
+ });
46
+
47
+ it("save() → crea, update() → modifica, destroy() → elimina", async () => {
48
+ /* ---------- save (create) ---------- */
49
+ const u = new User({ id: "u1", email: "a@b.com" });
50
+ await u.save();
51
+
52
+ let rows = await User.where();
53
+ expect(rows).toHaveLength(1);
54
+ expect(rows[0].email).toBe("a@b.com");
55
+
56
+ const firstCreated = rows[0].created;
57
+ const firstUpdated = rows[0].updated;
58
+
59
+ /* ---------- update ---------- */
60
+ await u.update({ email: "c@d.com" });
61
+
62
+ rows = await User.where();
63
+ expect(rows[0].email).toBe("c@d.com");
64
+ expect(rows[0].updated).not.toBe(firstUpdated);
65
+ expect(rows[0].created).toBe(firstCreated);
66
+
67
+ /* ---------- destroy ---------- */
68
+ await u.destroy();
69
+
70
+ rows = await User.where();
71
+ expect(rows).toEqual([]);
72
+ });
73
+
74
+ it("la tabla realmente contiene el ítem mientras existe", async () => {
75
+ const u = new User({ id: "u2", email: "real@test.com" });
76
+ await u.save();
77
+
78
+ let rows = await User.where();
79
+ expect(rows.map((x) => x.id)).toContain("u2");
80
+
81
+ await u.destroy();
82
+
83
+ rows = await User.where();
84
+ expect(rows.map((x) => x.id)).not.toContain("u2");
85
+ });
86
+ });
package/jest.config.ts ADDED
@@ -0,0 +1,23 @@
1
+ // jest.config.ts
2
+ import type { Config } from "@jest/types";
3
+
4
+ const config: Config.InitialOptions = {
5
+ preset: "ts-jest",
6
+ testEnvironment: "node",
7
+ testMatch: ["**/__tests__/**/*.spec.ts"],
8
+ collectCoverageFrom: ["src/**/*.ts"],
9
+ coverageDirectory: "coverage",
10
+ coverageReporters: ["text", "lcov"],
11
+ verbose: true,
12
+ clearMocks: true, // Limpia mocks automáticamente
13
+ transform: {
14
+ "^.+\\.tsx?$": "ts-jest",
15
+ },
16
+ moduleNameMapper: {
17
+ "^@/(.*)$": "<rootDir>/src/$1",
18
+ },
19
+ // Ignorar node_modules y cobertura
20
+ testPathIgnorePatterns: ["/node_modules/", "/coverage/"],
21
+ };
22
+
23
+ export default config;
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@arcaelas/dynamite",
3
+ "version": "1.0.0",
4
+ "main": "index.js",
5
+ "license": "MIT",
6
+ "dependencies": {
7
+ "@aws-sdk/client-dynamodb": "3.329.0",
8
+ "@aws-sdk/lib-dynamodb": "3.329.0",
9
+ "pluralize": "^8.0.0",
10
+ "uuid": "^11.1.0"
11
+ },
12
+ "scripts": {
13
+ "test": "jest",
14
+ "test:watch": "jest --watch",
15
+ "test:coverage": "jest --coverage",
16
+ "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand"
17
+ },
18
+ "devDependencies": {
19
+ "@types/jest": "^30.0.0",
20
+ "@types/node": "^24.0.6",
21
+ "jest": "^30.0.3",
22
+ "reflect-metadata": "^0.2.2",
23
+ "ts-jest": "^29.4.0",
24
+ "ts-node": "^10.9.2",
25
+ "tsconfig-paths": "^4.2.0",
26
+ "tsx": "^4.20.3",
27
+ "typescript": "^5.8.3"
28
+ }
29
+ }