@fitzzero/quickdraw-core 3.0.0 → 3.1.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.
@@ -101,7 +101,7 @@ export const server = [
101
101
  },
102
102
  plugins: { quickdraw: quickdrawPlugin },
103
103
  rules: {
104
- "quickdraw/no-direct-prisma-mutations": "warn",
104
+ "quickdraw/no-cross-service-mutations": "warn",
105
105
  "quickdraw/require-zod-schema": "warn",
106
106
  "quickdraw/no-service-method-record": "error",
107
107
  "quickdraw/no-unsafe-payload-cast": "warn",
@@ -121,12 +121,6 @@ export const server = [
121
121
  "@typescript-eslint/no-unnecessary-type-assertion": "off",
122
122
  },
123
123
  },
124
- {
125
- files: ["**/services/**/index.ts"],
126
- rules: {
127
- "quickdraw/no-direct-prisma-mutations": "off",
128
- },
129
- },
130
124
  ];
131
125
 
132
126
  export const client = [
@@ -1,4 +1,4 @@
1
- import noDirectPrismaMutations from "./rules/no-direct-prisma-mutations.mjs"
1
+ import noCrossServiceMutations from "./rules/no-cross-service-mutations.mjs"
2
2
  import requireZodSchema from "./rules/require-zod-schema.mjs"
3
3
  import noServiceMethodRecord from "./rules/no-service-method-record.mjs"
4
4
  import noUnsafePayloadCast from "./rules/no-unsafe-payload-cast.mjs"
@@ -6,7 +6,7 @@ import noUnsafePayloadCast from "./rules/no-unsafe-payload-cast.mjs"
6
6
  /** @type {import('eslint').ESLint.Plugin} */
7
7
  export default {
8
8
  rules: {
9
- "no-direct-prisma-mutations": noDirectPrismaMutations,
9
+ "no-cross-service-mutations": noCrossServiceMutations,
10
10
  "require-zod-schema": requireZodSchema,
11
11
  "no-service-method-record": noServiceMethodRecord,
12
12
  "no-unsafe-payload-cast": noUnsafePayloadCast,
@@ -0,0 +1,113 @@
1
+ const MUTATION_METHODS = new Set(["create", "update", "delete"])
2
+
3
+ /**
4
+ * Extract the service directory name from a file path.
5
+ * e.g. "/app/services/cloud-build/index.ts" → "cloud-build"
6
+ * "/app/services/task/helpers.ts" → "task"
7
+ */
8
+ function getServiceDirName(filename) {
9
+ const match = filename.match(/services\/([^/]+)\//)
10
+ return match ? match[1] : null
11
+ }
12
+
13
+ /**
14
+ * Convert a kebab-case or snake_case directory name to camelCase
15
+ * to match Prisma model names.
16
+ * e.g. "cloud-build" → "cloudBuild", "task" → "task"
17
+ */
18
+ function dirNameToModelName(dirName) {
19
+ return dirName.replace(/[-_](\w)/g, (_, c) => c.toUpperCase())
20
+ }
21
+
22
+ /** @type {import('eslint').Rule.RuleModule} */
23
+ export default {
24
+ meta: {
25
+ type: "problem",
26
+ docs: {
27
+ description:
28
+ "Disallow Prisma mutations on models not owned by the current service. " +
29
+ "Services should route mutations through the owning service, not bypass it with direct Prisma calls.",
30
+ },
31
+ messages: {
32
+ useBaseService:
33
+ "Use this.{{ method }}() from BaseService instead of this.prisma.{{ model }}.{{ method }}(). " +
34
+ "BaseService methods auto-emit updates to subscribers.",
35
+ crossServiceMutation:
36
+ "Direct mutation on '{{ model }}' is not owned by this service. " +
37
+ "Route through the owning service instead of this.prisma.{{ model }}.{{ method }}().",
38
+ },
39
+ schema: [
40
+ {
41
+ type: "object",
42
+ properties: {
43
+ allowedModels: {
44
+ type: "object",
45
+ description:
46
+ "Map of service directory name → array of foreign model names that are allowed. " +
47
+ 'e.g. { "task": ["chat"], "project": ["agent", "projectAgent"] }',
48
+ additionalProperties: {
49
+ type: "array",
50
+ items: { type: "string" },
51
+ },
52
+ },
53
+ },
54
+ additionalProperties: false,
55
+ },
56
+ ],
57
+ },
58
+ create(context) {
59
+ const filename = context.filename || context.getFilename()
60
+ const serviceDirName = getServiceDirName(filename)
61
+
62
+ if (!serviceDirName) return {}
63
+
64
+ const ownedModel = dirNameToModelName(serviceDirName)
65
+ const isIndexFile = filename.endsWith("/index.ts") || filename.endsWith("/index.js")
66
+ const options = context.options[0] || {}
67
+ const allowedModels = options.allowedModels || {}
68
+ const allowedForThisService = new Set(allowedModels[serviceDirName] || [])
69
+
70
+ return {
71
+ CallExpression(node) {
72
+ const callee = node.callee
73
+ if (callee.type !== "MemberExpression") return
74
+ if (callee.property.type !== "Identifier") return
75
+
76
+ const methodName = callee.property.name
77
+ if (!MUTATION_METHODS.has(methodName)) return
78
+
79
+ const obj = callee.object
80
+ if (obj.type !== "MemberExpression") return
81
+
82
+ const parentObj = obj.object
83
+ if (parentObj.type !== "MemberExpression") return
84
+ if (parentObj.property.type !== "Identifier") return
85
+ if (parentObj.property.name !== "prisma") return
86
+
87
+ if (parentObj.object.type !== "ThisExpression") return
88
+
89
+ const modelName =
90
+ obj.property.type === "Identifier" ? obj.property.name : "model"
91
+
92
+ if (modelName === ownedModel) {
93
+ if (!isIndexFile) {
94
+ context.report({
95
+ node,
96
+ messageId: "useBaseService",
97
+ data: { method: methodName, model: modelName },
98
+ })
99
+ }
100
+ return
101
+ }
102
+
103
+ if (allowedForThisService.has(modelName)) return
104
+
105
+ context.report({
106
+ node,
107
+ messageId: "crossServiceMutation",
108
+ data: { method: methodName, model: modelName },
109
+ })
110
+ },
111
+ }
112
+ },
113
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fitzzero/quickdraw-core",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "Fast fullstack patterns for real-time applications with Socket.io and TanStack Query",
5
5
  "type": "module",
6
6
  "main": "./dist/shared/index.js",