diff --git a/.gitignore b/.gitignore index 8c39356..8bfb8e6 100644 --- a/.gitignore +++ b/.gitignore @@ -137,3 +137,5 @@ dist demo/tsp-output demo/demo-entities demo/package-lock.json +build +dist \ No newline at end of file diff --git a/biome.json b/biome.json index 2eb0751..ba74412 100644 --- a/biome.json +++ b/biome.json @@ -1,21 +1,17 @@ { - "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "$schema": "https://biomejs.dev/schemas/2.3.8/schema.json", "vcs": { - "enabled": false, + "enabled": true, "clientKind": "git", - "useIgnoreFile": false + "useIgnoreFile": true }, "files": { - "ignoreUnknown": false, - "ignore": [] + "ignoreUnknown": false }, "formatter": { "enabled": true, "indentStyle": "tab" }, - "organizeImports": { - "enabled": true - }, "linter": { "enabled": true, "rules": { @@ -26,5 +22,13 @@ "formatter": { "quoteStyle": "double" } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } } } diff --git a/demo/index.ts b/demo/index.ts deleted file mode 100644 index c1e8522..0000000 --- a/demo/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; -import { Person } from "@demo/demo-entities"; -import { Entity } from "electrodb"; - -const client = new DynamoDBClient(); -const table = "electro"; -const person = new Entity(Person, { client, table }); diff --git a/demo/package.json b/demo/package.json deleted file mode 100644 index f80b813..0000000 --- a/demo/package.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "name": "@typespec-electrodb-emitter/demo", - "version": "1.0.0", - "main": "index.ts", - "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", - "build": "npm run build:tsp && npm run build:tsc", - "build:tsp": "tsp compile .", - "build:tsc": "tsc --noEmit index.ts", - "watch": "npx chokidar-cli \"../dist/\" -c \"tsp compile .\"" - }, - "author": "", - "license": "ISC", - "description": "", - "dependencies": { - "@typespec/compiler": "^1.1.0", - "electrodb": "^3.4.3" - }, - "devDependencies": { - "@aws-sdk/client-dynamodb": "^3.830.0", - "@demo/demo-entities": "file:demo-entities", - "chokidar-cli": "^3.0.0", - "entities": "file:demo-entities", - "typespec-electrodb-emitter": "file:.." - } -} diff --git a/demo/tspconfig.yaml b/demo/tspconfig.yaml deleted file mode 100644 index a25c592..0000000 --- a/demo/tspconfig.yaml +++ /dev/null @@ -1,7 +0,0 @@ -emit: - - "typespec-electrodb-emitter" -options: - "typespec-electrodb-emitter": - package-name: "@demo/demo-entities" - package-version: "2.0.42" - emitter-output-dir: "{project-root}/demo-entities" \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js deleted file mode 100644 index 75cb4f7..0000000 --- a/eslint.config.js +++ /dev/null @@ -1,11 +0,0 @@ -// @ts-check -import eslint from "@eslint/js"; -import tsEslint from "typescript-eslint"; - -export default tsEslint.config( - { - ignores: ["**/dist/**/*", "**/.temp/**/*"], - }, - eslint.configs.recommended, - ...tsEslint.configs.recommended, -); diff --git a/package-lock.json b/package-lock.json index 36f4af5..50b69b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ }, "devDependencies": { "@babel/types": "^7.27.6", - "@biomejs/biome": "1.9.4", + "@biomejs/biome": "^2.3.8", "@types/babel__generator": "^7.27.0", "@types/node": "latest", "@typescript-eslint/eslint-plugin": "^8.15.0", @@ -867,11 +867,10 @@ } }, "node_modules/@biomejs/biome": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-1.9.4.tgz", - "integrity": "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==", + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.8.tgz", + "integrity": "sha512-Qjsgoe6FEBxWAUzwFGFrB+1+M8y/y5kwmg5CHac+GSVOdmOIqsAiXM5QMVGZJ1eCUCLlPZtq4aFAQ0eawEUuUA==", "dev": true, - "hasInstallScript": true, "license": "MIT OR Apache-2.0", "bin": { "biome": "bin/biome" @@ -884,20 +883,20 @@ "url": "https://opencollective.com/biome" }, "optionalDependencies": { - "@biomejs/cli-darwin-arm64": "1.9.4", - "@biomejs/cli-darwin-x64": "1.9.4", - "@biomejs/cli-linux-arm64": "1.9.4", - "@biomejs/cli-linux-arm64-musl": "1.9.4", - "@biomejs/cli-linux-x64": "1.9.4", - "@biomejs/cli-linux-x64-musl": "1.9.4", - "@biomejs/cli-win32-arm64": "1.9.4", - "@biomejs/cli-win32-x64": "1.9.4" + "@biomejs/cli-darwin-arm64": "2.3.8", + "@biomejs/cli-darwin-x64": "2.3.8", + "@biomejs/cli-linux-arm64": "2.3.8", + "@biomejs/cli-linux-arm64-musl": "2.3.8", + "@biomejs/cli-linux-x64": "2.3.8", + "@biomejs/cli-linux-x64-musl": "2.3.8", + "@biomejs/cli-win32-arm64": "2.3.8", + "@biomejs/cli-win32-x64": "2.3.8" } }, "node_modules/@biomejs/cli-darwin-arm64": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-1.9.4.tgz", - "integrity": "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==", + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.8.tgz", + "integrity": "sha512-HM4Zg9CGQ3txTPflxD19n8MFPrmUAjaC7PQdLkugeeC0cQ+PiVrd7i09gaBS/11QKsTDBJhVg85CEIK9f50Qww==", "cpu": [ "arm64" ], @@ -912,9 +911,9 @@ } }, "node_modules/@biomejs/cli-darwin-x64": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-1.9.4.tgz", - "integrity": "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==", + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.8.tgz", + "integrity": "sha512-lUDQ03D7y/qEao7RgdjWVGCu+BLYadhKTm40HkpJIi6kn8LSv5PAwRlew/DmwP4YZ9ke9XXoTIQDO1vAnbRZlA==", "cpu": [ "x64" ], @@ -929,9 +928,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-1.9.4.tgz", - "integrity": "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==", + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.8.tgz", + "integrity": "sha512-Uo1OJnIkJgSgF+USx970fsM/drtPcQ39I+JO+Fjsaa9ZdCN1oysQmy6oAGbyESlouz+rzEckLTF6DS7cWse95g==", "cpu": [ "arm64" ], @@ -946,9 +945,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64-musl": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-1.9.4.tgz", - "integrity": "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==", + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.8.tgz", + "integrity": "sha512-PShR4mM0sjksUMyxbyPNMxoKFPVF48fU8Qe8Sfx6w6F42verbwRLbz+QiKNiDPRJwUoMG1nPM50OBL3aOnTevA==", "cpu": [ "arm64" ], @@ -963,9 +962,9 @@ } }, "node_modules/@biomejs/cli-linux-x64": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-1.9.4.tgz", - "integrity": "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==", + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.8.tgz", + "integrity": "sha512-QDPMD5bQz6qOVb3kiBui0zKZXASLo0NIQ9JVJio5RveBEFgDgsvJFUvZIbMbUZT3T00M/1wdzwWXk4GIh0KaAw==", "cpu": [ "x64" ], @@ -980,9 +979,9 @@ } }, "node_modules/@biomejs/cli-linux-x64-musl": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-1.9.4.tgz", - "integrity": "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==", + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.8.tgz", + "integrity": "sha512-YGLkqU91r1276uwSjiUD/xaVikdxgV1QpsicT0bIA1TaieM6E5ibMZeSyjQ/izBn4tKQthUSsVZacmoJfa3pDA==", "cpu": [ "x64" ], @@ -997,9 +996,9 @@ } }, "node_modules/@biomejs/cli-win32-arm64": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-1.9.4.tgz", - "integrity": "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==", + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.8.tgz", + "integrity": "sha512-H4IoCHvL1fXKDrTALeTKMiE7GGWFAraDwBYFquE/L/5r1927Te0mYIGseXi4F+lrrwhSWbSGt5qPFswNoBaCxg==", "cpu": [ "arm64" ], @@ -1014,9 +1013,9 @@ } }, "node_modules/@biomejs/cli-win32-x64": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-1.9.4.tgz", - "integrity": "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==", + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.8.tgz", + "integrity": "sha512-RguzimPoZWtBapfKhKjcWXBVI91tiSprqdBYu7tWhgN8pKRZhw24rFeNZTNf6UiBfjCYCi9eFQs/JzJZIhuK4w==", "cpu": [ "x64" ], diff --git a/package.json b/package.json index eec6995..1574316 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ }, "devDependencies": { "@babel/types": "^7.27.6", - "@biomejs/biome": "1.9.4", + "@biomejs/biome": "^2.3.8", "@types/babel__generator": "^7.27.0", "@types/node": "latest", "@typescript-eslint/eslint-plugin": "^8.15.0", @@ -37,10 +37,14 @@ "scripts": { "build": "tsc", "watch": "tsc --watch", - "test": "npm run test:biome && npm run build && npm run test:demo", + "pretest": "npm run build", + "fix": "biome check --write", + "lint": "biome check", + "test": "npm run test:biome && npm run test:emit && npm run test:unit", "prepublishOnly": "npm run build", "test:biome": "biome check src", - "test:demo": "cd demo; npm i; npm run build" + "test:emit": "tsp compile test/main.tsp --config test/tspconfig.yaml", + "test:unit": "node --test test/*.test.js" }, "dependencies": { "@babel/generator": "^7.27.5", diff --git a/src/decorators/$entity.ts b/src/decorators/$entity.ts index 72c7330..eee165b 100644 --- a/src/decorators/$entity.ts +++ b/src/decorators/$entity.ts @@ -1,7 +1,6 @@ import type { DecoratorContext, Model, - NumericLiteral, StringLiteral, } from "@typespec/compiler"; import { StateKeys } from "../lib.js"; diff --git a/src/emitter.ts b/src/emitter.ts index 9c650f2..1ea14d8 100644 --- a/src/emitter.ts +++ b/src/emitter.ts @@ -185,7 +185,7 @@ export async function $onEmit(context: EmitContext) { const packageName = context.options["package-name"]; const packageVersion = context.options["package-version"]; - // biome-ignore lint/suspicious/noExplicitAny: + // biome-ignore lint/suspicious/noExplicitAny: const entities: Record> = {}; for (const [model, props] of context.program diff --git a/src/index.ts b/src/index.ts index e3228d5..6325cbe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ -export * from "./emitter.js"; +export * from "./decorators/$createdAt.js"; export * from "./decorators/$entity.js"; export * from "./decorators/$index.js"; -export * from "./decorators/$createdAt.js"; -export * from "./decorators/$updatedAt.js"; export * from "./decorators/$label.js"; +export * from "./decorators/$updatedAt.js"; +export * from "./emitter.js"; export { $lib } from "./lib.js"; diff --git a/src/lib.ts b/src/lib.ts index 21dc54a..17236c3 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -1,4 +1,4 @@ -import { type JSONSchemaType, createTypeSpecLibrary } from "@typespec/compiler"; +import { createTypeSpecLibrary, type JSONSchemaType } from "@typespec/compiler"; export interface EmitterOptions { "package-name": string; "package-version": string; diff --git a/test/entities.test.js b/test/entities.test.js new file mode 100644 index 0000000..a63ba70 --- /dev/null +++ b/test/entities.test.js @@ -0,0 +1,280 @@ +import assert from "node:assert/strict"; +import { suite, test } from "node:test"; +import { Job, Person } from "../build/entities/index.js"; + +suite("Job Entity", () => { + test("Job entity has correct model configuration", () => { + assert.deepEqual(Job.model, { + entity: "job", + service: "org", + version: "1", + }); + }); + + test("Job entity has all required attributes", () => { + const attrs = Job.attributes; + + assert.deepEqual(attrs.pk, { + type: "string", + required: true, + }); + + assert.deepEqual(attrs.jobId, { + type: "string", + required: true, + }); + + assert.deepEqual(attrs.personId, { + type: "string", + required: true, + }); + + assert.deepEqual(attrs.description, { + type: "string", + required: true, + }); + }); + + test("Job entity has correct index configuration", () => { + const jobsIndex = Job.indexes.jobs; + + assert.deepEqual(jobsIndex.pk, { + field: "gsi1pk", + composite: ["personId"], + }); + + assert.deepEqual(jobsIndex.sk, { + field: "gsi1sk", + composite: ["jobId"], + }); + + assert.equal(jobsIndex.index, "gsi1"); + assert.equal(jobsIndex.collection, "jobs"); + }); +}); + +suite("Person Entity", () => { + test("Person entity has correct model configuration", () => { + assert.deepEqual(Person.model, { + entity: "person", + service: "org", + version: "1", + }); + }); + + suite("Basic Attributes", () => { + test("pk attribute is string and required", () => { + assert.deepEqual(Person.attributes.pk, { + type: "string", + required: true, + }); + }); + + test("personId attribute is string and required", () => { + assert.deepEqual(Person.attributes.personId, { + type: "string", + required: true, + }); + }); + + test("birthDate (utcDateTime) is mapped to string type", () => { + assert.deepEqual(Person.attributes.birthDate, { + type: "string", + required: true, + }); + }); + + test("age (int16) is mapped to number type", () => { + assert.deepEqual(Person.attributes.age, { + type: "number", + required: true, + }); + }); + }); + + suite("@label decorator", () => { + test("firstName has label 'fn'", () => { + assert.equal(Person.attributes.firstName.label, "fn"); + assert.equal(Person.attributes.firstName.type, "string"); + assert.equal(Person.attributes.firstName.required, true); + }); + }); + + suite("@createdAt decorator", () => { + test("createdAt has readOnly and default function", () => { + const createdAt = Person.attributes.createdAt; + + assert.equal(createdAt.type, "number"); + assert.equal(createdAt.readOnly, true); + assert.equal(createdAt.required, true); + assert.equal(typeof createdAt.default, "function"); + assert.equal(typeof createdAt.set, "function"); + }); + + test("createdAt default returns timestamp", () => { + const before = Date.now(); + const result = Person.attributes.createdAt.default(); + const after = Date.now(); + + assert.ok(result >= before && result <= after); + }); + }); + + suite("@updatedAt decorator", () => { + test("updatedAt has watch='*' and default function", () => { + const updatedAt = Person.attributes.updatedAt; + + assert.equal(updatedAt.type, "number"); + assert.equal(updatedAt.watch, "*"); + assert.equal(updatedAt.required, true); + assert.equal(typeof updatedAt.default, "function"); + assert.equal(typeof updatedAt.set, "function"); + }); + + test("updatedAt set returns timestamp", () => { + const before = Date.now(); + const result = Person.attributes.updatedAt.set(); + const after = Date.now(); + + assert.ok(result >= before && result <= after); + }); + }); + + suite("Optional fields", () => { + test("nickName is optional (required: false)", () => { + assert.deepEqual(Person.attributes.nickName, { + type: "string", + required: false, + }); + }); + }); + + suite("Nested map type (Address)", () => { + test("address is a map type with required: true", () => { + assert.equal(Person.attributes.address.type, "map"); + assert.equal(Person.attributes.address.required, true); + }); + + test("address.street property is string", () => { + assert.deepEqual(Person.attributes.address.properties.street, { + type: "string", + required: true, + }); + }); + + test("address.country property has enum values", () => { + assert.deepEqual(Person.attributes.address.properties.country, { + type: ["NL", "US", "DE"], + required: true, + }); + }); + + test("address.type property (union literal) is string", () => { + assert.deepEqual(Person.attributes.address.properties.type, { + type: "string", + required: true, + }); + }); + }); + + suite("List type (Contact[])", () => { + test("contact is a list type with required: true", () => { + assert.equal(Person.attributes.contact.type, "list"); + assert.equal(Person.attributes.contact.required, true); + }); + + test("contact items are map type", () => { + assert.equal(Person.attributes.contact.items.type, "map"); + }); + + test("contact item has value property", () => { + assert.deepEqual(Person.attributes.contact.items.properties.value, { + type: "string", + required: true, + }); + }); + + test("contact item has description property", () => { + assert.deepEqual( + Person.attributes.contact.items.properties.description, + { + type: "string", + required: true, + }, + ); + }); + }); + + suite("Index configurations", () => { + test("persons index (primary, pk only)", () => { + const personsIndex = Person.indexes.persons; + + assert.deepEqual(personsIndex.pk, { + field: "pk", + composite: ["pk"], + }); + + assert.deepEqual(personsIndex.sk, { + field: "sk", + composite: [], + }); + + // Primary index has no 'index' property + assert.equal(personsIndex.index, undefined); + }); + + test("jobs index (GSI with collection)", () => { + const jobsIndex = Person.indexes.jobs; + + assert.deepEqual(jobsIndex.pk, { + field: "gsi1pk", + composite: ["personId"], + }); + + assert.deepEqual(jobsIndex.sk, { + field: "gsi1sk", + composite: ["firstName"], + }); + + assert.equal(jobsIndex.index, "gsi1"); + assert.equal(jobsIndex.collection, "jobs"); + }); + + test("byName index (GSI with scope and empty pk)", () => { + const byNameIndex = Person.indexes.byName; + + assert.deepEqual(byNameIndex.pk, { + field: "gsi1pk", + composite: [], + }); + + assert.deepEqual(byNameIndex.sk, { + field: "gsi1sk", + composite: ["firstName"], + }); + + assert.equal(byNameIndex.index, "gsi1"); + assert.equal(byNameIndex.collection, "jobs"); + assert.equal(byNameIndex.scope, "org"); + }); + + test("byAge index (LSI with pk matching primary index)", () => { + const byAgeIndex = Person.indexes.byAge; + + // LSI pk field and composite must match the primary index + assert.deepEqual(byAgeIndex.pk, { + field: "pk", + composite: ["pk"], + }); + + assert.deepEqual(byAgeIndex.sk, { + field: "lsi1sk", + composite: ["age"], + }); + + assert.equal(byAgeIndex.index, "lsi1"); + // LSI has no collection + assert.equal(byAgeIndex.collection, undefined); + }); + }); +}); diff --git a/demo/main.tsp b/test/main.tsp similarity index 100% rename from demo/main.tsp rename to test/main.tsp diff --git a/test/tspconfig.yaml b/test/tspconfig.yaml new file mode 100644 index 0000000..cd10e30 --- /dev/null +++ b/test/tspconfig.yaml @@ -0,0 +1,7 @@ +emit: + - "typespec-electrodb-emitter" +options: + "typespec-electrodb-emitter": + package-name: "@mycorp/ddb-entities" + package-version: "0.1.0" + emitter-output-dir: "{cwd}/build/entities" \ No newline at end of file